Merge pull request #1210 from yelintong/feat/nav-toolbar

ui: rebuild the toolbar pill on a 32 pt grid, without the Resources button
This commit is contained in:
BFlat
2026-10-29 13:34:02 +07:00
committed by GitHub
31 changed files with 136 additions and 139 deletions
+2 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -352,6 +352,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-29 | minor | The toolbar on one 32 pt grid (M4, #1210): the pill's cells, padding, gaps and divider share one grid in every state, and the Resources button is gone, as in UI3; the Assets panel opens from the left rail, Shift+I, Alt+2 and View ▸ Panels ▸ Assets. Numbers unchanged |
| 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `crates/ui-egui/locales/ar.toml`, i18n catalog tests and [translation notes](docs/translating.md#arabic-draft) |
| 2026-10-28 | minor | M3, #922: instances scaled with the Scale tool keep their layers' scaled size and points when their main is rebuilt (`size`/`position` overrides recorded on scale). Numbers unchanged. |
| 2026-10-28 | minor | M6, #1059: `.fig` import copies embedded image blobs once per blob, not once per hash naming them. Numbers unchanged. |
-1
View File
@@ -1204,7 +1204,6 @@ status = "draft — needs native review"
"Resolve" = "حل"
"Resolve change" = "حل التغيير"
"Resolved" = "محلول"
"Resources" = "الموارد"
"Responsive" = "متجاوب"
"Restart" = "إعادة البدء"
"Restore" = "استعادة"
-1
View File
@@ -1164,7 +1164,6 @@ status = "draft — needs native review"
"Resolve" = "সমাধান করুন"
"Resolve change" = "পরিবর্তন সমাধান করুন"
"Resolved" = "সমাধান করা হয়েছে"
"Resources" = "রিসোর্সসমূহ"
"Responsive" = "রেসপনসিভ"
"Restart" = "পুনরায় শুরু করুন"
"Restore" = "পুনরুদ্ধার করুন"
-1
View File
@@ -1142,7 +1142,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"
-1
View File
@@ -1112,7 +1112,6 @@ status = "draft — needs native review"
"Resolve" = "Erledigen"
"Resolve change" = "Änderung erledigen"
"Resolved" = "Erledigt"
"Resources" = "Ressourcen"
"Responsive" = "Responsiv"
"Restart" = "Neu starten"
"Restore" = "Wiederherstellen"
-1
View File
@@ -1202,7 +1202,6 @@ name = "English"
"Resolve" = "Resolve"
"Resolve change" = "Resolve change"
"Resolved" = "Resolved"
"Resources" = "Resources"
"Responsive" = "Responsive"
"Restart" = "Restart"
"Restore" = "Restore"
-1
View File
@@ -1198,7 +1198,6 @@ status = "draft — needs native review"
"Resolve" = "Resolver"
"Resolve change" = "Resolver cambio"
"Resolved" = "Resuelto"
"Resources" = "Recursos"
"Responsive" = "Adaptable"
"Restart" = "Reiniciar"
"Restore" = "Restaurar"
-1
View File
@@ -1168,7 +1168,6 @@ status = "draft — needs native review"
"Resolve" = "Ratkaise"
"Resolve change" = "Ratkaise muutos"
"Resolved" = "Ratkaistu"
"Resources" = "Resurssit"
"Responsive" = "Mukautuva"
"Restart" = "Aloita uudelleen"
"Restore" = "Palauta"
-1
View File
@@ -1169,7 +1169,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"
-1
View File
@@ -1148,7 +1148,6 @@ status = "draft — needs native review"
"Resolve" = "Selesaikan"
"Resolve change" = "Selesaikan perubahan"
"Resolved" = "Terselesaikan"
"Resources" = "Sumber daya"
"Responsive" = "Responsif"
"Restart" = "Mulai ulang"
"Restore" = "Pulihkan"
-1
View File
@@ -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"
-1
View File
@@ -1156,7 +1156,6 @@ status = "draft — needs native review"
"Resolve" = "解決"
"Resolve change" = "変更を解決"
"Resolved" = "解決済み"
"Resources" = "リソース"
"Responsive" = "レスポンシブ"
"Restart" = "再起動"
"Restore" = "復元"
-1
View File
@@ -1179,7 +1179,6 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Resolve" = "გადაწყვეტა"
"Resolve change" = "ცვლილების გადაწყვეტა"
"Resolved" = "გადაწყვეტილია"
"Resources" = "რესურსები"
"Responsive" = "ადაპტური"
"Restart" = "გადატვირთვა"
"Restore" = "აღდგენა"
-1
View File
@@ -1106,7 +1106,6 @@ status = "draft — needs native review"
"Resolve" = "해결"
"Resolve change" = "변경 사항 해결"
"Resolved" = "해결됨"
"Resources" = "리소스"
"Responsive" = "반응형"
"Restart" = "다시 시작"
"Restore" = "복원"
-1
View File
@@ -1168,7 +1168,6 @@ status = "draft — needs native review"
"Resolve" = "Oplossen"
"Resolve change" = "Wijziging oplossen"
"Resolved" = "Opgelost"
"Resources" = "Bronnen"
"Responsive" = "Responsive"
"Restart" = "Opnieuw starten"
"Restore" = "Terugzetten"
-1
View File
@@ -1172,7 +1172,6 @@ name = "Polski"
"Resolve" = "Rozwiąż"
"Resolve change" = "Rozwiąż zmianę"
"Resolved" = "Rozwiązany"
"Resources" = "Zasoby"
"Responsive" = "Responsywny"
"Restart" = "Uruchom ponownie"
"Restore" = "Przywróć"
-1
View File
@@ -1105,7 +1105,6 @@ status = "draft — needs native review"
"Resolve" = "Resolver"
"Resolve change" = "Resolver alteração"
"Resolved" = "Resolvido"
"Resources" = "Recursos"
"Responsive" = "Responsivo"
"Restart" = "Reiniciar"
"Restore" = "Restaurar"
-1
View File
@@ -1172,7 +1172,6 @@ name = "Русский"
"Resolve" = "Решить"
"Resolve change" = "Разрешить изменение"
"Resolved" = "Решённые"
"Resources" = "Ресурсы"
"Responsive" = "Адаптивно"
"Restart" = "Начать заново"
"Restore" = "Восстановить"
-1
View File
@@ -1199,7 +1199,6 @@ name = "Svenska"
"Resolve" = "Lös"
"Resolve change" = "Lös ändring"
"Resolved" = "Löst"
"Resources" = "Resurser"
"Responsive" = "Responsiv"
"Restart" = "Starta om"
"Restore" = "Återställ"
-1
View File
@@ -1161,7 +1161,6 @@ status = "draft — needs native review"
"Resolve" = "แก้ไขแล้ว"
"Resolve change" = "แก้ไขการเปลี่ยนแปลง"
"Resolved" = "แก้ไขแล้ว"
"Resources" = "แหล่งข้อมูล"
"Responsive" = "รีสปอนซีฟ"
"Restart" = "เริ่มใหม่"
"Restore" = "กู้คืน"
-1
View File
@@ -1146,7 +1146,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"
-1
View File
@@ -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"
-1
View File
@@ -1114,7 +1114,6 @@ status = "draft — needs native review"
"Resolve" = "解决"
"Resolve change" = "解决更改"
"Resolved" = "已解决"
"Resources" = "资源"
"Responsive" = "响应式"
"Restart" = "重新开始"
"Restore" = "恢复"
+1 -1
View File
@@ -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 {
+2 -2
View File
@@ -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>()
}
+29 -30
View File
@@ -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:?}");
}
-5
View File
@@ -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);
+95 -73
View File
@@ -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);
}
x
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 + 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();
+3 -2
View File
@@ -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 {
+1 -1
View File
@@ -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 |
+3 -2
View File
File diff suppressed because one or more lines are too long