M8: every 24 pt icon button paints its glyph at 16 pt (#2238)

The Layers panel's Minimize, search and add page buttons, the Assets back button, the Versions panel's row menus, the Dev Mode status button, the Prototype panel's flow buttons, the preview pane's bar buttons, Home's Online row menus and the style picker's 24 pt buttons painted their glyphs through shrink(5.0), 14 pt. They go through widgets::icon_glyph_rect like the Design panel's (#2229), 16 pt.

Left as they were: the style picker's 22 pt edit/check button and 20 pt variable button, and the popover's close button (24 pt, deliberate 12 pt cross).

Tests: icon_button_size_tests (Layers and Assets, the status button) and icon_size_tests beside versions, proto_panel, online, the style picker and the player; header_icon_size_tests gains painted_rects and paints_icon_at_button_size for them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium
This commit is contained in:
Trevor Brown
2026-11-12 00:53:59 -05:00
co-authored by Claude Sonnet 5.5
parent 10e9686d03
commit ee11036d66
14 changed files with 178 additions and 17 deletions
+26 -4
View File
@@ -6,11 +6,13 @@ use egui::{Context, Rect, pos2, vec2};
use crate::icons::{self, Icon};
use crate::widgets::{icon_button_at, icon_glyph_rect, section_header};
/// Bounding box of everything painted in one frame of `add`.
fn painted_box(add: impl FnOnce(&mut egui::Ui)) -> Rect {
/// The bounding box of every glyph-like shape painted in one frame of `add`, one per shape, with
/// the pointer at `pointer` (hover-only buttons) when given.
pub(crate) fn painted_rects(pointer: Option<egui::Pos2>, add: impl FnOnce(&mut egui::Ui)) -> Vec<Rect> {
let ctx = Context::default();
let mut add = Some(add);
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 200.0))), ..Default::default() };
let events = pointer.map(egui::Event::PointerMoved).into_iter().collect();
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 600.0))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
if let Some(f) = add.take() {
f(ui);
@@ -21,7 +23,27 @@ fn painted_box(add: impl FnOnce(&mut egui::Ui)) -> Rect {
.iter()
.filter(|s| matches!(s.shape, egui::Shape::Path(_) | egui::Shape::Mesh(_) | egui::Shape::LineSegment { .. } | egui::Shape::Circle(_)))
.map(|s| s.shape.visual_bounding_rect())
.fold(Rect::NOTHING, |a, b| a.union(b))
.collect()
}
/// Bounding box of everything painted in one frame of `add`.
fn painted_box(add: impl FnOnce(&mut egui::Ui)) -> Rect {
painted_rects(None, add).into_iter().fold(Rect::NOTHING, |a, b| a.union(b))
}
/// Whether `painted` holds `icon` drawn at the size of a 24 pt `icons::button`'s glyph (16 pt): the
/// reference glyph's shapes, moved by one offset, are all among `painted` (same sizes and spacing,
/// so a three-dot glyph's spread counts too).
pub(crate) fn paints_icon_at_button_size(painted: &[Rect], icon: Icon) -> bool {
let reference = painted_rects(None, |ui| {
icons::button(ui, icon, 24.0, false, "reference");
});
let same = |a: Rect, b: Rect| (a.min - b.min).length() < 0.5 && (a.size() - b.size()).length() < 0.5;
let Some(first) = reference.first() else { return false };
painted.iter().any(|p| {
let shift = p.min - first.min;
reference.iter().all(|r| painted.iter().any(|q| same(*q, r.translate(shift))))
})
}
/// The header's eye, `icon_button_at`'s and a fill row's (`icons::button`, the reporter's
+28 -2
View File
@@ -180,7 +180,7 @@ fn row(app: &mut FigApp, ui: &mut Ui, t: &Tokens, f: &OnlineFile, busy: bool) {
if mresp.hovered() {
ui.painter().rect_filled(more, CornerRadius::same(5), t.pressed);
}
icons::paint(ui.painter(), more.shrink(5.0), Icon::Dots, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(more), Icon::Dots, t.icon);
let ctx = ui.ctx().clone();
let at = (f.server.as_str(), f.file.as_str());
crate::right::menu_popup(&mresp).width(240.0).show(|ui| {
@@ -302,7 +302,7 @@ fn team_row(app: &mut FigApp, ui: &mut Ui, t: &Tokens, server: &str, web: &str,
if mresp.hovered() {
ui.painter().rect_filled(more, CornerRadius::same(5), t.pressed);
}
icons::paint(ui.painter(), more.shrink(5.0), Icon::Dots, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(more), Icon::Dots, t.icon);
crate::right::menu_popup(&mresp).width(240.0).show(|ui| {
if crate::right::menu_item(ui, tr("Open"), "") {
open_team(app, server, f);
@@ -427,3 +427,29 @@ pub fn inspect(app: &FigApp) -> Value {
"rename": app.ui.online.rename.as_ref().map(|r| &r.2),
})
}
#[cfg(test)]
mod icon_size_tests {
use super::*;
use crate::header_icon_size_tests::{painted_rects, paints_icon_at_button_size};
use figcraft_engine::figcraft_collab::message::Access;
/// The menu buttons of Online's file rows and team rows paint their dots at 16 pt (#2238).
#[test]
fn online_row_menus_paint_16pt() {
let t_app = || FigApp::new(figcraft_engine::Session::new(), Box::new(crate::NoServices));
let mut app = t_app();
let file = OnlineFile { server: "wss://share.example/".into(), file: "f".into(), name: "Trail".into(), ..OnlineFile::default() };
let rows = painted_rects(None, |ui| {
let t = Tokens::get(ui.ctx());
row(&mut app, ui, &t, &file, false);
});
assert!(paints_icon_at_button_size(&rows, Icon::Dots), "file row menu is not 16 pt");
let team = TeamFile { file: "f".into(), name: "Trail".into(), updated: 0, people: 0, access: Access::Edit };
let teams = painted_rects(None, |ui| {
let t = Tokens::get(ui.ctx());
team_row(&mut app, ui, &t, "wss://share.example/", "", &team);
});
assert!(paints_icon_at_button_size(&teams, Icon::Dots), "team row menu is not 16 pt");
}
}
@@ -0,0 +1,37 @@
//! Every 24 pt icon button paints its glyph at 16 pt, the size `icons::button` draws (#2238, after
//! the Design panel's in #2227). The panels' own buttons: the Layers panel's header, the Assets
//! panel's back button and the Dev Mode status button. The tests of the other panels sit beside
//! them (`present/tests.rs`, and the `icon_size_tests` modules of `versions`, `proto_panel`,
//! `online` and the style picker).
use egui::{Rect, pos2, vec2};
use figcraft_engine::Session;
use crate::header_icon_size_tests::{painted_rects, paints_icon_at_button_size};
use crate::icons::Icon;
use crate::{FigApp, NoServices};
fn app() -> FigApp {
FigApp::new(Session::new(), Box::new(NoServices))
}
#[test]
fn layers_and_assets_icon_buttons_paint_16pt() {
let mut a = app();
let rail = painted_rects(None, |ui| crate::layers::left_panel(&mut a, ui));
for icon in [Icon::Panels, Icon::Search, Icon::Plus] {
assert!(paints_icon_at_button_size(&rail, icon), "Layers panel: {icon:?} is not 16 pt");
}
a.ui.libraries = true;
let back = painted_rects(None, |ui| crate::assets_panel::show(&mut a, ui));
assert!(paints_icon_at_button_size(&back, Icon::ChevronLeft), "Assets panel: the back chevron is not 16 pt");
}
#[test]
fn the_dev_mode_status_button_paints_a_16pt_glyph() {
let mut a = app();
let doc = a.session.doc().unwrap().clone();
let button = Rect::from_min_size(pos2(20.0, 20.0), vec2(24.0, 24.0));
let painted = painted_rects(None, |ui| crate::section_panel::status_icon(&mut a, ui, &doc, &[], button));
assert!(paints_icon_at_button_size(&painted, Icon::Code), "the status button's code glyph is not 16 pt");
}
+2
View File
@@ -132,6 +132,8 @@ mod home_tests;
#[cfg(test)]
mod host_save_tests;
#[cfg(test)]
mod icon_button_size_tests;
#[cfg(test)]
mod idle_tests;
#[cfg(test)]
mod ime_tests;
+1 -1
View File
@@ -818,7 +818,7 @@ fn libraries_view(app: &mut FigApp, ui: &mut Ui) {
if bresp.hovered() {
ui.painter().rect_filled(back, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), back.shrink(5.0), Icon::ChevronLeft, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(back), Icon::ChevronLeft, t.icon);
ui.painter().text(pos2(back.max.x + 6.0, hr.center().y), Align2::LEFT_CENTER, tr("Libraries"), semibold(12.0), t.text);
if bresp.clicked() {
app.ui.libraries = false;
+4 -4
View File
@@ -168,7 +168,7 @@ pub fn left_panel(app: &mut FigApp, ui: &mut Ui) {
if hresp.hovered() {
ui.painter().rect_filled(hr, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), hr.shrink(5.0), Icon::Panels, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(hr), Icon::Panels, t.icon);
if hresp.clicked() {
app.run("view.minimize", json!({}));
}
@@ -204,7 +204,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
if sresp.hovered() || app.ui.page_search {
ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), sr.shrink(5.0), Icon::Search, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(sr), Icon::Search, t.icon);
let search_id = Id::unique("page_search_field");
if sresp.clicked() {
app.ui.page_search = !app.ui.page_search;
@@ -218,7 +218,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
if aresp.hovered() {
ui.painter().rect_filled(ar, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), ar.shrink(5.0), Icon::Plus, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(ar), Icon::Plus, t.icon);
if aresp.clicked() {
app.ui.pages_focus = app.session.ds().ok().map(|d| d.uid);
if !crate::menus::dev_mode_refuses(app, "page.add") {
@@ -1090,7 +1090,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
if sresp.hovered() || app.ui.layer_search {
ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), sr.shrink(5.0), Icon::Search, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(sr), Icon::Search, t.icon);
let search_id = Id::unique("layer_search_field");
if sresp.clicked() {
app.ui.layer_search = !app.ui.layer_search;
+19 -1
View File
@@ -374,7 +374,7 @@ fn icon_button(ui: &mut Ui, id: Id, rect: Rect, icon: Icon, tip: &str) -> bool {
if resp.hovered() {
ui.painter().rect_filled(rect, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), rect.shrink(5.0), icon, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(rect), icon, t.icon);
resp.clicked()
}
@@ -1354,3 +1354,21 @@ fn scroll_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId],
});
}
}
#[cfg(test)]
mod icon_size_tests {
use super::*;
use crate::header_icon_size_tests::{painted_rects, paints_icon_at_button_size};
/// The panel's 24 pt icon buttons (flow play, preview, remove) paint their glyphs at 16 pt (#2238).
#[test]
fn prototype_icon_buttons_paint_16pt() {
for icon in [Icon::Play, Icon::Preview, Icon::Minus] {
let painted = painted_rects(None, |ui| {
let rect = Rect::from_min_size(pos2(20.0, 20.0), vec2(24.0, 24.0));
icon_button(ui, Id::unique("proto_icon_size"), rect, icon, "x");
});
assert!(paints_icon_at_button_size(&painted, icon), "{icon:?} is not 16 pt");
}
}
}
+1 -1
View File
@@ -71,7 +71,7 @@ pub fn status_icon(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId]
Some((DevStatus::None, _)) | None => t.icon,
Some(_) => t.accent_text,
};
icons::paint(ui.painter(), rect.shrink(5.0), Icon::Code, color);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(rect), Icon::Code, color);
menu_popup(&resp).width(160.0).show(|ui| status_menu(app, ui, ids, doc));
}
+24 -2
View File
@@ -441,7 +441,7 @@ fn version_row(ui: &mut Ui, row: &Row, last: bool, previewing: bool) -> Option<A
if more.hovered() || menu_open {
ui.painter().rect_filled(br, CornerRadius::same(5), t.pressed);
}
icons::paint(ui.painter(), br.shrink(5.0), Icon::Dots, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(br), Icon::Dots, t.icon);
}
let more = more.tip(tr("More"));
let mut menu = |ui: &mut Ui| {
@@ -496,7 +496,7 @@ fn branch_row(ui: &mut Ui, b: &BranchRow) -> Option<Action> {
if more.hovered() || menu_open {
ui.painter().rect_filled(br, CornerRadius::same(5), t.pressed);
}
icons::paint(ui.painter(), br.shrink(5.0), Icon::Dots, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(br), Icon::Dots, t.icon);
}
let more = more.tip(tr("More"));
let mut menu = |ui: &mut Ui| {
@@ -607,3 +607,25 @@ fn buttons(ui: &mut Ui, primary: &str, enabled: bool) -> Option<bool> {
})
.inner
}
#[cfg(test)]
mod icon_size_tests {
use super::*;
use crate::header_icon_size_tests::{painted_rects, paints_icon_at_button_size};
/// The version and branch rows' More buttons (shown on hover) paint their dots at 16 pt (#2238).
#[test]
fn version_and_branch_menus_paint_16pt() {
let over = Some(pos2(20.0, 20.0));
let row = Row { id: "v".into(), name: "Draft".into(), description: String::new(), time: 0, auto: false };
let version = painted_rects(over, |ui| {
version_row(ui, &row, true, false);
});
assert!(paints_icon_at_button_size(&version, Icon::Dots), "version row menu is not 16 pt");
let branch = BranchRow { id: "b".into(), name: "Try".into(), time: 0, state: BranchState::Active };
let branches = painted_rects(over, |ui| {
branch_row(ui, &branch);
});
assert!(paints_icon_at_button_size(&branches, Icon::Dots), "branch row menu is not 16 pt");
}
}
+1 -1
View File
@@ -173,7 +173,7 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome)
resp = resp.tip(tr("Inspect the layer under the pointer: size, colours, typography"));
}
if chrome.pane {
icons::paint(ui.painter(), br.shrink(5.0), *icon, if on { t.accent_on } else { t.icon });
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(br), *icon, if on { t.accent_on } else { t.icon });
} else {
ui.painter().text(br.center(), Align2::CENTER_CENTER, shown, regular(11.5), if on { t.accent_on } else { text });
}
+18
View File
@@ -436,3 +436,21 @@ fn smart_animate_frame_cost() {
let per = t0.elapsed().as_secs_f64() * 1000.0 / f64::from(n);
eprintln!("smart animate: {per:.2} ms per frame (80 layers, 400x800 px)");
}
/// The preview pane's 24 pt bar buttons paint their glyphs at 16 pt (#2238).
#[test]
fn pane_bar_buttons_paint_16pt() {
use super::layout::Chrome;
use super::player::player;
use crate::header_icon_size_tests::{painted_rects, paints_icon_at_button_size};
use crate::icons::Icon;
let (d, a, ..) = device_doc();
let mut p = player_on(d, a);
let pane = Chrome { pane: true, bar_h: 32.0, pad: 12.0, round: 8, keys: false };
let painted = painted_rects(None, |ui| {
player(&mut p, ui, Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 500.0)), 0.0, pane);
});
for icon in [Icon::Close, Icon::Play, Icon::Reset] {
assert!(paints_icon_at_button_size(&painted, icon), "{icon:?} is not 16 pt");
}
}
+2 -1
View File
@@ -303,7 +303,8 @@ fn menu_icon(ui: &mut Ui, rect: Rect, icon: Icon, tip: &str, on: bool) -> Respon
if on || resp.hovered() {
ui.painter().rect_filled(rect, CornerRadius::same(5), t.menu_hover);
}
icons::paint(ui.painter(), rect.shrink(5.0), icon, t.menu_text);
let glyph = if rect.size() == vec2(24.0, 24.0) { crate::widgets::icon_glyph_rect(rect) } else { rect.shrink(5.0) };
icons::paint(ui.painter(), glyph, icon, t.menu_text);
resp
}
@@ -558,3 +558,17 @@ fn text_style_editor_binds_variables_from_its_fields() {
assert_eq!((def.font_size, def.font_style.as_str()), (18.0, "Bold"), "the style takes the variables' values");
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
}
/// The picker's 24 pt icon buttons paint their glyphs at 16 pt; a smaller button keeps a smaller one
/// (#2238).
#[test]
fn style_picker_icon_buttons_paint_16pt() {
use crate::header_icon_size_tests::{painted_rects, paints_icon_at_button_size};
use crate::icons::Icon;
for icon in [Icon::Plus, Icon::Detach, Icon::ChevronLeft] {
let painted = painted_rects(None, |ui| {
menu_icon(ui, Rect::from_min_size(egui::pos2(20.0, 20.0), vec2(24.0, 24.0)), icon, "x", false);
});
assert!(paints_icon_at_button_size(&painted, icon), "{icon:?} is not 16 pt");
}
}
+1
View File
@@ -241,6 +241,7 @@ This audit records candidates for subsequent fixes; their behaviour is unchanged
| 2026-11-06 | patch | M7, #2032: closing a tab whose file has unsaved changes, by its X, a middle click, File ▸ Close tab or its shortcut, only showed a toast ("save first, or close again with Alt to discard") and left the tab open, so closing seemed not to work. It now asks the unsaved-changes question the window asks (`windows::TabClose`): Don't Save closes the tab, Cancel or Escape keeps it, Save saves the file and then closes it (a file without a path, or saved as .fig, asks where with Save as first and stays open if that is cancelled); Alt+close still drops the changes at once, and `file.close`'s `dialog.unsaved` request asks too. The three toasts left the catalogs. Tests: `titlebar_tests::closing_a_tab_with_unsaved_changes_asks`, `titlebar_tests::save_in_the_tab_question_saves_then_closes`. Reordering tabs by dragging, the issue's other half, landed on 2026-10-19 (`dragging_a_tab_along_the_bar_reorders_it_without_detaching`) |
| 2026-11-05 | minor | M4, #1830: a paragraph's base direction can be forced (`TextDirection`: Auto, Ltr, Rtl on `ParaList`): Text ▸ Text direction ▸ Auto / Left to right / Right to left (`text.directionAuto` / `Ltr` / `Rtl`) and `text.direction {direction, ids?}` set the edited paragraphs while editing, else the layer's; `node.set textDirection`; the Type settings control once a right-to-left script is in the text; inspect `textDirection` / `lineDirections`; CSS `direction`; `.fig` lines' `sourceDirectionality` in and out. Layout gives each paragraph its base level (`Bidi::of` runs the algorithm per paragraph, as it starts over at each). Before, direction came from the letters only ("controls are available to override text direction per paragraph", Add right-to-left text). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests::a_lines_source_directionality_is_its_paragraphs_direction_both_ways`. |
| 2026-11-02 | minor | M1, #1836: pick select. Tab while the keyboard selection box shows, or Alt/Ctrl+Space again, puts a pink outline on the first selected layer (the page's top layer with nothing selected); Tab and Shift+Tab move it between sibling layers as the Layers panel lists them, Enter into a frame's top child, Shift+Enter out to the parent (never the page), Space selects the layer under it, Escape or a press on the canvas ends it; the chord again while the pick cursor shows brings the box back (Accessibility at Figma). The control channel's `ui.key` sends the box's and the pick cursor's keys to them too. One documented row more (135, 134 bound); four gesture rows. Tests: `layers_keyboard_tests::tab_switches_the_selection_box_to_pick_select`. |
| 2026-11-12 | patch | M8, #2238: every 24 pt icon button outside the Design panel draws its glyph at 16 pt through `widgets::icon_glyph_rect`, not 14 pt: the Layers panel's Minimize, search and add page buttons, the Assets back button, the Versions panel's row menus, the Dev Mode status button, the Prototype panel's flow buttons, the preview pane's bar buttons, Home's Online row menus and the style picker's 24 pt buttons. Smaller buttons (the picker's 22 and 20 pt ones, the popover's 24 pt close button with its 12 pt cross) keep their smaller glyphs. Tests: `icon_button_size_tests` and the `icon_size_tests` beside the panels. Numbers unchanged. |
| 2026-11-10 | patch | M10, #2227: the Design panel's section header buttons (Appearance, Typography, Fill, Stroke, Effects) draw their icons at the size of the panel's other 24 pt icon buttons, 16 pt (the icons' native size, as a fill row's eye), not 12 pt; `icon_button_at` goes from 14 to 16 pt with them; the Close and Swap buttons of the assets and prototype panels and the Design panel's Swap orientation and quick-action buttons share the inset. Test: `header_icon_size_tests`. Numbers unchanged. |
| 2026-11-05 | patch | M1, #2003: the Design panel's boolean operation menu leads Union, Subtract, Intersect and Exclude with original icons drawn in code (two offset squares, the result filled at reduced alpha). Numbers unchanged. |
| 2026-11-02 | minor | M8, #1875: a fill per region. With regions of a vector selected in edit mode (a press inside one with the Move tool, `vector.edit.select {regions}`), the Fill section lists the regions' own fills (the layer's for a region that has none; mixed content when they differ) and its rows and the colour picker edit them: `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` on the edited layer go to the selected regions' stacks (`Region.fills`, what the renderer already draws), the layer's fills and fill style untouched; `regions: [index, ...]` names them on any vector layer (Edit vector layers). Tests: `regions_tests::the_fill_commands_edit_the_selected_regions`. Numbers unchanged. |