From 406293fd531fe97c99a8d31b09ede1623bb3d8f8 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Wed, 14 Oct 2026 23:18:48 -0400 Subject: [PATCH] Title bar: inactive tabs without outlines, File menu, Help on the right (#1277) * Title bar: inactive tabs without outlines, File menu, Help on the right Inactive tabs go back to the plain bar colour (the Home tab's readable hover stays). The separate Home button and theme toggle are gone: Home is a tab and the theme is in Preferences. The folder icon becomes a File text menu, Save moves into it (Ctrl+S unchanged), and Help sits on the right next to Report a bug. Tabs start right after File, Undo and Redo. Co-Authored-By: Claude Opus 5.5 * Title bar: File button hover uses the app bar hover token Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- assets/styles/ui/palettes.rs | 1 - crates/ui-egui/src/colour_vision_tests.rs | 1 - crates/ui-egui/src/documents.rs | 18 +-- crates/ui-egui/src/theme.rs | 2 - crates/ui-egui/src/titlebar.rs | 4 +- crates/ui-egui/src/toolbar.rs | 154 +++++++++++++++------- docs/book/src/guide/ui-tour.md | 8 +- 7 files changed, 117 insertions(+), 71 deletions(-) diff --git a/assets/styles/ui/palettes.rs b/assets/styles/ui/palettes.rs index 310a07da..d28cfd69 100644 --- a/assets/styles/ui/palettes.rs +++ b/assets/styles/ui/palettes.rs @@ -76,7 +76,6 @@ impl Tokens { rubber_band: Color32::from_rgb(30, 120, 230), app_bar_text: Color32::from_rgb(230, 234, 242), app_bar_hover: Color32::from_rgba_unmultiplied_const(255, 255, 255, 28), - app_bar_tab_edge: Color32::from_rgba_unmultiplied_const(255, 255, 255, 40), timeline_item: Color32::WHITE, timeline_item_error: Color32::from_rgb(250, 220, 220), timeline_marker: Color32::from_rgb(70, 76, 88), diff --git a/crates/ui-egui/src/colour_vision_tests.rs b/crates/ui-egui/src/colour_vision_tests.rs index 5d5449f4..c0ea5f5c 100644 --- a/crates/ui-egui/src/colour_vision_tests.rs +++ b/crates/ui-egui/src/colour_vision_tests.rs @@ -373,7 +373,6 @@ const NO_MEANING: &[(&str, &str)] = &[ ("overlay", "a chip's ground over the view"), ("app_bar_text", "text"), ("app_bar_hover", "a hovered tab, translucent white"), - ("app_bar_tab_edge", "an inactive tab's outline, translucent white"), ("timeline_marker", "the rollback marker, alone"), ("field", "a field's ground"), // Highlights: shades of the accent blue over what they mark, by design close to each other. diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index 0289c3a1..cacae7ad 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -455,7 +455,7 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) { } /// One tab of the strip: the active tab takes the toolbar's colour and runs into it; the others -/// sit on the application bar with a faint outline (no bottom edge) so neighbours stay apart. +/// sit on the application bar, lighter while hovered. fn tab_shape(painter: &egui::Painter, t: &Tokens, r: Rect, active: bool, hovered: bool) { let corners = egui::CornerRadius { nw: 5, ne: 5, sw: 0, se: 0 }; let fill = if active { @@ -466,11 +466,6 @@ fn tab_shape(painter: &egui::Painter, t: &Tokens, r: Rect, active: bool, hovered t.app_bar }; painter.rect_filled(r, corners, fill); - if !active { - // Drawn past the bottom and clipped, so only the top and the sides show. - let outline = Rect::from_min_max(r.min, pos2(r.max.x, r.max.y + 2.0)); - painter.with_clip_rect(r).rect_stroke(outline, corners, Stroke::new(1.0, t.app_bar_tab_edge), egui::StrokeKind::Inside); - } } /// Which tabs the strip shows in `room` points: (first, how many, tab width). Tabs shrink to @@ -629,10 +624,9 @@ mod tests { } /// The tabs sit on the dark application bar in both themes: a hovered tab (the Home tab - /// included) keeps the bar's light text readable, and an inactive tab is outlined so - /// neighbouring tabs stay apart. + /// included) keeps the bar's light text readable, and no tab is outlined. #[test] - fn title_bar_tabs_are_outlined_and_keep_their_text_readable_on_hover() { + fn title_bar_tabs_keep_their_text_readable_on_hover() { fn luminance(c: Color32) -> f32 { let c = egui::Rgba::from(c); 0.2126 * c.r() + 0.7152 * c.g() + 0.0722 * c.b() @@ -662,11 +656,7 @@ mod tests { let (hi, lo) = (luminance(t.app_bar_text).max(luminance(bg)), luminance(t.app_bar_text).min(luminance(bg))); assert!((hi + 0.05) / (lo + 0.05) >= 4.5, "text on a tab (hovered: {hovered}) must stay readable: {bg:?} {t:?}"); } - let outline = rects.iter().find(|(rect, _)| rect.stroke.width > 0.0); - assert_eq!(outline.is_some(), !active, "only inactive tabs are outlined (active: {active})"); - if let Some((rect, clip)) = outline { - assert!(rect.rect.bottom() > r.bottom() && clip.bottom() <= r.bottom(), "the outline has no bottom edge"); - } + assert!(rects.iter().all(|(rect, _)| rect.stroke.width == 0.0), "tabs have no outline (active: {active})"); } } } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 9cb26a78..beb974b7 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -266,8 +266,6 @@ pub struct Tokens { pub app_bar_text: Color32, /// A hovered tab on the application bar (keeps `app_bar_text` readable). pub app_bar_hover: Color32, - /// Outline of the inactive tabs on the application bar, so neighbouring tabs stay apart. - pub app_bar_tab_edge: Color32, /// Timeline items: normal and failed. pub timeline_item: Color32, pub timeline_item_error: Color32, diff --git a/crates/ui-egui/src/titlebar.rs b/crates/ui-egui/src/titlebar.rs index 7d7af003..b8807a2f 100644 --- a/crates/ui-egui/src/titlebar.rs +++ b/crates/ui-egui/src/titlebar.rs @@ -286,7 +286,7 @@ mod tests { fn empty_bar_space_drags_and_double_click_maximizes() { let mut w = Win::new(1600.0); w.frame(vec![]); - let empty = pos2(500.0, 17.0); + let empty = pos2(900.0, 17.0); let cmds = w.gesture(&[empty, empty + vec2(12.0, 4.0), empty + vec2(30.0, 8.0)]); assert_eq!(cmds.iter().filter(|c| matches!(c, ViewportCommand::StartDrag)).count(), 1, "{cmds:?}"); let mut cmds = w.click(empty); @@ -299,7 +299,7 @@ mod tests { assert!(cmds.contains(&ViewportCommand::Maximized(false)), "{cmds:?}"); // The bar's buttons keep their clicks: dragging from Undo doesn't move the window. w.time += 1.0; - let undo = pos2(128.0 + 2.0 * 30.0, 17.0); + let undo = crate::scenario::handle_at("appbar:undo").unwrap(); let cmds = w.gesture(&[undo, undo + vec2(12.0, 4.0), undo + vec2(30.0, 8.0)]); assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::StartDrag)), "{cmds:?}"); } diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 6f816139..b9c1a7e8 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -146,10 +146,19 @@ fn menu_commands( out } +/// What the right-hand group keeps clear between Report a bug and the window's caption buttons: +/// Help, Preferences and the version. +const RIGHT_GROUP: f32 = 120.0; + +/// The centre of the Help (?) button: left of Preferences. +fn help_x(bar: Rect, captions: f32) -> f32 { + bar.right() - captions - 101.0 +} + /// The Report a bug button: labelled when there is room for its text (`label_width`, measured in /// the current language), otherwise just the icon. fn report_rect(bar: Rect, captions: f32, tabs_start: f32, label_width: f32) -> (Rect, bool) { - let right = bar.right() - captions - 90.0; + let right = bar.right() - captions - RIGHT_GROUP; // Icon, text and a margin; never narrower than the English button. let full = (REPORT_TEXT_X + label_width + 10.0).max(124.0); let labeled = right - tabs_start >= 216.0 + full; @@ -184,12 +193,34 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) { ui.painter().text(pos2(left + 36.0, r.center().y), Align2::LEFT_CENTER, "SolidCraft", FontId::proportional(14.0), t.app_bar_text); } let mut x = left + if compact_actions { 60.0 } else { 128.0 }; + // While a file loads the design waits: no menus or commands from the bar. + let idle = !app.file_load.busy(); + // The File menu: a text button (New, Open, Save, Save As, Export…). Its handle keeps the + // old `appbar:folder` name too, for scripts written against the folder button. + let file = { + let label = app.tr("ui.file").to_string(); + let galley = ui.painter().layout_no_wrap(label.clone(), FontId::proportional(13.0), t.app_bar_text); + let br = Rect::from_min_size(pos2(x - 13.0, r.center().y - 13.0), vec2(galley.size().x + 16.0, 26.0)); + x = br.right() + 17.0; + let resp = ui.interact(br, ui.id().with(("ab", "file")), if idle { Sense::click() } else { Sense::hover() }); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, idle, &label)); + crate::scenario::publish_handle("appbar:file", br.center()); + crate::scenario::publish_handle("appbar:folder", br.center()); + let open = ui.ctx().data(|d| d.get_temp::(egui::Id::new("sc_file_menu")).unwrap_or(false)); + if idle && (resp.hovered() || open) { + ui.painter().rect_filled(br, 4.0, t.app_bar_hover); + } + let ink = if idle { t.app_bar_text } else { t.app_bar_text.gamma_multiply(0.35) }; + ui.painter().galley(pos2(br.left() + 8.0, br.center().y - galley.size().y / 2.0), galley, ink); + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("sc_file_menu_x"), br.left())); + idle && resp.clicked() + }; // A disabled button is dimmed and ignores clicks (its tooltip still shows). - let mut click = |ui: &mut egui::Ui, icon: &str, tip: &str, enabled: bool| -> bool { + let click = |ui: &mut egui::Ui, at: f32, icon: &str, tip: &str, enabled: bool| -> bool { let width = if compact_actions { 22.0 } else { 26.0 }; - let br = Rect::from_center_size(pos2(x, r.center().y), vec2(width, 26.0)); - x += if compact_actions { 22.0 } else { 30.0 }; + let br = Rect::from_center_size(pos2(at, r.center().y), vec2(width, 26.0)); let resp = ui.interact(br, ui.id().with(("ab", icon)), if enabled { Sense::click() } else { Sense::hover() }); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, tip)); crate::scenario::publish_handle(&format!("appbar:{icon}"), br.center()); if enabled && resp.hovered() { ui.painter().rect_filled(br, 4.0, Color32::from_white_alpha(30)); @@ -199,61 +230,53 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) { let clicked = resp.on_hover_text(tip).clicked(); enabled && clicked }; - // While a file loads the design waits: no menus or commands from the bar. - let idle = !app.file_load.busy(); - let file = click(ui, "folder", app.tr("ui.file"), idle); - // Save, Undo and Redo act on the design, which Home hides. + let step = if compact_actions { 22.0 } else { 30.0 }; + // Undo and Redo act on the design, which Home hides. Save lives in the File menu (Ctrl+S). let shown = design_shown(app) && idle; let tip = |id: &str, key: &str| { let label = app.tr(id); if shown { format!("{label} ({key})") } else { crate::i18n::format("ui.needs_design", &[("command", label)]) } }; - let (save_tip, undo_tip, redo_tip) = (tip("ui.save", "Ctrl+S"), tip("ui.undo", "Ctrl+Z"), tip("ui.redo", "Ctrl+Y")); - if click(ui, "save", &save_tip, shown) { - save(app); - } - if click(ui, "undo", &undo_tip, shown) { - let _ = app.run("edit.undo", json!({})); - } - if click(ui, "redo", &redo_tip, shown) { - let _ = app.run("edit.redo", json!({})); - } - if click(ui, "home", app.tr("ui.home"), idle) { - let rev = app.session.revision; - app.home.show(rev); - } - let theme_tip = if app.ui.dark { app.tr("ui.light") } else { app.tr("ui.dark") }; - if !compact_actions && click(ui, if app.ui.dark { "sun" } else { "moon" }, theme_tip, true) { - app.ui.dark = !app.ui.dark; - app.preferences.theme = if app.ui.dark { "dark" } else { "light" }.into(); - } - if click(ui, "help", app.tr("ui.help"), true) { - app.help.menu = !app.help.menu; - let at = ui.ctx().input(|i| i.pointer.latest_pos()).map_or(300.0, |p| p.x); - ui.ctx().data_mut(|d| { - d.insert_temp(egui::Id::new("sc_help_opened"), true); - d.insert_temp(egui::Id::new("sc_help_anchor"), at); - }); + let (undo_tip, redo_tip) = (tip("ui.undo", "Ctrl+Z"), tip("ui.redo", "Ctrl+Y")); + // A very narrow window keeps its room for the tabs (Home first); Ctrl+Z and Ctrl+Y still undo and redo. + if !compact_actions { + if click(ui, x, "undo", &undo_tip, shown) { + let _ = app.run("edit.undo", json!({})); + } + x += step; + if click(ui, x, "redo", &redo_tip, shown) { + let _ = app.run("edit.redo", json!({})); + } + x += step; } if file { app.ui.palette_open = false; let ctx = ui.ctx().clone(); let open = ctx.data(|d| d.get_temp::(egui::Id::new("sc_file_menu")).unwrap_or(false)); let pass = ctx.cumulative_pass_nr(); - // The folder button toggles the menu. Remember the pass it opened on so the same + // The File button toggles the menu. Remember the pass it opened on so the same // click isn't also treated as a click outside the menu (which would close it at once). ctx.data_mut(|d| { d.insert_temp(egui::Id::new("sc_file_menu"), !open); d.insert_temp(egui::Id::new("sc_file_menu_opened"), pass); }); } - // Document tabs. + // Document tabs (Home first), on the left after File, Undo and Redo. let captions = if app.custom_titlebar { crate::titlebar::WIDTH } else { 0.0 }; - let tabs_start = x + 16.0; + let tabs_start = x - step + 25.0; let report_text = crate::i18n::tr("ui.report_a_bug"); let report_font = FontId::proportional(13.0); let label_width = ui.painter().layout_no_wrap(report_text.to_string(), report_font.clone(), t.app_bar_text).size().x; let (report, report_label) = report_rect(r, captions, tabs_start, label_width); + // Help, on the right between Report a bug and Preferences. + if click(ui, help_x(r, captions), "help", app.tr("ui.help"), true) { + app.help.menu = !app.help.menu; + let at = help_x(r, captions); + ui.ctx().data_mut(|d| { + d.insert_temp(egui::Id::new("sc_help_opened"), true); + d.insert_temp(egui::Id::new("sc_help_anchor"), at); + }); + } // A newer release: its Update button, left of Report a bug (`update`). let update = crate::update::button(app, ui, report.left() - 6.0, report.top(), report_label); let tabs_end = update.map_or(report.left(), |u| u.left()) - 8.0; @@ -315,7 +338,7 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) { } let mut close = false; let resp = crate::frame::area(egui::Id::new("sc_file_area")) - .fixed_pos(pos2(100.0 + if app.integrated_titlebar { crate::titlebar::TRAFFIC_LIGHTS } else { 0.0 }, 34.0)) + .fixed_pos(pos2(ctx.data(|d| d.get_temp::(egui::Id::new("sc_file_menu_x"))).unwrap_or(100.0), 34.0)) .order(egui::Order::Foreground) .show(ctx, |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { @@ -1288,10 +1311,11 @@ mod header_report_tests { let (report, labeled) = report_rect(bar, captions, 354.0, label_width); assert!(bar.contains_rect(report)); let prefs = Rect::from_center_size(pos2(width - captions - 70.0, 17.0), vec2(24.0, 24.0)); - assert!(!report.intersects(prefs)); - assert!(report.right() < width - captions - 82.0); + let help = Rect::from_center_size(pos2(help_x(bar, captions), 17.0), vec2(26.0, 26.0)); + assert!(!report.intersects(prefs) && !report.intersects(help) && !help.intersects(prefs)); + assert!(report.right() < help.left() && help.right() < prefs.left()); let full = (REPORT_TEXT_X + label_width + 10.0).max(124.0); - assert_eq!(labeled, width - captions - 90.0 - 354.0 >= 216.0 + full); + assert_eq!(labeled, width - captions - RIGHT_GROUP - 354.0 >= 216.0 + full); if labeled { assert!(REPORT_TEXT_X + label_width < report.width(), "{label_width}: the text overruns its button"); } @@ -1301,6 +1325,39 @@ mod header_report_tests { } } + /// The title bar: the File menu, Undo, Redo and every tab on the left; Help, Report a bug, + /// Preferences and the window's buttons on the right. Home is a tab and the theme a + /// preference, so neither has a button of its own, and Save is in the File menu (Ctrl+S). + #[test] + fn title_bar_keeps_tabs_left_and_help_right() { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.custom_titlebar = true; + h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); + h.call("ui.resize", json!({"width":1400,"height":720})); + h.frames(3); + for gone in ["appbar:home", "appbar:sun", "appbar:moon", "appbar:save"] { + assert!(crate::scenario::handle_at(gone).is_none(), "{gone} is no longer in the title bar"); + } + let at = |name: &str| crate::scenario::handle_at(name).unwrap_or_else(|| panic!("{name}")).x; + let [report, _] = h.widget("Report a bug").unwrap(); + let file = h.widget("File").expect("the File menu has a label"); + assert_eq!(file[0] as f32, at("appbar:file")); + assert!(at("appbar:file") < at("appbar:undo") && at("appbar:undo") < at("appbar:redo")); + assert!(at("appbar:redo") < at("tab:home") && at("tab:home") < at("tab:design:0")); + assert!((at("tab:design:0") as f64) < report && report < at("appbar:help") as f64); + assert!(at("appbar:help") < at("titlebar:preferences")); + h.step(&json!({"click":{"handle":"appbar:help"}})).unwrap(); + assert!(h.app.help.menu, "? opens the Help menu"); + h.step(&json!({"key":"Escape"})).unwrap(); + h.step(&json!({"click":{"handle":"tab:home"}})).unwrap(); + assert!(h.app.home.open, "the Home tab shows the start page"); + let dark = h.app.ui.dark; + h.call("ui.prefs", json!({"theme": if dark { "light" } else { "dark" }})); + h.frames(2); + assert_ne!(h.app.ui.dark, dark, "the theme is a preference"); + } + #[test] fn header_report_button_widens_for_a_longer_translation() { let width_in = |language: &str| { @@ -1312,7 +1369,7 @@ mod header_report_tests { h.frames(3); let center = crate::scenario::handle_at("titlebar:report").expect("the report button is drawn"); // The button's right edge is fixed; its width is twice the distance to its centre. - 2.0 * (1400.0 - crate::titlebar::WIDTH - 90.0 - center.x) + 2.0 * (1400.0 - crate::titlebar::WIDTH - RIGHT_GROUP - center.x) }; let (en, ru) = (width_in("en"), width_in("ru")); assert!((en - 124.0).abs() < 0.5, "{en}"); @@ -1345,8 +1402,8 @@ mod header_report_tests { } h.step(&json!({"widget":"Close draft"})).unwrap(); assert!(h.app.issue_report.draft.is_none(), "width {width}: review closes through its actual button"); - h.step(&json!({"click":{"handle":"appbar:home"}})).unwrap(); - assert!(h.app.home.open, "width {width}: Home remains reachable beside Report"); + h.step(&json!({"click":{"handle":"tab:home"}})).unwrap(); + assert!(h.app.home.open, "width {width}: the Home tab remains reachable beside Report"); assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.revision)); } } @@ -1392,7 +1449,7 @@ mod header_report_tests { mod home_tests { use super::*; - /// Home hides the design: its Save, Undo and Redo buttons, the File menu's Insert, Save + /// Home hides the design: its Undo and Redo buttons, the File menu's Insert, Save /// and Export, and the design's keyboard shortcuts leave it alone. They work again once /// the design is back. #[test] @@ -1434,7 +1491,7 @@ mod home_tests { h.call("ui.click", json!({"x": at.x, "y": at.y})); }; let mut actions: Vec<(String, Box)> = Vec::new(); - for icon in ["save", "undo", "redo"] { + for icon in ["undo", "redo"] { actions.push((format!("{icon} button"), Box::new(move |h| click(h, &format!("appbar:{icon}"))))); } for (key, cmd) in [("S", true), ("Z", true), ("Y", true), ("Delete", false), ("V", false), ("E", false), ("S", false)] { @@ -1473,8 +1530,9 @@ mod home_tests { h.call("ui.key", json!({"key": "Z", "cmd": true})); h.settle(); assert_eq!(h.app.session.redo.len(), 1, "Ctrl+Z works once Home is closed"); - let at = crate::scenario::handle_at("appbar:save").unwrap(); - h.call("ui.click", json!({"x": at.x, "y": at.y})); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("sc_file_menu"), false)); + click(&mut h, "appbar:file"); + h.step(&json!({"widget": "Save"})).unwrap(); h.settle(); assert!(h.app.session.path.is_some() && !h.app.session.is_dirty(), "the design saves once Home is closed"); } diff --git a/docs/book/src/guide/ui-tour.md b/docs/book/src/guide/ui-tour.md index b0971850..99559fbb 100644 --- a/docs/book/src/guide/ui-tour.md +++ b/docs/book/src/guide/ui-tour.md @@ -2,9 +2,11 @@ ![The SolidCraft window](../generated/sample-plate.png) -- **Title bar.** On Windows and Linux the application bar is the window's title bar: File, save, - undo and redo, the start page, the theme, Help, and one tab per open design (each with its own - undo history, selection and camera). +- **Title bar.** On Windows and Linux the application bar is the window's title bar. On the left: + the File menu (New, Open, Save, Save As, Export…), undo and redo, the Home tab (the start page) + and one tab per open design (each with its own undo history, selection and camera; a dot marks + unsaved changes). On the right: Report a bug, Help (?), Preferences (which hold the theme) and the + window's buttons. - **Toolbar.** The workspace switcher (DESIGN) sits on the left. The tabs (SOLID, SURFACE, SHEET METAL, PLASTIC, UTILITIES, and SKETCH while you sketch) hold panels of commands. Click a panel's name for all its commands. Drag a command from the dropdown onto a panel's label