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 <noreply@anthropic.com>
This commit is contained in:
parent
09a7082887
commit
b3a3b33fcd
|
|
@ -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 <port>`: 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 |
|
||||
|
|
@ -423,7 +423,10 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap<String, (Str
|
|||
if !matches!(ext.as_str(), "ttf" | "otf" | "ttc" | "otc") {
|
||||
continue;
|
||||
}
|
||||
// Skip very large files (emoji, CJK collections) unless asked for by name later.
|
||||
// Skip very large files (colour emoji); they are not outline fonts we can draw anyway.
|
||||
if std::fs::metadata(&path).map(|m| m.len() > 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<String> {
|
|||
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<String, (String, Vec<Pending>)> = 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<NodeId> = 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::<bool>(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::<bool>(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::<bool>(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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue