diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index 50fdc3589..5d29bb9a4 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -382,6 +382,7 @@ fn main() -> std::process::ExitCode { .with_title("DesignCraft") .with_inner_size([1440.0, 900.0]) .with_min_inner_size([900.0, 560.0]) + .with_decorations(!cfg!(target_os = "windows")) .with_drag_and_drop(true) .with_fullsize_content_view(true) .with_titlebar_shown(false) @@ -495,6 +496,7 @@ fn main() -> std::process::ExitCode { app.status(note); } app.integrated_titlebar = cfg!(target_os = "macos"); + app.custom_titlebar = cfg!(target_os = "windows"); if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); app = app.with_control(rx); diff --git a/crates/ui-egui/examples/ui_shot.rs b/crates/ui-egui/examples/ui_shot.rs index c0bf38943..65d426f22 100644 --- a/crates/ui-egui/examples/ui_shot.rs +++ b/crates/ui-egui/examples/ui_shot.rs @@ -23,7 +23,8 @@ fn main() { }; let (tx, rx) = std::sync::mpsc::channel::(); let mut app = DesignApp::new(Session::new(), services).with_control(rx); - app.integrated_titlebar = true; + app.integrated_titlebar = cfg!(target_os = "macos"); + app.custom_titlebar = cfg!(target_os = "windows"); let lines: Vec = script.lines().filter(|l| !l.trim().is_empty()).map(|l| serde_json::from_str(l).expect("json line")).collect(); if lines.first().is_none_or(|l| l.get("empty").is_none()) { diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 68de574f7..750bc7a92 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -189,7 +189,12 @@ pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) { let home = if mac { 22.0 } else { 24.0 }; let resp = egui::Panel::top("app_bar") .exact_size(bar + APP_BAR_BAND) - .frame(egui::Frame::NONE.fill(t.app_bar).inner_margin(egui::Margin { left, right: 10, top: 0, bottom: (APP_BAR_BAND - SEPARATOR) as i8 })) + .frame(egui::Frame::NONE.fill(t.app_bar).inner_margin(egui::Margin { + left, + right: if app.custom_titlebar { 0 } else { 10 }, + top: 0, + bottom: (APP_BAR_BAND - SEPARATOR) as i8, + })) .show(ui, |ui| { let full = ui.max_rect(); if mac { @@ -203,109 +208,149 @@ pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) { } let mut menus_end = full.min.x; let mut tools_start = full.max.x; - // The right-hand controls' width, measured last frame: in a window too narrow for them - // and the menus they follow the menus, and the bar scrolls sideways. - let tools_w_id = ui.id().with("tools_width"); - let tools_w: f32 = ui.data(|d| d.get_temp(tools_w_id)).unwrap_or(0.0); - crate::widgets::overflow_scrolling(ui); - egui::ScrollArea::horizontal().id_salt("app_bar_scroll").auto_shrink([false, false]).show(ui, |ui| { - ui.horizontal_centered(|ui| { - if icons::button(ui, "home", home, app.session.active().is_none(), crate::i18n::tr(&app.ui.language, "Home")).clicked() { - app.session_home(); - } - ui.add_space(6.0); - if !app.native_menu { - ui.style_mut().visuals.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT; - ui.style_mut().visuals.widgets.inactive.bg_stroke = Stroke::NONE; - ui.style_mut().visuals.widgets.hovered.bg_stroke = Stroke::NONE; - ui.style_mut().spacing.button_padding = vec2(7.0, 3.0); - crate::menus::menu_bar(app, ui); - } - menus_end = ui.min_rect().max.x; - let size = vec2(tools_slot_width(ui.available_width(), tools_w), ui.available_height()); - let tools = ui.allocate_ui_with_layout(size, egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.add_space(4.0); - search_field(app, ui); - ui.add_space(8.0); - let current = app.ui.workspace.clone(); - let shown = crate::i18n::workspace_name(&app.ui.language, ¤t); - crate::menus::menu_button( - ui, - crate::rtl::widget(ui, egui::RichText::new(format!("{shown} ▾")).font(semibold(11.5)).color(t.text)), - |ui| { - let customs: Vec = app.ui.custom_workspaces.iter().map(|w| w.name.clone()).collect(); - for w in [ - "Essentials", - "Advanced", - "Book", - "Digital Publishing", - "Interactive for PDF", - "Printing and Proofing", - "Typography", - ] - .into_iter() - .map(str::to_string) - .chain(customs.iter().cloned()) - { + // Windows caption buttons stay at the right edge; the rest of the bar scrolls. + let caption_w = if app.custom_titlebar { CAPTION_W } else { 0.0 }; + // Undecorated window: the bar moves it. The drag area is the whole bar but the caption + // buttons, lower band included, registered before the menus and tools so they stay on + // top of it: any gap drags, and the lower band is always there, however narrow the + // window (the menus and tools scroll). + if app.custom_titlebar { + let drag = egui::Rect::from_min_max( + egui::pos2(full.min.x - left as f32, full.min.y), + egui::pos2(full.max.x - caption_w, full.min.y + bar + APP_BAR_BAND), + ); + let response = ui.interact(drag, egui::Id::new("window_title_drag"), Sense::click_and_drag()); + // Only from an empty part of the bar: the lower band, or the gap between the menus + // and the tools (as they were last frame). A menu title pressed and dragged doesn't + // move the window, as with native title bars. + let (gap0, gap1) = + ui.data(|d| (d.get_temp::(egui::Id::new("app_bar_menus_end")), d.get_temp::(egui::Id::new("app_bar_tools_start")))); + let empty = |p: egui::Pos2| p.y >= full.min.y + bar || matches!((gap0, gap1), (Some(a), Some(b)) if p.x >= a && p.x <= b); + let from_empty = ui.input(|i| i.pointer.press_origin().or(i.pointer.interact_pos())).is_some_and(empty); + if response.double_clicked() && from_empty { + let on = !ui.input(|i| i.viewport().maximized.unwrap_or(false)); + let _ = app.run("window.maximize", json!({"on": on})); + } else if response.drag_started_by(egui::PointerButton::Primary) && from_empty { + let _ = app.run("window.drag", json!({})); + } + app.flush_window_commands(ui.ctx()); + } + if app.custom_titlebar { + let caption = egui::Rect::from_min_max(egui::pos2(full.max.x - caption_w, full.min.y), full.max); + ui.scope_builder(egui::UiBuilder::new().max_rect(caption).layout(egui::Layout::right_to_left(egui::Align::Center)), |ui| { + caption_buttons(app, ui); + }); + } + let scrolled = egui::Rect::from_min_max(full.min, egui::pos2(full.max.x - caption_w, full.max.y)); + ui.scope_builder(egui::UiBuilder::new().max_rect(scrolled), |ui| { + // The right-hand controls' width, measured last frame: in a window too narrow for them + // and the menus they follow the menus, and the bar scrolls sideways. + let tools_w_id = ui.id().with("tools_width"); + let tools_w: f32 = ui.data(|d| d.get_temp(tools_w_id)).unwrap_or(0.0); + crate::widgets::overflow_scrolling(ui); + // (Drag-scrolling is touch-only by default, so a mouse drag on the bar moves the window.) + egui::ScrollArea::horizontal().id_salt("app_bar_scroll").auto_shrink([false, false]).show(ui, |ui| { + ui.horizontal_centered(|ui| { + if icons::button(ui, "home", home, app.session.active().is_none(), crate::i18n::tr(&app.ui.language, "Home")).clicked() { + app.session_home(); + } + ui.add_space(6.0); + if !app.native_menu { + ui.style_mut().visuals.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT; + ui.style_mut().visuals.widgets.inactive.bg_stroke = Stroke::NONE; + ui.style_mut().visuals.widgets.hovered.bg_stroke = Stroke::NONE; + ui.style_mut().spacing.button_padding = vec2(7.0, 3.0); + crate::menus::menu_bar(app, ui); + } + menus_end = ui.min_rect().max.x; + // Where the menus end on screen (moves as a narrow bar scrolls). + ui.data_mut(|d| d.insert_temp(egui::Id::new("app_bar_menus_end"), menus_end)); + let size = vec2(tools_slot_width(ui.available_width(), tools_w), ui.available_height()); + let tools = ui.allocate_ui_with_layout(size, egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(4.0); + search_field(app, ui); + ui.add_space(8.0); + let current = app.ui.workspace.clone(); + let shown = crate::i18n::workspace_name(&app.ui.language, ¤t); + crate::menus::menu_button( + ui, + crate::rtl::widget(ui, egui::RichText::new(format!("{shown} ▾")).font(semibold(11.5)).color(t.text)), + |ui| { + let customs: Vec = app.ui.custom_workspaces.iter().map(|w| w.name.clone()).collect(); + for w in [ + "Essentials", + "Advanced", + "Book", + "Digital Publishing", + "Interactive for PDF", + "Printing and Proofing", + "Typography", + ] + .into_iter() + .map(str::to_string) + .chain(customs.iter().cloned()) + { + if ui + .selectable_label(w == current, crate::rtl::widget(ui, crate::i18n::workspace_name(&app.ui.language, &w))) + .clicked() + { + let _ = app.run("window.workspace", json!({"name": w})); + ui.close(); + } + } + ui.separator(); if ui - .selectable_label(w == current, crate::rtl::widget(ui, crate::i18n::workspace_name(&app.ui.language, &w))) + .button(crate::rtl::widget( + ui, + format!( + "{} {}", + crate::i18n::tr(&app.ui.language, "Reset"), + crate::i18n::workspace_name(&app.ui.language, ¤t) + ), + )) .clicked() { - let _ = app.run("window.workspace", json!({"name": w})); + let _ = app.run("window.resetWorkspace", json!({})); ui.close(); } - } - ui.separator(); - if ui - .button(crate::rtl::widget( - ui, - format!( - "{} {}", - crate::i18n::tr(&app.ui.language, "Reset"), - crate::i18n::workspace_name(&app.ui.language, ¤t) - ), - )) - .clicked() - { - let _ = app.run("window.resetWorkspace", json!({})); - ui.close(); - } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Workspace…"))).clicked() { - let _ = app.run("window.newWorkspace", json!({})); - ui.close(); - } - if !customs.is_empty() { - crate::menus::menu_button( - ui, - crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Workspace")), - |ui| { - for w in &customs { - if ui.button(w).clicked() { - let _ = app.run("window.deleteWorkspace", json!({"name": w})); - ui.close(); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Workspace…"))).clicked() { + let _ = app.run("window.newWorkspace", json!({})); + ui.close(); + } + if !customs.is_empty() { + crate::menus::menu_button( + ui, + crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Workspace")), + |ui| { + for w in &customs { + if ui.button(w).clicked() { + let _ = app.run("window.deleteWorkspace", json!({"name": w})); + ui.close(); + } } - } - }, - ); - } - }, - ); - ui.add_space(6.0); - if icons::button(ui, "share", 22.0, false, crate::i18n::tr(&app.ui.language, "Share")).clicked() { - app.status(crate::i18n::tr(&app.ui.language, "Export a PDF, IDML or package to share — no cloud account needed.")); + }, + ); + } + }, + ); + ui.add_space(6.0); + if icons::button(ui, "share", 22.0, false, crate::i18n::tr(&app.ui.language, "Share")).clicked() { + app.status(crate::i18n::tr(&app.ui.language, "Export a PDF, IDML or package to share — no cloud account needed.")); + } + ui.add_space(8.0); + // Always one click away: the ArtCraft community Discord. + if crate::about::discord_button(ui, "Discord", vec2(78.0, 22.0)) { + let _ = app.run("help.discord", json!({})); + } + tools_start = ui.min_rect().min.x; + ui.data_mut(|d| d.insert_temp(egui::Id::new("app_bar_tools_start"), tools_start)); + ui.min_rect().width() + }); + if (tools.inner - tools_w).abs() > 0.5 { + ui.data_mut(|d| d.insert_temp(tools_w_id, tools.inner)); + ui.ctx().request_discard("app bar: the right-hand controls changed width"); } - ui.add_space(8.0); - // Always one click away: the ArtCraft community Discord. - if crate::about::discord_button(ui, "Discord", vec2(78.0, 22.0)) { - let _ = app.run("help.discord", json!({})); - } - tools_start = ui.min_rect().min.x; - ui.min_rect().width() }); - if (tools.inner - tools_w).abs() > 0.5 { - ui.data_mut(|d| d.insert_temp(tools_w_id, tools.inner)); - ui.ctx().request_discard("app bar: the right-hand controls changed width"); - } }); }); // Centred title. @@ -326,6 +371,85 @@ pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) { ui.painter().line_segment([egui::pos2(r.min.x, r.max.y), egui::pos2(r.max.x, r.max.y)], Stroke::new(1.0, t.border)); } +const CAPTION_BUTTON_W: f32 = 46.0; +/// Minimize, maximize and close, and the space after them. +const CAPTION_W: f32 = 3.0 * CAPTION_BUTTON_W + 8.0; + +/// Windows caption buttons. The macOS traffic lights remain native. +fn caption_buttons(app: &mut DesignApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let maximized = ui.input(|i| i.viewport().maximized.unwrap_or(false)); + let spacing = ui.spacing().item_spacing.x; + ui.spacing_mut().item_spacing.x = 0.0; + for (command, label) in + [("window.close", "Close"), ("window.maximize", if maximized { "Restore" } else { "Maximize" }), ("window.minimize", "Minimize")] + { + let (rect, _) = ui.allocate_exact_size(vec2(CAPTION_BUTTON_W, APP_BAR), Sense::hover()); + let response = ui.interact(rect, egui::Id::new(command), Sense::click()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label)); + let hovered = response.hovered(); + if hovered { + ui.painter().rect_filled(rect, 0.0, if command == "window.close" { t.caption_close } else { t.hover }); + } + let color = if hovered && command == "window.close" { t.caption_close_text } else { t.text }; + let stroke = Stroke::new(1.0, color); + let icon = egui::Rect::from_center_size(rect.center(), vec2(10.0, 10.0)); + match command { + "window.close" => { + ui.painter().line_segment([icon.left_top(), icon.right_bottom()], stroke); + ui.painter().line_segment([icon.right_top(), icon.left_bottom()], stroke); + } + "window.maximize" if maximized => { + let back = icon.translate(vec2(2.0, -2.0)); + ui.painter().line_segment([back.left_top(), back.right_top()], stroke); + ui.painter().line_segment([back.right_top(), back.right_bottom()], stroke); + ui.painter().rect_stroke(icon, 0.0, stroke, egui::StrokeKind::Inside); + } + "window.maximize" => { + ui.painter().rect_stroke(icon, 0.0, stroke, egui::StrokeKind::Inside); + } + _ => { + ui.painter().line_segment([icon.left_center(), icon.right_center()], stroke); + } + } + if response.clicked() { + let params = if command == "window.maximize" { json!({"on": !maximized}) } else { json!({}) }; + let _ = app.run(command, params); + app.flush_window_commands(ui.ctx()); + } + response.on_hover_text(crate::i18n::tr(&app.ui.language, label)); + } + ui.spacing_mut().item_spacing.x = spacing; + ui.add_space(8.0); +} + +/// Undecorated Windows windows need resize grips as well as caption controls. +pub(crate) fn resize_borders(app: &mut DesignApp, ui: &mut egui::Ui) { + if ui.input(|i| i.viewport().maximized.unwrap_or(false) || i.viewport().fullscreen.unwrap_or(false)) { + return; + } + let r = ui.ctx().content_rect(); + let edge = 4.0; + let corner = 10.0; + let rect = |x0, y0, x1, y1| egui::Rect::from_min_max(egui::pos2(x0, y0), egui::pos2(x1, y1)); + for (name, region, cursor) in [ + ("northWest", rect(r.left(), r.top(), r.left() + corner, r.top() + corner), egui::CursorIcon::ResizeNwSe), + ("northEast", rect(r.right() - corner, r.top(), r.right(), r.top() + corner), egui::CursorIcon::ResizeNeSw), + ("southWest", rect(r.left(), r.bottom() - corner, r.left() + corner, r.bottom()), egui::CursorIcon::ResizeNeSw), + ("southEast", rect(r.right() - corner, r.bottom() - corner, r.right(), r.bottom()), egui::CursorIcon::ResizeNwSe), + ("north", rect(r.left() + corner, r.top(), r.right() - corner, r.top() + edge), egui::CursorIcon::ResizeVertical), + ("south", rect(r.left() + corner, r.bottom() - edge, r.right() - corner, r.bottom()), egui::CursorIcon::ResizeVertical), + ("west", rect(r.left(), r.top() + corner, r.left() + edge, r.bottom() - corner), egui::CursorIcon::ResizeHorizontal), + ("east", rect(r.right() - edge, r.top() + corner, r.right(), r.bottom() - corner), egui::CursorIcon::ResizeHorizontal), + ] { + let response = ui.interact(region, egui::Id::new(("window_resize", name)), Sense::drag()).on_hover_cursor(cursor); + if response.drag_started_by(egui::PointerButton::Primary) { + let _ = app.run("window.resize", json!({"edge": name})); + app.flush_window_commands(ui.ctx()); + } + } +} + impl DesignApp { fn session_home(&mut self) { self.ui.status = crate::i18n::tr(&self.ui.language, "Home").into(); @@ -772,6 +896,7 @@ mod tests { use egui::{pos2, vec2}; use egui_kittest::kittest::{NodeT, Queryable}; + use super::*; use crate::test_window::{self, wheel}; /// The Control panel's last control with no text frame selected. @@ -1114,4 +1239,188 @@ mod tests { let columns = group(&h, "columns"); assert!(columns.width() >= 48.0 && columns.max.x <= 700.0, "Columns group squeezed or off screen: {columns:?}"); } + + struct WindowHarness { + ctx: egui::Context, + app: DesignApp, + time: f64, + maximized: bool, + size: egui::Vec2, + } + + impl WindowHarness { + fn new() -> Self { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.custom_titlebar = true; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, ""); + let mut harness = Self { ctx, app, time: 0.0, maximized: false, size: vec2(1440.0, 900.0) }; + harness.frame(vec![]); + harness.frame(vec![]); + harness + } + + fn frame(&mut self, events: Vec) -> Vec { + self.time += 0.1; + let mut input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, self.size)), + time: Some(self.time), + events, + ..Default::default() + }; + input.viewports.entry(egui::ViewportId::ROOT).or_default().maximized = Some(self.maximized); + let mut output = self.ctx.run_ui(input, |ui| { + app_bar(&mut self.app, ui); + resize_borders(&mut self.app, ui); + }); + output.textures_delta.clear(); + output.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default() + } + + fn press(&mut self, pos: egui::Pos2) -> Vec { + self.frame(vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }, + ]) + } + + fn click(&mut self, pos: egui::Pos2) -> Vec { + let mut commands = self.press(pos); + commands.extend(self.frame(vec![egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }])); + commands + } + + fn caption_center(&self, id: &str) -> egui::Pos2 { + self.ctx.read_response(egui::Id::new(id)).unwrap().rect.center() + } + } + + #[test] + fn caption_buttons_minimize_maximize_restore_and_close() { + let mut h = WindowHarness::new(); + for (id, command) in + [("window.minimize", egui::ViewportCommand::Minimized(true)), ("window.maximize", egui::ViewportCommand::Maximized(true))] + { + let p = h.caption_center(id); + assert!(h.click(p).contains(&command), "{id}"); + } + h.maximized = true; + h.frame(vec![]); + let p = h.caption_center("window.maximize"); + assert!(h.click(p).contains(&egui::ViewportCommand::Maximized(false))); + let p = h.caption_center("window.close"); + assert!(h.click(p).contains(&egui::ViewportCommand::Close)); + } + + #[test] + fn title_area_drags_and_double_clicks_but_menus_do_not_drag() { + let mut h = WindowHarness::new(); + let title = h.ctx.read_response(egui::Id::new("window_title_drag")).unwrap().rect; + assert!(title.width() > 0.0, "there is a drag region between menus and tools"); + let p = title.center(); + let mut commands = h.press(p); + commands.extend(h.frame(vec![egui::Event::PointerMoved(p + vec2(20.0, 0.0))])); + assert!(commands.contains(&egui::ViewportCommand::StartDrag)); + + let mut h = WindowHarness::new(); + h.click(p); + assert!(h.click(p).contains(&egui::ViewportCommand::Maximized(true))); + + let mut h = WindowHarness::new(); + let p = egui::pos2(70.0, 18.0); // File menu, outside the title's drag region. + let mut commands = h.press(p); + commands.extend(h.frame(vec![egui::Event::PointerMoved(title.center())])); + assert!(!commands.contains(&egui::ViewportCommand::StartDrag)); + } + + #[test] + fn edges_resize_only_when_the_window_is_restored() { + let mut h = WindowHarness::new(); + let p = egui::pos2(1438.0, 400.0); + let mut commands = h.press(p); + commands.extend(h.frame(vec![egui::Event::PointerMoved(p + vec2(-20.0, 0.0))])); + assert!(commands.contains(&egui::ViewportCommand::BeginResize(egui::ResizeDirection::East))); + let mut h = WindowHarness::new(); + h.maximized = true; + h.frame(vec![]); + let mut commands = h.press(p); + commands.extend(h.frame(vec![egui::Event::PointerMoved(p + vec2(-20.0, 0.0))])); + assert!(!commands.iter().any(|c| matches!(c, egui::ViewportCommand::BeginResize(_)))); + } + + #[test] + fn native_window_actions_are_unavailable_without_custom_chrome() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + assert!(!crate::menus::enabled(&app, "window.close")); + assert!(app.run("window.close", json!({})).is_err()); + assert!(app.pending_window_commands.is_empty()); + app.custom_titlebar = true; + assert!(app.run("window.resize", json!({"edge": "invalid"})).is_err()); + assert!(app.pending_window_commands.is_empty()); + } + + #[test] + fn caption_buttons_stay_put_while_a_narrow_app_bar_scrolls() { + let mut h = WindowHarness::new(); + h.size = vec2(640.0, 560.0); + h.frame(vec![]); + h.frame(vec![]); + let rect = |h: &WindowHarness, id: &str| h.ctx.read_response(egui::Id::new(id)).unwrap().rect; + let menus_end = |h: &WindowHarness| h.ctx.data(|d| d.get_temp::(egui::Id::new("app_bar_menus_end"))).unwrap(); + let (close, menus0) = (rect(&h, "window.close"), menus_end(&h)); + assert!(close.left() >= 0.0 && close.right() <= h.size.x, "{close:?}"); + let p = egui::pos2(200.0, 18.0); + h.frame(vec![ + egui::Event::PointerMoved(p), + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -3000.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }, + ]); + for _ in 0..10 { + h.frame(vec![]); + } + assert!(menus_end(&h) < menus0, "the menus scrolled"); + assert_eq!(rect(&h, "window.close"), close, "the caption buttons did not"); + assert!(h.click(close.center()).contains(&egui::ViewportCommand::Close)); + } + + #[test] + fn the_window_drags_from_its_lower_band_at_the_minimum_width() { + // At 900 px the menus and tools fill the bar's row; its lower band still moves the window, + // and it isn't drawn over the caption buttons. + let mut h = WindowHarness::new(); + h.size = vec2(900.0, 560.0); + h.frame(vec![]); + h.frame(vec![]); + let drag = h.ctx.read_response(egui::Id::new("window_title_drag")).unwrap().rect; + let close = h.ctx.read_response(egui::Id::new("window.close")).unwrap().rect; + assert!(drag.right() <= close.left() + 0.5, "{drag:?} {close:?}"); + let p = egui::pos2(450.0, APP_BAR + APP_BAR_BAND / 2.0); + assert!(drag.contains(p), "{drag:?}"); + let mut commands = h.press(p); + commands.extend(h.frame(vec![egui::Event::PointerMoved(p + vec2(20.0, 0.0))])); + assert!(commands.contains(&egui::ViewportCommand::StartDrag)); + } + + #[test] + fn caption_buttons_fit_the_minimum_window_width() { + let mut h = WindowHarness::new(); + h.size = vec2(900.0, 560.0); + h.frame(vec![]); + h.frame(vec![]); + for id in ["window.minimize", "window.maximize", "window.close"] { + let rect = h.ctx.read_response(egui::Id::new(id)).unwrap().rect; + assert!(rect.left() >= 0.0 && rect.right() <= h.size.x, "{id}: {rect:?}"); + let p = rect.center(); + assert!(!h.click(p).is_empty(), "{id} remains clickable"); + } + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3b5e44b4c..b4c9458c0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -552,6 +552,10 @@ pub struct DesignApp { /// window is ready; see [`DesignApp::open_documents_later`]. os_documents: Vec, pub integrated_titlebar: bool, + /// The desktop host uses our caption controls and window drag/resize regions (Windows). + pub custom_titlebar: bool, + pub(crate) window_maximized: bool, + pub(crate) pending_window_commands: Vec, /// The host installed a native menu bar (macOS): don't draw menus in the window. pub native_menu: bool, /// Shortcuts the native menu handles (skip them in the egui shortcut handler). @@ -625,6 +629,9 @@ impl DesignApp { fonts_ready: false, os_documents: Vec::new(), integrated_titlebar: false, + custom_titlebar: false, + window_maximized: false, + pending_window_commands: vec![], native_menu: false, native_shortcuts: Default::default(), last_time: 0.0, @@ -1023,6 +1030,7 @@ impl DesignApp { if self.egui_ctx.is_none() { self.egui_ctx = Some(ctx.clone()); } + self.window_maximized = ctx.input(|i| i.viewport().maximized.unwrap_or(false)); if !self.color_applied { self.color_applied = true; if let Some(cs) = self.ui.color_settings.clone() { @@ -1083,6 +1091,7 @@ impl DesignApp { if self.fonts_ready { menus::shortcuts(self, ctx); } + self.flush_window_commands(ctx); #[cfg(not(target_arch = "wasm32"))] for f in ctx.input(|i| i.raw.dropped_files.clone()) { let p = f.path().to_string_lossy().to_string(); @@ -1304,6 +1313,13 @@ impl DesignApp { r } + /// Deliver window actions to the host without persisting them in UI preferences. + pub(crate) fn flush_window_commands(&mut self, ctx: &egui::Context) { + for command in std::mem::take(&mut self.pending_window_commands) { + ctx.send_viewport_cmd(command); + } + } + /// Inject synthetic events (one pointer event per frame). pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { if self.synthetic.is_empty() { @@ -1451,6 +1467,9 @@ impl DesignApp { dialogs::show(self, &ctx); about::show(self, &ctx); menus::palette(self, &ctx); + if self.custom_titlebar { + chrome::resize_borders(self, ui); + } for url in std::mem::take(&mut self.ui.pending_urls) { ctx.open_url(egui::OpenUrl::new_tab(url)); } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9919d7216..866a82d09 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -236,6 +236,11 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("window.controlBar", "Control", Some("Cmd+Alt+6"), "{}"), ("window.split", "Split Window", None, "{on?: bool} — two views of the document side by side, each with its own zoom and scroll"), ("window.newWindow", "New Window", None, "{on?: bool} — another view of the active document in its own window"), + ("window.minimize", "Minimize Window", None, "{} — minimize the desktop window"), + ("window.maximize", "Maximize/Restore Window", None, "{on?: bool} — toggle maximization when omitted"), + ("window.close", "Close Window", None, "{} — request that the desktop window close"), + ("window.drag", "Move Window", None, "{} — begin a native window drag while the primary mouse button is held"), + ("window.resize", "Resize Window", None, "{edge: north|south|east|west|northEast|southEast|northWest|southWest} — begin a native edge drag"), ("window.taskBar", "Contextual Task Bar", None, "{}"), ( "window.taskBarPin", @@ -834,6 +839,9 @@ fn toggle_tabs_panel(app: &mut DesignApp) { /// Run a UI command; `None` if `id` isn't one. pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option> { + if matches!(id, "window.minimize" | "window.maximize" | "window.close" | "window.drag" | "window.resize") && !app.custom_titlebar { + return Some(Err("custom window controls are not available in this host".into())); + } if let Some(result) = crate::panel_docking::command(app, id, p) { return Some(result); } @@ -843,6 +851,38 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option { + app.pending_window_commands.push(egui::ViewportCommand::Minimized(true)); + Ok(Value::Null) + } + "window.maximize" => { + let on = p.get("on").and_then(Value::as_bool).unwrap_or(!app.window_maximized); + app.pending_window_commands.push(egui::ViewportCommand::Maximized(on)); + Ok(json!(on)) + } + "window.close" => { + app.pending_window_commands.push(egui::ViewportCommand::Close); + Ok(Value::Null) + } + "window.drag" => { + app.pending_window_commands.push(egui::ViewportCommand::StartDrag); + Ok(Value::Null) + } + "window.resize" => { + let edge = match p.get("edge").and_then(Value::as_str) { + Some("north") => egui::ResizeDirection::North, + Some("south") => egui::ResizeDirection::South, + Some("east") => egui::ResizeDirection::East, + Some("west") => egui::ResizeDirection::West, + Some("northEast") => egui::ResizeDirection::NorthEast, + Some("southEast") => egui::ResizeDirection::SouthEast, + Some("northWest") => egui::ResizeDirection::NorthWest, + Some("southWest") => egui::ResizeDirection::SouthWest, + _ => return Some(Err("invalid window resize edge".into())), + }; + app.pending_window_commands.push(egui::ViewportCommand::BeginResize(edge)); + Ok(Value::Null) + } "app.newDocumentDialog" => { app.ui.dialog = Some(crate::dialogs::Dialog::new("newDocument", json!({}))); Ok(Value::Null) @@ -1833,6 +1873,9 @@ pub fn enabled(app: &DesignApp, id: &str) -> bool { if waits_for_ime(app, id) { return false; } + if matches!(id, "window.minimize" | "window.maximize" | "window.close" | "window.drag" | "window.resize") { + return app.custom_titlebar; + } match designcraft_engine::find_command(id) { Some(c) => (c.enabled)(&app.session).is_ok(), None => true, @@ -2129,6 +2172,9 @@ pub fn menu_enabled(app: &DesignApp, id: &str) -> bool { if waits_for_ime(app, id) { return false; } + if matches!(id, "window.minimize" | "window.maximize" | "window.close" | "window.drag" | "window.resize") { + return app.custom_titlebar; + } if id == "app.justificationDialog" { return enabled(app, "type.para"); } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 45a0afafa..7b2364ffe 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -85,6 +85,9 @@ pub struct Tokens { pub measure_text: Color32, /// Live-corner widget and corner diamonds. pub live_corner: Color32, + /// The Windows caption Close button when hovered, and its glyph. + pub caption_close: Color32, + pub caption_close_text: Color32, } fn hex(s: u32) -> Color32 { @@ -128,6 +131,8 @@ impl Tokens { measure_bg: Color32::from_rgba_unmultiplied(70, 70, 70, 230), measure_text: Color32::WHITE, live_corner: hex(0xffe500), + caption_close: hex(0xc42b1c), + caption_close_text: Color32::WHITE, }; match b { Brightness::Dark => dark,