From ee11036d669583972e84bb0d6a56ada6c36d18f1 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Thu, 12 Nov 2026 00:53:59 -0500 Subject: [PATCH 1/2] 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 Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium --- crates/ui-egui/src/header_icon_size_tests.rs | 30 +++++++++++++-- crates/ui-egui/src/home/online.rs | 30 ++++++++++++++- crates/ui-egui/src/icon_button_size_tests.rs | 37 +++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/assets_panel.rs | 2 +- crates/ui-egui/src/panels/layers.rs | 8 ++-- crates/ui-egui/src/panels/proto_panel.rs | 20 +++++++++- crates/ui-egui/src/panels/section_panel.rs | 2 +- crates/ui-egui/src/panels/versions.rs | 26 ++++++++++++- crates/ui-egui/src/present/player.rs | 2 +- crates/ui-egui/src/present/tests.rs | 18 +++++++++ crates/ui-egui/src/widgets/style_picker.rs | 3 +- .../ui-egui/src/widgets/style_picker/tests.rs | 14 +++++++ docs/ui-parity.md | 1 + 14 files changed, 178 insertions(+), 17 deletions(-) create mode 100644 crates/ui-egui/src/icon_button_size_tests.rs diff --git a/crates/ui-egui/src/header_icon_size_tests.rs b/crates/ui-egui/src/header_icon_size_tests.rs index 917276c2a..a72a44b4e 100644 --- a/crates/ui-egui/src/header_icon_size_tests.rs +++ b/crates/ui-egui/src/header_icon_size_tests.rs @@ -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, add: impl FnOnce(&mut egui::Ui)) -> Vec { 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 diff --git a/crates/ui-egui/src/home/online.rs b/crates/ui-egui/src/home/online.rs index a011836dd..0b01017bb 100644 --- a/crates/ui-egui/src/home/online.rs +++ b/crates/ui-egui/src/home/online.rs @@ -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"); + } +} diff --git a/crates/ui-egui/src/icon_button_size_tests.rs b/crates/ui-egui/src/icon_button_size_tests.rs new file mode 100644 index 000000000..2d94f3fb8 --- /dev/null +++ b/crates/ui-egui/src/icon_button_size_tests.rs @@ -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"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 320f94ba2..5e31468a6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/panels/assets_panel.rs b/crates/ui-egui/src/panels/assets_panel.rs index 0da5a9bee..41f7762ff 100644 --- a/crates/ui-egui/src/panels/assets_panel.rs +++ b/crates/ui-egui/src/panels/assets_panel.rs @@ -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; diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 1a62a851c..28664cae7 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -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; diff --git a/crates/ui-egui/src/panels/proto_panel.rs b/crates/ui-egui/src/panels/proto_panel.rs index fa9f2585c..63b9278ac 100644 --- a/crates/ui-egui/src/panels/proto_panel.rs +++ b/crates/ui-egui/src/panels/proto_panel.rs @@ -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"); + } + } +} diff --git a/crates/ui-egui/src/panels/section_panel.rs b/crates/ui-egui/src/panels/section_panel.rs index 4ea146924..0aa880052 100644 --- a/crates/ui-egui/src/panels/section_panel.rs +++ b/crates/ui-egui/src/panels/section_panel.rs @@ -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)); } diff --git a/crates/ui-egui/src/panels/versions.rs b/crates/ui-egui/src/panels/versions.rs index 7046b5fa7..73ed3eb16 100644 --- a/crates/ui-egui/src/panels/versions.rs +++ b/crates/ui-egui/src/panels/versions.rs @@ -441,7 +441,7 @@ fn version_row(ui: &mut Ui, row: &Row, last: bool, previewing: bool) -> Option Option { 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 { }) .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"); + } +} diff --git a/crates/ui-egui/src/present/player.rs b/crates/ui-egui/src/present/player.rs index 8bcbea09d..66a8e30f3 100644 --- a/crates/ui-egui/src/present/player.rs +++ b/crates/ui-egui/src/present/player.rs @@ -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 }); } diff --git a/crates/ui-egui/src/present/tests.rs b/crates/ui-egui/src/present/tests.rs index bba998788..1a8b0f076 100644 --- a/crates/ui-egui/src/present/tests.rs +++ b/crates/ui-egui/src/present/tests.rs @@ -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"); + } +} diff --git a/crates/ui-egui/src/widgets/style_picker.rs b/crates/ui-egui/src/widgets/style_picker.rs index cc820ed71..57e045a06 100644 --- a/crates/ui-egui/src/widgets/style_picker.rs +++ b/crates/ui-egui/src/widgets/style_picker.rs @@ -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 } diff --git a/crates/ui-egui/src/widgets/style_picker/tests.rs b/crates/ui-egui/src/widgets/style_picker/tests.rs index 984f6c8d4..08c27f12d 100644 --- a/crates/ui-egui/src/widgets/style_picker/tests.rs +++ b/crates/ui-egui/src/widgets/style_picker/tests.rs @@ -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"); + } +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 0ee9bde96..0c9f44d4c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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. | From 70552482c428aef528a11ca67bf5b4d050f5f2bf Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Thu, 12 Nov 2026 02:51:37 -0500 Subject: [PATCH 2/2] M8: the popover's close glyph is 16 pt too, and the style picker's 24 pt check allows rounding (#2238) The popover's 24 pt close button painted its cross through rect.shrink(6.0), a 12 pt glyph; it now goes through icon_glyph_rect like the other 24 pt buttons, with a painted-size test that failed before the change. The style picker's menu_icon picked the 16 pt glyph with an exact size equality, so a button at a fractional x that measured a hair off 24 in f32 painted at 14 pt; it now allows 0.5 pt of rounding. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium --- crates/ui-egui/src/popover.rs | 4 ++-- crates/ui-egui/src/popover_tests.rs | 21 +++++++++++++++++++++ crates/ui-egui/src/widgets/style_picker.rs | 6 +++++- docs/ui-parity.md | 2 +- 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/popover.rs b/crates/ui-egui/src/popover.rs index d91558d46..82886227d 100644 --- a/crates/ui-egui/src/popover.rs +++ b/crates/ui-egui/src/popover.rs @@ -262,13 +262,13 @@ fn header_text(t: Tokens, dark: bool) -> Color32 { if dark { t.menu_text } else { t.text } } -/// The X: a 24-point button, its icon inset 6 (the colour picker's header). +/// The X: a 24-point button, its cross 16 points like every 24 pt icon button (the colour picker's header). fn close_button(ui: &mut Ui, id: Id, rect: Rect, dark: bool, t: Tokens) -> bool { let resp = ui.interact(rect, id, Sense::click()).tip(tr("Close")); if resp.hovered() { ui.painter().rect_filled(rect, CornerRadius::same(5), if dark { Color32::from_white_alpha(20) } else { t.hover }); } - icons::paint(ui.painter(), rect.shrink(6.0), Icon::Close, if dark { t.menu_text } else { t.icon }); + icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(rect), Icon::Close, if dark { t.menu_text } else { t.icon }); resp.clicked() } diff --git a/crates/ui-egui/src/popover_tests.rs b/crates/ui-egui/src/popover_tests.rs index 06daca851..6c4e559ee 100644 --- a/crates/ui-egui/src/popover_tests.rs +++ b/crates/ui-egui/src/popover_tests.rs @@ -194,3 +194,24 @@ fn a_caller_can_put_it_where_it_likes() { draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![]); assert!(near(p.rect.min, pos2(360.0, 300.0)), "dropped where the drag left it: {:?}", p.rect); } + +mod icon_size_tests { + use super::*; + use crate::header_icon_size_tests::paints_icon_at_button_size; + use crate::icons::Icon; + + /// The header's 24 pt X paints its cross at 16 pt, like every other 24 pt icon button (#2238). + #[test] + fn the_close_button_paints_a_16pt_cross() { + let ctx = context(); + let mut t = 0.0; + let mut p = Popover::new("popover_close_size", "Colour"); + let shapes = settle(&ctx, &mut t, &mut p, Open::At(pos2(160.0, 100.0))); + let painted: Vec = shapes + .iter() + .filter(|s| matches!(s, egui::Shape::Path(_) | egui::Shape::Mesh(_) | egui::Shape::LineSegment { .. } | egui::Shape::Circle(_))) + .map(|s| s.visual_bounding_rect()) + .collect(); + assert!(paints_icon_at_button_size(&painted, Icon::Close), "the popover's close cross is not 16 pt"); + } +} diff --git a/crates/ui-egui/src/widgets/style_picker.rs b/crates/ui-egui/src/widgets/style_picker.rs index 57e045a06..ca063abad 100644 --- a/crates/ui-egui/src/widgets/style_picker.rs +++ b/crates/ui-egui/src/widgets/style_picker.rs @@ -303,7 +303,11 @@ 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); } - let glyph = if rect.size() == vec2(24.0, 24.0) { crate::widgets::icon_glyph_rect(rect) } else { rect.shrink(5.0) }; + let glyph = if (rect.width() - 24.0).abs() < 0.5 && (rect.height() - 24.0).abs() < 0.5 { + crate::widgets::icon_glyph_rect(rect) + } else { + rect.shrink(5.0) + }; icons::paint(ui.painter(), glyph, icon, t.menu_text); resp } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 0c9f44d4c..524d64f03 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -241,7 +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-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. The popover's 24 pt close button draws its cross at 16 pt too. Smaller buttons (the picker's 22 and 20 pt ones) 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. |