Sketch Palette: a right-aligned control column, one Look At icon, a disclosure triangle, plain-noun labels (#497)

Every Options row puts its control in one right-aligned column, Look At is a single label with an icon button, Options folds with a disclosure triangle, and the toggles use plain nouns (with dedicated ids for Profile and Points so they don't collide with other labels).
This commit is contained in:
William Dunn authored and GitHub committed 2026-10-12 23:58:58 -04:00
1 parent 10c6b865c7
commit aaeb7ceea7
7 files changed
+226 -46

No files matched your search

+164 -34
View File
@@ -28,16 +28,41 @@ thread_local! {
/// Palette width (logical px).
const WIDTH: f32 = 236.0;
/// The display toggles: option key and label.
/// The display toggles: option key and label. Plain nouns throughout, no "Show " prefix: the
/// checkbox itself already says whether each one is on.
///
/// Seven of these pass their English source text directly, like the rest of this file's
/// `app.tr_label(...)` calls: `Locale::label` looks up a literal English string against every
/// other English catalog entry to find its id. "Profile" and "Points" cannot do that safely:
/// both already name something else under that same lookup (`SelInput::new("Profile", ...)` /
/// `SelInput::new("Points", ...)` in `dialogs.rs`, the Extrude/Sweep/Shell/Loft and
/// point-selection dialogs' own row labels, and "Profile" is also `context_menu.rs`'s "Show
/// Profile" / "Hide Profile" action by way of the existing `ui.show_profile` key). Giving the
/// palette row the same bare text would silently repoint one of those unrelated lookups to
/// whichever translation this file happens to carry. `ui.palette_profile` and
/// `ui.palette_points` are dedicated keys instead, carrying exactly "Profile" and "Points" in
/// English; passing the id itself (`Locale::label` falls back to a direct id lookup when the
/// text it was given does not match any other entry) reaches them without going through the
/// shared text lookup at all. See `i18n::tests::id_labels_resolve_to_their_own_translation_not_an_english_fallback`
/// and `i18n_ui_tests::sketch_palette_profile_and_points_rows_show_their_own_translation` for
/// proof they resolve to the real translation in German, Japanese and Korean rather than
/// silently falling back to English.
///
/// The id-fallback check still runs the text lookup first: a future English catalog entry
/// whose value happened to be the literal string "ui.palette_profile" or "ui.palette_points"
/// would silently steal this lookup (`Locale::label`'s `ids.get(label)` would then match
/// before the id fallback ever runs). Nobody writes UI copy that looks like a dotted id, so
/// this is a remote hazard, not a likely one, but it is why these two ids are spelled out here
/// rather than reused anywhere else.
pub const TOGGLES: [(&str, &str); 9] = [
("grid", "Sketch Grid"),
("snap", "Snap"),
("slice", "Slice"),
("show_profile", "Show Profile"),
("show_points", "Show Points"),
("show_dimensions", "Show Dimensions"),
("show_constraints", "Show Constraints"),
("show_projected", "Show Projected Geometries"),
("show_profile", "ui.palette_profile"),
("show_points", "ui.palette_points"),
("show_dimensions", "Dimensions"),
("show_constraints", "Constraints"),
("show_projected", "Projected Geometries"),
("sketch_3d", "3D Sketch"),
];
@@ -191,6 +216,17 @@ pub fn set_linetype(app: &mut SolveApp, kind: u8) {
}
}
/// A small disclosure triangle for the Options section: pointing right when collapsed, down
/// when open. Kept separate from the palette's own "−"/"+" fold, which stays a plain glyph.
fn disclosure_triangle(p: &egui::Painter, c: egui::Pos2, open: bool, col: Color32) {
let pts = if open {
vec![egui::pos2(c.x - 4.0, c.y - 2.0), egui::pos2(c.x + 4.0, c.y - 2.0), egui::pos2(c.x, c.y + 3.0)]
} else {
vec![egui::pos2(c.x - 2.5, c.y - 4.0), egui::pos2(c.x - 2.5, c.y + 4.0), egui::pos2(c.x + 3.0, c.y)]
};
p.add(egui::Shape::convex_polygon(pts, col, Stroke::NONE));
}
/// Draw the palette (while sketching and no command dialog is open).
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
app.viewport.palette_rect = None;
@@ -259,10 +295,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
}
let ofold = OPTIONS_FOLDED.with(Cell::get);
crate::frame::heading(ui, Level::Section, width, |ui| {
if ui
.add(egui::Button::new(RichText::new(format!("{} {}", if ofold { "+" } else { "−" }, app.tr("ui.options"))).size(12.0)).frame(false))
.clicked()
{
let (tri_rect, tri_resp) = ui.allocate_exact_size(vec2(14.0, 14.0), egui::Sense::click());
disclosure_triangle(ui.painter(), tri_rect.center(), !ofold, t.text);
crate::scenario::publish_handle("sketch_palette:options_fold", tri_rect.center());
let label_resp = ui.add(egui::Label::new(RichText::new(app.tr("ui.options")).size(12.0)).sense(egui::Sense::click()));
if tri_resp.clicked() || label_resp.clicked() {
OPTIONS_FOLDED.with(|f| f.set(!ofold));
}
});
@@ -272,36 +309,72 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let options_height = (bottom - ui.cursor().top() - footer).max(24.0);
let options =
egui::ScrollArea::vertical().id_salt("sc_palette_options").max_height(options_height).auto_shrink([false, true]).show(ui, |ui| {
// Every row below: its label at the left, its control right-aligned at the
// same column (`frame::right_aligned`), so every row lines up instead of
// each control sitting wherever its label happened to end.
ui.vertical(|ui| {
ui.label(app.tr("ui.linetype"));
ui.horizontal(|ui| {
// With a selection: its line type, and a click converts it; without:
// the mode new curves are drawn in.
let current = mode(app);
for (i, (label, tip)) in [("—", "Normal"), ("- -", "Construction"), ("-·-", "Centerline")].iter().enumerate() {
let on = if has_curves { selected_types.get(i).copied().unwrap_or(false) } else { current == i as u8 };
let tr_tip = app.tr_label(tip);
let tip = if has_curves {
crate::i18n::format("sketch.make_selection", &[("linetype", tr_tip)])
} else {
crate::i18n::format("sketch.draw_curves", &[("linetype", tr_tip)])
};
if ui.add(egui::Button::new(*label).selected(on).min_size(vec2(30.0, 20.0))).on_hover_text(tip).clicked() {
linetype = Some(i as u8);
}
ui.label(app.tr("ui.linetype"));
let btn_w = 30.0;
let group_w = 3.0 * btn_w + 2.0 * ui.spacing().item_spacing.x;
crate::frame::right_aligned(ui, width, group_w, |ui| {
ui.horizontal(|ui| {
// With a selection: its line type, and a click converts it;
// without: the mode new curves are drawn in.
let current = mode(app);
for (i, (label, tip)) in [("—", "Normal"), ("- -", "Construction"), ("-·-", "Centerline")].iter().enumerate() {
let on = if has_curves { selected_types.get(i).copied().unwrap_or(false) } else { current == i as u8 };
let tr_tip = app.tr_label(tip);
let tip = if has_curves {
crate::i18n::format("sketch.make_selection", &[("linetype", tr_tip)])
} else {
crate::i18n::format("sketch.draw_curves", &[("linetype", tr_tip)])
};
if ui.add(egui::Button::new(*label).selected(on).min_size(vec2(btn_w, 20.0))).on_hover_text(tip).clicked() {
linetype = Some(i as u8);
}
}
});
});
});
ui.horizontal(|ui| {
// One label, one icon button (the "lookat" glyph already used on the
// viewport's own navigation overlay): not the word "Look At" twice.
// The button carries no visible text of its own; `labelled_by` gives
// it the row's own label as its accessible name, so a screen reader
// still hears "Look At" the way the old text button said it.
let label_resp = ui.label(app.tr("ui.look_at"));
let icon_w = 28.0;
let resp = crate::frame::right_aligned(ui, width, icon_w, |ui| {
let r = ui.add(egui::Button::new("").min_size(vec2(icon_w, 20.0)));
let side = (r.rect.width().min(r.rect.height()) - 4.0).max(12.0);
let icon_rect = egui::Rect::from_center_size(r.rect.center(), vec2(side, side));
crate::icons::paint(ui.painter(), icon_rect, "lookat", t.icon, t.icon_fill, t.accent);
crate::scenario::publish_handle("sketch_palette:look_at", r.rect.center());
r.labelled_by(label_resp.id)
});
let resp = resp.on_hover_text(app.tr("ui.look_at"));
if resp.clicked() {
look = true;
}
});
ui.label(app.tr("ui.look_at"));
if ui.button(app.tr("ui.look_at")).clicked() {
look = true;
}
});
for (k, label) in TOGGLES {
// The label is part of the checkbox: clicking it toggles too.
let mut on = shown(k);
if ui.checkbox(&mut on, app.tr_label(label)).changed() {
set = Some((k.to_string(), on));
}
ui.horizontal(|ui| {
// The label is still part of the toggle: clicking it flips the
// checkbox too, same as before the checkbox moved to its own column.
let label_resp = ui.add(egui::Label::new(app.tr_label(label)).sense(egui::Sense::click()));
let mut on = shown(k);
let box_w = ui.spacing().interact_size.y;
let cb = crate::frame::right_aligned(ui, width, box_w, |ui| {
ui.add(egui::Checkbox::without_text(&mut on)).labelled_by(label_resp.id)
});
if cb.changed() {
set = Some((k.to_string(), on));
} else if label_resp.clicked() {
set = Some((k.to_string(), !on));
}
});
}
});
crate::scenario::publish_rect("sketch_palette:options", options.inner_rect);
@@ -426,6 +499,63 @@ mod tests {
assert_eq!(pressed(&mut h, &[]), [false, true, false]);
}
#[test]
fn look_at_is_one_label_and_an_icon_button_that_animates_the_camera() {
let mut h = crate::scenario::Harness::new();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.settle();
// Point the camera somewhere clearly not looking straight down the sketch plane, so the
// click's effect is unambiguous regardless of where `sketch.create` happened to leave
// it. `Harness::call` settles any camera animation to completion before it returns, so
// checking `cam_anim` right after the click (as an earlier version of this test did)
// never sees it: the animation is always finished and cleared by then. Check where the
// camera actually ended up instead.
h.app.cam.yaw = 1.0;
h.app.cam.pitch = 0.2;
h.settle();
// The row's own label is the only place the words "Look At" are drawn; the control
// next to it is an icon button with no visible text of its own.
assert!(h.widget("Look At").is_some(), "the row label must still be reachable by name");
let at = crate::scenario::handle_at("sketch_palette:look_at").unwrap();
h.call("ui.click", json!({"x": at.x, "y": at.y}));
let (yaw, pitch) = (h.app.cam.yaw, h.app.cam.pitch);
assert!((pitch - std::f64::consts::FRAC_PI_2).abs() < 0.01, "must look straight down the XY sketch plane: pitch={pitch}");
assert!(yaw.abs() < 0.01, "must align with the sketch's own x axis: yaw={yaw}");
}
#[test]
fn options_disclosure_triangle_folds_and_unfolds_the_section() {
OPTIONS_FOLDED.with(|v| v.set(false));
let mut h = crate::scenario::Harness::new();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.settle();
assert!(h.widget("Sketch Grid").is_some(), "options are open by default");
let at = crate::scenario::handle_at("sketch_palette:options_fold").unwrap();
h.call("ui.click", json!({"x": at.x, "y": at.y}));
h.settle();
assert!(h.widget("Sketch Grid").is_none(), "the triangle must fold the options section");
h.call("ui.click", json!({"x": at.x, "y": at.y}));
h.settle();
assert!(h.widget("Sketch Grid").is_some(), "the triangle must unfold it again");
OPTIONS_FOLDED.with(|v| v.set(false));
}
#[test]
fn toggle_row_label_and_checkbox_both_flip_the_option() {
let mut h = crate::scenario::Harness::new();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.settle();
let active = h.app.session.active_sketch.unwrap();
let slice_on = |h: &mut crate::scenario::Harness| h.app.session.world_state().sketch(active).unwrap().sketch.view.slice;
assert!(!slice_on(&mut h));
// Clicking the row's label (not just the checkbox) toggles the option, same as before
// the checkbox and the label became separate widgets in their own column.
let [x, y] = h.widget("Slice").unwrap();
h.call("ui.click", json!({"x": x, "y": y}));
h.settle();
assert!(slice_on(&mut h), "clicking the \"Slice\" label must toggle it on");
}
#[test]
fn dof_readout() {
assert_eq!(dof_text(0, true), "Fully constrained");