diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index fa4fb66ea..3ef6b3726 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -421,6 +421,7 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { draw(&p, icon); } +/// Draw `icon` on the 16-unit grid through `p`, which maps grid units to the target box. fn draw(p: &P, icon: Icon) { use Icon::*; match icon { @@ -446,18 +447,23 @@ fn draw(p: &P, icon: Icon) { p.path(&h, false); } Frame => { - p.line(5.5, 2.0, 5.5, 14.0); - p.line(10.5, 2.0, 10.5, 14.0); - p.line(2.0, 5.5, 14.0, 5.5); - p.line(2.0, 10.5, 14.0, 10.5); + // Inset a unit from the box: four long hinted strokes read heavier than the + // anti-aliased outlines beside them at full size (#737). + p.line(5.5, 3.0, 5.5, 13.0); + p.line(10.5, 3.0, 10.5, 13.0); + p.line(3.0, 5.5, 13.0, 5.5); + p.line(3.0, 10.5, 13.0, 10.5); } Section | Section2 => { p.rect(2.5, 5.5, 13.5, 13.5, 1.0); p.fill_rect(2.0, 2.0, 9.0, 6.0, 1.0); } Slice => { - p.rect(2.5, 2.5, 13.5, 13.5, 0.0); - p.line(2.5, 13.5, 13.5, 2.5); + // A knife, one silhouette (#739): a slim blade to the top right, outlined like the + // rest of the row, and a solid handle whose end overlaps the blade's base so the two + // join at every size. + p.poly(&[(13.5, 2.5), (5.0, 8.0), (8.0, 11.0)], true); + p.fill(&capsule((2.8, 13.2), (5.9, 10.1), 1.3)); } Bend => { // A curve with a smooth point and its handle line. @@ -523,11 +529,12 @@ fn draw(p: &P, icon: Icon) { p.line(9.0, 4.5, 11.5, 7.0); } Text => { - p.line(2.0, 3.5, 13.0, 3.5); - p.line(7.5, 3.0, 7.5, 13.0); - p.line(2.5, 3.0, 2.5, 6.0); - p.line(12.5, 3.0, 12.5, 6.0); - p.line(5.0, 12.5, 10.0, 12.5); + // Inset like the Frame mark, so its long strokes don't outweigh the outlines (#737). + p.line(3.0, 3.5, 12.0, 3.5); + p.line(7.5, 3.0, 7.5, 12.5); + p.line(3.5, 3.0, 3.5, 5.5); + p.line(11.5, 3.0, 11.5, 5.5); + p.line(5.5, 12.5, 9.5, 12.5); } Comment => { let mut b: Vec<(f32, f32)> = arc_pts(8.5, 7.5, 5.0, 2.75, TAU + 1.95).collect(); @@ -1226,4 +1233,59 @@ mod tests { assert!(!rotation.is_empty() && !radius.is_empty(), "both draw an icon"); assert_ne!(rotation, radius); } + + /// The shapes `icon` paints in a 32-point box at the origin: two points per grid unit. + fn shapes_at_32(icon: Icon) -> Vec { + let ctx = egui::Context::default(); + let mut out = + ctx.run_ui(egui::RawInput::default(), |ui| paint(ui.painter(), Rect::from_min_size(Pos2::ZERO, Vec2::splat(32.0)), icon, Color32::WHITE)); + out.textures_delta.clear(); + out.shapes.into_iter().map(|c| c.shape).collect() + } + + /// Frame and Text stay a grid unit inside the box, so their long hinted strokes don't + /// outweigh the outlines beside them in the toolbar (#737). + #[test] + fn frame_and_text_marks_are_inset() { + for icon in [Icon::Frame, Icon::Text] { + let pts: Vec = shapes_at_32(icon) + .iter() + .flat_map(|s| match s { + Shape::LineSegment { points, .. } => points.to_vec(), + other => panic!("{icon:?} draws straight strokes only: {other:?}"), + }) + .collect(); + assert!(!pts.is_empty()); + for p in pts { + // Grid units 3..13, two points each. + assert!((6.0..=26.0).contains(&p.x) && (6.0..=26.0).contains(&p.y), "{icon:?} reaches {p:?}"); + } + } + } + + /// Slice is one knife (#739): an outlined blade and a solid handle that covers the middle of + /// the blade's base, so the two read as one mark rather than separate pieces. + #[test] + fn slice_is_one_connected_knife() { + let shapes = shapes_at_32(Icon::Slice); + let paths: Vec<_> = shapes + .iter() + .map(|s| match s { + Shape::Path(p) => p, + other => panic!("Slice draws paths only: {other:?}"), + }) + .collect(); + let blade = paths.iter().find(|p| p.closed && p.fill == Color32::TRANSPARENT).expect("an outlined blade"); + let handle = paths.iter().find(|p| p.fill != Color32::TRANSPARENT).expect("a solid handle"); + assert_eq!((paths.len(), blade.points.len()), (2, 3), "a blade and a handle, nothing else"); + // The blade's base runs between its second and third points; the handle is convex. + let base = blade.points[1] + (blade.points[2] - blade.points[1]) / 2.0; + let h = &handle.points; + let side = |i: usize| { + let (a, b) = (h[i], h[(i + 1) % h.len()]); + (b - a).x * (base - a).y - (b - a).y * (base - a).x + }; + let inside = (0..h.len()).all(|i| side(i) >= 0.0) || (0..h.len()).all(|i| side(i) <= 0.0); + assert!(inside, "the handle covers the blade's base at {base:?}"); + } } diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 6da9badb6..2f8c19724 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -11,6 +11,18 @@ use crate::i18n::{tr, tr_noop, trf}; use crate::icons::{self, Icon}; use crate::theme::{ACCENT, Tokens, regular}; +/// Every mark in the toolbar row is painted in a box this size, so they share one optical size +/// and one stroke width (#737, #738). +const GLYPH: f32 = 18.0; +/// The flyout carets: smaller than the marks, but on a box whose stroke rounds to the same width +/// as theirs at 1×, 1.5× and 2× (a 10-point box drew them one physical pixel thinner). +const CARET: f32 = 14.0; + +/// `GLYPH`'s box centred in `r`. +fn glyph_rect(r: Rect) -> Rect { + Rect::from_center_size(r.center(), vec2(GLYPH, GLYPH)) +} + struct Group { key: &'static str, /// The flyout button's name for screen readers. @@ -82,6 +94,8 @@ pub(crate) fn main_width() -> f32 { GROUPS.len() as f32 * (cell + gap) + gap * 10.0 + 2.0 * (cell + gap) + 16.0 + 4.0 * 34.0 + 24.0 } +/// The main toolbar pill: the tool groups with their flyouts, Resources and Actions, then the +/// Draw · Design · Prototype · Inspect mode switcher. fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { let (cell, gap) = (32.0, 4.0); let width = main_width(); @@ -121,7 +135,7 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { if cresp.hovered() { ui.painter().rect_filled(cr, CornerRadius::same(4), t.hover); } - icons::paint(ui.painter(), Rect::from_center_size(cr.center(), vec2(10.0, 10.0)), Icon::Chevron, t.icon_secondary); + icons::paint(ui.painter(), Rect::from_center_size(cr.center(), vec2(CARET, CARET)), Icon::Chevron, t.icon_secondary); if cresp.clicked() { app.ui.tool_flyout = if app.ui.tool_flyout == Some(g.key) { None } else { Some(g.key) }; } @@ -149,7 +163,7 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { } else if resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); } - icons::paint(ui.painter(), r.shrink(7.0), icon, if actions_open { Color32::WHITE } else { t.icon }); + icons::paint(ui.painter(), glyph_rect(r), icon, if actions_open { Color32::WHITE } else { t.icon }); if resp.clicked() { if id == "actions" { crate::menus::toggle_palette(&mut app.ui); @@ -181,7 +195,7 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { } else if resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(6), t.hover); } - icons::paint(ui.painter(), r.shrink(9.0), *icon, if i == active { t.accent_text } else { t.icon_secondary }); + icons::paint(ui.painter(), glyph_rect(r), *icon, if i == active { t.accent_text } else { t.icon_secondary }); if resp.clicked() { match i { 0 => { @@ -379,7 +393,7 @@ fn tool_cell(ui: &mut Ui, t: &Tokens, r: Rect, id: impl egui::AsIdSalt, icon: Ic Color32::TRANSPARENT }; ui.painter().rect_filled(r, CornerRadius::same(5), bg); - icons::paint(ui.painter(), r.shrink(7.0), icon, if active { Color32::WHITE } else { t.icon }); + icons::paint(ui.painter(), glyph_rect(r), icon, if active { Color32::WHITE } else { t.icon }); resp } @@ -452,3 +466,52 @@ fn flyout_id(key: &str) -> egui::Id { pub fn pretty_shortcut(s: &str) -> String { figcraft_engine::keymap::display(s, crate::shortcuts::platform()) } + +#[cfg(test)] +mod tests { + use super::*; + use egui::Shape; + + /// The stroke widths `icon` paints with in a `size` box at `ppp` pixels per point. + fn widths(ppp: f32, icon: Icon, size: f32) -> Vec { + let ctx = egui::Context::default(); + let input = || { + let mut i = egui::RawInput::default(); + i.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(ppp); + i + }; + ctx.run_ui(input(), |_| {}).textures_delta.clear(); + let mut out = + ctx.run_ui(input(), |ui| icons::paint(ui.painter(), Rect::from_center_size(pos2(50.0, 50.0), vec2(size, size)), icon, Color32::WHITE)); + out.textures_delta.clear(); + assert_eq!(ctx.pixels_per_point(), ppp); + let mut w: Vec = out + .shapes + .iter() + .filter_map(|c| match &c.shape { + Shape::LineSegment { stroke, .. } => Some(stroke.width), + Shape::Path(p) => Some(p.stroke.width), + Shape::Rect(r) => Some(r.stroke.width), + _ => None, + }) + .filter(|w| *w > 0.0) + .collect(); + w.dedup(); + w + } + + /// The flyout carets and the marks beside them draw with one stroke width (#737), and the + /// mode switcher's marks are the tool marks' size (#738). + #[test] + fn toolbar_marks_share_one_stroke_width() { + for ppp in [1.0, 1.5, 2.0] { + let mark = widths(ppp, Icon::Rectangle, GLYPH); + assert_eq!(mark.len(), 1, "one width at {ppp}×: {mark:?}"); + assert_eq!(widths(ppp, Icon::Chevron, CARET), mark, "caret at {ppp}×"); + assert_eq!(widths(ppp, Icon::Code, GLYPH), mark, "mode mark at {ppp}×"); + for icon in [Icon::Frame, Icon::Text] { + assert_eq!(widths(ppp, icon, GLYPH), mark, "{icon:?} at {ppp}×"); + } + } + } +}