From dd980c884fae132703ae26612751a459e8e55f46 Mon Sep 17 00:00:00 2001 From: Yarila Date: Sat, 17 Oct 2026 22:00:16 +0500 Subject: [PATCH 1/4] M7: search known files and templates from Home --- ROADMAP.md | 3 +- crates/ui-egui/locales/en.toml | 4 + crates/ui-egui/locales/es.toml | 4 + crates/ui-egui/locales/ru.toml | 4 + crates/ui-egui/src/control.rs | 5 +- crates/ui-egui/src/home.rs | 187 ++++++++++++++++++++++++++++++- crates/ui-egui/src/home_tests.rs | 77 +++++++++++++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/online.rs | 2 +- docs/control-protocol.md | 5 +- docs/gaps.md | 3 +- docs/ui-parity.md | 4 +- 12 files changed, 287 insertions(+), 12 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3908e591..5a77b095 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Home search across known files and starter templates; community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **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 @@ -316,6 +316,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Home sidebar search filters open, recent and saved online files plus starter templates, opens a result through its existing action, and shows an empty state; no server-wide query. Numbers unchanged. | | 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged | | 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. | | 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 59b25d94..27b8ec3d 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -830,6 +830,7 @@ name = "English" "No contributor data was built into this copy." = "No contributor data was built into this copy." "No device" = "No device" "No fonts match" = "No fonts match" +"No files or templates match your search" = "No files or templates match your search" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." "No layer changes to show." = "No layer changes to show." "No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." @@ -1175,6 +1176,8 @@ name = "English" "Search actions, layers and tools" = "Search actions, layers and tools" "Search assets" = "Search assets" "Search comments" = "Search comments" +"Search files and templates" = "Search files and templates" +"Search results" = "Search results" "Search fonts" = "Search fonts" "Search layers" = "Search layers" "Search shortcuts" = "Search shortcuts" @@ -1403,6 +1406,7 @@ name = "English" "Truncate with an ellipsis" = "Truncate with an ellipsis" "Try again" = "Try again" "Try another search or filter." = "Try another search or filter." +"Try another search, or browse templates." = "Try another search, or browse templates." "Try another word, or look at every kind." = "Try another word, or look at every kind." "Turn a layer into a component, reuse it, and change every copy at once" = "Turn a layer into a component, reuse it, and change every copy at once" "Two digits" = "Two digits" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 01c5813b..8c28e149 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -831,6 +831,7 @@ status = "draft — needs native review" "No contributor data was built into this copy." = "Esta copia no incluye datos de colaboradores." "No device" = "Sin dispositivo" "No fonts match" = "Ninguna fuente coincide" +"No files or templates match your search" = "Ningún archivo ni plantilla coincide con tu búsqueda" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Aquí no hay código para unirse (el servidor es anterior a los códigos, o esta dirección no cabe en uno): comparte el enlace." "No layer changes to show." = "No hay cambios de capas que mostrar." "No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Aún no hay bibliotecas. Añade un archivo .figcraft publicado como biblioteca o pega el enlace de una biblioteca de equipo." @@ -1176,6 +1177,8 @@ status = "draft — needs native review" "Search actions, layers and tools" = "Buscar acciones, capas y herramientas" "Search assets" = "Buscar recursos" "Search comments" = "Buscar comentarios" +"Search files and templates" = "Buscar archivos y plantillas" +"Search results" = "Resultados de búsqueda" "Search fonts" = "Buscar fuentes" "Search layers" = "Buscar capas" "Search shortcuts" = "Buscar atajos" @@ -1404,6 +1407,7 @@ status = "draft — needs native review" "Truncate with an ellipsis" = "Truncar con puntos suspensivos" "Try again" = "Reintentar" "Try another search or filter." = "Prueba otra búsqueda u otro filtro." +"Try another search, or browse templates." = "Prueba otra búsqueda o explora las plantillas." "Try another word, or look at every kind." = "Prueba con otra palabra o mira todos los tipos." "Turn a layer into a component, reuse it, and change every copy at once" = "Convierte una capa en componente, reutilízalo y cambia todas las copias a la vez" "Two digits" = "Dos dígitos" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 998eefda..a0675ee0 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -818,6 +818,7 @@ name = "Русский" "No contributor data was built into this copy." = "В этой сборке нет данных об участниках." "No device" = "Без устройства" "No fonts match" = "Подходящих шрифтов нет" +"No files or templates match your search" = "Файлы и шаблоны по запросу не найдены" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Код подключения недоступен (сервер не поддерживает коды или адрес нельзя включить в код): поделитесь ссылкой." "No layer changes to show." = "Нет изменений слоёв для показа." "No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Библиотек пока нет. Добавьте файл .figcraft, опубликованный как библиотека, или вставьте ссылку на командную библиотеку." @@ -1156,6 +1157,8 @@ name = "Русский" "Search actions, layers and tools" = "Поиск действий, слоёв и инструментов" "Search assets" = "Поиск ресурсов" "Search comments" = "Поиск комментариев" +"Search files and templates" = "Поиск файлов и шаблонов" +"Search results" = "Результаты поиска" "Search fonts" = "Поиск шрифтов" "Search layers" = "Поиск слоёв" "Search shortcuts" = "Поиск сочетаний клавиш" @@ -1381,6 +1384,7 @@ name = "Русский" "Truncate with an ellipsis" = "Обрезать с многоточием" "Try again" = "Повторить" "Try another search or filter." = "Попробуйте другой запрос или фильтр." +"Try another search, or browse templates." = "Попробуйте другой запрос или просмотрите шаблоны." "Try another word, or look at every kind." = "Попробуйте другое слово или посмотрите все категории." "Turn a layer into a component, reuse it, and change every copy at once" = "Превратите слой в компонент, используйте повторно и изменяйте все копии сразу" "Two digits" = "Две цифры" diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index a66e1ed9..a4a8dd15 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -15,7 +15,7 @@ //! for the frames that follow (all false: released); `gamepad` presses a controller button in the //! prototype playing (presenter or preview pane) //! - `ui.menu.invoke {id}` — a menu item exactly like clicking it (may open a dialog) -//! - `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, focusRegion?, preview?, presentScroll?, …}` +//! - `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeSearch?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, focusRegion?, preview?, presentScroll?, …}` //! - `ui.screenshot {path?}` — PNG of the window (needs a visible window), written to `path` or //! returned as base64 `data` //! - `ui.render {path?, scale?, ids?}` — PNG of the document (no UI chrome; works without a @@ -429,6 +429,9 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul if let Some(section) = p.get("homeSection").and_then(Value::as_str).and_then(crate::home::Section::parse) { app.ui.home_view.section = section; } + if let Some(query) = p.get("homeSearch").and_then(Value::as_str) { + app.ui.home_view.file_query = query.chars().take(256).collect(); + } if let Some(view) = p.get("homeRecentsView").and_then(Value::as_str).and_then(crate::home_recents::View::parse) { view.store(&mut app.session.prefs); } diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 856b018e..3ea25576 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -4,6 +4,7 @@ //! the documents themselves, searched and filtered by kind). use std::collections::BTreeMap; +use std::path::Path; use egui::{Align2, Color32, CornerRadius, Id, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use figcraft_engine::templates::{Kind, TEMPLATES, Template}; @@ -51,6 +52,8 @@ impl Section { #[derive(Default)] pub struct Home { pub section: Section, + /// Searches known files and starter templates together from the home sidebar. + pub file_query: String, /// The Templates search text and kind filter. pub query: String, pub kind: Option, @@ -143,7 +146,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { let left = Rect::from_min_size(rect.min, vec2(SIDEBAR, rect.height())); ui.painter().line_segment([left.right_top(), left.right_bottom()], Stroke::new(1.0, t.border)); sidebar(app, ui, &t, left); - if app.ui.home_view.section == Section::Online { + if app.ui.home_view.file_query.trim().is_empty() && app.ui.home_view.section == Section::Online { // Your online files lay themselves out in the area (and scroll their own list). let area = Rect::from_min_max(pos2(left.max.x + 40.0, rect.min.y + 24.0), rect.max - vec2(40.0, 24.0)); crate::online::section(app, ui, area, &t); @@ -155,9 +158,14 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { egui::ScrollArea::vertical().id_salt("home").auto_shrink([false, false]).show(ui, |ui| { egui::Frame::NONE.inner_margin(egui::Margin::symmetric(40, 24)).show(ui, |ui| { ui.spacing_mut().item_spacing = vec2(GAP, 0.0); - match app.ui.home_view.section { - Section::Templates => templates(app, ui, &t), - _ => recents(app, ui, &t), + if app.ui.home_view.file_query.trim().is_empty() { + match app.ui.home_view.section { + Section::Templates => templates(app, ui, &t), + _ => recents(app, ui, &t), + } + } else { + search_results(app, ui, &t); + opened_join(app); } }); }); @@ -198,10 +206,39 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) { icons::paint(ui.painter(), Rect::from_min_size(pos2(left.min.x + 16.0, y), vec2(20.0, 20.0)), Icon::Logo, ACCENT); ui.painter().text(pos2(left.min.x + 44.0, y + 10.0), Align2::LEFT_CENTER, "FigCraft", semibold(14.0), t.text); y += 48.0; + let search = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(SIDEBAR - 16.0, 32.0)); + ui.scope_builder(egui::UiBuilder::new().max_rect(search), |ui| { + ui.painter().rect_filled(search, CornerRadius::same(5), t.field); + icons::paint( + ui.painter(), + Rect::from_center_size(pos2(search.min.x + 18.0, search.center().y), vec2(16.0, 16.0)), + Icon::Search, + t.icon_secondary, + ); + let text_rect = Rect::from_min_max(pos2(search.min.x + 36.0, search.center().y - 8.0), pos2(search.max.x - 8.0, search.center().y + 8.0)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_rect)); + let response = child.add( + egui::TextEdit::singleline(&mut app.ui.home_view.file_query) + .id(Id::new("home_file_search")) + .frame(egui::Frame::NONE) + .font(regular(12.0)) + .hint_text(tr("Search files and templates")) + .vertical_align(egui::Align::Center) + .desired_width(text_rect.width()), + ); + widgets::select_all_on_focus(ui, &response, &app.ui.home_view.file_query); + a11y::text(&response, tr("Search files and templates"), &app.ui.home_view.file_query); + if response.has_focus() { + ui.painter().rect_stroke(search, CornerRadius::same(5), Stroke::new(1.0, t.accent), StrokeKind::Inside); + } else if ui.rect_contains_pointer(search) { + ui.painter().rect_stroke(search, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), StrokeKind::Inside); + } + }); + y += 32.0 + 4.0; for (section, icon, label) in Section::ALL { let r = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(SIDEBAR - 16.0, 32.0)); let resp = ui.interact(r, ui.id().with(("home_section", label)), Sense::click()); - let current = app.ui.home_view.section == section; + let current = app.ui.home_view.file_query.trim().is_empty() && app.ui.home_view.section == section; a11y::tab(&resp, tr(label), current); if current || resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(5), if current { t.selected_row } else { t.hover }); @@ -211,11 +248,151 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) { ui.painter().text(pos2(r.min.x + 36.0, r.center().y), Align2::LEFT_CENTER, tr(label), regular(12.0), t.text); if resp.clicked() { app.ui.home_view.section = section; + app.ui.home_view.file_query.clear(); } y += 34.0; } } +fn file_matches(name: &str, location: &str, query: &str) -> bool { + name.to_lowercase().contains(query) || location.to_lowercase().contains(query) +} + +/// A search result with a name and optional location on separate lines. The larger hit target +/// keeps a short results list readable on the wide home screen. +fn search_file_card(ui: &mut Ui, t: &Tokens, id: Id, row: &FileRow) -> bool { + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width().min(640.0), 64.0), Sense::hover()); + let resp = ui.interact(r, id, Sense::click()); + a11y::button(&resp, row.name); + ui.painter().rect_filled(r, CornerRadius::same(8), if resp.hovered() { t.hover } else { t.field }); + ui.painter().rect_stroke(r, CornerRadius::same(8), Stroke::new(1.0, t.border), StrokeKind::Inside); + focus_ring(ui, t, r, &resp, 8); + if let Some((icon, tint)) = row.mark { + let mark = Rect::from_center_size(pos2(r.min.x + 29.0, r.center().y), vec2(20.0, 20.0)); + icons::paint(ui.painter(), mark, icon, tint); + } + let width = (r.width() - 78.0).max(1.0); + let name = widgets::elide_font(ui, row.name, width, semibold(13.0)); + let name_y = if row.detail.is_empty() { r.center().y } else { r.min.y + 23.0 }; + ui.painter().text(pos2(r.min.x + 58.0, name_y), Align2::LEFT_CENTER, name, semibold(13.0), row.ink); + if !row.detail.is_empty() { + let detail = widgets::elide_start_font(ui, row.detail, width, regular(row.detail_size)); + ui.painter().text(pos2(r.min.x + 58.0, r.min.y + 44.0), Align2::LEFT_CENTER, detail, regular(row.detail_size), row.detail_ink); + } + if !row.detail.is_empty() { resp.on_hover_text(row.detail).clicked() } else { resp.clicked() } +} + +/// One search over known files and starter templates. It never sends a query or owner token to a +/// server; online files are opened through their existing owner-token command only on click. +fn search_results(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { + heading(ui, t, tr("Search results")); + let query = app.ui.home_view.file_query.trim().to_lowercase(); + let mut count = 0; + let mut switch = None; + let open_paths: Vec<&str> = app.session.docs.iter().filter_map(|d| d.path.as_deref()).collect(); + + let open: Vec<_> = + app.session.docs.iter().enumerate().filter(|(_, d)| file_matches(&d.doc.name, d.path.as_deref().unwrap_or_default(), &query)).collect(); + if !open.is_empty() { + subheading(ui, t, tr("Open files")); + for (index, d) in open { + count += 1; + let row = FileRow { + mark: Some((Icon::Logo, ACCENT)), + name: &d.doc.name, + ink: t.text, + detail: d.path.as_deref().unwrap_or_default(), + detail_size: 11.0, + detail_ink: t.text_secondary, + }; + if search_file_card(ui, t, ui.id().with(("search_open", d.uid)), &row) { + switch = Some(index); + } + ui.add_space(8.0); + } + } + + let recent: Vec<(String, String)> = app + .session + .prefs + .recent_files + .iter() + .filter(|p| !open_paths.contains(&p.as_str())) + .filter_map(|path| { + let p = Path::new(path); + let name = p.file_stem().or_else(|| p.file_name()).map_or_else(|| path.clone(), |n| n.to_string_lossy().into_owned()); + file_matches(&name, path, &query).then(|| (path.clone(), name)) + }) + .collect(); + let mut open_recent = None; + if !recent.is_empty() { + subheading(ui, t, tr("Recent files")); + for (path, name) in &recent { + count += 1; + let row = FileRow { + mark: Some((Icon::Page, t.icon_secondary)), + name, + ink: t.text, + detail: path, + detail_size: 11.0, + detail_ink: t.text_secondary, + }; + if search_file_card(ui, t, ui.id().with(("search_recent", path)), &row) { + open_recent = Some(path.clone()); + } + ui.add_space(8.0); + } + } + + let online: Vec<_> = app.session.prefs.online_files.iter().filter(|f| file_matches(&f.name, &f.server, &query)).cloned().collect(); + let mut open_online = None; + if !online.is_empty() { + subheading(ui, t, tr("Online files")); + for f in &online { + count += 1; + let host = figcraft_engine::online::server_host(&f.server); + let icon = crate::online::mark(f.shared.is_some()); + let row = FileRow { + mark: Some((icon, if f.shared.is_some() { t.success } else { t.icon_secondary })), + name: &f.name, + ink: t.text, + detail: host, + detail_size: 11.0, + detail_ink: t.text_secondary, + }; + if search_file_card(ui, t, ui.id().with(("search_online", &f.server, &f.file)), &row) { + open_online = Some(f.clone()); + } + ui.add_space(8.0); + } + } + let templates: Vec<&Template> = TEMPLATES.iter().filter(|template| matches(template, &query, None)).collect(); + if !templates.is_empty() { + subheading(ui, t, tr("Templates")); + template_grid(app, ui, t, &templates); + } + if count == 0 && templates.is_empty() { + ui.label(egui::RichText::new(tr("No files or templates match your search")).font(semibold(13.0)).color(t.text)); + ui.add_space(6.0); + widgets::label(ui, tr("Try another search, or browse templates."), t.text_secondary); + ui.add_space(16.0); + if widgets::button(ui, tr("Show all templates"), false, true).clicked() { + app.ui.home_view.file_query.clear(); + app.ui.home_view.section = Section::Templates; + } + } + if let Some(index) = switch { + app.run("doc.switch", json!({"index": index})); + app.ui.home = false; + } else if let Some(path) = open_recent { + if app.run_user("file.open", json!({"path": path})).is_some() { + app.ui.home = false; + } + } else if let Some(file) = open_online { + crate::online::open(app, &file); + } +} + /// A ring around a card or row that has the keyboard. fn focus_ring(ui: &Ui, t: &Tokens, rect: Rect, resp: &Response, radius: u8) { if resp.has_focus() { diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index a975bec2..db052303 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -6,6 +6,7 @@ use egui::accesskit::Role; use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, Shape}; use figcraft_engine::Session; +use figcraft_engine::online::OnlineFile; use figcraft_engine::templates::TEMPLATES; use serde_json::json; @@ -184,6 +185,82 @@ fn typing_in_the_search_field_filters() { assert!(u.app.session.docs.is_empty(), "typing opens nothing"); } +#[test] +fn home_search_finds_known_files_and_opens_an_existing_tab() { + let mut session = Session::new(); + session.execute("file.rename", &json!({"name": "North Trail"})).unwrap(); + session.docs[0].path = Some("C:/designs/North Trail.figcraft".into()); + session.prefs.recent_files = + vec!["C:/designs/North Trail.figcraft".into(), "C:/designs/South Trail.figcraft".into(), "C:/designs/Other.figcraft".into()]; + session.prefs.online_files.push(OnlineFile { + server: "wss://share.example/".into(), + file: "trail".into(), + name: "Trail Live".into(), + owner: "test-only-secret".into(), + ..OnlineFile::default() + }); + let mut u = Ui::new(session); + u.app.ui.home = true; + u.app.ui.home_view.file_query = " TRAIL ".into(); + let s = shapes(&mut u); + assert!(has_text(&s, "Search results")); + assert!(has_text(&s, "North Trail") && has_text(&s, "South Trail") && has_text(&s, "Trail Live")); + assert!(!has_text(&s, "Other") && !has_text(&s, "test-only-secret")); + assert_eq!(texts(&s).iter().filter(|(name, pos, _)| name == "North Trail" && pos.y > 90.0).count(), 1, "an open file is not repeated in recents"); + assert_eq!(u.app.inspect()["homeSearch"], " TRAIL "); + + u.app.ui.home_view.file_query = "nothing matches".into(); + let s = shapes(&mut u); + assert!(has_text(&s, "No files or templates match your search")); + assert!(!has_text(&s, "Trail Live") && !has_text(&s, "South Trail")); + assert!(!texts(&s).iter().any(|(name, pos, _)| name == "North Trail" && pos.y > 90.0)); + + u.app.ui.home_view.file_query = "north".into(); + let s = shapes(&mut u); + click(&mut u, text_center(&s, "North Trail")); + assert!(!u.app.ui.home, "choosing an open result shows its editor tab"); + assert_eq!(u.app.session.docs.len(), 1); +} + +#[test] +fn home_search_field_filters_as_typed_and_a_section_click_clears_it() { + let mut u = first_run(); + u.app.session.prefs.recent_files = vec!["C:/designs/Glacier.figcraft".into(), "C:/designs/Forest.figcraft".into()]; + let s = shapes(&mut u); + click(&mut u, text_center(&s, "Search files and templates")); + u.frame(vec![Event::Text("glacier".into())]); + let s = shapes(&mut u); + assert_eq!(u.app.ui.home_view.file_query, "glacier"); + assert!(has_text(&s, "Glacier") && !has_text(&s, "Forest")); + click(&mut u, text_center(&s, "Recents")); + assert!(u.app.ui.home_view.file_query.is_empty()); + assert!(has_text(&shapes(&mut u), "Forest")); +} + +#[test] +fn home_search_includes_templates_and_opens_a_matching_card() { + let mut u = first_run(); + u.app.ui.home_view.file_query = "MOBILE".into(); + let s = settle(&mut u); + assert!(has_text(&s, "Templates") && has_text(&s, "Mobile app")); + assert!(!has_text(&s, "Dashboard")); + click(&mut u, text_center(&s, "Mobile app")); + assert_eq!(u.app.session.doc().unwrap().name, "Mobile app"); + assert!(!u.app.ui.home); +} + +#[test] +fn empty_home_search_can_browse_all_templates() { + let mut u = first_run(); + u.app.ui.home_view.file_query = "no such design".into(); + let s = shapes(&mut u); + assert!(has_text(&s, "No files or templates match your search")); + click(&mut u, text_center(&s, "Show all templates")); + assert!(u.app.ui.home_view.file_query.is_empty()); + assert_eq!(u.app.ui.home_view.section, Section::Templates); + assert!(has_text(&settle(&mut u), "Dashboard")); +} + #[test] fn thumbnails_are_cached_per_theme() { let mut u = first_run(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e6a14ede..b2d6831f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1656,6 +1656,7 @@ impl FigApp { "lastError": self.ui.last_error, "importReport": self.ui.import_report.as_ref().map(import_report::Report::inspect), }); + state["homeSearch"] = json!(self.ui.home_view.file_query); state["pendingOpens"] = json!(self.ui.pending_opens.len()); state } diff --git a/crates/ui-egui/src/online.rs b/crates/ui-egui/src/online.rs index 261424cb..2f054986 100644 --- a/crates/ui-egui/src/online.rs +++ b/crates/ui-egui/src/online.rs @@ -72,7 +72,7 @@ fn manage(app: &mut FigApp, ctx: &egui::Context, f: (&str, &str), action: &str, } /// Open recorded file `f` as its owner; its tab shows once the file arrives. -fn open(app: &mut FigApp, f: &OnlineFile) { +pub(crate) fn open(app: &mut FigApp, f: &OnlineFile) { let Some(r) = app.run("collab.openFile", json!({"server": f.server, "file": f.file})) else { return }; if r["alreadyOpen"] == true && !r["document"].is_null() { app.ui.home = false; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3a06874e..c9bf9de0 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -247,12 +247,13 @@ separate calls. Returns `{queued}`. Invoke a menu item exactly like clicking it (this may open a dialog). Returns `{ok, lastError}`. -### `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, mainMenu?, focusRegion?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?, iconGallery?, protoEditor?, contextMenu?, contextSelectLayer?, commentOpen?, commentDraft?, commentFilter?, commentSort?, commentSearch?, commentReply?, commentEdit?, presentComments?, devCompare?, devCompareMode?, devCompareSplit?, annotationFilter?, resetFrameStats?, pluginsWindow?, pluginsTab?, pluginConfirm?, pluginForm?}` +### `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeSearch?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, mainMenu?, focusRegion?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?, iconGallery?, protoEditor?, contextMenu?, contextSelectLayer?, commentOpen?, commentDraft?, commentFilter?, commentSort?, commentSearch?, commentReply?, commentEdit?, presentComments?, devCompare?, devCompareMode?, devCompareSplit?, annotationFilter?, resetFrameStats?, pluginsWindow?, pluginsTab?, pluginConfirm?, pluginForm?}` Switch panels and modes: `rightTab` `design|prototype|inspect`, `leftTab` `file|assets`, `theme`, `home` (bool), `homeSection` (`recents`, `online` or `templates`: which section the home screen shows, as `ui.inspect` reports it; opening `online` asks each server once what became of your online files), `homeRecentsView` (`grid|list`), `homeRecentsSort` (`modified|name|created`) and `homeRecentsOrder` (`desc|asc`): how the home screen shows its recent files (kept in the preferences; `ui.inspect` reports them under `homeRecents` with each file and whether its picture is in), `palette` (bool: the Actions palette), `mainMenu` (bool, or a top-level menu such -as `"Plugins"` to open the main menu with it open), `pluginsWindow` (bool: Plugins ▸ Manage +as `"Plugins"` to open the main menu with it open), `homeSearch` (text, at most 256 characters: filters locally known open, recent and online files plus starter templates in Home; an empty string returns to the selected section), +`pluginsWindow` (bool: Plugins ▸ Manage plugins…), `pluginsTab` (`installed|browse`: that window's tab), `pluginConfirm` (a plugin id of the directory listed last: its install confirmation shows, as its Install button would; `null` closes it), `pluginForm` (`{id, command?}`: start that plugin command like its menu item, opening diff --git a/docs/gaps.md b/docs/gaps.md index 9b1a770c..85ed33ad 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Home search across known files and starter templates; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -444,6 +444,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Home sidebar searches known open, recent and online files plus starter templates. Accounts and server-wide discovery remain in gap 2; estimates unchanged. | | 2026-10-17 | minor | The 2026-10-17 landings. Gap 1: a new Landed entry (community batches #727 and #728, then #745: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments, images in the message, instance layers' size and resizing overrides, Hug where nothing is recorded; budgets from the file's size; the import report; a `.fig` copy from Save as; paste from another design app), its Missing list cut to what is still left, and the wrong "whether other apps open its files is unknown" replaced by what was checked; all on synthetic files, so the beta blocker and hours stand. Gap 2: server limits, log recovery and web join links in the evidence. Gap 4: menus, the Actions palette and the editor fixes (#733, #735, #736, #729), then Selection colors and smart-selection spacing (#753) landed, none compared side by side. Gap 5: catalog counts re-measured (2,272 entries; drafts at 95–99%; Indonesian, Czech, Turkish and Swedish listed). Gap 8: the Linux and BSD clipboard changes and Wayland window controls, not run there by the maintainers. Ranking and hours unchanged | | 2026-10-15 | minor | Gap 7: bounded canvas overscan for smoother panning. Estimates and ranking unchanged. | | 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 0cccf008..40568c6c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Home sidebar file and template search; Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -22,6 +22,7 @@ interaction feel not yet compared. | Actions palette (Cmd+K, Cmd+/) | done | tabs: All (every command, grouped by menu, or Other), Assets (components and styles) and Plugins & widgets; results ranked prefix first, then shortest, at most 60 rows; Up/Down move a highlight and Enter runs it; Recents list what was run from the palette (matching translated labels); one open/toggle path from every entry point (`palette_tests`). Ranking and the Assets results not compared with Figma's | | Context menus | done | canvas and layers; submenus as in the main menu below, and a right-click in a new spot starts a fresh menu (Select layer closed); Shift+F10 opens them from the keyboard (at the focused layer row or below the canvas selection) and the menu takes the keyboard; the Menu key isn't delivered by the windowing layer (egui 0.36 has no key for it) | | Home screen: Recents, Online and Templates | done (ours) | The sidebar switches between the three (`ui.set {homeSection}`). Online: your online files (see the collaboration checklist). Recents: new file, open, join with a code, open files, recent files (cards with the picture each file carries on its page colour, the file's icon, name and "Edited ... ago" or "File not found", full path on hover; or a Name, Last modified and Created table with sortable headers; sorted by name, creation or last change either way; grid or list and the sort kept in the preferences, `ui.set {homeRecentsView, homeRecentsSort, homeRecentsOrder}`; a click selects, a double-click or Enter opens, arrow keys move the selection; Open, Show in folder and Remove from recents on right-click), a Getting started card (six tips with their shortcuts from the keymap; its close button is remembered in the preferences and Help ▸ Getting started brings it back) and, while there are no recent files, four templates to start from. Templates: the eight starter templates as cards (thumbnail rendered from the document, cached per theme; name, description, kind and size), a search field and a kind filter, an empty state; Tab reaches every card and Enter opens it; cards and tabs are named for screen readers. A first run opens here. No Community files or drafts folder | +| Home search | done (ours) | A field in the left sidebar filters open tabs, recent local files and saved online files by name, path or server, plus starter templates by name, description, kind and size. Matching file cards and template cards open through their existing actions; selecting a section clears the query. It does not search other people's projects or files that have never been opened here (`ui.set {homeSearch}`; `ui.inspect.homeSearch`). | | Main menu from the logo | done | generated from `catalog.rs`; popups sized to content. Submenus open after 0.15 s on their row (a click or the keyboard opens one at once) and close at once when another row is hovered; while the pointer heads for an open submenu inside the triangle to its near edge, the rows it crosses don't take over, and resting 0.3 s on one hands over; closing the menu, however it closes, clears which submenus were open, so it reopens with none (`menu_aim_tests`, 10 tests; timings tuned by feel, not measured against Figma). Menu and palette items that need a value ask for it first: Create link, Offset path and Move to page in a dialog of the shared controls, the language, user name, server and nudge items in Preferences, Open recent on the home screen's Recents; File ▸ Rename edits the file name in place | | Keyboard shortcuts panel | done | closes when the home screen shows (#548); docked, tabbed by group like the documentation, searchable, per-platform keycaps; held keys, gestures and presentation keys listed beside the bindings; a label too long for its column ends with "…" and shows in full on hover | | Several windows | partial | File ▸ New window, Open in new window on a tab, a tab dragged off the strip; screen-reader tree in the main window only; positions not restored | @@ -172,6 +173,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Home sidebar search over known open, recent and online files plus starter templates, with a no-results state. No server-wide search. Estimates unchanged. | | 2026-10-17 | minor | The community batches #733, #735, #736, #729 and #753: submenus open after a short hover, keep their place along a safe triangle and reopen closed (main and context menus); the Actions palette's tabs, ranked search, keyboard highlight and recents; menu items that need a value ask for it, File ▸ Rename edits in place; Shift+I and Resources open Assets; a draggable Pages/Layers divider; Shift-drag moves the selection; a double-click near a thin stroke, merging dropped points and Flatten keeping open paths in vector editing; live font preview and the selection highlight after line breaks; the colour picker on a new fill or stroke; Paste over and Paste properties from the keyboard; Selection colors (partial: no library styles or variables there) and smart-selection spacing fields and canvas handles (partial: no reorder rings), both also checked by their author on macOS; the keyboard shortcuts panel closes on the home screen; Adwaita window controls on Wayland compositors without server decorations (checked by its author on GNOME; KDE not run). Checked in the app on Windows (submenus by their author), not side by side with Figma. Summary and hours unchanged | | 2026-10-15 | minor | Hex fields take CSS colour names (`red` → `FF0000`): fills, strokes, effects, grids, variables, backgrounds and the colour picker | | 2026-10-15 | minor | Typography: drag the line-height, letter-spacing and paragraph-spacing icons to scrub their values; shared numeric-field rates, resize cursor and one Undo step per gesture (#373). | From 1f0d9a9107bfd611aa4ee13b5c973948826c4a98 Mon Sep 17 00:00:00 2001 From: Yarila Date: Sat, 17 Oct 2026 22:30:03 +0500 Subject: [PATCH 2/4] M7: show file previews in Home search --- ROADMAP.md | 2 +- crates/ui-egui/src/home.rs | 60 ++++++++++++------------------ crates/ui-egui/src/home_recents.rs | 52 +++++++++++++++++++++++++- crates/ui-egui/src/home_tests.rs | 29 +++++++++++++++ docs/ui-parity.md | 4 +- 5 files changed, 107 insertions(+), 40 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5a77b095..3e9319d1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -316,7 +316,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| -| 2026-10-17 | minor | Home sidebar search filters open, recent and saved online files plus starter templates, opens a result through its existing action, and shows an empty state; no server-wide query. Numbers unchanged. | +| 2026-10-17 | minor | Home sidebar search filters open, recent and saved online files plus starter templates, opens a result through its existing action, and shows an empty state. Local results reuse recent-file previews and file-type icons; no server-wide query. Numbers unchanged. | | 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged | | 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. | | 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 3ea25576..77d1a1a3 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -12,6 +12,7 @@ use serde_json::json; use crate::FigApp; use crate::a11y; +use crate::home_recents::SearchHit; use crate::i18n::{tr, tr_noop}; use crate::icons::{self, Icon}; use crate::theme::{ACCENT, Tokens, regular, semibold}; @@ -258,8 +259,8 @@ fn file_matches(name: &str, location: &str, query: &str) -> bool { name.to_lowercase().contains(query) || location.to_lowercase().contains(query) } -/// A search result with a name and optional location on separate lines. The larger hit target -/// keeps a short results list readable on the wide home screen. +/// An online search result with a name and optional location on separate lines. Local files use +/// the Recent files cards below so their stored preview and file-type icon are also shown here. fn search_file_card(ui: &mut Ui, t: &Tokens, id: Id, row: &FileRow) -> bool { let (r, _) = ui.allocate_exact_size(vec2(ui.available_width().min(640.0), 64.0), Sense::hover()); let resp = ui.interact(r, id, Sense::click()); @@ -269,7 +270,8 @@ fn search_file_card(ui: &mut Ui, t: &Tokens, id: Id, row: &FileRow) -> bool { focus_ring(ui, t, r, &resp, 8); if let Some((icon, tint)) = row.mark { let mark = Rect::from_center_size(pos2(r.min.x + 29.0, r.center().y), vec2(20.0, 20.0)); - icons::paint(ui.painter(), mark, icon, tint); + ui.painter().rect_filled(mark, CornerRadius::same(4), t.field); + icons::paint(ui.painter(), mark.shrink(2.0), icon, tint); } let width = (r.width() - 78.0).max(1.0); let name = widgets::elide_font(ui, row.name, width, semibold(13.0)); @@ -293,30 +295,15 @@ fn search_results(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { let open: Vec<_> = app.session.docs.iter().enumerate().filter(|(_, d)| file_matches(&d.doc.name, d.path.as_deref().unwrap_or_default(), &query)).collect(); - if !open.is_empty() { - subheading(ui, t, tr("Open files")); - for (index, d) in open { - count += 1; - let row = FileRow { - mark: Some((Icon::Logo, ACCENT)), - name: &d.doc.name, - ink: t.text, - detail: d.path.as_deref().unwrap_or_default(), - detail_size: 11.0, - detail_ink: t.text_secondary, - }; - if search_file_card(ui, t, ui.id().with(("search_open", d.uid)), &row) { - switch = Some(index); - } - ui.add_space(8.0); - } - } + let open_hits: Vec = + open.iter().map(|(_, d)| SearchHit { key: format!("open:{}", d.uid), path: d.path.clone(), name: d.doc.name.clone() }).collect(); let recent: Vec<(String, String)> = app .session .prefs .recent_files .iter() + .filter(|p| figcraft_engine::file_access::allowed(p, figcraft_engine::file_access::Access::Read)) .filter(|p| !open_paths.contains(&p.as_str())) .filter_map(|path| { let p = Path::new(path); @@ -324,23 +311,24 @@ fn search_results(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { file_matches(&name, path, &query).then(|| (path.clone(), name)) }) .collect(); + let recent_hits: Vec = + recent.iter().map(|(path, name)| SearchHit { key: format!("recent:{path}"), path: Some(path.clone()), name: name.clone() }).collect(); + let preview_paths: Vec = open_hits.iter().chain(&recent_hits).filter_map(|hit| hit.path.clone()).collect(); + let reduce_motion = app.reduce_motion(); + app.ui.home_view.recents.prepare_search(ui.ctx(), &preview_paths, ui.input(|i| i.time)); + if !open_hits.is_empty() { + subheading(ui, t, tr("Open files")); + count += open_hits.len(); + if let Some(hit) = crate::home_recents::search_grid(ui, t, &mut app.ui.home_view.recents, &open_hits, reduce_motion) { + switch = open.get(hit).map(|(index, _)| *index); + } + } let mut open_recent = None; - if !recent.is_empty() { + if !recent_hits.is_empty() { subheading(ui, t, tr("Recent files")); - for (path, name) in &recent { - count += 1; - let row = FileRow { - mark: Some((Icon::Page, t.icon_secondary)), - name, - ink: t.text, - detail: path, - detail_size: 11.0, - detail_ink: t.text_secondary, - }; - if search_file_card(ui, t, ui.id().with(("search_recent", path)), &row) { - open_recent = Some(path.clone()); - } - ui.add_space(8.0); + count += recent_hits.len(); + if let Some(hit) = crate::home_recents::search_grid(ui, t, &mut app.ui.home_view.recents, &recent_hits, reduce_motion) { + open_recent = recent_hits.get(hit).and_then(|hit| hit.path.clone()); } } diff --git a/crates/ui-egui/src/home_recents.rs b/crates/ui-egui/src/home_recents.rs index 1796eb02..8ccc3cec 100644 --- a/crates/ui-egui/src/home_recents.rs +++ b/crates/ui-egui/src/home_recents.rs @@ -504,6 +504,12 @@ impl Recents { paths.iter().map(|p| Shown::new(p, self.files.get(p).and_then(|k| k.stat))).collect() } + /// Share the Recent files thumbnail cache with Home search. The existing loader keeps file + /// reads off the UI thread and only requests pictures for cards that are on screen. + pub(crate) fn prepare_search(&mut self, ctx: &egui::Context, paths: &[String], time: f64) { + self.refresh(ctx, paths, time); + } + /// For `ui.inspect`: each file listed, whether it is there and whether its picture is in. pub fn inspect(&self, prefs: &Prefs) -> Value { let paths: Vec = listed(&prefs.recent_files).take(SHOWN).cloned().collect(); @@ -724,7 +730,8 @@ fn arrows(ui: &Ui, recents: &mut Recents, files: &[Shown], across: usize, down: /// The square file-type mark before a name. fn file_icon(ui: &Ui, t: &Tokens, center: egui::Pos2, path: &str) { let r = Rect::from_center_size(center, vec2(16.0, 16.0)); - match FileKind::of(Path::new(path)) { + let kind = if path.is_empty() { FileKind::FigCraft } else { FileKind::of(Path::new(path)) }; + match kind { FileKind::FigCraft | FileKind::Fig => { ui.painter().rect_filled(r, CornerRadius::same(4), ACCENT); icons::paint(ui.painter(), r.shrink(3.0), Icon::Logo, Color32::WHITE); @@ -741,6 +748,49 @@ fn file_icon(ui: &Ui, t: &Tokens, center: egui::Pos2, path: &str) { } } +/// A file found by the Home sidebar search. A tab without a disk path has no stored preview yet. +pub(crate) struct SearchHit { + pub key: String, + pub path: Option, + pub name: String, +} + +/// Search results use the same preview, file-type icon and footer as Recent files. A single click +/// still opens the result, as it did before the visual change. +pub(crate) fn search_grid(ui: &mut Ui, t: &Tokens, recents: &mut Recents, files: &[SearchHit], reduce_motion: bool) -> Option { + let (columns, card_w) = grid_columns(ui.available_width()); + let (picture_h, card_h) = card_heights(card_w); + let (time, now) = (ui.input(|i| i.time), figcraft_engine::cmd::comment::now_secs()); + let mut chosen = None; + let mut fading = false; + for (row_index, row) in files.chunks(columns).enumerate() { + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), card_h), Sense::hover()); + for (column, file) in row.iter().enumerate() { + let rect = Rect::from_min_size(pos2(r.min.x + column as f32 * (card_w + GAP), r.min.y), vec2(card_w, card_h)); + let resp = ui.interact(rect, ui.id().with(("search_file_card", &file.key)), Sense::click()); + let stat = file.path.as_deref().and_then(|path| recents.files.get(path).and_then(|k| k.stat)); + let shown = Shown { path: file.path.clone().unwrap_or_default(), name: file.name.clone(), stat }; + a11y::named_button(&resp, &spoken(&shown, now)); + let resp = if let Some(path) = file.path.as_deref() { resp.on_hover_text(path) } else { resp }; + let visible = ui.is_rect_visible(rect); + let picture = file.path.as_deref().and_then(|path| recents.picture(path, visible, time)); + if visible { + let alpha = picture.as_ref().map_or(1.0, |(_, _, a)| if reduce_motion { 1.0 } else { *a }); + fading |= alpha < 1.0; + card(ui, t, rect, picture_h, &resp, false, &shown, stat.unwrap_or_default(), picture, alpha, now); + } + if resp.clicked() { + chosen = Some(row_index * columns + column); + } + } + ui.add_space(GAP); + } + if fading { + ui.ctx().request_repaint(); + } + chosen +} + /// The name a card or row has for screen readers: "name, Edited 3 hours ago". fn spoken(file: &Shown, now: u64) -> String { let detail = file.detail(now); diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index db052303..cf34e5b3 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -222,6 +222,35 @@ fn home_search_finds_known_files_and_opens_an_existing_tab() { assert_eq!(u.app.session.docs.len(), 1); } +#[test] +fn home_search_shows_a_saved_files_preview() { + let dir = std::env::temp_dir().join(format!("figcraft-home-search-preview-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Search preview.figcraft"); + let image = image::RgbaImage::from_pixel(64, 32, image::Rgba([30, 140, 210, 255])); + let mut png = Vec::new(); + image.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let doc = figcraft_doc::Document::new("Search preview"); + std::fs::write(&path, figcraft_format::save_bytes(&doc, Some(&png)).unwrap()).unwrap(); + + let mut session = Session::empty(); + session.prefs.recent_files.push(path.to_string_lossy().into_owned()); + let mut u = Ui::new(session); + u.app.ui.home_view.file_query = "Search preview".into(); + for _ in 0..400 { + u.frame(vec![]); + if u.app.inspect()["homeRecents"]["files"][0]["picture"] == "picture" { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let s = shapes(&mut u); + assert!(has_text(&s, "Search preview")); + assert!(s.iter().any(|s| matches!(s, Shape::Mesh(m) if m.texture_id != egui::TextureId::default())), "the saved preview is drawn"); + let _ = std::fs::remove_file(path); + let _ = std::fs::remove_dir(dir); +} + #[test] fn home_search_field_filters_as_typed_and_a_section_click_clears_it() { let mut u = first_run(); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 40568c6c..13f89118 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -22,7 +22,7 @@ interaction feel not yet compared. | Actions palette (Cmd+K, Cmd+/) | done | tabs: All (every command, grouped by menu, or Other), Assets (components and styles) and Plugins & widgets; results ranked prefix first, then shortest, at most 60 rows; Up/Down move a highlight and Enter runs it; Recents list what was run from the palette (matching translated labels); one open/toggle path from every entry point (`palette_tests`). Ranking and the Assets results not compared with Figma's | | Context menus | done | canvas and layers; submenus as in the main menu below, and a right-click in a new spot starts a fresh menu (Select layer closed); Shift+F10 opens them from the keyboard (at the focused layer row or below the canvas selection) and the menu takes the keyboard; the Menu key isn't delivered by the windowing layer (egui 0.36 has no key for it) | | Home screen: Recents, Online and Templates | done (ours) | The sidebar switches between the three (`ui.set {homeSection}`). Online: your online files (see the collaboration checklist). Recents: new file, open, join with a code, open files, recent files (cards with the picture each file carries on its page colour, the file's icon, name and "Edited ... ago" or "File not found", full path on hover; or a Name, Last modified and Created table with sortable headers; sorted by name, creation or last change either way; grid or list and the sort kept in the preferences, `ui.set {homeRecentsView, homeRecentsSort, homeRecentsOrder}`; a click selects, a double-click or Enter opens, arrow keys move the selection; Open, Show in folder and Remove from recents on right-click), a Getting started card (six tips with their shortcuts from the keymap; its close button is remembered in the preferences and Help ▸ Getting started brings it back) and, while there are no recent files, four templates to start from. Templates: the eight starter templates as cards (thumbnail rendered from the document, cached per theme; name, description, kind and size), a search field and a kind filter, an empty state; Tab reaches every card and Enter opens it; cards and tabs are named for screen readers. A first run opens here. No Community files or drafts folder | -| Home search | done (ours) | A field in the left sidebar filters open tabs, recent local files and saved online files by name, path or server, plus starter templates by name, description, kind and size. Matching file cards and template cards open through their existing actions; selecting a section clears the query. It does not search other people's projects or files that have never been opened here (`ui.set {homeSearch}`; `ui.inspect.homeSearch`). | +| Home search | done (ours) | A field in the left sidebar filters open tabs, recent local files and saved online files by name, path or server, plus starter templates by name, description, kind and size. Local file results reuse the Recent files thumbnail and file-type icon; online files have an icon but no local preview. Matching cards open through their existing actions; selecting a section clears the query. It does not search other people's projects or files that have never been opened here (`ui.set {homeSearch}`; `ui.inspect.homeSearch`). | | Main menu from the logo | done | generated from `catalog.rs`; popups sized to content. Submenus open after 0.15 s on their row (a click or the keyboard opens one at once) and close at once when another row is hovered; while the pointer heads for an open submenu inside the triangle to its near edge, the rows it crosses don't take over, and resting 0.3 s on one hands over; closing the menu, however it closes, clears which submenus were open, so it reopens with none (`menu_aim_tests`, 10 tests; timings tuned by feel, not measured against Figma). Menu and palette items that need a value ask for it first: Create link, Offset path and Move to page in a dialog of the shared controls, the language, user name, server and nudge items in Preferences, Open recent on the home screen's Recents; File ▸ Rename edits the file name in place | | Keyboard shortcuts panel | done | closes when the home screen shows (#548); docked, tabbed by group like the documentation, searchable, per-platform keycaps; held keys, gestures and presentation keys listed beside the bindings; a label too long for its column ends with "…" and shows in full on hover | | Several windows | partial | File ▸ New window, Open in new window on a tab, a tab dragged off the strip; screen-reader tree in the main window only; positions not restored | @@ -173,7 +173,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| -| 2026-10-17 | minor | Home sidebar search over known open, recent and online files plus starter templates, with a no-results state. No server-wide search. Estimates unchanged. | +| 2026-10-17 | minor | Home sidebar search over known open, recent and online files plus starter templates, with a no-results state. Local results reuse the Recent files previews and file-type icons. No server-wide search. Estimates unchanged. | | 2026-10-17 | minor | The community batches #733, #735, #736, #729 and #753: submenus open after a short hover, keep their place along a safe triangle and reopen closed (main and context menus); the Actions palette's tabs, ranked search, keyboard highlight and recents; menu items that need a value ask for it, File ▸ Rename edits in place; Shift+I and Resources open Assets; a draggable Pages/Layers divider; Shift-drag moves the selection; a double-click near a thin stroke, merging dropped points and Flatten keeping open paths in vector editing; live font preview and the selection highlight after line breaks; the colour picker on a new fill or stroke; Paste over and Paste properties from the keyboard; Selection colors (partial: no library styles or variables there) and smart-selection spacing fields and canvas handles (partial: no reorder rings), both also checked by their author on macOS; the keyboard shortcuts panel closes on the home screen; Adwaita window controls on Wayland compositors without server decorations (checked by its author on GNOME; KDE not run). Checked in the app on Windows (submenus by their author), not side by side with Figma. Summary and hours unchanged | | 2026-10-15 | minor | Hex fields take CSS colour names (`red` → `FF0000`): fills, strokes, effects, grids, variables, backgrounds and the colour picker | | 2026-10-15 | minor | Typography: drag the line-height, letter-spacing and paragraph-spacing icons to scrub their values; shared numeric-field rates, resize cursor and one Undo step per gesture (#373). | From 6a334fa3541f364aebe786bd97949bdb04ac6ce2 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 11:37:58 +0700 Subject: [PATCH 3/4] #765: drop the progress-doc edits (maintainer adds them) --- ROADMAP.md | 3 +-- docs/gaps.md | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3732103c..8a587286 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-17 · **Change:** minor (Home search across known files and starter templates; community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **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 @@ -316,7 +316,6 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| -| 2026-10-17 | minor | Home sidebar search filters open, recent and saved online files plus starter templates, opens a result through its existing action, and shows an empty state. Local results reuse recent-file previews and file-type icons; no server-wide query. Numbers unchanged. | | 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged | | 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. | | 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | diff --git a/docs/gaps.md b/docs/gaps.md index 85ed33ad..9b1a770c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Home search across known files and starter templates; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -444,7 +444,6 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| -| 2026-10-17 | minor | Home sidebar searches known open, recent and online files plus starter templates. Accounts and server-wide discovery remain in gap 2; estimates unchanged. | | 2026-10-17 | minor | The 2026-10-17 landings. Gap 1: a new Landed entry (community batches #727 and #728, then #745: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments, images in the message, instance layers' size and resizing overrides, Hug where nothing is recorded; budgets from the file's size; the import report; a `.fig` copy from Save as; paste from another design app), its Missing list cut to what is still left, and the wrong "whether other apps open its files is unknown" replaced by what was checked; all on synthetic files, so the beta blocker and hours stand. Gap 2: server limits, log recovery and web join links in the evidence. Gap 4: menus, the Actions palette and the editor fixes (#733, #735, #736, #729), then Selection colors and smart-selection spacing (#753) landed, none compared side by side. Gap 5: catalog counts re-measured (2,272 entries; drafts at 95–99%; Indonesian, Czech, Turkish and Swedish listed). Gap 8: the Linux and BSD clipboard changes and Wayland window controls, not run there by the maintainers. Ranking and hours unchanged | | 2026-10-15 | minor | Gap 7: bounded canvas overscan for smoother panning. Estimates and ranking unchanged. | | 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | From f90becadab1085c60e1d0dcd17d176597759889e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 11:52:32 +0700 Subject: [PATCH 4/4] #765: search results use Home's own Open files rows and Recent files section (the full list stays known, so pictures and the selection survive a search); search_grid, SearchHit, prepare_search and search_file_card go; the field is widgets::text_input and Escape clears it --- crates/ui-egui/src/home.rs | 158 +++++++---------------------- crates/ui-egui/src/home_recents.rs | 72 ++++--------- crates/ui-egui/src/home_tests.rs | 56 +++++++++- 3 files changed, 107 insertions(+), 179 deletions(-) diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 77d1a1a3..236e2786 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -4,7 +4,6 @@ //! the documents themselves, searched and filtered by kind). use std::collections::BTreeMap; -use std::path::Path; use egui::{Align2, Color32, CornerRadius, Id, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use figcraft_engine::templates::{Kind, TEMPLATES, Template}; @@ -12,7 +11,6 @@ use serde_json::json; use crate::FigApp; use crate::a11y; -use crate::home_recents::SearchHit; use crate::i18n::{tr, tr_noop}; use crate::icons::{self, Icon}; use crate::theme::{ACCENT, Tokens, regular, semibold}; @@ -207,35 +205,16 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) { icons::paint(ui.painter(), Rect::from_min_size(pos2(left.min.x + 16.0, y), vec2(20.0, 20.0)), Icon::Logo, ACCENT); ui.painter().text(pos2(left.min.x + 44.0, y + 10.0), Align2::LEFT_CENTER, "FigCraft", semibold(14.0), t.text); y += 48.0; - let search = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(SIDEBAR - 16.0, 32.0)); + let search = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(SIDEBAR - 16.0, widgets::FIELD_H)); ui.scope_builder(egui::UiBuilder::new().max_rect(search), |ui| { - ui.painter().rect_filled(search, CornerRadius::same(5), t.field); - icons::paint( - ui.painter(), - Rect::from_center_size(pos2(search.min.x + 18.0, search.center().y), vec2(16.0, 16.0)), - Icon::Search, - t.icon_secondary, - ); - let text_rect = Rect::from_min_max(pos2(search.min.x + 36.0, search.center().y - 8.0), pos2(search.max.x - 8.0, search.center().y + 8.0)); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_rect)); - let response = child.add( - egui::TextEdit::singleline(&mut app.ui.home_view.file_query) - .id(Id::new("home_file_search")) - .frame(egui::Frame::NONE) - .font(regular(12.0)) - .hint_text(tr("Search files and templates")) - .vertical_align(egui::Align::Center) - .desired_width(text_rect.width()), - ); - widgets::select_all_on_focus(ui, &response, &app.ui.home_view.file_query); + let response = + widgets::text_input(ui, Id::new("home_file_search"), &mut app.ui.home_view.file_query, search.width(), tr("Search files and templates")); a11y::text(&response, tr("Search files and templates"), &app.ui.home_view.file_query); - if response.has_focus() { - ui.painter().rect_stroke(search, CornerRadius::same(5), Stroke::new(1.0, t.accent), StrokeKind::Inside); - } else if ui.rect_contains_pointer(search) { - ui.painter().rect_stroke(search, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), StrokeKind::Inside); + if (response.has_focus() || response.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.home_view.file_query.clear(); } }); - y += 32.0 + 4.0; + y += widgets::FIELD_H + 12.0; for (section, icon, label) in Section::ALL { let r = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(SIDEBAR - 16.0, 32.0)); let resp = ui.interact(r, ui.id().with(("home_section", label)), Sense::click()); @@ -259,78 +238,13 @@ fn file_matches(name: &str, location: &str, query: &str) -> bool { name.to_lowercase().contains(query) || location.to_lowercase().contains(query) } -/// An online search result with a name and optional location on separate lines. Local files use -/// the Recent files cards below so their stored preview and file-type icon are also shown here. -fn search_file_card(ui: &mut Ui, t: &Tokens, id: Id, row: &FileRow) -> bool { - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width().min(640.0), 64.0), Sense::hover()); - let resp = ui.interact(r, id, Sense::click()); - a11y::button(&resp, row.name); - ui.painter().rect_filled(r, CornerRadius::same(8), if resp.hovered() { t.hover } else { t.field }); - ui.painter().rect_stroke(r, CornerRadius::same(8), Stroke::new(1.0, t.border), StrokeKind::Inside); - focus_ring(ui, t, r, &resp, 8); - if let Some((icon, tint)) = row.mark { - let mark = Rect::from_center_size(pos2(r.min.x + 29.0, r.center().y), vec2(20.0, 20.0)); - ui.painter().rect_filled(mark, CornerRadius::same(4), t.field); - icons::paint(ui.painter(), mark.shrink(2.0), icon, tint); - } - let width = (r.width() - 78.0).max(1.0); - let name = widgets::elide_font(ui, row.name, width, semibold(13.0)); - let name_y = if row.detail.is_empty() { r.center().y } else { r.min.y + 23.0 }; - ui.painter().text(pos2(r.min.x + 58.0, name_y), Align2::LEFT_CENTER, name, semibold(13.0), row.ink); - if !row.detail.is_empty() { - let detail = widgets::elide_start_font(ui, row.detail, width, regular(row.detail_size)); - ui.painter().text(pos2(r.min.x + 58.0, r.min.y + 44.0), Align2::LEFT_CENTER, detail, regular(row.detail_size), row.detail_ink); - } - if !row.detail.is_empty() { resp.on_hover_text(row.detail).clicked() } else { resp.clicked() } -} - /// One search over known files and starter templates. It never sends a query or owner token to a /// server; online files are opened through their existing owner-token command only on click. fn search_results(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { heading(ui, t, tr("Search results")); let query = app.ui.home_view.file_query.trim().to_lowercase(); - let mut count = 0; - let mut switch = None; - let open_paths: Vec<&str> = app.session.docs.iter().filter_map(|d| d.path.as_deref()).collect(); - - let open: Vec<_> = - app.session.docs.iter().enumerate().filter(|(_, d)| file_matches(&d.doc.name, d.path.as_deref().unwrap_or_default(), &query)).collect(); - let open_hits: Vec = - open.iter().map(|(_, d)| SearchHit { key: format!("open:{}", d.uid), path: d.path.clone(), name: d.doc.name.clone() }).collect(); - - let recent: Vec<(String, String)> = app - .session - .prefs - .recent_files - .iter() - .filter(|p| figcraft_engine::file_access::allowed(p, figcraft_engine::file_access::Access::Read)) - .filter(|p| !open_paths.contains(&p.as_str())) - .filter_map(|path| { - let p = Path::new(path); - let name = p.file_stem().or_else(|| p.file_name()).map_or_else(|| path.clone(), |n| n.to_string_lossy().into_owned()); - file_matches(&name, path, &query).then(|| (path.clone(), name)) - }) - .collect(); - let recent_hits: Vec = - recent.iter().map(|(path, name)| SearchHit { key: format!("recent:{path}"), path: Some(path.clone()), name: name.clone() }).collect(); - let preview_paths: Vec = open_hits.iter().chain(&recent_hits).filter_map(|hit| hit.path.clone()).collect(); - let reduce_motion = app.reduce_motion(); - app.ui.home_view.recents.prepare_search(ui.ctx(), &preview_paths, ui.input(|i| i.time)); - if !open_hits.is_empty() { - subheading(ui, t, tr("Open files")); - count += open_hits.len(); - if let Some(hit) = crate::home_recents::search_grid(ui, t, &mut app.ui.home_view.recents, &open_hits, reduce_motion) { - switch = open.get(hit).map(|(index, _)| *index); - } - } - let mut open_recent = None; - if !recent_hits.is_empty() { - subheading(ui, t, tr("Recent files")); - count += recent_hits.len(); - if let Some(hit) = crate::home_recents::search_grid(ui, t, &mut app.ui.home_view.recents, &recent_hits, reduce_motion) { - open_recent = recent_hits.get(hit).and_then(|hit| hit.path.clone()); - } - } + let mut count = open_files(app, ui, t, &query); + count += crate::home_recents::section(app, ui, t, &query); let online: Vec<_> = app.session.prefs.online_files.iter().filter(|f| file_matches(&f.name, &f.server, &query)).cloned().collect(); let mut open_online = None; @@ -348,10 +262,9 @@ fn search_results(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { detail_size: 11.0, detail_ink: t.text_secondary, }; - if search_file_card(ui, t, ui.id().with(("search_online", &f.server, &f.file)), &row) { + if listed(ui, t, ui.id().with(("search_online", &f.server, &f.file)), 36.0, &row) { open_online = Some(f.clone()); } - ui.add_space(8.0); } } let templates: Vec<&Template> = TEMPLATES.iter().filter(|template| matches(template, &query, None)).collect(); @@ -369,14 +282,7 @@ fn search_results(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { app.ui.home_view.section = Section::Templates; } } - if let Some(index) = switch { - app.run("doc.switch", json!({"index": index})); - app.ui.home = false; - } else if let Some(path) = open_recent { - if app.run_user("file.open", json!({"path": path})).is_some() { - app.ui.home = false; - } - } else if let Some(file) = open_online { + if let Some(file) = open_online { crate::online::open(app, &file); } } @@ -455,22 +361,7 @@ fn recents(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { ui.add_space(28.0); getting_started(app, ui, t); } - if !app.session.docs.is_empty() { - subheading(ui, t, tr("Open files")); - let mut switch = None; - for (i, d) in app.session.docs.iter().enumerate() { - let detail = d.path.as_deref().unwrap_or_default(); - let row = - FileRow { mark: Some((Icon::Logo, ACCENT)), name: &d.doc.name, ink: t.text, detail, detail_size: 11.0, detail_ink: t.text_secondary }; - if listed(ui, t, ui.id().with(("open", d.uid)), 36.0, &row) { - switch = Some(i); - } - } - if let Some(i) = switch { - app.run("doc.switch", json!({"index": i})); - app.ui.home = false; - } - } + open_files(app, ui, t, ""); if crate::home_recents::listed(&app.session.prefs.recent_files).next().is_none() { // A first run: nothing to reopen yet, so offer something to start from. subheading(ui, t, tr("Start from a template")); @@ -482,7 +373,32 @@ fn recents(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { } return; } - crate::home_recents::section(app, ui, t); + crate::home_recents::section(app, ui, t, ""); +} + +/// The Open files list: the documents whose name or path has `query` (lower case; all for ""), a +/// row each; choosing one shows its tab. How many are listed. +fn open_files(app: &mut FigApp, ui: &mut Ui, t: &Tokens, query: &str) -> usize { + let hit = |d: &&figcraft_engine::DocState| file_matches(&d.doc.name, d.path.as_deref().unwrap_or_default(), query); + let count = app.session.docs.iter().filter(hit).count(); + if count == 0 { + return 0; + } + subheading(ui, t, tr("Open files")); + let mut switch = None; + for (i, d) in app.session.docs.iter().enumerate().filter(|(_, d)| hit(d)) { + let detail = d.path.as_deref().unwrap_or_default(); + let row = + FileRow { mark: Some((Icon::Logo, ACCENT)), name: &d.doc.name, ink: t.text, detail, detail_size: 11.0, detail_ink: t.text_secondary }; + if listed(ui, t, ui.id().with(("open", d.uid)), 36.0, &row) { + switch = Some(i); + } + } + if let Some(i) = switch { + app.run("doc.switch", json!({"index": i})); + app.ui.home = false; + } + count } /// The file joined from the home screen (a code, or one of your online files) opened in a tab of diff --git a/crates/ui-egui/src/home_recents.rs b/crates/ui-egui/src/home_recents.rs index 8ccc3cec..aeeb0f27 100644 --- a/crates/ui-egui/src/home_recents.rs +++ b/crates/ui-egui/src/home_recents.rs @@ -504,12 +504,6 @@ impl Recents { paths.iter().map(|p| Shown::new(p, self.files.get(p).and_then(|k| k.stat))).collect() } - /// Share the Recent files thumbnail cache with Home search. The existing loader keeps file - /// reads off the UI thread and only requests pictures for cards that are on screen. - pub(crate) fn prepare_search(&mut self, ctx: &egui::Context, paths: &[String], time: f64) { - self.refresh(ctx, paths, time); - } - /// For `ui.inspect`: each file listed, whether it is there and whether its picture is in. pub fn inspect(&self, prefs: &Prefs) -> Value { let paths: Vec = listed(&prefs.recent_files).take(SHOWN).cloned().collect(); @@ -560,16 +554,27 @@ enum Action { View(View), } -/// The Recent files heading, its toolbar and the files as cards or a table. -pub fn section(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { - let paths: Vec = listed(&app.session.prefs.recent_files).take(SHOWN).cloned().collect(); +/// The Recent files heading, its toolbar and the files as cards or a table. With a `query` (lower +/// case, from the Home search) only the files not open whose path has it, from every recent file; +/// nothing at all when none has it. How many files are shown. +pub fn section(app: &mut FigApp, ui: &mut Ui, t: &Tokens, query: &str) -> usize { + let searching = !query.is_empty(); + // A search reaches every recent file; the files shown without one stay known through it. + let paths: Vec = listed(&app.session.prefs.recent_files).take(if searching { usize::MAX } else { SHOWN }).cloned().collect(); let time = ui.input(|i| i.time); let now = figcraft_engine::cmd::comment::now_secs(); let reduce_motion = app.reduce_motion(); let (view, sort) = (View::of(&app.session.prefs), Sort::of(&app.session.prefs)); let state = &mut app.ui.home_view.recents; state.refresh(ui.ctx(), &paths, time); - let files = sorted(state.shown(&paths), sort); + let mut files = sorted(state.shown(&paths), sort); + if searching { + let docs = &app.session.docs; + files.retain(|f| f.path.to_lowercase().contains(query) && !docs.iter().any(|d| d.path.as_deref() == Some(f.path.as_str()))); + if files.is_empty() { + return 0; + } + } let with_created = files.iter().any(|f| f.stat.is_some_and(|s| s.created.is_some())); let mut action = toolbar(ui, t, view, sort, with_created); ui.add_space(16.0); @@ -599,6 +604,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { } None => {} } + files.len() } /// "Recent files", and at the right the sort menu and the grid and list buttons. @@ -730,8 +736,7 @@ fn arrows(ui: &Ui, recents: &mut Recents, files: &[Shown], across: usize, down: /// The square file-type mark before a name. fn file_icon(ui: &Ui, t: &Tokens, center: egui::Pos2, path: &str) { let r = Rect::from_center_size(center, vec2(16.0, 16.0)); - let kind = if path.is_empty() { FileKind::FigCraft } else { FileKind::of(Path::new(path)) }; - match kind { + match FileKind::of(Path::new(path)) { FileKind::FigCraft | FileKind::Fig => { ui.painter().rect_filled(r, CornerRadius::same(4), ACCENT); icons::paint(ui.painter(), r.shrink(3.0), Icon::Logo, Color32::WHITE); @@ -748,49 +753,6 @@ fn file_icon(ui: &Ui, t: &Tokens, center: egui::Pos2, path: &str) { } } -/// A file found by the Home sidebar search. A tab without a disk path has no stored preview yet. -pub(crate) struct SearchHit { - pub key: String, - pub path: Option, - pub name: String, -} - -/// Search results use the same preview, file-type icon and footer as Recent files. A single click -/// still opens the result, as it did before the visual change. -pub(crate) fn search_grid(ui: &mut Ui, t: &Tokens, recents: &mut Recents, files: &[SearchHit], reduce_motion: bool) -> Option { - let (columns, card_w) = grid_columns(ui.available_width()); - let (picture_h, card_h) = card_heights(card_w); - let (time, now) = (ui.input(|i| i.time), figcraft_engine::cmd::comment::now_secs()); - let mut chosen = None; - let mut fading = false; - for (row_index, row) in files.chunks(columns).enumerate() { - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), card_h), Sense::hover()); - for (column, file) in row.iter().enumerate() { - let rect = Rect::from_min_size(pos2(r.min.x + column as f32 * (card_w + GAP), r.min.y), vec2(card_w, card_h)); - let resp = ui.interact(rect, ui.id().with(("search_file_card", &file.key)), Sense::click()); - let stat = file.path.as_deref().and_then(|path| recents.files.get(path).and_then(|k| k.stat)); - let shown = Shown { path: file.path.clone().unwrap_or_default(), name: file.name.clone(), stat }; - a11y::named_button(&resp, &spoken(&shown, now)); - let resp = if let Some(path) = file.path.as_deref() { resp.on_hover_text(path) } else { resp }; - let visible = ui.is_rect_visible(rect); - let picture = file.path.as_deref().and_then(|path| recents.picture(path, visible, time)); - if visible { - let alpha = picture.as_ref().map_or(1.0, |(_, _, a)| if reduce_motion { 1.0 } else { *a }); - fading |= alpha < 1.0; - card(ui, t, rect, picture_h, &resp, false, &shown, stat.unwrap_or_default(), picture, alpha, now); - } - if resp.clicked() { - chosen = Some(row_index * columns + column); - } - } - ui.add_space(GAP); - } - if fading { - ui.ctx().request_repaint(); - } - chosen -} - /// The name a card or row has for screen readers: "name, Edited 3 hours ago". fn spoken(file: &Shown, now: u64) -> String { let detail = file.detail(now); diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index cf34e5b3..c493a0a1 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -202,7 +202,7 @@ fn home_search_finds_known_files_and_opens_an_existing_tab() { let mut u = Ui::new(session); u.app.ui.home = true; u.app.ui.home_view.file_query = " TRAIL ".into(); - let s = shapes(&mut u); + let s = settle(&mut u); assert!(has_text(&s, "Search results")); assert!(has_text(&s, "North Trail") && has_text(&s, "South Trail") && has_text(&s, "Trail Live")); assert!(!has_text(&s, "Other") && !has_text(&s, "test-only-secret")); @@ -258,12 +258,62 @@ fn home_search_field_filters_as_typed_and_a_section_click_clears_it() { let s = shapes(&mut u); click(&mut u, text_center(&s, "Search files and templates")); u.frame(vec![Event::Text("glacier".into())]); - let s = shapes(&mut u); + let s = settle(&mut u); assert_eq!(u.app.ui.home_view.file_query, "glacier"); assert!(has_text(&s, "Glacier") && !has_text(&s, "Forest")); click(&mut u, text_center(&s, "Recents")); assert!(u.app.ui.home_view.file_query.is_empty()); - assert!(has_text(&shapes(&mut u), "Forest")); + let s = shapes(&mut u); + assert!(has_text(&s, "Forest")); + // Escape in the field clears the search too. + click(&mut u, text_center(&s, "Search files and templates")); + u.frame(vec![Event::Text("forest".into())]); + assert_eq!(u.app.ui.home_view.file_query, "forest"); + u.frame(vec![Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + assert!(u.app.ui.home_view.file_query.is_empty(), "Escape clears the search"); +} + +/// A search keeps what the recent files it doesn't match know: back from it, their pictures and +/// the selection are still there (nothing is read again). +#[test] +fn a_search_keeps_the_other_recent_files_pictures_and_the_selection() { + let dir = std::env::temp_dir().join(format!("figcraft-home-search-cache-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut png = Vec::new(); + image::RgbaImage::from_pixel(8, 8, image::Rgba([30, 140, 210, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let paths: Vec = ["Alpine", "Basin"] + .into_iter() + .map(|name| { + let path = dir.join(format!("{name}.figcraft")); + std::fs::write(&path, figcraft_format::save_bytes(&figcraft_doc::Document::new(name), Some(&png)).unwrap()).unwrap(); + path.to_string_lossy().into_owned() + }) + .collect(); + let mut session = Session::empty(); + session.prefs.recent_files = paths.clone(); + let mut u = Ui::new(session); + let pictures = |u: &mut Ui| -> Vec { + u.app.inspect()["homeRecents"]["files"].as_array().unwrap().iter().map(|f| f["picture"].clone()).collect() + }; + for _ in 0..400 { + u.frame(vec![]); + if pictures(&mut u).iter().all(|p| p == "picture") { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(pictures(&mut u).iter().all(|p| p == "picture"), "both pictures are in"); + u.app.ui.home_view.recents.selected = Some(paths[1].clone()); + u.app.ui.home_view.file_query = "alpine".into(); + let s = shapes(&mut u); + assert!(has_text(&s, "Alpine") && !has_text(&s, "Basin")); + u.app.ui.home_view.file_query.clear(); + u.frame(vec![]); + assert!(pictures(&mut u).iter().all(|p| p == "picture"), "no picture is read again"); + assert_eq!(u.app.ui.home_view.recents.selected.as_deref(), Some(paths[1].as_str()), "the selection stays"); + let _ = std::fs::remove_dir_all(dir); } #[test]