From b3a3b33fcd50da52838f43334992a9c4212db7f8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 9 Oct 2026 13:17:23 -0400 Subject: [PATCH] M1.3: UI3 fidelity pass (icon rail, panel sublabels, toolbar modes, help), font scan off the lock, ROADMAP Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 137 ++++++++++++++++++++++++++++++++++ crates/text/src/fontdb.rs | 28 ++++++- crates/ui-egui/src/layers.rs | 126 ++++++++++++++++++------------- crates/ui-egui/src/lib.rs | 11 +++ crates/ui-egui/src/right.rs | 136 +++++++++++++++++++-------------- crates/ui-egui/src/toolbar.rs | 94 +++++++++++++++++------ crates/ui-egui/src/widgets.rs | 18 +++++ 7 files changed, 416 insertions(+), 134 deletions(-) create mode 100644 ROADMAP.md diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..e4bc204 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,137 @@ +# FigCraft Roadmap + +FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow. It +runs on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive it completely through +the CLI, a JSON control channel and MCP. + +This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of +continuous Claude Opus 5.5 agent work (including builds and the CI gate), for **one agent** and for +**4–6 parallel agents** on disjoint crates. They are counted from the remaining work in the parity +table below and updated as work lands. + +_Last updated: 2026-10-09 (end of day 1: foundation, renderer, engine, UI3 shell)._ + +## Where we are + +| Dimension | Status | +|---|---| +| Infrastructure (crates, command registry, undo, renderer, formats, control channel, tests) | **~70%** | +| Look & feel vs the incumbent's 2026 UI (measured side by side) | **~65%** | +| Menu/command breadth (`docs/parity.md`) | **96%** of the catalogued menu tree has a command | +| Feature depth (weighted rubric below) | **~40%** | +| Parity including interaction fidelity and hardening | **~25–30%** | +| Time to **feature parity** | **~260–380 h** one agent · **~70–110 h** with 4–6 agents | +| Time to **full parity** (feature parity + fidelity pass + hardening) | **~420–600 h** one agent · **~110–170 h** with 4–6 agents | + +## Honest assessment (2026-10-09) + +**In one line:** the architecture is complete end to end (document model → commands → renderer → +UI, CLI/MCP and control channel), and the everyday core (frames, shapes, text, auto layout, +components with overrides, variants, styles, variables, effects, booleans, prototyping basics, +SVG/PNG/JPG export) works. What's missing is depth: vector editing, rich text editing, the long tail +of auto-layout and component behaviours, real-file interop and a fidelity pass against the +incumbent. + +**How much to trust the numbers:** scores are self-graded by the agents that built the features from +public documentation and black-box observation. Treat them as upper bounds. Look & feel is the only +dimension measured side by side. + +### Where we're lacking (priority order) + +1. **Vector edit mode** (Enter on a vector): vertex/handle editing, bend, paint bucket, lasso, + cut/erase, vector networks with branching. 25–35 h. +2. **Text editing depth**: mouse selection inside text, styled range editing UI, lists polish, + OpenType features panel, variable font axes, font picker with previews. 20–30 h. +3. **Auto layout depth**: on-canvas padding/gap handles, drag-reorder polish, min/max UI, baseline + alignment, absolute positioning UI, grid tracks (fr/fixed), wrap polish. 15–25 h. +4. **Components depth**: component properties panel (create/edit/bind), instance swap UI, nested + instance overrides, slots, library publishing (local files), Assets panel drag-to-canvas. 20–30 h. +5. **Prototyping depth**: on-canvas noodle editing, overlays/scroll/fixed elements, smart animate + (matching layers), variables-driven interactions, device frames. 20–30 h. +6. **Interop**: .fig import (best effort), PDF export (in progress), image crop/adjustments UI, + paste from other apps, Figma REST JSON import. 25–40 h. +7. **Look & feel and interaction-fidelity pass**: tool by tool, panel by panel against the incumbent + (cursors, modifier keys, hover states, menus, popovers). 40–60 h. +8. **Hardening and performance**: real-world file corpus, perf budgets at 50k layers (tile cache, + incremental re-render), Windows/Linux/browser QA, accessibility. 40–60 h. + +### Where we're going + +- **Next:** vector edit mode (1) and text depth (2) in parallel with the fidelity pass (7). +- **Then:** auto layout/components/prototyping depth (3–5), interop (6). +- **Alpha:** release builds on every push to `release` (packaging in progress), demo files, docs. + +## Shipped so far + +- **Architecture:** 13 crates with enforced layering (`cargo xtask layers`): geom, color, doc, + layout, text, pathops, render, format, svg, engine, ui-egui (+ mcp, pdf in progress). Every action + is a command (~250); undo is unlimited via structural sharing (`Arc` node snapshots); gestures are + one undo step; `command.batch` runs several commands as one step. +- **Document model:** pages, frames, groups, sections, rectangles, ellipses (arcs/rings), polygons, + stars, lines/arrows, vector networks, text with styled runs, boolean groups, components, component + sets (variants), instances with per-layer overrides, slices; fills (solid, linear/radial/angular/ + diamond gradients, images with fill/fit/crop/tile + adjustments), multiple strokes with + inside/center/outside alignment, per-side weights, dashes, caps/arrowheads, joins; effects (drop + and inner shadow with spread, layer blur, background blur, noise, texture); corner radii with + smoothing; constraints; auto layout (horizontal, vertical, wrap, grid; hug/fill/fixed; min/max; + absolute children); layout grids; export settings; prototype interactions, flows and transitions; + paint/text/effect/grid styles; variables with collections, modes and aliases. +- **Renderer:** CPU (tiny-skia) scene renderer used for canvas, export and the web: isolated layers + for opacity/blend/effects, clip content, masks, booleans (linesweeper), text (harfrust shaping, + skrifa outlines, font fallback), aligned strokes with caching. +- **Engine:** pointer tools (move/marquee/deep select/resize/rotate/Alt-duplicate with smart-guide + snapping, frame/section/slice/shapes/line/arrow with Shift/Alt, text, pen, pencil, hand, + eyedropper), text editing (caret, selection, words, lines), auto layout reorder by dragging, + drop into frames, align/distribute/tidy, z-order, flip/rotate, scale tool, booleans, flatten, + outline stroke, components (create, instances, sync with overrides, reset, detach, push changes, + variants, properties), styles and variables, prototype commands, find/replace, batch rename, + copy/paste (internal + SVG/text), file new/open/save/import/export (native `.figcraft`, SVG + in/out, PNG/JPG/WebP), version snapshots. +- **UI (egui, swappable):** the incumbent's 2026 layout: tab strip, icon rail, file header, pages and + layers tree (rename, visibility/lock, drag to reorder/reparent), Assets panel, floating bottom + toolbar with flyouts and mode switcher, Design / Prototype / Inspect (CSS) panels, colour picker + (HSB square, hue/alpha, hex, gradient stops, image fills), main menu generated from the catalog, + context menu, Actions palette (⌘K), keyboard shortcuts from the catalog, presentation player with + transitions, light and dark themes, home screen, About/Preferences/shortcuts/variables dialogs. +- **Automation:** desktop control channel (`--control `: execute commands, inspect document/UI, + synthetic pointer/keys/typing, menu invocation, screenshots, renders). +- **Tests:** ~120 automated tests including a junk-parameter sweep over every command. + +## Parity estimate (weighted rubric) + +Scores by behaviour, 0–100%. Hours are what remains (one agent). + +| Area | Weight | Score | Remaining work | Hours | +|---|---|---|---|---| +| Canvas & navigation | 6 | 60% | rulers/guides dragging, pixel preview, minimap-free perf at scale | 8–12 | +| Selection & transform | 8 | 55% | rotated multi-select handles, scale tool polish, measure hover polish, nudge-resize | 12–18 | +| Layers panel | 5 | 55% | search/filter, multi-select ranges, collapse all, thumbnails, drag polish | 8–12 | +| Shapes & vector | 8 | 30% | vector edit mode, bend/paint bucket, live boolean editing, arc handles on canvas | 30–40 | +| Paint | 7 | 55% | gradient handles on canvas, image crop mode, document colours, colour models | 12–18 | +| Strokes | 3 | 55% | arrowhead set, dash/gap UI, per-side UI, stroke styles | 5–8 | +| Effects | 4 | 60% | effect styles UI, glass/texture fidelity, shadow spread on non-rects exactness | 6–10 | +| Frames, groups, sections, masks | 5 | 55% | section tooling, mask types (luminance/outline), frame presets UI | 6–10 | +| Auto layout & constraints | 9 | 45% | canvas handles, baseline, grid tracks, nested hug/fill edge cases | 15–25 | +| Text | 8 | 35% | in-text mouse selection, styled ranges UI, lists, OpenType, variable fonts | 20–30 | +| Components & variants | 9 | 40% | properties UI, swap UI, nested overrides, libraries, slots | 20–30 | +| Styles & variables | 5 | 45% | style picker everywhere, variable binding UI, aliasing UI, scoping | 10–16 | +| Prototyping | 6 | 30% | noodle editing, overlays, scrolling, smart animate | 20–30 | +| Import/export | 6 | 45% | PDF (in progress), .fig import, export panel polish | 20–35 | +| Dev mode / inspect | 2 | 35% | measurements on canvas, code variants (Swift/Compose), assets | 6–10 | +| Comments, versions, misc | 3 | 25% | comments, version history UI, plugins (WASM) | 15–25 | + +Weighted feature depth ≈ **43%**. + +## Milestones + +| Milestone | Status | +|---|---| +| M0 Foundation: workspace, model, renderer, engine, UI shell, app, control channel | done (2026-10-09) | +| M0.9 CLI + MCP, web build, release packaging, PDF export | in progress | +| M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~60% | +| M2 Layout depth | ~45% | +| M3 Components & styles depth | ~40% | +| M4 Vector & text depth | ~30% | +| M5 Prototype & present | ~30% | +| M6 Interop & dev mode | ~35% | +| M7 Release (signed builds for all platforms, README screenshots) | started | diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 1e71a1f..ef3fec4 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -423,7 +423,10 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap 64 * 1024 * 1024).unwrap_or(true) { + continue; + } let Ok(bytes) = std::fs::read(&path) else { continue }; let count = match FileRef::new(bytes.as_slice()) { Ok(FileRef::Collection(c)) => c.len(), @@ -462,8 +465,27 @@ pub fn family_styles(family: &str) -> Vec { db().write().unwrap_or_else(std::sync::PoisonError::into_inner).styles(family) } -/// Scan system fonts into the global db (desktop; a no-op on the web). +/// Scan system fonts into the global db (desktop; a no-op on the web). The directory walk runs +/// without holding the database lock; results are merged in one short write. pub fn scan_system_fonts() { #[cfg(not(target_arch = "wasm32"))] - db().write().unwrap_or_else(std::sync::PoisonError::into_inner).scan_system(); + { + if db().read().unwrap_or_else(std::sync::PoisonError::into_inner).system_scanned { + return; + } + let mut found: BTreeMap)> = BTreeMap::new(); + for dir in system_font_dirs() { + scan_dir(&dir, 0, &mut found); + } + let mut w = db().write().unwrap_or_else(std::sync::PoisonError::into_inner); + if w.system_scanned { + return; + } + w.system_scanned = true; + for (k, v) in found { + if !w.families.contains_key(&k) { + w.pending.entry(k).or_insert(v); + } + } + } } diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 160657f..b040944 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -59,65 +59,81 @@ pub fn node_icon(doc: &Document, id: NodeId) -> Icon { } } -pub fn left_panel(app: &mut FigApp, ui: &mut Ui) { +/// The slim icon rail at the far left: main menu, File, Assets, Variables. +pub fn rail(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - let full = ui.max_rect(); - // Header: logo (main menu) + file name. - let (head, _) = ui.allocate_exact_size(vec2(full.width(), 48.0), Sense::hover()); - let lr = Rect::from_min_size(pos2(head.min.x + 8.0, head.min.y + 8.0), vec2(48.0, 32.0)); - let lresp = ui.interact(lr, ui.id().with("mainmenu"), Sense::click()).on_hover_text("Main menu"); + let r = ui.max_rect(); + let mut y = r.min.y + 8.0; + // Main menu (logo). + let lr = Rect::from_center_size(pos2(r.center().x, y + 16.0), vec2(36.0, 32.0)); + let lresp = ui.interact(lr, ui.id().with("rail_menu"), Sense::click()).on_hover_text("Main menu"); if lresp.hovered() || app.ui.main_menu { - ui.painter().rect_filled(lr, CornerRadius::same(5), t.hover); + ui.painter().rect_filled(lr, CornerRadius::same(6), t.hover); } - icons::paint(ui.painter(), Rect::from_min_size(pos2(lr.min.x + 6.0, lr.center().y - 9.0), vec2(18.0, 18.0)), Icon::Logo, ACCENT); - icons::paint(ui.painter(), Rect::from_min_size(pos2(lr.min.x + 28.0, lr.center().y - 6.0), vec2(12.0, 12.0)), Icon::Chevron, t.icon_secondary); + icons::paint(ui.painter(), Rect::from_center_size(lr.center(), vec2(18.0, 18.0)), Icon::Logo, t.icon); if lresp.clicked() { app.ui.main_menu = !app.ui.main_menu; } - let name = app.session.doc().map(|d| d.name.clone()).unwrap_or_default(); - let nr = Rect::from_min_max(pos2(lr.max.x + 6.0, head.min.y + 6.0), pos2(head.max.x - 40.0, head.min.y + 26.0)); - if let Some(new) = crate::widgets::text_field_inline(ui, Id::new("filename"), &name, nr, semibold(12.0)) { - app.run("file.rename", json!({"name": new})); + y += 44.0; + ui.painter().line_segment([pos2(r.min.x + 18.0, y), pos2(r.max.x - 18.0, y)], Stroke::new(1.0, t.border)); + y += 12.0; + let items = [(Icon::Page, "File", Some(LeftTab::File)), (Icon::Assets, "Assets", Some(LeftTab::Assets)), (Icon::Styles, "Variables", None)]; + for (icon, label, tab) in items { + let ir = Rect::from_center_size(pos2(r.center().x, y + 14.0), vec2(36.0, 28.0)); + let hit = Rect::from_min_max(pos2(r.min.x, y), pos2(r.max.x, y + 48.0)); + let resp = ui.interact(hit, ui.id().with(("rail", label)), Sense::click()).on_hover_text(label); + let active = tab.is_some_and(|tb| tb == app.ui.left_tab && !app.ui.left_collapsed); + if active { + ui.painter().rect_filled(ir, CornerRadius::same(6), t.selected_row); + } else if resp.hovered() { + ui.painter().rect_filled(ir, CornerRadius::same(6), t.hover); + } + icons::paint(ui.painter(), Rect::from_center_size(ir.center(), vec2(16.0, 16.0)), icon, if active { t.accent_text } else { t.icon }); + ui.painter().text(pos2(r.center().x, y + 38.0), Align2::CENTER_CENTER, label, regular(10.0), t.text); + if resp.clicked() { + match tab { + Some(tb) => { + if app.ui.left_tab == tb && !app.ui.left_collapsed { + app.ui.left_collapsed = true; + } else { + app.ui.left_tab = tb; + app.ui.left_collapsed = false; + } + } + None => { + ui.data_mut(|d| d.insert_temp(Id::new("variables_open"), true)); + } + } + } + y += 60.0; } - ui.painter().text(pos2(nr.min.x + 2.0, head.min.y + 34.0), Align2::LEFT_CENTER, "Drafts", regular(11.0), t.text_secondary); - // Hide UI toggle. - let hr = Rect::from_center_size(pos2(head.max.x - 22.0, head.center().y), vec2(24.0, 24.0)); - let hresp = ui.interact(hr, ui.id().with("hideui"), Sense::click()).on_hover_text("Minimize UI ⇧⌘\\"); +} + +pub fn left_panel(app: &mut FigApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let full = ui.max_rect(); + // Header: file name (or "Assets") + drafts + collapse. + let (head, _) = ui.allocate_exact_size(vec2(full.width(), 64.0), Sense::hover()); + if app.ui.left_tab == LeftTab::Assets { + ui.painter().text(pos2(head.min.x + 16.0, head.min.y + 24.0), Align2::LEFT_CENTER, "Assets", semibold(13.0), t.text); + } else { + let name = app.session.doc().map(|d| d.name.clone()).unwrap_or_default(); + let nr = Rect::from_min_max(pos2(head.min.x + 14.0, head.min.y + 14.0), pos2(head.max.x - 48.0, head.min.y + 34.0)); + if let Some(new) = crate::widgets::text_field_inline(ui, Id::new("filename"), &name, nr, semibold(13.0)) { + app.run("file.rename", json!({"name": new})); + } + ui.painter().text(pos2(head.min.x + 16.0, head.min.y + 46.0), Align2::LEFT_CENTER, "Drafts", regular(11.0), t.text_secondary); + } + let hr = Rect::from_center_size(pos2(head.max.x - 24.0, head.min.y + 24.0), vec2(24.0, 24.0)); + let hresp = ui.interact(hr, ui.id().with("hideui"), Sense::click()).on_hover_text("Collapse panel"); 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); if hresp.clicked() { - app.run("view.toggleUi", json!({})); + app.ui.left_collapsed = true; } - // Tabs row. - let (tabs, _) = ui.allocate_exact_size(vec2(full.width(), 40.0), Sense::hover()); - ui.painter().line_segment([tabs.left_top(), tabs.right_top()], Stroke::new(1.0, t.border)); - let mut x = tabs.min.x + 8.0; - for (tab, label) in [(LeftTab::File, "File"), (LeftTab::Assets, "Assets")] { - let galley = ui.painter().layout_no_wrap(label.into(), semibold(11.0), t.text); - let r = Rect::from_min_size(pos2(x, tabs.center().y - 12.0), vec2(galley.size().x + 16.0, 24.0)); - let resp = ui.interact(r, ui.id().with(("ltab", label)), Sense::click()); - let active = app.ui.left_tab == tab; - if active { - ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); - } - ui.painter().galley(pos2(r.min.x + 8.0, r.center().y - galley.size().y / 2.0), galley, if active { t.text } else { t.text_secondary }); - if resp.clicked() { - app.ui.left_tab = tab; - } - x = r.max.x + 2.0; - } - let sr = Rect::from_center_size(pos2(tabs.max.x - 22.0, tabs.center().y), vec2(24.0, 24.0)); - let sresp = ui.interact(sr, ui.id().with("lsearch"), Sense::click()).on_hover_text("Find layers ⌘F"); - if sresp.hovered() { - ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover); - } - icons::paint(ui.painter(), sr.shrink(5.0), Icon::Search, t.icon); - if sresp.clicked() { - app.ui.find = Some((String::new(), String::new())); - } - ui.painter().line_segment([tabs.left_bottom(), tabs.right_bottom()], Stroke::new(1.0, t.border)); + ui.painter().line_segment([head.left_bottom(), head.right_bottom()], Stroke::new(1.0, t.border)); match app.ui.left_tab { LeftTab::File => { pages(app, ui); @@ -136,6 +152,15 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { if tresp.clicked() { app.ui.pages_open = !app.ui.pages_open; } + let sr = Rect::from_center_size(pos2(hr.max.x - 50.0, hr.center().y), vec2(24.0, 24.0)); + let sresp = ui.interact(sr, ui.id().with("lsearch"), Sense::click()).on_hover_text("Find ⌘F"); + if sresp.hovered() { + ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover); + } + icons::paint(ui.painter(), sr.shrink(5.0), Icon::Search, t.icon); + if sresp.clicked() { + app.ui.find = Some((String::new(), String::new())); + } let ar = Rect::from_center_size(pos2(hr.max.x - 22.0, hr.center().y), vec2(24.0, 24.0)); let aresp = ui.interact(ar, ui.id().with("addpage"), Sense::click()).on_hover_text("Add new page"); if aresp.hovered() { @@ -156,19 +181,16 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { for (id, name) in pages { let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click()); let r = r.shrink2(vec2(8.0, 0.0)); - if resp.hovered() { + if resp.hovered() || id == cur { ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); } - if id == cur { - icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(12.0, 12.0)), Icon::Check, t.icon); - } if app.ui.rename_page.as_ref().is_some_and(|(i, _)| *i == id) { - let tr = Rect::from_min_max(pos2(r.min.x + 28.0, r.min.y + 4.0), pos2(r.max.x - 4.0, r.max.y - 4.0)); + let tr = Rect::from_min_max(pos2(r.min.x + 8.0, r.min.y + 4.0), pos2(r.max.x - 4.0, r.max.y - 4.0)); if let Some(new) = crate::widgets::text_field_inline(ui, Id::new(("pagename", id)), &name, tr, if id == cur { semibold(11.0) } else { regular(11.0) }) { rename_done = Some((id, new)); } } else { - ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, &name, if id == cur { semibold(11.0) } else { regular(11.0) }, t.text); + ui.painter().text(pos2(r.min.x + 10.0, r.center().y), Align2::LEFT_CENTER, &name, if id == cur { semibold(11.0) } else { regular(11.0) }, t.text); } if resp.clicked() { select = Some(id); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3d4bc48..57b69fb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -102,6 +102,7 @@ pub struct UiState { pub export_preview: bool, pub zoom_menu: bool, pub find: Option<(String, String)>, + pub left_collapsed: bool, } impl Default for UiState { @@ -137,6 +138,7 @@ impl Default for UiState { export_preview: false, zoom_menu: false, find: None, + left_collapsed: false, } } } @@ -305,12 +307,21 @@ impl FigApp { let hidden = self.session.ds().map(|d| d.view.ui_hidden).unwrap_or(false); tabs::tab_bar(self, ui); if !hidden { + egui::Panel::left("left_rail") + .exact_size(56.0) + .resizable(false) + .frame(egui::Frame::NONE.fill(t.panel).stroke(egui::Stroke::new(1.0, t.border))) + .show(ui, |ui| layers::rail(self, ui)); + } + if !hidden && !self.ui.left_collapsed { egui::Panel::left("left_panel") .resizable(true) .default_size(self.ui.left_width) .size_range(200.0..=480.0) .frame(egui::Frame::NONE.fill(t.panel).stroke(egui::Stroke::new(1.0, t.border))) .show(ui, |ui| layers::left_panel(self, ui)); + } + if !hidden { egui::Panel::right("right_panel") .resizable(true) .default_size(self.ui.right_width) diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index f6fcb96..808b58c 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -47,22 +47,25 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { let w = ui.available_width(); // Top bar: avatar, present, share. let (top, _) = ui.allocate_exact_size(vec2(w, 48.0), Sense::hover()); - ui.painter().circle_filled(pos2(top.min.x + 24.0, top.center().y), 12.0, Color32::from_rgb(0x14, 0xAE, 0x5C)); - ui.painter().text(pos2(top.min.x + 24.0, top.center().y), Align2::CENTER_CENTER, "Y", semibold(11.0), Color32::WHITE); - let share = Rect::from_min_size(pos2(top.max.x - 72.0, top.center().y - 12.0), vec2(60.0, 24.0)); + ui.painter().circle_filled(pos2(top.min.x + 26.0, top.center().y), 13.0, Color32::from_rgb(0x14, 0xAE, 0x5C)); + ui.painter().text(pos2(top.min.x + 26.0, top.center().y), Align2::CENTER_CENTER, "Y", semibold(12.0), Color32::WHITE); + icons::paint(ui.painter(), Rect::from_center_size(pos2(top.min.x + 48.0, top.center().y), vec2(10.0, 10.0)), Icon::Chevron, t.icon_secondary); + let share = Rect::from_min_size(pos2(top.max.x - 70.0, top.center().y - 16.0), vec2(56.0, 32.0)); let sresp = ui.interact(share, ui.id().with("share"), Sense::click()).on_hover_text("Export and share"); - ui.painter().rect_filled(share, CornerRadius::same(5), if sresp.hovered() { t.accent_hover } else { ACCENT }); - ui.painter().text(share.center(), Align2::CENTER_CENTER, "Share", semibold(11.0), Color32::WHITE); + ui.painter().rect_filled(share, CornerRadius::same(6), if sresp.hovered() { t.accent_hover } else { ACCENT }); + ui.painter().text(share.center(), Align2::CENTER_CENTER, "Share", semibold(11.5), Color32::WHITE); if sresp.clicked() { app.ui.export_preview = true; } - let play = Rect::from_min_size(pos2(share.min.x - 44.0, top.center().y - 12.0), vec2(36.0, 24.0)); + let play = Rect::from_min_size(pos2(share.min.x - 50.0, top.center().y - 14.0), vec2(42.0, 28.0)); let presp = ui.interact(play, ui.id().with("play"), Sense::click()).on_hover_text("Present ⌥⌘↵"); if presp.hovered() { ui.painter().rect_filled(play, CornerRadius::same(5), t.hover); } - icons::paint(ui.painter(), Rect::from_center_size(play.center() - vec2(5.0, 0.0), vec2(12.0, 12.0)), Icon::Play, t.icon); - icons::paint(ui.painter(), Rect::from_center_size(play.center() + vec2(9.0, 0.0), vec2(10.0, 10.0)), Icon::Chevron, t.icon_secondary); + let tri = Rect::from_center_size(play.center() - vec2(7.0, 0.0), vec2(16.0, 16.0)); + let c = tri.center(); + ui.painter().add(egui::Shape::closed_line(vec![c + vec2(-5.0, -7.0), c + vec2(7.0, 0.0), c + vec2(-5.0, 7.0)], Stroke::new(1.4, t.icon))); + icons::paint(ui.painter(), Rect::from_center_size(play.center() + vec2(12.0, 0.0), vec2(10.0, 10.0)), Icon::Chevron, t.icon_secondary); if presp.clicked() { app.run("proto.present", json!({})); } @@ -269,7 +272,7 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { if resp.hovered() { ui.painter().rect_filled(lr, CornerRadius::same(5), t.hover); } - ui.painter().text(pos2(lr.min.x + 8.0, lr.center().y), Align2::LEFT_CENTER, &label, semibold(11.0), t.text); + ui.painter().text(pos2(lr.min.x + 8.0, lr.center().y), Align2::LEFT_CENTER, &label, semibold(13.0), t.text); if is_frame { icons::paint(ui.painter(), Rect::from_center_size(pos2(lr.min.x + 18.0 + label.len() as f32 * 6.5, lr.center().y), vec2(10.0, 10.0)), Icon::Chevron, t.icon_secondary); let ids: Vec = sel.to_vec(); @@ -396,6 +399,8 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { widgets::section_header(ui, "Position", &[], false); let ids = sel.to_vec(); + let col = widgets::col_width(ui.available_width()); + widgets::sublabels(ui, "Alignment", None); row(ui, 32.0, |ui| { let items = [ (Icon::AlignLeft, "Align left ⌥A"), @@ -406,48 +411,52 @@ fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { (Icon::AlignBottom, "Align bottom ⌥S"), ]; let cmds = ["align.left", "align.hcenter", "align.right", "align.top", "align.vcenter", "align.bottom"]; - let w = ((ui.available_width() - 8.0) / 2.0 / 3.0).max(20.0); + let w = col / 3.0; if let Some(i) = widgets::segmented(ui, &items[..3], None, w) { app.run(cmds[i], json!({"ids": ids})); } if let Some(i) = widgets::segmented(ui, &items[3..], None, w) { app.run(cmds[i + 3], json!({"ids": ids})); } + if sel.len() > 2 { + let more = icons::button(ui, Icon::DistributeH, 24.0, false, "Distribute and tidy"); + egui::Popup::menu(&more).show(|ui| { + for (l, c) in [("Distribute horizontal spacing", "distribute.horizontal"), ("Distribute vertical spacing", "distribute.vertical"), ("Tidy up", "arrange.tidy")] { + if menu_item(ui, l, figcraft_engine::catalog::shortcut(c).unwrap_or("")) { + app.run(c, json!({"ids": ids})); + ui.close(); + } + } + }); + } }); let x = common(doc, sel, |n| (n.transform.as_coeffs()[4] * 100.0).round() / 100.0); let y = common(doc, sel, |n| (n.transform.as_coeffs()[5] * 100.0).round() / 100.0); let rot = common(doc, sel, |n| (figcraft_geom::rotation_degrees(n.transform) * 100.0).round() / 100.0); - let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0; + widgets::sublabels(ui, "Position", None); row(ui, 32.0, |ui| { - let e = widgets::number_field(ui, Id::new("pos_x"), Lead::Text("X"), x, "", half, 1.0); + let e = widgets::number_field(ui, Id::new("pos_x"), Lead::Text("X"), x, "", col, 1.0); app.field(&ids, "x", e, |v| json!(v)); - let e = widgets::number_field(ui, Id::new("pos_y"), Lead::Text("Y"), y, "", half, 1.0); + let e = widgets::number_field(ui, Id::new("pos_y"), Lead::Text("Y"), y, "", col, 1.0); app.field(&ids, "y", e, |v| json!(v)); }); + widgets::sublabels(ui, "Rotation", None); row(ui, 32.0, |ui| { - let e = widgets::number_field(ui, Id::new("pos_rot"), Lead::Icon(Icon::Rotate), rot, "°", half, 1.0); + let e = widgets::number_field(ui, Id::new("pos_rot"), Lead::Icon(Icon::CornerRadius), rot, "°", col, 1.0); app.field(&ids, "rotation", e, |v| json!(v)); - if icons::button(ui, Icon::Rotate, 24.0, false, "Rotate 90° right").clicked() { - app.run("object.rotateRight", json!({"ids": ids})); - } - if icons::button(ui, Icon::FlipH, 24.0, false, "Flip horizontal ⇧H").clicked() { - app.run("object.flipH", json!({"ids": ids})); - } - if icons::button(ui, Icon::FlipV, 24.0, false, "Flip vertical ⇧V").clicked() { - app.run("object.flipV", json!({"ids": ids})); + let items = [(Icon::Rotate, "Rotate 90° right"), (Icon::FlipH, "Flip horizontal ⇧H"), (Icon::FlipV, "Flip vertical ⇧V")]; + if let Some(i) = widgets::segmented(ui, &items, None, col / 3.0) { + app.run(["object.rotateRight", "object.flipH", "object.flipV"][i], json!({"ids": ids})); } }); // Constraints (children of non-auto-layout frames). let in_frame = sel.iter().all(|i| doc.node(*i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like() && p.layout.is_none())); if in_frame { let c = common(doc, sel, |n| n.constraints); - row(ui, 32.0, |ui| { - let t = Tokens::get(ui.ctx()); - widgets::label(ui, "Constraints", t.text_secondary); - }); + widgets::sublabels(ui, "Constraints", None); row(ui, 32.0, |ui| { let h = c.map(|c| format!("{:?}", c.horizontal)).unwrap_or_else(|| "Mixed".into()); - let resp = widgets::dropdown(ui, &pretty(&h), half, None); + let resp = widgets::dropdown(ui, &pretty(&h), col, None); egui::Popup::menu(&resp).show(|ui| { for (l, v) in [("Left", "left"), ("Right", "right"), ("Left & right", "leftRight"), ("Center", "center"), ("Scale", "scale")] { if menu_item(ui, l, "") { @@ -457,7 +466,7 @@ fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { } }); let v = c.map(|c| format!("{:?}", c.vertical)).unwrap_or_else(|| "Mixed".into()); - let resp = widgets::dropdown(ui, &pretty(&v), half, None); + let resp = widgets::dropdown(ui, &pretty(&v), col, None); egui::Popup::menu(&resp).show(|ui| { for (l, val) in [("Top", "top"), ("Bottom", "bottom"), ("Top & bottom", "topBottom"), ("Center", "center"), ("Scale", "scale")] { if menu_item(ui, l, "") { @@ -468,6 +477,7 @@ fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { }); }); } + ui.add_space(8.0); } fn pretty(s: &str) -> String { @@ -489,7 +499,6 @@ fn layout(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { if let Some(_b) = widgets::section_header(ui, title, &btns, false) { app.run(if has_al { "layout.remove" } else { "layout.add" }, json!({"ids": ids})); } - let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0; if let Some(Some(l)) = &al { // Direction. row(ui, 32.0, |ui| { @@ -511,14 +520,21 @@ fn layout(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { } }); } - // W / H with sizing. + // W / H with the proportion lock. let w = common(doc, sel, |n| (n.width * 100.0).round() / 100.0); let h = common(doc, sel, |n| (n.height * 100.0).round() / 100.0); + let col = widgets::col_width(ui.available_width()); + let half = col; + widgets::sublabels(ui, "Dimensions", None); row(ui, 32.0, |ui| { - let e = widgets::number_field(ui, Id::new("lay_w"), Lead::Text("W"), w, "", half, 1.0); + let e = widgets::number_field(ui, Id::new("lay_w"), Lead::Text("W"), w, "", col, 1.0); app.field(&ids, "width", e, |v| json!(v.max(0.0))); - let e = widgets::number_field(ui, Id::new("lay_h"), Lead::Text("H"), h, "", half, 1.0); + let e = widgets::number_field(ui, Id::new("lay_h"), Lead::Text("H"), h, "", col, 1.0); app.field(&ids, "height", e, |v| json!(v.max(0.0))); + let locked = common(doc, sel, |n| n.constrain_proportions).unwrap_or(false); + if icons::button(ui, if locked { Icon::LinkOn } else { Icon::LinkOff }, 24.0, locked, "Constrain proportions").clicked() { + app.set_props(&ids, json!({"constrainProportions": !locked})); + } }); let parent_al = sel.iter().all(|i| doc.node(*i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some())); let is_text = sel.iter().all(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Text(_)))); @@ -645,23 +661,26 @@ fn layout(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids = sel.to_vec(); let visible = common(doc, sel, |n| n.visible).unwrap_or(true); - if let Some(i) = widgets::section_header(ui, "Appearance", &[(Icon::Blend, "Blend mode"), (if visible { Icon::Eye } else { Icon::EyeOff }, "Show/Hide ⇧⌘H")], false) - && i == 1 - { + let hdr = widgets::section_header(ui, "Appearance", &[(if visible { Icon::Eye } else { Icon::EyeOff }, "Show/Hide ⇧⌘H"), (Icon::Blend, "Blend mode")], false); + if hdr == Some(0) { app.run("object.toggleVisible", json!({"ids": ids})); } - let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0; + if hdr == Some(1) { + ui.data_mut(|d| d.insert_temp(Id::new("blend_open"), !d.get_temp::(Id::new("blend_open")).unwrap_or(false))); + } + let col = widgets::col_width(ui.available_width()); let op = common(doc, sel, |n| (f64::from(n.opacity) * 100.0).round()); let radius_ok = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.has_corner_radius())); let r = common(doc, sel, |n| n.corners.uniform().map(|r| (r * 100.0).round() / 100.0)); + widgets::sublabels(ui, "Opacity", radius_ok.then_some("Corner radius")); row(ui, 32.0, |ui| { - let e = widgets::number_field(ui, Id::new("ap_op"), Lead::Icon(Icon::Opacity), op, "%", half, 1.0); + let e = widgets::number_field(ui, Id::new("ap_op"), Lead::Icon(Icon::Opacity), op, "%", col, 1.0); app.field(&ids, "opacity", e, |v| json!((v / 100.0).clamp(0.0, 1.0))); if radius_ok { - let e = widgets::number_field(ui, Id::new("ap_rad"), Lead::Icon(Icon::CornerRadius), r.flatten(), "", half - 32.0, 1.0); + let e = widgets::number_field(ui, Id::new("ap_rad"), Lead::Icon(Icon::CornerRadius), r.flatten(), "", col, 1.0); app.field(&ids, "cornerRadius", e, |v| json!(v.max(0.0))); let open = ui.data(|d| d.get_temp::(Id::new("indep_corners"))).unwrap_or(false); - if icons::button(ui, Icon::IndependentCorners, 24.0, open, "Independent corners").clicked() { + if icons::button(ui, Icon::IndependentCorners, 24.0, open, "Individual corners").clicked() { ui.data_mut(|d| d.insert_temp(Id::new("indep_corners"), !open)); } } @@ -671,38 +690,45 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let keys = ["topLeftRadius", "topRightRadius", "bottomRightRadius", "bottomLeftRadius"]; row(ui, 32.0, |ui| { for (k, key) in keys.iter().enumerate().take(2) { - let e = widgets::number_field(ui, Id::new(("corner", k)), Lead::Icon(Icon::CornerRadius), radii.map(|r| r[k]), "", half, 1.0); + let e = widgets::number_field(ui, Id::new(("corner", k)), Lead::Icon(Icon::CornerRadius), radii.map(|r| r[k]), "", col, 1.0); app.field(&ids, key, e, |v| json!(v.max(0.0))); } }); row(ui, 32.0, |ui| { for (k, key) in keys.iter().enumerate().skip(2) { - let e = widgets::number_field(ui, Id::new(("corner", k)), Lead::Icon(Icon::CornerRadius), radii.map(|r| r[k]), "", half, 1.0); + let e = widgets::number_field(ui, Id::new(("corner", k)), Lead::Icon(Icon::CornerRadius), radii.map(|r| r[k]), "", col, 1.0); app.field(&ids, key, e, |v| json!(v.max(0.0))); } }); let sm = common(doc, sel, |n| (n.corners.smoothing * 100.0).round()); + widgets::sublabels(ui, "Corner smoothing", None); row(ui, 32.0, |ui| { - let e = widgets::number_field(ui, Id::new("smoothing"), Lead::Text("Smoothing"), sm, "%", half * 2.0 + 8.0, 1.0); + let e = widgets::number_field(ui, Id::new("smoothing"), Lead::None, sm, "%", col, 1.0); app.field(&ids, "cornerSmoothing", e, |v| json!((v / 100.0).clamp(0.0, 1.0))); + widgets::label(ui, "iOS: 60%", Tokens::get(ui.ctx()).text_tertiary); }); } let blend = common(doc, sel, |n| n.blend); - row(ui, 32.0, |ui| { - let label = blend.map(|b| b.label()).unwrap_or("Mixed"); - let resp = widgets::dropdown(ui, label, half * 2.0 + 8.0, Some(Icon::Blend)); - egui::Popup::menu(&resp).width(200.0).show(|ui| { - for b in BlendMode::ALL { - if menu_item(ui, b.label(), "") { - app.set_props(&ids, json!({"blend": b.css()})); - ui.close(); + let show_blend = ui.data(|d| d.get_temp::(Id::new("blend_open"))).unwrap_or(false) || blend.is_some_and(|b| !matches!(b, BlendMode::Normal | BlendMode::PassThrough)); + if show_blend { + widgets::sublabels(ui, "Blend mode", None); + row(ui, 32.0, |ui| { + let label = blend.map(|b| b.label()).unwrap_or("Mixed"); + let resp = widgets::dropdown(ui, label, col * 2.0 + 8.0, Some(Icon::Blend)); + egui::Popup::menu(&resp).width(200.0).show(|ui| { + for b in BlendMode::ALL { + if menu_item(ui, b.label(), "") { + app.set_props(&ids, json!({"blend": b.css()})); + ui.close(); + } + if matches!(b, BlendMode::Normal | BlendMode::ColorBurn | BlendMode::ColorDodge | BlendMode::HardLight | BlendMode::Exclusion) { + ui.separator(); + } } - if matches!(b, BlendMode::Normal | BlendMode::ColorBurn | BlendMode::ColorDodge | BlendMode::HardLight | BlendMode::Exclusion) { - ui.separator(); - } - } + }); }); - }); + } + ui.add_space(8.0); } fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: bool) { diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 4cbe034..fd35af5 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -41,8 +41,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { let canvas = app.canvas.rect; let cell = 32.0; let gap = 4.0; - let n_cells = GROUPS.len() as f32 + 1.0; - let width = n_cells * (cell + 10.0) + 16.0 + 64.0; + let width = GROUPS.len() as f32 * (cell + 4.0) + 4.0 * 10.0 + 2.0 * (cell + 4.0) + 16.0 + 4.0 * 34.0 + 24.0; let height = 48.0; let rect = Rect::from_center_size(pos2(canvas.center().x, canvas.max.y - 12.0 - height / 2.0), vec2(width, height)); let painter = ui.painter(); @@ -88,30 +87,77 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { flyout(app, ui, g, pos2(r.min.x, rect.min.y - 8.0)); } } - // Separator and Actions. - ui.painter().line_segment([pos2(x + 2.0, cy - 12.0), pos2(x + 2.0, cy + 12.0)], Stroke::new(1.0, t.border)); + // Resources (components, plugins) opens the Assets panel; Actions palette. + for (icon, tip, id) in [(Icon::Assets, "Resources ⇧I", "resources"), (Icon::Actions, "Actions ⌘K", "actions")] { + let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell)); + let resp = ui.interact(r, ui.id().with(id), Sense::click()).on_hover_text(tip); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); + } + icons::paint(ui.painter(), r.shrink(7.0), icon, t.icon); + if resp.clicked() { + if id == "actions" { + app.ui.palette = Some(String::new()); + } else { + app.ui.left_tab = crate::LeftTab::Assets; + app.ui.left_collapsed = false; + } + } + x += cell + 4.0; + } + ui.painter().line_segment([pos2(x + 2.0, cy - 14.0), pos2(x + 2.0, cy + 14.0)], Stroke::new(1.0, t.border)); x += 8.0; - let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell)); - let resp = ui.interact(r, ui.id().with("actions"), Sense::click()).on_hover_text("Actions ⌘K"); - if resp.hovered() { - ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); - } - icons::paint(ui.painter(), r.shrink(7.0), Icon::Actions, t.icon); - if resp.clicked() { - app.ui.palette = Some(String::new()); - } - x += cell + 6.0; - // Dev mode toggle look-alike: the Code (inspect) panel. - let r = Rect::from_min_size(pos2(x, cy - 14.0), vec2(56.0, 28.0)); - let resp = ui.interact(r, ui.id().with("devmode"), Sense::click()).on_hover_text("Inspect: code and measurements"); - let on = app.ui.right_tab == crate::RightTab::Inspect; - ui.painter().rect_filled(r, CornerRadius::same(14), if on { Color32::from_rgb(0x0F, 0xA9, 0x58) } else { t.field }); - let knob = if on { pos2(r.max.x - 14.0, cy) } else { pos2(r.min.x + 14.0, cy) }; - ui.painter().circle_filled(knob, 11.0, Color32::WHITE); - icons::paint(ui.painter(), Rect::from_center_size(knob, vec2(14.0, 14.0)), Icon::Code, if on { Color32::from_rgb(0x0F, 0xA9, 0x58) } else { t.icon }); - if resp.clicked() { - app.ui.right_tab = if on { crate::RightTab::Design } else { crate::RightTab::Inspect }; + // Mode switcher: Draw · Design · Prototype · Inspect (code). + let modes = [(Icon::Pencil, "Draw"), (Icon::Frame, "Design"), (Icon::Prototype, "Prototype"), (Icon::Code, "Inspect")]; + let active = match app.ui.right_tab { + crate::RightTab::Design if app.session.tool.tool == ToolId::Pencil => 0, + crate::RightTab::Design => 1, + 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); + 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 resp = ui.interact(r, ui.id().with(("mode", i)), Sense::click()).on_hover_text(*tip); + 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); + } else if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(6), t.hover); + } + icons::paint(ui.painter(), r.shrink(9.0), *icon, if i == active { t.accent_text } else { t.icon_secondary }); + if resp.clicked() { + match i { + 0 => { + app.ui.right_tab = crate::RightTab::Design; + app.run("tool.set", json!({"tool": "pencil"})); + } + 1 => { + app.ui.right_tab = crate::RightTab::Design; + if app.session.tool.tool == ToolId::Pencil { + app.run("tool.set", json!({"tool": "move"})); + } + } + 2 => app.ui.right_tab = crate::RightTab::Prototype, + _ => app.ui.right_tab = crate::RightTab::Inspect, + } + } } + // Floating help button at the bottom right of the canvas. + let hr = Rect::from_center_size(pos2(canvas.max.x - 32.0, canvas.max.y - 32.0), vec2(32.0, 32.0)); + let hresp = ui.interact(hr, ui.id().with("help"), Sense::click()).on_hover_text("Help and resources"); + ui.painter().circle_filled(hr.center() + vec2(0.0, 1.0), 17.0, Color32::from_black_alpha(20)); + ui.painter().circle_filled(hr.center(), 16.0, t.panel); + ui.painter().text(hr.center(), Align2::CENTER_CENTER, "?", crate::theme::regular(15.0), t.text); + egui::Popup::menu(&hresp).show(|ui| { + for (l, c) in [("Keyboard shortcuts", "help.shortcuts"), ("Help page", "help.page"), ("About FigCraft", "help.about")] { + if crate::right::menu_item(ui, l, figcraft_engine::catalog::shortcut(c).unwrap_or("")) { + app.run(c, json!({})); + ui.close(); + } + } + }); } fn flyout(app: &mut FigApp, ui: &mut Ui, g: &Group, bottom_left: egui::Pos2) { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 1543cd2..00a02d9 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -436,3 +436,21 @@ pub fn text_field_inline(ui: &mut Ui, id: Id, value: &str, rect: Rect, font: egu } } } + +/// A small grey label above a row of fields ("Position", "Dimensions"…). With two labels the +/// second starts at the second field column. +pub fn sublabels(ui: &mut Ui, a: &str, b: Option<&str>) { + let t = Tokens::get(ui.ctx()); + let w = ui.available_width(); + let (r, _) = ui.allocate_exact_size(vec2(w, 18.0), Sense::hover()); + ui.painter().text(pos2(r.min.x + PAD, r.max.y - 2.0), Align2::LEFT_BOTTOM, a, regular(10.0), t.text_secondary); + if let Some(b) = b { + let col = col_width(w); + ui.painter().text(pos2(r.min.x + PAD + col + 8.0, r.max.y - 2.0), Align2::LEFT_BOTTOM, b, regular(10.0), t.text_secondary); + } +} + +/// Width of one of the two field columns in a panel `w` wide (leaving the icon column). +pub fn col_width(w: f32) -> f32 { + ((w - PAD * 2.0 - 8.0 - 32.0) / 2.0).max(40.0) +}