From d68b59e538c757146ac82007a36dcb87d6d9a66e Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:03:35 +0200 Subject: [PATCH 1/2] Integrate Windows window controls into the menu bar --- apps/designcraft/src/main.rs | 2 + crates/ui-egui/examples/ui_shot.rs | 3 +- crates/ui-egui/src/chrome.rs | 486 ++++++++++++++++++++++------- crates/ui-egui/src/lib.rs | 19 ++ crates/ui-egui/src/menus.rs | 46 +++ 5 files changed, 448 insertions(+), 108 deletions(-) diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index 26ce2ee4f..d1ed4a154 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -271,6 +271,7 @@ fn main() -> eframe::Result { .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) @@ -335,6 +336,7 @@ fn main() -> eframe::Result { } load_prefs(&mut app); 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 c2f1cb48e..93b269455 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -16,125 +16,141 @@ pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) { // 36 pt bar + 7 pt lower band (InDesign 2026), then a 1 pt border. let resp = egui::Panel::top("app_bar") .exact_size(43.0) - .frame(egui::Frame::NONE.fill(t.app_bar).inner_margin(egui::Margin { left, right: 10, top: 0, bottom: 7 })) + .frame(egui::Frame::NONE.fill(t.app_bar).inner_margin(egui::Margin { + left, + right: if app.custom_titlebar { 0 } else { 10 }, + top: 0, + bottom: 7, + })) .show(ui, |ui| { let full = ui.max_rect(); 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", 24.0, 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(ui.available_width().max(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. - let (r, _) = ui.allocate_exact_size(vec2(125.0, 18.0), Sense::click()); - ui.painter().rect(r, 1.0, t.input, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); - icons::paint(ui.painter(), egui::Rect::from_min_size(r.min + vec2(3.0, 2.0), vec2(14.0, 14.0)), "search", t.icon); - crate::rtl::paint( - ui.painter(), - r.min + vec2(20.0, 9.0), - egui::Align2::LEFT_CENTER, - crate::i18n::tr(&app.ui.language, "Search"), - egui::FontId::proportional(11.0), - t.text_dim, - ); - 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 }; + 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); + egui::ScrollArea::horizontal().id_salt("app_bar_scroll").auto_shrink([false, false]).show(ui, |ui| { + ui.horizontal_centered(|ui| { + if icons::button(ui, "home", 24.0, 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(ui.available_width().max(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. + let (r, _) = ui.allocate_exact_size(vec2(125.0, 18.0), Sense::click()); + ui.painter().rect(r, 1.0, t.input, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); + icons::paint(ui.painter(), egui::Rect::from_min_size(r.min + vec2(3.0, 2.0), vec2(14.0, 14.0)), "search", t.icon); + crate::rtl::paint( + ui.painter(), + r.min + vec2(20.0, 9.0), + egui::Align2::LEFT_CENTER, + crate::i18n::tr(&app.ui.language, "Search"), + egui::FontId::proportional(11.0), + t.text_dim, + ); + 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("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("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.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. @@ -146,6 +162,17 @@ pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) { let galley = crate::rtl::plain(ui.ctx(), &title, egui::FontId::proportional(11.5), t.text); let w = galley.size().x; let cx = full.center().x; + if app.custom_titlebar { + let drag = egui::Rect::from_min_max(egui::pos2(menus_end, full.top()), egui::pos2(tools_start.max(menus_end), full.top() + 36.0)); + let response = ui.interact(drag, egui::Id::new("window_title_drag"), Sense::click_and_drag()); + if response.double_clicked() { + 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) { + let _ = app.run("window.drag", json!({})); + } + app.flush_window_commands(ui.ctx()); + } if cx - w / 2.0 > menus_end + 12.0 && cx + w / 2.0 < tools_start - 12.0 { ui.painter().galley(egui::pos2(cx - w / 2.0, full.min.y + 18.0 - galley.size().y / 2.0), galley, t.text); } @@ -155,6 +182,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, 36.0), 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" { Color32::from_rgb(196, 43, 28) } else { t.hover }); + } + let color = if hovered && command == "window.close" { Color32::WHITE } 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 = "Home".into(); @@ -793,6 +899,7 @@ mod tests { use egui::{pos2, vec2}; use egui_kittest::kittest::Queryable; + use super::*; use crate::test_window::{self, wheel}; /// The Control panel's last control with nothing selected. @@ -890,4 +997,169 @@ mod tests { let columns = h.get_by_label("Columns").rect(); assert!(columns.height() < 20.0 && columns.width() > columns.height(), "Columns wraps: {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 (close, title) = (rect(&h, "window.close"), rect(&h, "window_title_drag")); + 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!(rect(&h, "window_title_drag").left() < title.left(), "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 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 fc7a3e634..00efede01 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -508,6 +508,10 @@ pub struct DesignApp { pub restyle: bool, fonts_ready: bool, 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). @@ -560,6 +564,9 @@ impl DesignApp { restyle: false, fonts_ready: false, integrated_titlebar: false, + custom_titlebar: false, + window_maximized: false, + pending_window_commands: vec![], native_menu: false, native_shortcuts: Default::default(), last_time: 0.0, @@ -761,6 +768,7 @@ impl DesignApp { /// Per-frame logic before layout. pub fn logic(&mut self, ctx: &egui::Context) { + 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() { @@ -815,6 +823,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(); @@ -1005,6 +1014,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() { @@ -1151,6 +1167,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 872cb81d0..aee673927 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -165,6 +165,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", @@ -752,6 +757,9 @@ pub fn ui_label(id: &str) -> Option<(&'static str, Option<&'static str>)> { /// 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); } @@ -761,6 +769,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) @@ -1635,6 +1675,9 @@ pub(crate) fn export_pdf(app: &mut DesignApp, p: &Value) -> Result bool { + 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, @@ -1856,6 +1899,9 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option { /// Enablement for menu display (UI commands need a document unless they're app/window-level). pub fn menu_enabled(app: &DesignApp, id: &str) -> bool { + if matches!(id, "window.minimize" | "window.maximize" | "window.close" | "window.drag" | "window.resize") { + return app.custom_titlebar; + } if ui_label(id).is_some() { return app.session.active().is_some() || id.starts_with("app.") || id.starts_with("window."); } From 0ba90118c79d8dd69004af6ae1338215631c079c Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 13 Oct 2026 15:21:34 +0200 Subject: [PATCH 2/2] fix(ui): the whole empty app bar moves the window; caption colours are theme tokens - The window drags from any empty part of the bar: the gap between the menus and the tools, and the 7 pt lower band, which is always there, so at the 900 px minimum width (menus and tools filling the row) the window can still be moved. Menu titles and tools keep their clicks; a menu title pressed and dragged doesn't move the window. - The Close button's hover fill and glyph are Tokens::caption_close and caption_close_text instead of fixed RGB values. - BAR_H and LOWER_BAND replace the literal 36 (drag area, caption buttons, title centre). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/chrome.rs | 74 +++++++++++++++++++++++++++--------- crates/ui-egui/src/theme.rs | 5 +++ 2 files changed, 62 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 93b269455..fdfb383ea 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -28,6 +28,31 @@ pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) { let mut tools_start = full.max.x; // 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_H + LOWER_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_H || 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| { @@ -41,6 +66,7 @@ pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) { 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", 24.0, app.session.active().is_none(), crate::i18n::tr(&app.ui.language, "Home")).clicked() { @@ -55,6 +81,8 @@ pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) { 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(ui.available_width().max(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); @@ -144,6 +172,7 @@ pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) { 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 { @@ -162,19 +191,8 @@ pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) { let galley = crate::rtl::plain(ui.ctx(), &title, egui::FontId::proportional(11.5), t.text); let w = galley.size().x; let cx = full.center().x; - if app.custom_titlebar { - let drag = egui::Rect::from_min_max(egui::pos2(menus_end, full.top()), egui::pos2(tools_start.max(menus_end), full.top() + 36.0)); - let response = ui.interact(drag, egui::Id::new("window_title_drag"), Sense::click_and_drag()); - if response.double_clicked() { - 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) { - let _ = app.run("window.drag", json!({})); - } - app.flush_window_commands(ui.ctx()); - } if cx - w / 2.0 > menus_end + 12.0 && cx + w / 2.0 < tools_start - 12.0 { - ui.painter().galley(egui::pos2(cx - w / 2.0, full.min.y + 18.0 - galley.size().y / 2.0), galley, t.text); + ui.painter().galley(egui::pos2(cx - w / 2.0, full.min.y + BAR_H / 2.0 - galley.size().y / 2.0), galley, t.text); } }); let r = resp.response.rect; @@ -182,6 +200,9 @@ 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)); } +/// The app bar: its 36 pt row and the 7 pt lower band under it (InDesign 2026). +const BAR_H: f32 = 36.0; +const LOWER_BAND: f32 = 7.0; 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; @@ -195,14 +216,14 @@ fn caption_buttons(app: &mut DesignApp, ui: &mut egui::Ui) { 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, 36.0), Sense::hover()); + let (rect, _) = ui.allocate_exact_size(vec2(CAPTION_BUTTON_W, BAR_H), 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" { Color32::from_rgb(196, 43, 28) } else { t.hover }); + ui.painter().rect_filled(rect, 0.0, if command == "window.close" { t.caption_close } else { t.hover }); } - let color = if hovered && command == "window.close" { Color32::WHITE } else { t.text }; + 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 { @@ -1129,7 +1150,8 @@ mod tests { h.frame(vec![]); h.frame(vec![]); let rect = |h: &WindowHarness, id: &str| h.ctx.read_response(egui::Id::new(id)).unwrap().rect; - let (close, title) = (rect(&h, "window.close"), rect(&h, "window_title_drag")); + 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![ @@ -1144,11 +1166,29 @@ mod tests { for _ in 0..10 { h.frame(vec![]); } - assert!(rect(&h, "window_title_drag").left() < title.left(), "the menus scrolled"); + 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, BAR_H + LOWER_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(); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 193abf344..3a86d3fa1 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -83,6 +83,9 @@ pub struct Tokens { /// Measurement labels on the canvas (smart dimensions, gaps). pub measure_bg: Color32, pub measure_text: 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 { @@ -125,6 +128,8 @@ impl Tokens { smart_guide: hex(0x00c853), measure_bg: Color32::from_rgba_unmultiplied(70, 70, 70, 230), measure_text: Color32::WHITE, + caption_close: hex(0xc42b1c), + caption_close_text: Color32::WHITE, }; match b { Brightness::Dark => dark,