Browse / github.com/storytold/figcraft / patch
ui: rebuild the toolbar pill on a 32 pt grid, without the Resources button
ui: rebuild the toolbar pill on a 32 pt grid, without the Resources button ## Toolbar pill on a 32 pt grid - 32 pt cells, 8 pt left and right padding, 8 pt between items (the divider included), on a 48 pt tall pill. - Flyout carets get a 16 pt hit box, 1 pt off their button. - The mode switcher (Draw · Design · Prototype · Inspect) is a 32 pt strip; the current mode is a plain white inset, no outline, and its marks are 14 pt. - The divider is 1 pt wide and as tall as the content. - **The Resources button is removed.** The left rail's Assets tab, Shift+I, Alt+2 and View ▸ Panels ▸ Assets remain the ways into the Assets panel; `docs/ui-parity.md` says so. - The pill's
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- yelintong (imported from pull request #1210, open)
- Base
43af145a12eacd1e87f5f5e6984c49b7da040288- Size
- +132 −136 in 29 files
- Review
- pending
- Shared
- 2026-10-28
- Id
de686083353d13bcfac6d7b9
How it went
No other operator has reported using it yet.
Diff
raw · crates/ui-egui/locales/bn.toml crates/ui-egui/locales/cs.toml crates/ui-egui/locales/de.toml crates/ui-egui/locales/en.toml crates/ui-egui/locales/es.toml crates/ui-egui/locales/fi.toml crates/ui-egui/locales/fr.toml crates/ui-egui/locales/id.toml crates/ui-egui/locales/it.toml crates/ui-egui/locales/ja.toml crates/ui-egui/locales/ka.toml crates/ui-egui/locales/ko.toml crates/ui-egui/locales/nl.toml crates/ui-egui/locales/pl.toml crates/ui-egui/locales/pt-BR.toml crates/ui-egui/locales/ru.toml crates/ui-egui/locales/sv.toml crates/ui-egui/locales/th.toml crates/ui-egui/locales/tr.toml crates/ui-egui/locales/vi.toml crates/ui-egui/locales/zh-Hans.toml crates/ui-egui/src/board_picker_tests.rs crates/ui-egui/src/board_ui_tests.rs crates/ui-egui/src/resources_tests.rs crates/ui-egui/src/shortcuts.rs crates/ui-egui/src/toolbar.rs crates/ui-egui/src/widgets/board_picker.rs docs/boards-parity.md docs/ui-parity.md
@@ -1163,7 +1163,6 @@ status = "draft — needs native review" "Resolve" = "সমাধান করুন" "Resolve change" = "পরিবর্তন সমাধান করুন" "Resolved" = "সমাধান করা হয়েছে"-"Resources" = "রিসোর্সসমূহ" "Responsive" = "রেসপনসিভ" "Restart" = "পুনরায় শুরু করুন" "Restore" = "পুনরুদ্ধার করুন"@@ -1141,7 +1141,6 @@ status = "draft — needs native review" "Resolve" = "Vyřešit" "Resolve change" = "Vyřešit změnu" "Resolved" = "Vyřešené"-"Resources" = "Zdroje" "Responsive" = "Responzivní" "Restart" = "Znovu spustit" "Restore" = "Obnovit"@@ -1111,7 +1111,6 @@ status = "draft — needs native review" "Resolve" = "Erledigen" "Resolve change" = "Änderung erledigen" "Resolved" = "Erledigt"-"Resources" = "Ressourcen" "Responsive" = "Responsiv" "Restart" = "Neu starten" "Restore" = "Wiederherstellen"@@ -1198,7 +1198,6 @@ name = "English" "Resolve" = "Resolve" "Resolve change" = "Resolve change" "Resolved" = "Resolved"-"Resources" = "Resources" "Responsive" = "Responsive" "Restart" = "Restart" "Restore" = "Restore"@@ -1194,7 +1194,6 @@ status = "draft — needs native review" "Resolve" = "Resolver" "Resolve change" = "Resolver cambio" "Resolved" = "Resuelto"-"Resources" = "Recursos" "Responsive" = "Adaptable" "Restart" = "Reiniciar" "Restore" = "Restaurar"@@ -1167,7 +1167,6 @@ status = "draft — needs native review" "Resolve" = "Ratkaise" "Resolve change" = "Ratkaise muutos" "Resolved" = "Ratkaistu"-"Resources" = "Resurssit" "Responsive" = "Mukautuva" "Restart" = "Aloita uudelleen" "Restore" = "Palauta"@@ -1168,7 +1168,6 @@ status = "draft — needs native review" "Resolve" = "Résoudre" "Resolve change" = "Résoudre la modification" "Resolved" = "Résolu"-"Resources" = "Ressources" "Responsive" = "Adaptatif" "Restart" = "Recommencer" "Restore" = "Restaurer"@@ -1147,7 +1147,6 @@ status = "draft — needs native review" "Resolve" = "Selesaikan" "Resolve change" = "Selesaikan perubahan" "Resolved" = "Terselesaikan"-"Resources" = "Sumber daya" "Responsive" = "Responsif" "Restart" = "Mulai ulang" "Restore" = "Pulihkan"@@ -1128,7 +1128,6 @@ status = "draft — needs native review" "Resolve" = "Risolvi" "Resolve change" = "Risolvi la modifica" "Resolved" = "Risolto"-"Resources" = "Risorse" "Responsive" = "Responsive" "Restart" = "Riavvia" "Restore" = "Ripristina"@@ -1155,7 +1155,6 @@ status = "draft — needs native review" "Resolve" = "解決" "Resolve change" = "変更を解決" "Resolved" = "解決済み"-"Resources" = "リソース" "Responsive" = "レスポンシブ" "Restart" = "再起動" "Restore" = "復元"@@ -1165,7 +1165,6 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Resolve" = "გადაწყვეტა" "Resolve change" = "ცვლილების გადაწყვეტა" "Resolved" = "გადაწყვეტილია"-"Resources" = "რესურსები" "Responsive" = "ადაპტური" "Restart" = "გადატვირთვა" "Restore" = "აღდგენა"@@ -1105,7 +1105,6 @@ status = "draft — needs native review" "Resolve" = "해결" "Resolve change" = "변경 사항 해결" "Resolved" = "해결됨"-"Resources" = "리소스" "Responsive" = "반응형" "Restart" = "다시 시작" "Restore" = "복원"@@ -1167,7 +1167,6 @@ status = "draft — needs native review" "Resolve" = "Oplossen" "Resolve change" = "Wijziging oplossen" "Resolved" = "Opgelost"-"Resources" = "Bronnen" "Responsive" = "Responsive" "Restart" = "Opnieuw starten" "Restore" = "Terugzetten"@@ -1171,7 +1171,6 @@ name = "Polski" "Resolve" = "Rozwiąż" "Resolve change" = "Rozwiąż zmianę" "Resolved" = "Rozwiązany"-"Resources" = "Zasoby" "Responsive" = "Responsywny" "Restart" = "Uruchom ponownie" "Restore" = "Przywróć"@@ -1104,7 +1104,6 @@ status = "draft — needs native review" "Resolve" = "Resolver" "Resolve change" = "Resolver alteração" "Resolved" = "Resolvido"-"Resources" = "Recursos" "Responsive" = "Responsivo" "Restart" = "Reiniciar" "Restore" = "Restaurar"@@ -1171,7 +1171,6 @@ name = "Русский" "Resolve" = "Решить" "Resolve change" = "Разрешить изменение" "Resolved" = "Решённые"-"Resources" = "Ресурсы" "Responsive" = "Адаптивно" "Restart" = "Начать заново" "Restore" = "Восстановить"@@ -1173,7 +1173,6 @@ name = "Svenska" "Resolve" = "Lös" "Resolve change" = "Lös ändring" "Resolved" = "Löst"-"Resources" = "Resurser" "Responsive" = "Responsiv" "Restart" = "Starta om" "Restore" = "Återställ"@@ -1161,7 +1161,6 @@ status = "draft — needs native review" "Resolve" = "แก้ไขแล้ว" "Resolve change" = "แก้ไขการเปลี่ยนแปลง" "Resolved" = "แก้ไขแล้ว"-"Resources" = "แหล่งข้อมูล" "Responsive" = "รีสปอนซีฟ" "Restart" = "เริ่มใหม่" "Restore" = "กู้คืน"@@ -1145,7 +1145,6 @@ name = "Türkçe" "Resolve" = "Çöz" "Resolve change" = "Değişikliği çöz" "Resolved" = "Çözüldü"-"Resources" = "Kaynaklar" "Responsive" = "Duyarlı" "Restart" = "Yeniden başlat" "Restore" = "Geri yükle"@@ -1129,7 +1129,6 @@ status = "draft — needs native review" "Resolve" = "Giải quyết" "Resolve change" = "Giải quyết thay đổi" "Resolved" = "Đã giải quyết"-"Resources" = "Tài nguyên" "Responsive" = "Đáp ứng" "Restart" = "Bắt đầu lại" "Restore" = "Khôi phục"@@ -1113,7 +1113,6 @@ status = "draft — needs native review" "Resolve" = "解决" "Resolve change" = "解决更改" "Resolved" = "已解决"-"Resources" = "资源" "Responsive" = "响应式" "Restart" = "重新开始" "Restore" = "恢复"@@ -27,7 +27,7 @@ fn board() -> Ui { /// The templates button: left of the Design panel's button at the board toolbar's right end. fn templates_button(u: &Ui) -> Pos2 { let p = crate::toolbar::pill_rect(u.app.canvas.rect, crate::toolbar::bar_width(&u.app));- pos2(p.max.x - 8.0 - 32.0 - 4.0 - 16.0, p.center().y)+ pos2(p.max.x - 8.0 - 32.0 - 8.0 - 16.0, p.center().y) } fn count(u: &Ui, f: impl Fn(&BoardItem) -> bool) -> usize {@@ -39,10 +39,10 @@ fn pill(u: &Ui) -> Rect { } /// The left edge of board toolbar group `i` (button 32 wide, a 16-wide caret 2 after it on the-/// groups with flyouts, 4 between): move, ink, sticky, shapes, connector, text, section, stamp,+/// groups with flyouts, 8 between): move, ink, sticky, shapes, connector, text, section, stamp, /// comment. fn group_x(u: &Ui, i: usize) -> f32 {- const WIDTHS: [f32; 9] = [54.0, 54.0, 54.0, 54.0, 36.0, 36.0, 36.0, 54.0, 36.0];+ const WIDTHS: [f32; 9] = [58.0, 58.0, 58.0, 58.0, 40.0, 40.0, 40.0, 58.0, 40.0]; pill(u).min.x + 8.0 + WIDTHS.iter().take(i).sum::<f32>() } @@ -1,19 +1,14 @@-//! The toolbar's Resources button and Shift+I open the Assets panel (#710), from a collapsed-//! sidebar and from the version history too.+//! Shift+I opens the Assets panel (#710), from a collapsed sidebar and from the version history+//! too — and the toolbar has no Resources button for it any more (the left rail's Assets tab,+//! Shift+I, Alt+2 and View ▸ Panels ▸ Assets are the ways in). use egui::accesskit::Role;-use egui::{Event, Key, Modifiers, Pos2, pos2};+use egui::{Event, Key, Modifiers}; use figcraft_engine::Session; use crate::LeftTab; use crate::layers_drag_tests::Ui; -fn click(u: &mut Ui, p: Pos2) {- u.button(p, true);- u.button(p, false);- u.frame(vec![]);-}- fn shift_i(u: &mut Ui) { let modifiers = Modifiers::SHIFT; u.frame(vec![@@ -23,22 +18,11 @@ fn shift_i(u: &mut Ui) { u.frame(vec![]); } -/// The middle of the toolbar's Resources button, found in the AccessKit tree.-fn resources_button(u: &mut Ui) -> Pos2 {- u.ctx.enable_accesskit();- u.frame(vec![]);- let out = u.frame_output(vec![]);- let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes;- let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::Button && n.label() == Some("Resources")).expect("Resources");- let b = n.bounds().expect("bounds");- pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)-}- fn assets_shown(u: &Ui) -> bool { u.app.ui.left_tab == LeftTab::Assets && !u.app.ui.left_collapsed && !u.app.ui.versions.open } -/// The three states the sidebar can be in when Resources is asked for.+/// The three states the sidebar can be in when Shift+I is pressed. fn starts() -> Vec<(&'static str, Ui)> { let shown = Ui::new(Session::new()); let mut collapsed = Ui::new(Session::new());@@ -48,15 +32,6 @@ fn starts() -> Vec<(&'static str, Ui)> { vec![("layers", shown), ("collapsed", collapsed), ("version history", versions)] } -#[test]-fn the_resources_button_opens_the_assets_panel() {- for (name, mut u) in starts() {- let at = resources_button(&mut u);- click(&mut u, at);- assert!(assets_shown(&u), "{name}: the Assets panel is open");- }-}- #[test] fn shift_i_opens_the_assets_panel() { for (name, mut u) in starts() {@@ -65,3 +40,27 @@ fn shift_i_opens_the_assets_panel() { assert!(assets_shown(&u), "{name}: the Assets panel is open"); } }++/// The toolbar's buttons, as screen readers get them.+fn toolbar_labels(u: &mut Ui) -> Vec<String> {+ u.ctx.enable_accesskit();+ u.frame(vec![]);+ let out = u.frame_output(vec![]);+ out.platform_output+ .accesskit_update+ .expect("an AccessKit update")+ .nodes+ .iter()+ .filter(|(_, n)| n.role() == Role::Button)+ .filter_map(|(_, n)| n.label().map(str::to_string))+ .collect()+}++#[test]+fn the_toolbar_has_no_resources_button() {+ let mut u = Ui::new(Session::new());+ let labels = toolbar_labels(&mut u);+ // Actions stays (the palette's button); Resources went (#1210).+ assert!(labels.iter().any(|l| l == "Actions"), "Actions is in the toolbar: {labels:?}");+ assert!(!labels.iter().any(|l| l == "Resources"), "no Resources button: {labels:?}");+}@@ -73,11 +73,6 @@ pub fn tip(label: &'static str, id: &str) -> &'static str { cached_tip(label, shortcut(id)) } -/// A tooltip naming a key ("Resources ⇧I").-pub fn key_tip(label: &'static str, key: &'static str) -> &'static str {- cached_tip(label, Some(key))-}- /// Panels ask for their tooltips every frame: each (label, key, platform) is built once. fn cached_tip(label: &'static str, key: Option<&'static str>) -> &'static str { type Entry = (Platform, &'static str, &'static str, &'static str);@@ -21,6 +21,19 @@ const CARET: f32 = 14.0; const FLYOUT_W: f32 = 16.0; const FLYOUT_GAP: f32 = 2.0; +// The pill's grid: every state of it (the tools, the vector and shape builder ones) is laid out on+// one 32 pt cell, PAD in from its ends and GAP between its top-level items, the divider included.+/// A toolbar button's size: the cell everything in the pill is placed on.+const TOOL_CELL: f32 = 32.0;+/// The pill's left and right padding.+const PAD: f32 = 8.0;+/// Between the pill's top-level items — the divider included.+const GAP: f32 = 8.0;+/// The divider's width.+const DIVIDER: f32 = 1.0;+/// The mode switcher's marks: smaller than the tools' (13–14 pt).+const MODE_GLYPH: f32 = 14.0;+ /// `GLYPH`'s box centred in `r`. fn glyph_rect(r: Rect) -> Rect { Rect::from_center_size(r.center(), vec2(GLYPH, GLYPH))@@ -129,17 +142,18 @@ pub(crate) fn bar_width(app: &FigApp) -> f32 { main_width(if board { BOARD_GROUPS } else { GROUPS }, board) } -/// The main toolbar pill's width: the tool groups with their flyout carets, Resources and-/// Actions, and the mode switcher (boards: the Design panel's button instead).+/// The main toolbar pill's width: the tool groups with their flyout carets, Actions, the divider+/// and the mode switcher (boards: the templates and the Design panel's buttons instead). fn main_width(groups: &[Group], board: bool) -> f32 {- let (cell, gap) = (TOOL_CELL, 4.0);- let carets = groups.iter().filter(|g| g.has_flyout()).count() as f32 * (FLYOUT_GAP + FLYOUT_W);- let end = if board { 2.0 * cell + gap + 16.0 } else { 4.0 * 34.0 + 24.0 };- groups.len() as f32 * (cell + gap) + carets + 2.0 * (cell + gap) + 16.0 + end+ let tools: f32 = groups.iter().map(|g| TOOL_CELL + if g.has_flyout() { FLYOUT_GAP + FLYOUT_W } else { 0.0 }).sum();+ // The end, and the gaps that aren't a group's own: after Actions and after the divider (and+ // between a board's two buttons, which the end counts as one item).+ let (end, gaps) = if board { (2.0 * TOOL_CELL, 3.0) } else { (4.0 * TOOL_CELL, 2.0) };+ PAD * 2.0 + tools + (groups.len() as f32 + gaps) * GAP + TOOL_CELL + DIVIDER + end } -/// The main toolbar pill: the tool groups with their flyouts, Resources and Actions, then the-/// Draw · Design · Prototype · Inspect mode switcher (on a board, the Design panel's button).+/// The main toolbar pill: the tool groups with their flyouts, Actions, then the+/// Draw · Design · Prototype · Inspect mode switcher (on a board, its two buttons). fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens, board: bool) { let groups = if board { BOARD_GROUPS } else { GROUPS }; let width = main_width(groups, board);@@ -148,16 +162,16 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens, board: let scroll = pill_scroll(ui, rect, width); let outer_clip = ui.clip_rect(); ui.set_clip_rect(rect.intersect(outer_clip));- let mut x = rect.min.x + 8.0 - scroll;+ let mut x = rect.min.x + PAD - scroll; for g in groups { x = group_cell(app, ui, g, pos2(x, cy), rect.min.y, t); }- x = side_buttons(app, ui, x, cy, t);+ x = actions_button(app, ui, x, cy, t); divider(ui, t, x, cy);- x += 8.0;+ x += DIVIDER + GAP; if board { // Boards have no prototypes or Dev Mode; the Design panel is hidden until asked for.- x = crate::board_picker::button(app, ui, pos2(x, cy), rect.min.y, t);+ x = crate::board_picker::button(app, ui, pos2(x, cy), rect.min.y, t) + GAP; let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL)); let tip = crate::shortcuts::tip(tr("Design panel"), "view.design"); if tool_cell(ui, t, r, "board_panel", Icon::Panels, app.ui.board_panel, tip).clicked() {@@ -185,9 +199,6 @@ fn pill_scroll(ui: &Ui, rect: Rect, width: f32) -> f32 { scroll } -/// A toolbar button's size.-const TOOL_CELL: f32 = 32.0;- /// Tool group `g` at `at` (its left edge, the pill's middle): the shown tool's button, its flyout /// caret and, while open, its flyout above the pill (whose top is `top`). Where the next starts. fn group_cell(app: &mut FigApp, ui: &mut Ui, g: &Group, at: egui::Pos2, top: f32, t: &Tokens) -> f32 {@@ -223,7 +234,7 @@ fn group_cell(app: &mut FigApp, ui: &mut Ui, g: &Group, at: egui::Pos2, top: f32 options_flyout(app, ui, g, pos2(r.min.x, top - 8.0)); } }- x + 4.0+ x + GAP } /// The caret after group `g`'s button (at `at`, its left edge and middle): opens or closes its@@ -245,36 +256,23 @@ fn flyout_caret(app: &mut FigApp, ui: &mut Ui, g: &Group, at: egui::Pos2, t: &To } } -/// Resources (components, plugins: the Assets panel) and the Actions palette's button, from `x`.-/// Where the next part starts.-fn side_buttons(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens) -> f32 {- for (icon, tip, id) in [- (Icon::Assets, crate::shortcuts::key_tip(tr("Resources"), "Shift+I"), "resources"),- (Icon::Actions, crate::shortcuts::tip(tr("Actions"), "app.actions"), "actions"),- ] {- let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL));- let resp = ui.interact(r, ui.scope_id().with(id), Sense::click()).tip(tip);- if id == "actions" {- app.ui.actions_rect = Some(r);- }- // The Actions button reads on while its panel is open; clicking it again closes it.- let actions_open = id == "actions" && app.ui.palette.is_some();- if actions_open {- ui.painter().rect_filled(r, CornerRadius::same(5), t.accent);- } else if resp.hovered() {- ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);- }- icons::paint(ui.painter(), glyph_rect(r), icon, if actions_open { t.accent_on } else { t.icon });- if resp.clicked() {- if id == "actions" {- crate::menus::toggle_palette(&mut app.ui);- } else {- app.run("view.assets", json!({}));- }- }- x += TOOL_CELL + 4.0;+/// The Actions palette's button, from `x`. Where the next part starts.+fn actions_button(app: &mut FigApp, ui: &mut Ui, x: f32, cy: f32, t: &Tokens) -> f32 {+ let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL));+ let resp = ui.interact(r, ui.scope_id().with("actions"), Sense::click()).tip(crate::shortcuts::tip(tr("Actions"), "app.actions"));+ app.ui.actions_rect = Some(r);+ // The Actions button reads on while its panel is open; clicking it again closes it.+ let open = app.ui.palette.is_some();+ if open {+ ui.painter().rect_filled(r, CornerRadius::same(5), t.accent);+ } else if resp.hovered() {+ ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);+ }+ icons::paint(ui.painter(), glyph_rect(r), Icon::Actions, if open { t.accent_on } else { t.icon });+ if resp.clicked() {+ crate::menus::toggle_palette(&mut app.ui); }- x+ x + TOOL_CELL + GAP } /// The mode switcher from `x`: Draw · Design · Prototype · Inspect (code).@@ -286,19 +284,24 @@ fn mode_switcher(app: &mut FigApp, ui: &mut Ui, x: f32, cy: f32, t: &Tokens) { crate::RightTab::Prototype => 2, crate::RightTab::Inspect => 3, };- let seg = Rect::from_min_size(pos2(x, cy - 18.0), vec2(4.0 * 34.0 + 4.0, 36.0));- ui.painter().rect_filled(seg, CornerRadius::same(8), t.field);+ let seg = Rect::from_min_size(pos2(x, cy - TOOL_CELL / 2.0), vec2(4.0 * TOOL_CELL, TOOL_CELL));+ ui.painter().rect_filled(seg, CornerRadius::same(5), t.field); for (i, (icon, tip)) in modes.iter().enumerate() {- let r = Rect::from_min_size(pos2(seg.min.x + 2.0 + i as f32 * 34.0, seg.min.y + 2.0), vec2(34.0, 32.0));+ let r = Rect::from_min_size(pos2(seg.min.x + i as f32 * TOOL_CELL, seg.min.y), vec2(TOOL_CELL, TOOL_CELL)); let resp = ui.interact(r, ui.scope_id().with(("mode", i)), Sense::click()).on_hover_text(*tip); a11y::toggle(&resp, tip, i == active); if i == active {- ui.painter().rect_filled(r, CornerRadius::same(6), t.panel);- ui.painter().rect_stroke(r, CornerRadius::same(6), Stroke::new(1.0, t.border), StrokeKind::Inside);+ // The current mode lifts with a plain inset, no outline.+ ui.painter().rect_filled(r.shrink(2.0), CornerRadius::same(3), t.panel); } else if resp.hovered() {- ui.painter().rect_filled(r, CornerRadius::same(6), t.hover);+ ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); }- icons::paint(ui.painter(), glyph_rect(r), *icon, if i == active { t.accent_text } else { t.icon_secondary });+ icons::paint(+ ui.painter(),+ Rect::from_center_size(r.center(), vec2(MODE_GLYPH, MODE_GLYPH)),+ *icon,+ if i == active { t.accent_text } else { t.icon_secondary },+ ); if resp.clicked() { pick_mode(app, i); }@@ -359,15 +362,14 @@ fn view_only_toolbar(app: &mut FigApp, ui: &mut Ui, why: &'static str) { app.run("tool.set", json!({"tool": "move"})); } let label = if why == figcraft_engine::live::PUBLISHING { tr("Publishing…") } else { tr("View only") };- let cell = 32.0; let text = ui.painter().layout_no_wrap(label.to_string(), crate::theme::semibold(11.5), t.text); let badge_w = 12.0 + 14.0 + 6.0 + text.size().x + 12.0;- let width = 2.0 * (cell + 4.0) + 8.0 + 12.0 + badge_w + 8.0;+ let width = PAD * 2.0 + 2.0 * (TOOL_CELL + GAP) + DIVIDER + GAP + badge_w; let rect = pill(ui, &mut app.ui.nav, app.canvas.rect, width, &t); let cy = rect.center().y;- let mut x = rect.min.x + 8.0;+ let mut x = rect.min.x + PAD; for (tool, icon) in [(ToolId::Move, Icon::Move), (ToolId::Hand, Icon::Hand)] {- let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell));+ let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL)); let active = app.session.tool.tool == tool; let resp = tool_cell(ui, &t, r, ("tool", tool.id()), icon, active, &format!("{} {}", tr(tool.label()), pretty_shortcut(tool.shortcut()))); if active {@@ -376,10 +378,10 @@ fn view_only_toolbar(app: &mut FigApp, ui: &mut Ui, why: &'static str) { if resp.clicked() { app.run("tool.set", json!({"tool": tool.id()})); }- x += cell + 4.0;+ x += TOOL_CELL + GAP; } divider(ui, &t, x, cy);- let badge = Rect::from_min_size(pos2(x + 12.0, cy - 13.0), vec2(badge_w, 26.0));+ let badge = Rect::from_min_size(pos2(x + DIVIDER + GAP, cy - 13.0), vec2(badge_w, 26.0)); let resp = ui.interact(badge, ui.scope_id().with("view_only"), Sense::hover()).on_hover_text(why); a11y::status(&resp, label, why); ui.painter().rect_filled(badge, CornerRadius::same(13), t.field);@@ -391,15 +393,14 @@ fn view_only_toolbar(app: &mut FigApp, ui: &mut Ui, why: &'static str) { fn edit_toolbar(app: &mut FigApp, ui: &mut Ui, current: VTool) { let t = Tokens::get(ui.ctx()); let canvas = app.canvas.rect;- let cell = 32.0;- let width = VTool::ALL.len() as f32 * (cell + 4.0) + 8.0 + 12.0 + DONE_W + 8.0;+ let width = VTool::ALL.len() as f32 * (TOOL_CELL + GAP) + PAD * 2.0 + DIVIDER + GAP + DONE_W; let rect = pill(ui, &mut app.ui.nav, canvas, width, &t); let cy = rect.center().y;- let mut x = rect.min.x + 8.0;+ let mut x = rect.min.x + PAD; // Holding Cmd with the Move tool bends: show it. let held = current == VTool::Move && ui.input(|i| i.modifiers.command); for tool in VTool::ALL {- let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell));+ let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL)); let tip = if tool == VTool::Bend { trf("{tool} hold {key}", &[("tool", &tr(tool.label())), ("key", &pretty_shortcut(tool.shortcut()))]) } else {@@ -423,7 +424,7 @@ fn edit_toolbar(app: &mut FigApp, ui: &mut Ui, current: VTool) { if resp.clicked() { app.run("vector.edit.tool", json!({"tool": tool.id()})); }- x += cell + 4.0;+ x += TOOL_CELL + GAP; } if done_button(ui, &t, x, cy, &format!("{} Esc", tr("Done editing"))) { app.run("vector.edit.end", json!({}));@@ -433,7 +434,6 @@ fn edit_toolbar(app: &mut FigApp, ui: &mut Ui, current: VTool) { /// The pill shown while the shape builder is active: Merge and Remove modes, a hint and Done. fn builder_toolbar(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx());- let cell = 32.0; let remove = app.session.tool.builder_remove; let has_regions = figcraft_engine::shapebuilder::current(&app.session).is_some_and(|a| !a.regions.is_empty()); let alt = pretty_shortcut("Alt");@@ -445,12 +445,12 @@ fn builder_toolbar(app: &mut FigApp, ui: &mut Ui) { trf("Click or drag to merge · {alt} removes", &[("alt", &alt)]) }; let galley = ui.painter().layout_no_wrap(hint, regular(12.0), t.text_secondary);- let width = 2.0 * (cell + 4.0) + 8.0 + 12.0 + galley.size().x + 12.0 + 12.0 + DONE_W + 8.0;+ let width = PAD * 2.0 + 2.0 * (TOOL_CELL + GAP) + 2.0 * (DIVIDER + GAP) + galley.size().x + GAP + DONE_W; let rect = pill(ui, &mut app.ui.nav, app.canvas.rect, width, &t); let cy = rect.center().y;- let mut x = rect.min.x + 8.0;+ let mut x = rect.min.x + PAD; for (mode, icon, tip) in [("merge", Icon::ShapeBuilder, tr("Merge regions")), ("remove", Icon::ShapeBuilderRemove, tr("Remove regions"))] {- let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell));+ let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL)); let active = (mode == "remove") == remove; let resp = tool_cell(ui, &t, r, ("sbmode", mode), icon, active, tip); if active {@@ -459,13 +459,13 @@ fn builder_toolbar(app: &mut FigApp, ui: &mut Ui) { if resp.clicked() { app.run("shapeBuilder.mode", json!({"mode": mode})); }- x += cell + 4.0;+ x += TOOL_CELL + GAP; } divider(ui, &t, x, cy);- x += 12.0;+ x += DIVIDER + GAP; let w = galley.size().x; ui.painter().galley(pos2(x, cy - galley.size().y / 2.0), galley, t.text_secondary);- x += w + 12.0;+ x += w + GAP; if done_button(ui, &t, x, cy, &format!("{} {}", tr("Done"), pretty_shortcut("Enter"))) { app.run("tool.set", json!({"tool": "move"})); }@@ -515,9 +515,10 @@ pub(crate) fn tool_cell(ui: &mut Ui, t: &Tokens, r: Rect, id: impl egui::AsIdSal resp } -/// A vertical separator just after `x`.+/// A vertical separator just after `x`: DIVIDER wide, as tall as the pill's content. fn divider(ui: &Ui, t: &Tokens, x: f32, cy: f32) {- ui.painter().line_segment([pos2(x + 2.0, cy - 14.0), pos2(x + 2.0, cy + 14.0)], Stroke::new(1.0, t.border));+ let half = TOOL_CELL / 2.0;+ ui.painter().line_segment([pos2(x + DIVIDER / 2.0, cy - half), pos2(x + DIVIDER / 2.0, cy + half)], Stroke::new(DIVIDER, t.border)); } const DONE_W: f32 = 56.0;@@ -525,7 +526,7 @@ const DONE_W: f32 = 56.0; /// A divider at `x` and the accent Done button after it; true when clicked. fn done_button(ui: &mut Ui, t: &Tokens, x: f32, cy: f32, tip: &str) -> bool { divider(ui, t, x, cy);- let done = Rect::from_min_size(pos2(x + 12.0, cy - 14.0), vec2(DONE_W, 28.0));+ let done = Rect::from_min_size(pos2(x + DIVIDER + GAP, cy - 14.0), vec2(DONE_W, 28.0)); let resp = ui.interact(done, ui.scope_id().with("tool_done"), Sense::click()).tip(tip); ui.painter().rect_filled(done, CornerRadius::same(6), if resp.hovered() { t.accent_hover } else { t.accent }); ui.painter().text(done.center(), Align2::CENTER_CENTER, tr("Done"), crate::theme::semibold(11.5), t.accent_on);@@ -642,12 +643,33 @@ mod tests { let phone = Rect::from_min_size(pos2(0.0, 0.0), vec2(375.0, 700.0)); let pill = pill_rect(phone, full); assert!(pill.width() <= phone.width() - 16.0 && pill.min.x >= 8.0 && pill.max.x <= phone.max.x - 8.0, "{pill:?}");+ // Narrower than its items, so it scrolls under a drag or the wheel (#1209).+ assert!(full > pill.width(), "{full} in {}", pill.width()); // A desktop canvas keeps the full width, and a degenerate one doesn't go negative. let wide = Rect::from_min_size(pos2(0.0, 0.0), vec2(1400.0, 800.0)); assert_eq!(pill_rect(wide, full).width(), full); assert_eq!(pill_rect(Rect::from_min_size(pos2(0.0, 0.0), vec2(4.0, 4.0)), full).width(), 0.0); } + /// The width the pill asks for is the width its items take — the groups, Actions, the divider+ /// and the end (the mode switcher, or a board's two buttons), PAD in from either end and one+ /// GAP apart — so its last item ends exactly PAD from its right edge.+ #[test]+ fn the_pill_is_as_wide_as_its_items() {+ let canvas = Rect::from_min_size(pos2(0.0, 0.0), vec2(1400.0, 800.0));+ for (groups, board) in [(GROUPS, false), (BOARD_GROUPS, true)] {+ let pill = pill_rect(canvas, main_width(groups, board));+ let mut x = pill.min.x + PAD;+ for g in groups {+ x += TOOL_CELL + if g.has_flyout() { FLYOUT_GAP + FLYOUT_W } else { 0.0 } + GAP;+ }+ x += TOOL_CELL + GAP; // Actions+ x += DIVIDER + GAP; // the divider+ let end = if board { 2.0 * TOOL_CELL + GAP } else { 4.0 * TOOL_CELL };+ assert!((x + end - (pill.max.x - PAD)).abs() < 0.01, "board {board}: {} vs {}", x + end, pill.max.x - PAD);+ }+ }+ /// 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();@@ -25,7 +25,8 @@ const GAP: f32 = 8.0; const TITLE_H: f32 = 26.0; /// The templates button at `at` (its left edge, the toolbar's middle) and, while open, its picker-/// above the toolbar (whose top is `top`). Where the next button starts.+/// above the toolbar (whose top is `top`). The button's right edge: the toolbar puts the next+/// button a GAP after it. pub(crate) fn button(app: &mut FigApp, ui: &mut Ui, at: Pos2, top: f32, t: &Tokens) -> f32 { let r = Rect::from_min_size(pos2(at.x, at.y - 16.0), vec2(32.0, 32.0)); let open = app.ui.tool_flyout == Some(KEY);@@ -39,7 +40,7 @@ pub(crate) fn button(app: &mut FigApp, ui: &mut Ui, at: Pos2, top: f32, t: &Toke if app.ui.tool_flyout == Some(KEY) { picker(app, ui, r, top - 8.0, t); }- r.max.x + 4.0+ r.max.x } fn area_id() -> egui::Id {@@ -27,7 +27,7 @@ all end with the same geometry. | Feature | Status | Notes | |---|---|---| | Board files: File ▸ New board, the home screen's New board card, a light page | done | `file.newBoard`; the kind shows in `inspect_document`, tabs and file rows (a note mark; design files a pen nib) |-| Board toolbar at the bottom | done | Move/Hand, Marker/Highlighter/Eraser, Sticky (colour flyout), Shapes (flyout), Connector, Text, Section, Stamp (flyout), Comment, Resources, Actions, the Design panel's button |+| Board toolbar at the bottom | done | Move/Hand, Marker/Highlighter/Eraser, Sticky (colour flyout), Shapes (flyout), Connector, Text, Section, Stamp (flyout), Comment, Actions, then the templates and the Design panel's buttons — on the same 32 pt grid as a design file's (no Resources button, as there) | | Design panel hidden by default; a contextual bar above the selection | done | colour, shape, text size (S/M/L/XL), connector straight/elbow/curved and its arrow; the Design panel opens with its button or View ▸ Design panel | | Prototyping and Dev Mode refuse on boards | done | `proto.*`, `dev.*`, `view.prototype`, `view.devMode` say why | | Sticky notes | done | a square frame (200 wide, growing taller through auto layout), 8 soft colours, a soft shadow, the note's text size shrinking as it grows (24 → 11, by characters and lines), the author's name at the bottom; a click places one and starts typing; Enter or a double-click edits; Shift+Cmd/Ctrl+Enter adds the next one beside it |@@ -43,7 +43,7 @@ interaction feel not yet compared. | Rename: double click, F2 / Cmd+R, Tab to the next row; several layers open Rename layers (match, regular expressions, current name and number codes, start value, preview) | done | numbered in stacking order, pinned | | Find layers (filter by name) | done | not covered by the documentation | | Pages: add, rename, drag to reorder, duplicate ("Copy of"), delete, dividers (a blank page named with a hyphen) | done | the line between Pages and Layers drags to size the pages list, kept in the preferences; a long list scrolls in its own area and a page dropped outside the visible list stays put (#272) (`layers_behaviour_tests`) |-| Assets panel: folders from page, frames and slash names (fold away), search, thumbnails, drag onto the canvas or into frames and auto layout, Alt-drop swap, enabled libraries | done | Shift+I (Quick insert) and the toolbar's Resources open it (they didn't, #710) |+| Assets panel: folders from page, frames and slash names (fold away), search, thumbnails, drag onto the canvas or into frames and auto layout, Alt-drop swap, enabled libraries | done | Shift+I (Quick insert), Alt+2 and View ▸ Panels ▸ Assets open it; the toolbar's Resources button was removed (the left rail's Assets tab and those shortcuts are the ways in) | | Hide when publishing from the Assets panel's context menu | done | a right-click on one of this file's components (also Libraries ▸ Hide when publishing for the selection, `component.hideWhenPublishing {ids?, on?}`): the model's sign of it is the name, so hiding puts a `.` before the name and showing takes the leading `.` and `_` away (one undo step, saved and synced like a rename; a variant or a layer inside hides its whole component or set); a hidden component shows a crossed eye on its tile and a dimmer name, the menu item its check mark; the next publish leaves it out and brings it back when shown again (`hide_publishing_tests`). Library components (read-only here) have no such menu | ## Selection and transform