Merge pull request #826 from slyim/fix/toolbar-icon-rhythm

M4: toolbar marks share one size and stroke weight; a simpler Slice icon (#737, #738, #739)
This commit is contained in:
BFlat
2026-10-20 01:08:37 +07:00
committed by GitHub
2 changed files with 140 additions and 15 deletions
+73 -11
View File
@@ -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<Shape> {
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<Pos2> = 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:?}");
}
}
+67 -4
View File
@@ -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<f32> {
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<f32> = 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}×");
}
}
}
}