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 <noreply@anthropic.com>
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-12 10:07:23 -04:00
1 parent 87bb2f37f8
commit df09fdbc32
4 files changed
+130 -35

No files matched your search

+25 -5
View File
@@ -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<Pos2> = 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" => {
+49 -30
View File
@@ -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 {
+32
View File
@@ -54,6 +54,8 @@ thread_local! {
/// and counts by name, for checks.
static RECTS: RefCell<Vec<(String, egui::Rect)>> = const { RefCell::new(Vec::new()) };
static COUNTS: RefCell<Vec<(String, f64)>> = const { RefCell::new(Vec::new()) };
/// Widths a scenario's `same_width` checks remembered (by rect name).
static WIDTHS: RefCell<Vec<(String, f32)>> = 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).
@@ -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}}}
]