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