From df09fdbc3204bc2a37ed7509edf93286cca43ff3 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Mon, 12 Oct 2026 13:43:17 +0000 Subject: [PATCH] Preferences: one fixed size, buttons flush right, a real gear icon (#290) - The dialog draws every page into a fixed, clipped 744x400 area, so no page (or translation) changes the window width; descriptions wrap and tall pages scroll inside it. - OK / Cancel / Apply sit flush right in the bottom bar (right-to-left layout within the fixed width); Restore Defaults stays left. - The "settings" icon is a gear: a solid ring with six square-ish teeth and a see-through centre hole, instead of the sun-like rays. - Scenario s46 visits every page and checks the window keeps its width and that Apply ends at the bottom bar's right edge (new `same_width` and `right_edge` scenario checks). Fixes #290 Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/icons.rs | 30 +++++-- crates/ui-egui/src/prefs.rs | 79 ++++++++++++------- crates/ui-egui/src/scenario.rs | 32 ++++++++ .../scenarios/s46_preferences_fixed_size.json | 24 ++++++ 4 files changed, 130 insertions(+), 35 deletions(-) create mode 100644 crates/ui-egui/tests/scenarios/s46_preferences_fixed_size.json diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index b1034fc5..0f7fa4e3 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -482,12 +482,32 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce pen.circle(4.0, 20.0, 2.0, ink, ink); } "settings" => { - pen.circle(12.0, 12.0, 6.5, pen.fill, ink); - pen.circle(12.0, 12.0, 2.5, Color32::WHITE, ink); - for i in 0..8 { - let t = i as f32 / 8.0 * std::f32::consts::TAU; - pen.line(&[(12.0 + 6.5 * t.cos(), 12.0 + 6.5 * t.sin()), (12.0 + 9.0 * t.cos(), 12.0 + 9.0 * t.sin())], ink); + // A gear: a solid ring with six chunky, square-ish teeth and a see-through centre + // hole (#290; thin rays read as a sun). + let (teeth, root, tip, hole) = (6usize, 7.0f32, 10.5f32, 3.2f32); + let pitch = std::f32::consts::TAU / teeth as f32; + // Half-widths (radians) of a tooth at its root and at its tip: nearly parallel sides. + let (half_root, half_tip) = (pitch * 0.3, pitch * 0.22); + let polar = |a: f32, r: f32| (12.0 + r * a.cos(), 12.0 + r * a.sin()); + let start = -std::f32::consts::FRAC_PI_2; + let mut outline = Vec::new(); + for i in 0..teeth { + let c = start + i as f32 * pitch; + // The tooth, filled; it reaches into the ring so no seam shows. + let quad = [polar(c - half_root, root - 1.0), polar(c - half_tip, tip), polar(c + half_tip, tip), polar(c + half_root, root - 1.0)]; + let v: Vec = quad.iter().map(|(x, y)| pen.at(*x, *y)).collect(); + p.add(Shape::convex_polygon(v, pen.fill, Stroke::NONE)); + outline.extend([polar(c - half_root, root), polar(c - half_tip, tip), polar(c + half_tip, tip), polar(c + half_root, root)]); + // The root circle up to the next tooth. + for k in 1..4 { + let a = c + half_root + (pitch - 2.0 * half_root) * k as f32 / 4.0; + outline.push(polar(a, root)); + } } + // The ring: a thick stroke, so the hole stays transparent on any background. + p.circle_stroke(pen.at(12.0, 12.0), (root + hole) * 0.5 * pen.s(), Stroke::new((root - hole) * pen.s(), pen.fill)); + pen.closed(&outline, ink); + pen.circle(12.0, 12.0, hole, Color32::TRANSPARENT, ink); } // Sketch tools (sketch-style: thin outlines, accent for what the tool makes). "canvas" | "decal" => { diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 78b2d56b..7fa1e704 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -396,52 +396,64 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let mut open = true; let mut button: Option<&str> = None; let before = app.preferences.clone(); - crate::frame::window(ctx, app.tr("ui.preferences"), crate::frame::Width::Wide) + let shown = crate::frame::window(ctx, app.tr("ui.preferences"), crate::frame::Width::Wide) .id(egui::Id::new("sc_prefs")) .open(&mut open) .collapsible(false) .resizable(false) .show(ctx, |ui| { - // A fixed page area: the vertical separator fills the height it is given, so an - // auto-sized one would grow every frame. - ui.allocate_ui_with_layout(egui::vec2(700.0, 400.0), egui::Layout::left_to_right(egui::Align::Min), |ui| { - ui.set_min_size(egui::vec2(700.0, 400.0)); - ui.vertical(|ui| { - ui.set_width(140.0); - for (i, s) in PAGES.iter().enumerate() { - if ui.selectable_label(app.prefs_window.section == i, app.tr_label(s)).clicked() { - app.prefs_window.section = i; - app.keymap.read_only = false; - } + // One fixed size for every page (#290): the page is drawn into a clipped child of + // that size and only that size is allocated, so no page (or translation) can widen + // the window; descriptions wrap and tall pages scroll inside it. + ui.set_max_width(PREFS_W); + let (body, _) = ui.allocate_exact_size(egui::vec2(PREFS_W, PREFS_H), egui::Sense::hover()); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(body).layout(egui::Layout::left_to_right(egui::Align::Min))); + child.shrink_clip_rect(body); + let ui_body = &mut child; + ui_body.vertical(|ui| { + ui.set_width(PREFS_NAV_W); + for (i, s) in PAGES.iter().enumerate() { + if ui.selectable_label(app.prefs_window.section == i, app.tr_label(s)).clicked() { + app.prefs_window.section = i; + app.keymap.read_only = false; } - }); - ui.separator(); - ui.vertical(|ui| { - // A fixed width, so the window keeps its size from page to page. - ui.set_width(540.0); - ui.label(RichText::new(app.tr_label(PAGES.get(app.prefs_window.section).copied().unwrap_or_default())).strong().size(14.0)); - ui.add_space(6.0); + } + }); + ui_body.separator(); + ui_body.vertical(|ui| { + ui.set_width(ui.available_width()); + ui.label(RichText::new(app.tr_label(PAGES.get(app.prefs_window.section).copied().unwrap_or_default())).strong().size(14.0)); + ui.add_space(6.0); + if app.prefs_window.section == PAGE_SHORTCUTS { + // The shortcuts table scrolls by itself. page(app, ui, &t); - }); + } else { + egui::ScrollArea::vertical().id_salt("sc_prefs_page").auto_shrink([false, false]).show(ui, |ui| page(app, ui, &t)); + } }); ui.separator(); + // Restore Defaults at the left; OK, Cancel and Apply flush right, as in every dialog. ui.horizontal(|ui| { + ui.set_width(PREFS_W); if ui.button(app.tr("ui.restore_defaults")).on_hover_text(app.tr("ui.reset_the_settings_on_this_page")).clicked() { button = Some("defaults"); } - crate::frame::right_aligned(ui, 700.0, 170.0, |ui| { - if ui.button(app.tr("ui.heading_ok")).clicked() { - button = Some("ok"); - } - if ui.button(app.tr("ui.cancel")).clicked() { - button = Some("cancel"); - } - if ui.button(app.tr("ui.apply")).clicked() { - button = Some("apply"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Right to left: Apply is the rightmost. + for (key, id) in [("ui.apply", "apply"), ("ui.cancel", "cancel"), ("ui.heading_ok", "ok")] { + let r = ui.button(app.tr(key)); + crate::scenario::publish_rect(&format!("prefs:{id}"), r.rect); + if r.clicked() { + button = Some(id); + } } }); + crate::scenario::publish_rect("prefs:footer", ui.max_rect()); }); }); + if let Some(w) = &shown { + crate::scenario::publish_rect("prefs:window", w.response.rect); + } if app.preferences.msaa != before.msaa { save_graphics(app); } @@ -485,12 +497,19 @@ fn up_axis_changed(app: &mut SolveApp) { } } +/// The Preferences window's content size: the same on every page. +const PREFS_W: f32 = 744.0; +const PREFS_H: f32 = 400.0; +/// The page list at its left. +const PREFS_NAV_W: f32 = 128.0; + /// One page of the dialog. fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { let _language = crate::i18n::scope(&app.preferences.language); let note = |ui: &mut egui::Ui, s: &str| { ui.label(""); - ui.label(RichText::new(s).size(11.0).color(t.text_dim)); + // Descriptions wrap within the page instead of widening it. + ui.add(egui::Label::new(RichText::new(s).size(11.0).color(t.text_dim)).wrap()); ui.end_row(); }; if app.prefs_window.section == PAGE_SHORTCUTS { diff --git a/crates/ui-egui/src/scenario.rs b/crates/ui-egui/src/scenario.rs index c035b58d..0ee144c8 100644 --- a/crates/ui-egui/src/scenario.rs +++ b/crates/ui-egui/src/scenario.rs @@ -54,6 +54,8 @@ thread_local! { /// and counts by name, for checks. static RECTS: RefCell> = const { RefCell::new(Vec::new()) }; static COUNTS: RefCell> = const { RefCell::new(Vec::new()) }; + /// Widths a scenario's `same_width` checks remembered (by rect name). + static WIDTHS: RefCell> = const { RefCell::new(Vec::new()) }; } /// A manipulator's handle drawn this frame at `at` (screen), by name: "arrow" (a dialog's value @@ -161,6 +163,7 @@ impl Harness { time: 0.0, size: egui::vec2(1600.0, 1000.0), }; + WIDTHS.with(|m| m.borrow_mut().clear()); h.install(); h.ctx.enable_accesskit(); h.frames(3); @@ -625,6 +628,35 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> { return Err(format!("count {n}: got {got:?}, want {}", v["value"])); } } + "same_width" => { + // {"same_width": "name"}: the published rect is as wide as the first time this + // scenario checked it (±0.5 px): a window that keeps its size. + let n = v.as_str().unwrap_or_default(); + let w = rect_of(n).ok_or(format!("no rect {n}"))?.width(); + let first = WIDTHS.with(|m| { + let mut m = m.borrow_mut(); + match m.iter().find(|(k, _)| k == n) { + Some(&(_, w0)) => w0, + None => { + m.push((n.to_string(), w)); + w + } + } + }); + if (w - first).abs() > 0.5 { + return Err(format!("{n} is {w:.1} px wide, was {first:.1} px")); + } + } + "right_edge" => { + // {"right_edge": {"inner": a, "outer": b, "tol": px}}: published rect a ends at + // b's right edge (within tol). + let (a, b) = (v["inner"].as_str().unwrap_or_default(), v["outer"].as_str().unwrap_or_default()); + let (ra, rb) = (rect_of(a).ok_or(format!("no rect {a}"))?, rect_of(b).ok_or(format!("no rect {b}"))?); + let tol = v["tol"].as_f64().unwrap_or(2.0) as f32; + if (ra.right() - rb.right()).abs() > tol { + return Err(format!("{a} ends at x={:.1}, {b} at x={:.1} (±{tol})", ra.right(), rb.right())); + } + } "spans" => { // {"spans": {"inner": a, "outer": b, "axis": "y", "tol": px}}: published rect a // covers b's extent along the axis (within tol). diff --git a/crates/ui-egui/tests/scenarios/s46_preferences_fixed_size.json b/crates/ui-egui/tests/scenarios/s46_preferences_fixed_size.json new file mode 100644 index 00000000..bd37acc6 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/s46_preferences_fixed_size.json @@ -0,0 +1,24 @@ +[ + {"note": "#290: Preferences keeps one size on every page; descriptions wrap inside it, and OK / Cancel / Apply sit flush right in the bottom bar."}, + {"click": {"handle": "titlebar:preferences"}}, + {"expect": {"prefs": {"dialog": "General"}, "same_width": "prefs:window", "right_edge": {"inner": "prefs:apply", "outer": "prefs:footer", "tol": 1}, "spans": {"inner": "prefs:footer", "outer": "prefs:window", "axis": "x", "tol": 12}}}, + {"widget": "Navigation"}, + {"expect": {"prefs": {"dialog": "Navigation"}, "same_width": "prefs:window", "right_edge": {"inner": "prefs:apply", "outer": "prefs:footer", "tol": 1}}}, + {"widget": "Fusion"}, + {"widget": "SolidWorks"}, + {"expect": {"prefs": {"nav": "solidworks"}, "same_width": "prefs:window"}}, + {"widget": "Keyboard Shortcuts"}, + {"expect": {"prefs": {"dialog": "Keyboard Shortcuts"}, "same_width": "prefs:window", "right_edge": {"inner": "prefs:apply", "outer": "prefs:footer", "tol": 1}}}, + {"widget": "Design"}, + {"expect": {"prefs": {"dialog": "Design"}, "same_width": "prefs:window", "right_edge": {"inner": "prefs:apply", "outer": "prefs:footer", "tol": 1}}}, + {"widget": "Graphics"}, + {"expect": {"prefs": {"dialog": "Graphics"}, "same_width": "prefs:window", "right_edge": {"inner": "prefs:apply", "outer": "prefs:footer", "tol": 1}}}, + {"widget": "Sketch"}, + {"expect": {"prefs": {"dialog": "Sketch"}, "same_width": "prefs:window", "right_edge": {"inner": "prefs:apply", "outer": "prefs:footer", "tol": 1}}}, + {"widget": "Selection"}, + {"expect": {"prefs": {"dialog": "Selection"}, "same_width": "prefs:window", "right_edge": {"inner": "prefs:apply", "outer": "prefs:footer", "tol": 1}}}, + {"widget": "General"}, + {"expect": {"prefs": {"dialog": "General"}, "same_width": "prefs:window", "right_edge": {"inner": "prefs:apply", "outer": "prefs:footer", "tol": 1}}}, + {"widget": "Cancel"}, + {"expect": {"prefs": {"dialog": null}}} +]