diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index 802d229a..40451d43 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -71,6 +71,9 @@ struct WebShell { impl eframe::App for WebShell { // eframe runs `logic` for visible tabs and, with only `run_logic`, for hidden ones: keep // autosave/live/control deadlines moving without a paint. `FigApp::ui` does not repeat it. + /// Run the browser shell's eframe logic callback: guard `background_tick` so hidden tabs + /// still advance autosave, live and control deadlines without painting. `FigApp::ui` does + /// not repeat it. fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { figcraft_ui_egui::windows::guard(&mut self.fig, ctx, |fig| fig.background_tick(ctx)); } diff --git a/apps/figcraft/src/control_server.rs b/apps/figcraft/src/control_server.rs index 2af5610d..ada44c0f 100644 --- a/apps/figcraft/src/control_server.rs +++ b/apps/figcraft/src/control_server.rs @@ -97,6 +97,9 @@ fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut } } +/// Serve one control connection: read bounded JSON lines, reject oversized frames and a +/// full queue, queue one request, wait under its gate, and write one reply. Non-JSON input +/// answers with an error and closes the connection so HTTP body lines cannot become commands. fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> std::io::Result<()> { out.set_read_timeout(Some(timeout))?; out.set_write_timeout(Some(timeout))?; diff --git a/apps/figcraft/src/control_server_tests.rs b/apps/figcraft/src/control_server_tests.rs index 6144cdf3..f29a7773 100644 --- a/apps/figcraft/src/control_server_tests.rs +++ b/apps/figcraft/src/control_server_tests.rs @@ -28,6 +28,7 @@ fn reply(reader: &mut BufReader) -> Value { serde_json::from_str(&line).unwrap() } +/// Build a `ui.inspect` control request with a throwaway reply channel. fn request() -> ControlRequest { ControlRequest { id: json!(0), method: "ui.inspect".into(), params: json!({}), reply: mpsc::channel().0, state: Default::default() } } @@ -115,6 +116,8 @@ fn full_queue_rejects_before_dispatch_and_ui_queue_stays_bounded() { worker.join().unwrap().unwrap(); } +/// Asserts a commit that writes the file and queues its reply wins the gate: the transport's +/// timeout branch consumes that reply and leaves the state `Replied`. #[test] fn a_committed_reply_wins_over_the_transport_timeout() { let dir = std::env::temp_dir().join(format!("figcraft-control-commit-{}", std::process::id())); @@ -139,6 +142,7 @@ fn a_committed_reply_wins_over_the_transport_timeout() { let _ = std::fs::remove_dir_all(&dir); } +/// Asserts a zero-duration wait reports `Timeout` and cancels the open gate. #[test] fn a_timeout_cancels_an_open_request() { let gate = RequestGate::new(); @@ -147,6 +151,8 @@ fn a_timeout_cancels_an_open_request() { assert_eq!(gate.state(), RequestState::Cancelled, "nothing may write after the timeout"); } +/// Asserts a closed reply channel reports `Disconnected` and leaves the gate `Open`, not +/// cancelled. #[test] fn a_disconnected_reply_channel_is_not_reported_as_a_timeout() { let gate = RequestGate::new(); @@ -268,6 +274,9 @@ fn a_limit_sized_frame_is_accepted_at_eof() { worker.join().unwrap().unwrap(); } +/// Asserts expired requests stop occupying the UI queue cap, a later request is admitted +/// and answered, and a window that finally draws discards its expired command before +/// dispatch. #[test] fn timed_out_requests_release_a_full_deferred_ui_queue() { use figcraft_ui_egui::{FigApp, NoServices, control, windows}; diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index be380c60..4a1488ad 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -62,6 +62,9 @@ struct App { } impl eframe::App for App { + /// Native eframe logic callback: store the first context, drain control requests, run + /// guarded background work, poll macOS open events, handle close requests and title + /// changes, and schedule recovery repaints on the app clock. fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { { let mut slot = self.ctx_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner); diff --git a/crates/collab-net/src/sessions_tests.rs b/crates/collab-net/src/sessions_tests.rs index 3c39e4d8..cf569a6d 100644 --- a/crates/collab-net/src/sessions_tests.rs +++ b/crates/collab-net/src/sessions_tests.rs @@ -89,6 +89,9 @@ fn rect(s: &mut Session) -> u64 { s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap() } +/// Asserts two files are hosted and joined independently: each has its own port, session and +/// edits, presence stays in its file's session, and closing one file's session leaves the +/// other live. #[test] fn two_files_are_hosted_and_joined_independently() { let mut host = session("Hana"); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 044ae884..38b24ea6 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -601,6 +601,10 @@ pub fn design_clipboard_buffer(html: &str) -> Option> { figcraft_format::fig::is_fig(&bytes).then_some(bytes) } +/// Paste design layers from clipboard HTML or base64 `data`: decode and reject missing, +/// invalid or oversized data, load a temporary `.fig`, take its top-level page layers, +/// store them as a foreign-file clipboard source with a bumped layers generation, then +/// delegate to `paste` and append import warnings. fn paste_design_layers(s: &mut Session, p: &Value) -> Result { const CMD: &str = "edit.pasteDesignLayers"; let bytes = match (string(p, "html"), string(p, "data")) { diff --git a/crates/engine/src/text_behaviour_tests.rs b/crates/engine/src/text_behaviour_tests.rs index d88066cb..8cd8b145 100644 --- a/crates/engine/src/text_behaviour_tests.rs +++ b/crates/engine/src/text_behaviour_tests.rs @@ -134,6 +134,8 @@ fn ime_composition_with_cjk() { assert!(s.ds().unwrap().text_edit.as_ref().unwrap().preedit.is_none()); } +/// Asserts `delete_surrounding_bytes` removes an exact UTF-8 byte range across CJK and a +/// supplementary emoji, leaves nothing past the start, and the whole edit is one undo step. #[test] fn ime_delete_surrounding_bytes_is_exact_and_one_undo_step() { // 0.37's `DeleteSurroundingBytes` counts UTF-8 bytes around the selection, before the paired @@ -156,6 +158,9 @@ fn ime_delete_surrounding_bytes_is_exact_and_one_undo_step() { assert_eq!(chars(&s, t), "ab\u{4F60}\u{597D}c\u{1F600}d", "one undo restores the whole session"); } +/// Asserts a byte range that only partly covers a character leaves it in place, while an +/// exact range deletes it, and a selection survives for the paired commit with its +/// direction preserved. #[test] fn ime_delete_surrounding_bytes_keeps_partial_characters_and_selection() { // The fork deletes the byte range exactly and leaves a character the range only partly diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index 9c62c331..6551783c 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -69,6 +69,8 @@ pub struct ImeSurrounding { /// A mutex (rather than an atomic) also works on wasm32 targets without 64-bit atomics. static EDITING_SESSION: LazyLock> = LazyLock::new(|| Mutex::new(0)); +/// Return the next editing-session token from a mutex-protected counter (`wrapping_add`). +/// Zero is a valid token; the mutex also works on wasm32 without 64-bit atomics. fn next_editing_session() -> u64 { let mut session = match EDITING_SESSION.lock() { Ok(session) => session, @@ -143,6 +145,9 @@ fn range(te: &TextEdit) -> (usize, usize) { (te.caret.min(te.anchor), te.caret.max(te.anchor)) } +/// Apply an unconditional text-model mutation through `edit_if`, whose success clears the +/// composition, clamps caret and anchor to the character count, marks a `characters` override +/// when the node is source-backed, normalizes the document and bumps the revision. fn edit(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit)) -> Result<()> { edit_if(s, |t, te| { f(t, te); @@ -230,6 +235,11 @@ pub fn insert_ime_commit(s: &mut Session, text: &str) -> Result { insert_model(s, text, true) } +/// Insert committed text, optionally as an IME commit: normalize line endings, drop control +/// characters except `\n`/`\t`, ignore a bare newline/CR IME commit, clear an active +/// composition for an empty or control-only IME commit, and turn a committed space after a +/// line-start `-`, `*` or `1.` into a bulleted/numbered list. Reports whether the insertion was +/// plain, transformed, or a no-op. fn insert_model(s: &mut Session, text: &str, ime_commit: bool) -> Result { // Stock `TextEdit` deliberately ignores these two commits (they are not a mutation); only a // real commit may do so, since typing Enter goes through `insert`. @@ -894,6 +904,9 @@ mod tests { e } + /// Asserts double-clicking a text layer selects all text, a second double-click selects the + /// word without changing the editing session, and drag and Shift+click extend the + /// selection. #[test] fn pointer_double_click_drag_and_shift_click() { let (mut s, id) = setup("Hello world"); @@ -1132,6 +1145,8 @@ mod tests { } } + /// Asserts surrounding text is logical UTF-8 text with byte offsets and keeps a reversed + /// selection reversed. #[test] fn ime_surrounding_uses_logical_utf8_bytes_and_keeps_selection_direction() { let (mut s, id) = setup("café😀e\u{301}x"); @@ -1146,6 +1161,8 @@ mod tests { assert_eq!(ime.owner.document, s.ds().unwrap().uid); } + /// Asserts an active composition is excluded from surrounding text and an empty text layer + /// reports an available, empty payload. #[test] fn ime_surrounding_excludes_the_composition_and_empty_text_is_available() { let (mut s, id) = setup("Hi"); @@ -1162,6 +1179,8 @@ mod tests { assert_eq!((ime.text.as_str(), ime.cursor, ime.anchor), ("", 0, 0), "an empty layer is available, not unavailable"); } + /// Asserts surrounding text is capped at `MAX_IME_SURROUNDING_BYTES`, an exactly budget-sized + /// selection is describable, and one byte more is `SelectionTooWide`. #[test] fn ime_surrounding_is_bounded_and_a_too_wide_selection_is_explicitly_unavailable() { let long = "a".repeat(5000); @@ -1182,6 +1201,8 @@ mod tests { assert_eq!(ime_surrounding(&s), Err(ImeSurroundingUnavailable::SelectionTooWide)); } + /// Asserts edits and caret moves keep the same IME owner, while ending and beginning a new + /// edit session changes the generation. #[test] fn ime_owner_is_stable_through_edits_and_the_session_changes_on_begin() { let (mut s, id) = setup("Hello"); @@ -1197,6 +1218,8 @@ mod tests { assert_ne!(replaced.editing_session, first.editing_session, "a new edit session is a replacement epoch"); } + /// Asserts byte deletion around a reversed selection removes whole characters on both + /// sides, keeps the selected text and direction, and a final no-op leaves the text alone. #[test] fn ime_delete_surrounding_bytes_keeps_the_reversed_selection_and_deletes_around_it() { let (mut s, id) = setup("ab你好cd"); @@ -1215,6 +1238,8 @@ mod tests { assert_eq!(data(&s, id).characters, "你好", "a no-op delete does not touch the text"); } + /// Asserts character deletion keeps and shifts the selection, and a no-op delete leaves an + /// active composition and the revision untouched. #[test] fn ime_delete_surrounding_chars_keeps_the_selection_and_ignores_a_no_op() { let (mut s, id) = setup("ab你好cd"); @@ -1235,6 +1260,7 @@ mod tests { assert_eq!(s.ds().unwrap().revision, revision); } + /// Asserts a text layer in another document has a different IME owner. #[test] fn ime_owner_identity_changes_with_the_document() { let (mut s, id) = setup("Hi"); @@ -1250,6 +1276,8 @@ mod tests { assert_eq!(second.node, other); } + /// Asserts a space committed after a line-start `-` becomes a bulleted list and reports + /// `Transformed`; a plain `"x"` commit reports `Plain`, and an empty commit reports `Nothing`. #[test] fn ime_commit_reports_list_autoformat_as_an_application_transformation() { let (mut s, id) = setup("-"); @@ -1261,6 +1289,8 @@ mod tests { assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); } + /// Asserts CRLF normalization reports `Transformed`, control-only input reports `Nothing`, + /// and bare newline/CR commits are ignored while a live composition remains. #[test] fn ime_commit_normalisation_is_a_transformation_and_a_bare_newline_is_ignored() { let (mut s, id) = setup("Hi"); @@ -1283,6 +1313,8 @@ mod tests { assert_eq!(data(&s, id).characters, "Hia\nb\n"); } + /// Asserts an empty or control-only IME commit ends a live composition (bumping the revision + /// once), inserts nothing, and is a harmless no-op when no composition is active. #[test] fn an_empty_ime_commit_ends_the_composition_without_inserting_text() { let (mut s, id) = setup("Hello"); diff --git a/crates/engine/src/textfit_tests.rs b/crates/engine/src/textfit_tests.rs index f9508942..e35f810c 100644 --- a/crates/engine/src/textfit_tests.rs +++ b/crates/engine/src/textfit_tests.rs @@ -64,6 +64,8 @@ fn a_one_word_last_line_is_avoided() { assert!(l.lines.len() == 1 || words.split_whitespace().count() > 1 || words.contains('\u{a0}'), "{words:?}"); } +/// Asserts `data.fill` keeps a short copy's font size, shrinks a long copy, and leaves the +/// source text layer untouched. #[test] fn data_fill_sizes_each_copys_text() { figcraft_text::scan_system_fonts(); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index e6776d2c..e65afe2c 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -2010,6 +2010,10 @@ impl Session { Ok(()) } + /// Handle a double-click gesture: finish a pen path, rename or click labels/widgets, + /// cancel an in-progress move/resize/draw/marquee, handle board double-clicks, enter text + /// editing (select-all on entry, word-select inside the running edit), enter vector editing + /// for selected shapes, and step groups/frames one level deeper. fn double_click(&mut self, ev: &PointerEvent) -> Result<()> { let ds = self.ds()?; let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); diff --git a/crates/text/build.rs b/crates/text/build.rs index ecca6a45..ba087983 100644 --- a/crates/text/build.rs +++ b/crates/text/build.rs @@ -6,6 +6,9 @@ use std::fmt::Write as _; use std::path::PathBuf; +/// Font build script: emit `craft_fonts.rs` from `input_fonts`, passing the +/// `CRAFT_FONTS_DIR`, `CRAFT_FONTS_REQUIRED`, wasm target and `FIGCRAFT_EMBED_CJK` inputs; +/// report generation and write failures with `cargo::error`. fn main() { println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR"); println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED"); @@ -77,6 +80,10 @@ struct Catalog { faces: Vec<(String, String)>, } +/// Parse a font manifest of `family | style | file | scripts` rows into `CraftFont` +/// entries: skip Adobe-authored families, skip CJK unless requested, keep the web catalog +/// to its budgeted families, canonicalize and watch each face, and emit `include_bytes!` +/// items. fn craft_fonts(dir: &std::path::Path, manifest_rel: &str, wasm: bool, embed_cjk: bool) -> Result { let manifest = dir.join(manifest_rel); watch(&manifest); @@ -121,6 +128,8 @@ mod tests { struct Fixture(PathBuf); impl Fixture { + /// Test fixture: create a unique temporary directory, write a synthetic face file and + /// install the default Georgian manifest. fn new() -> Self { let dir = std::env::temp_dir().join(format!("fig-font-input-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); std::fs::create_dir_all(dir.join("fonts")).unwrap(); @@ -131,6 +140,7 @@ mod tests { fixture } + /// Test fixture: write manifest rows that all point at the fixture's `fonts/face.ttf` face. fn manifest(&self, name: &str, faces: &[(&str, &str)]) { let text = faces.iter().map(|(family, style)| format!("{family} | {style} | fonts/face.ttf | Latn\n")).collect::(); std::fs::write(self.0.join("fonts").join(name), text).unwrap(); @@ -138,11 +148,15 @@ mod tests { } impl Drop for Fixture { + /// Remove the fixture's temporary directory, ignoring errors. fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } + /// Asserts the native Latin catalog keeps its 43 permitted presentation faces, the web + /// catalog keeps only the four Inter faces, Adobe-authored families are absent, and + /// permitted native faces remain. #[test] fn restored_latin_catalog_preserves_permitted_native_faces_and_web_budget() { let f = Fixture::new(); @@ -160,6 +174,7 @@ mod tests { assert!(input_fonts(Some(&f.0), true, true, false).is_ok()); } + /// Asserts required web input rejects a catalog missing Inter Regular or Inter SemiBold. #[test] fn required_web_rejects_core_only_and_partial_latin_catalogs() { let f = Fixture::new(); @@ -168,6 +183,8 @@ mod tests { assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("Inter SemiBold")); } + /// Asserts required web input embeds the Inter and Georgian catalogs (three entries) and + /// rejects a manifest without Noto Sans Georgian Regular. #[test] fn required_web_embeds_both_catalogs_and_requires_georgian() { let f = Fixture::new(); @@ -180,6 +197,8 @@ mod tests { assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("Noto Sans Georgian Regular")); } + /// Asserts absent font input is empty when optional and an error when required, while present + /// input includes Noto Sans Georgian. #[test] fn optional_and_native_inputs_keep_their_existing_policy() { let f = Fixture::new(); @@ -189,6 +208,8 @@ mod tests { assert!(input_fonts(Some(&f.0), false, true, false).unwrap().contains("Noto Sans Georgian")); } + /// Asserts required web input rejects a manifest whose face file is missing, even when every + /// face row is listed. #[test] fn required_web_rejects_missing_asset_even_with_all_face_rows() { let f = Fixture::new(); diff --git a/crates/ui-egui/src/a11y.rs b/crates/ui-egui/src/a11y.rs index 79a28118..3c5fafb6 100644 --- a/crates/ui-egui/src/a11y.rs +++ b/crates/ui-egui/src/a11y.rs @@ -253,6 +253,8 @@ struct Shared { name: String, } +/// Run `f` on the per-context temporary `Shared` accessibility state keyed by +/// `figcraft_a11y`. fn shared(ctx: &Context, f: impl FnOnce(&mut Shared) -> R) -> R { ctx.data_mut(|d| f(d.get_temp_mut_or_default::(Id::unique("figcraft_a11y")))) } diff --git a/crates/ui-egui/src/a11y_tests.rs b/crates/ui-egui/src/a11y_tests.rs index a2c54cbe..14be637d 100644 --- a/crates/ui-egui/src/a11y_tests.rs +++ b/crates/ui-egui/src/a11y_tests.rs @@ -404,6 +404,8 @@ fn the_align_buttons_are_off_for_a_layer_alone_on_the_page() { assert!(!align_left(&tree(&mut u)), "one in a frame aligns to it"); } +/// Asserts side-panel and Pages edges are not focusable, and Tab never stops on the +/// side-panel edges. #[test] fn the_custom_panel_edges_never_enter_the_keyboard_or_focus_order() { // FigCraft's panels are fixed, so egui makes no `panel.__resize` widgets; the draggable edges diff --git a/crates/ui-egui/src/assets_panel.rs b/crates/ui-egui/src/assets_panel.rs index e9f0ffce..db0cc0f7 100644 --- a/crates/ui-egui/src/assets_panel.rs +++ b/crates/ui-egui/src/assets_panel.rs @@ -164,6 +164,7 @@ enum Act { Hide(NodeId, bool), } +/// Stable egui id for the assets drag payload. fn drag_id() -> Id { Id::unique("asset_drag") } @@ -581,6 +582,10 @@ fn render_thumbs(ui: &Ui, w: f32, thumbs: &mut Thumbs, wanted: &[(Source, NodeId } } +/// Draw the Assets panel: refresh libraries, switch to the libraries view when asked, +/// otherwise cache a listing keyed by revision, query and library state, render local and +/// enabled-library assets with reused thumbnails, and dispatch place, style, hide, toggle +/// and drag actions. pub fn show(app: &mut FigApp, ui: &mut Ui) { refresh_libraries(app, ui); if app.ui.libraries { @@ -773,6 +778,8 @@ fn drag_feedback(app: &mut FigApp, ui: &mut Ui, doc: &Document, libs: &HashMap) { let t = Tokens::get(ui.ctx()); let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, Id::unique("asset_ghost"))); @@ -831,6 +838,10 @@ fn libraries_view(app: &mut FigApp, ui: &mut Ui) { }); } +/// Draw the publish form: status, publishable components/styles/collections, description and +/// publish buttons. Run `library.publish` locally or publish to the server in the +/// background, reset the form on success, and report when the file is unsaved so other files +/// cannot yet use it. fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { let form_id = Id::unique("asset_publish_form"); let mut form: PublishForm = ui.data(|d| d.get_temp(form_id)).unwrap_or_default(); @@ -1010,6 +1021,8 @@ fn usage(app: &mut FigApp, ui: &mut Ui, w: f32, link: &str) { ui.add_space(8.0); } +/// Draw the libraries enabled in this file with version/count/status, remove and swap +/// actions, add-file and pasted-link controls, and background fetch/cancel handling. fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { let t = Tokens::get(ui.ctx()); title(ui, w, tr("Libraries in this file")); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4211b3cc..a6770823 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -163,6 +163,10 @@ fn pointer_event(app: &FigApp, kind: PointerKind, pos: Pos2, m: &egui::Modifiers } } +/// Run one canvas pass: update the viewport size and first-fit zoom; handle pointer/key input +/// and accessibility; draw the drop overlay, guides, live pointer and preedit; request/poll +/// renders and upload the texture; draw the page background and last texture; then draw overlays, +/// labels, handles, comments, versions/branch/dev/live UI and the cursor. pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { let rect = ui.max_rect(); app.canvas.rect = rect; diff --git a/crates/ui-egui/src/clipboard_fence_tests.rs b/crates/ui-egui/src/clipboard_fence_tests.rs index 9211a4d4..76ddf81c 100644 --- a/crates/ui-egui/src/clipboard_fence_tests.rs +++ b/crates/ui-egui/src/clipboard_fence_tests.rs @@ -25,14 +25,17 @@ struct Recorded { struct Clipboard(Arc>); impl Services for Clipboard { + /// Record a system-clipboard text write in the test clipboard service. fn clipboard_set_text(&mut self, s: &str) { let mut r = self.0.lock().unwrap(); r.text.push(s.to_string()); r.last_text = Some(s.to_string()); } + /// Return the test system clipboard's last text. fn clipboard_get_text(&mut self) -> Option { self.0.lock().unwrap().last_text.clone() } + /// Count a PNG clipboard write in the test clipboard service. fn clipboard_set_png(&mut self, _w: u32, _h: u32, _rgba: &[u8]) { self.0.lock().unwrap().pngs += 1; } @@ -46,6 +49,7 @@ struct Win { } impl Win { + /// Create a test window over a session containing one selected rectangle. fn new() -> Self { let mut s = Session::new(); s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap(); @@ -54,6 +58,7 @@ impl Win { win } + /// Build a test window around `s` with a recording clipboard service and run three frames. fn with_session(s: Session) -> Self { let recorded = Arc::new(Mutex::new(Recorded::default())); let app = FigApp::new(s, Box::new(Clipboard(recorded.clone()))); @@ -64,6 +69,7 @@ impl Win { win } + /// Run one production UI frame with `events` at the next 60 Hz test time. fn frame(&mut self, events: Vec) { self.time += 1.0 / 60.0; self.app.clock.set_test_now(Some(self.time)); @@ -74,12 +80,15 @@ impl Win { out.textures_delta.clear(); } + /// Return the recorded `(text writes, PNG writes)` count. fn writes(&self) -> (usize, usize) { let r = self.recorded.lock().unwrap(); (r.text.len(), r.pngs) } } +/// Asserts that a control-channel copy shortcut writes no system clipboard, still runs the +/// command and is acknowledged. #[test] fn a_synthetic_copy_shortcut_leaves_the_system_clipboard_alone() { let mut w = Win::new(); @@ -106,6 +115,8 @@ fn a_synthetic_copy_shortcut_leaves_the_system_clipboard_alone() { assert!(rx.try_recv().is_ok(), "the batch is acknowledged"); } +/// Asserts copy commands run under the synthetic-control fence write nothing, while the same +/// command afterwards writes once. #[test] fn a_copy_command_invoked_for_a_synthetic_click_still_keeps_the_clipboard() { let mut w = Win::new(); @@ -120,6 +131,7 @@ fn a_copy_command_invoked_for_a_synthetic_click_still_keeps_the_clipboard() { assert_eq!(w.writes(), (1, 0)); } +/// Return the characters of a text node in the test window. fn edit_text(w: &Win, id: NodeId) -> String { match &w.app.session.doc().unwrap().node(id).unwrap().kind { NodeKind::Text(t) => t.characters.clone(), @@ -127,6 +139,8 @@ fn edit_text(w: &Win, id: NodeId) -> String { } } +/// Asserts a synthetic Cut deletes the selected text, keeps the edit session active, writes +/// no system clipboard, and ends its fence with the frame. #[test] fn a_synthetic_cut_deletes_the_selection_but_leaves_the_users_clipboard_alone() { let mut s = Session::new(); @@ -147,6 +161,8 @@ fn a_synthetic_cut_deletes_the_selection_but_leaves_the_users_clipboard_alone() assert!(!w.app.control_synthetic, "the fence ends with the frame"); } +/// Asserts a programmatic copy leaves the system clipboard alone and invalidates the stale +/// mirror, so a later user paste uses the system text rather than the programmatic layers. #[test] fn a_programmatic_copy_does_not_let_a_stale_mirror_hide_the_users_paste() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index 90d00c3f..f1cde02f 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -339,6 +339,8 @@ fn hue_color(ctx: &egui::Context, h: f32) -> Color32 { doc_c32(ctx, Hsb { h, s: 1.0, b: 1.0 }.to_color()) } +/// Draw the saturation/value square and its knob, updating `hsb.s` and `hsb.b` on click or +/// drag; returns `(changed, done)`. fn sv_square(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) { // White → hue horizontally, then transparent → black vertically. let mut m = Mesh::default(); @@ -377,6 +379,8 @@ fn sv_square(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) { (changed, resp.drag_stopped() || resp.clicked()) } +/// Draw the hue gradient and knob, updating `hsb.h` clamped below 360; returns +/// `(changed, done)`. fn hue_slider(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) { let n = 12; let mut m = Mesh::default(); @@ -417,6 +421,8 @@ fn slider_knob(ui: &mut Ui, rect: Rect, t: f32, id: Id) -> Option<(f32, bool)> { None } +/// Draw the checkerboard and transparent-to-opaque alpha gradient with a knob; returns the +/// new alpha and done state, or `None` when the slider is not interacting. fn alpha_slider(ui: &mut Ui, rect: Rect, color: Color) -> Option<(f32, bool)> { widgets::checker(&ui.painter().with_clip_rect(rect), rect, rect.height() / 2.0); let mut m = Mesh::default(); @@ -441,6 +447,9 @@ pub fn window_id() -> Id { Id::unique("color_picker") } +/// Draw the colour picker for the current target: close it when there is no target or the +/// target's selection no longer matches, otherwise apply eyedropper, paint-kind, colour and +/// gradient-stop edits live, positioned beside a plugin field when applicable. pub fn show(app: &mut FigApp, ctx: &egui::Context) { let Some(target) = app.ui.picker.clone() else { // A colour sampled for a picker that has closed has no paint to go to. diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 4c3be3de..97e62d1f 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -70,18 +70,22 @@ pub struct RequestGate { } impl RequestGate { + /// Create a request gate in its default `Open` state. pub fn new() -> Self { Self::default() } + /// Return a copy of the gate's current request state. pub fn state(&self) -> RequestState { *self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner) } + /// Whether the gate is `Cancelled`. pub fn is_cancelled(&self) -> bool { self.state() == RequestState::Cancelled } + /// Whether the gate is `Replied`. pub fn is_replied(&self) -> bool { self.state() == RequestState::Replied } @@ -121,6 +125,7 @@ pub struct ControlRequest { } impl ControlRequest { + /// Whether this request's gate is `Cancelled`. pub fn is_cancelled(&self) -> bool { self.state.is_cancelled() } @@ -147,6 +152,7 @@ struct SlotInner { } impl CaptureSlot { + /// Create an empty capture slot for `viewport`, holding a clone of the UI context. fn new(ctx: &Context, viewport: egui::ViewportId) -> Arc { Arc::new(Self { inner: Mutex::new(SlotInner::default()), ctx: ctx.clone(), viewport }) } @@ -161,6 +167,8 @@ impl CaptureSlot { }) } + /// Store the first delivered image only while the slot is open and untaken, ignoring later + /// or closed deliveries, then wake the viewport after releasing the lock. pub(crate) fn complete(&self, image: Arc) { { let mut inner = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner); @@ -214,6 +222,7 @@ pub struct CaptureSpec { const SHOT_PNG_OVERHEAD: usize = 64 * 1024; impl CaptureSpec { + /// Return the checked pixel byte count (`width × height × 4`), or `None` on overflow. fn bytes(&self) -> Option { self.size_px[0].checked_mul(self.size_px[1])?.checked_mul(4) } @@ -272,6 +281,7 @@ pub struct ShotRequest { } impl ShotRequest { + /// Close and drop the capture slot, if any, and zero the reserved byte count. fn release(&mut self) { if let Some(slot) = self.slot.take() { slot.close(); @@ -327,6 +337,7 @@ fn current_spec(ctx: &Context, viewport: egui::ViewportId) -> Option) { let _ = r.reply.send(ControlReply { id: r.id.clone(), result }); } @@ -819,6 +830,8 @@ enum EncodedShot { } impl EncodedShot { + /// Deliver the encoded shot: for `File`, write the PNG through `file_access` and return its + /// path and dimensions; for `Data`, return the prebuilt result. fn finish(self) -> Result { match self { Self::File { path, png, width, height } => { diff --git a/crates/ui-egui/src/control_capture_tests.rs b/crates/ui-egui/src/control_capture_tests.rs index ce4df1aa..6068ce52 100644 --- a/crates/ui-egui/src/control_capture_tests.rs +++ b/crates/ui-egui/src/control_capture_tests.rs @@ -12,6 +12,7 @@ use serde_json::json; use crate::control::{CaptureSpec, ControlReply, ControlRequest}; use crate::layers_drag_tests::Ui; +/// Queue a `ui.screenshot` request in the test app and return its reply receiver. fn push(u: &mut Ui, id: u64, params: serde_json::Value) -> std::sync::mpsc::Receiver { let (tx, rx) = std::sync::mpsc::channel(); u.app.control_queue.push(ControlRequest { id: json!(id), method: "ui.screenshot".into(), params, reply: tx, state: Default::default() }); @@ -38,10 +39,13 @@ fn resized_frame(u: &mut Ui, size: egui::Vec2) -> egui::FullOutput { out } +/// Whether any viewport in the frame output issues a `Screenshot` command. fn has_screenshot(out: &egui::FullOutput) -> bool { out.viewport_output.values().any(|v| v.commands.iter().any(|c| matches!(c, egui::ViewportCommand::Screenshot(_)))) } +/// Asserts reverse capture completion replies only to the matching request, then drains both +/// requests. #[test] fn two_captures_correlate_by_request_across_reverse_completion() { let mut u = Ui::new(Session::empty()); @@ -67,6 +71,7 @@ fn two_captures_correlate_by_request_across_reverse_completion() { assert!(u.app.capture.shots.is_empty(), "no retained requests"); } +/// Asserts a cancelled capture gets no reply and its slot is released. #[test] fn a_cancelled_capture_never_replies_or_writes() { let mut u = Ui::new(Session::empty()); @@ -89,6 +94,7 @@ fn commit_dir(tag: &str) -> std::path::PathBuf { dir } +/// Asserts cancellation after encoding still prevents the final file write and reply. #[test] fn a_cancellation_after_encoding_prevents_the_final_write() { let dir = commit_dir("cancelled"); @@ -107,6 +113,7 @@ fn a_cancellation_after_encoding_prevents_the_final_write() { let _ = std::fs::remove_dir_all(&dir); } +/// Asserts a capture that arrives past its deadline reports a timeout and writes no PNG. #[test] fn a_real_arrival_that_crosses_the_deadline_does_not_write_its_png() { let mut u = Ui::new(Session::empty()); @@ -129,6 +136,8 @@ fn a_real_arrival_that_crosses_the_deadline_does_not_write_its_png() { let _ = std::fs::remove_dir_all(&dir); } +/// Asserts an expiry during encoding reports the timeout under the gate, marks the request +/// replied and writes no file. #[test] fn an_expiry_during_encoding_prevents_the_final_write_and_reports_timeout() { let dir = commit_dir("expired"); @@ -149,6 +158,8 @@ fn an_expiry_during_encoding_prevents_the_final_write_and_reports_timeout() { let _ = std::fs::remove_dir_all(&dir); } +/// Asserts a commit before the deadline writes the file and queues one reply, and a second +/// commit is refused. #[test] fn a_commit_before_the_deadline_writes_and_queues_its_reply_once() { let dir = commit_dir("committed"); @@ -172,6 +183,8 @@ fn a_commit_before_the_deadline_writes_and_queues_its_reply_once() { let _ = std::fs::remove_dir_all(&dir); } +/// Asserts a capture past its deadline sends one timeout reply, releases its slot, and late +/// pixels are ignored. #[test] fn a_capture_after_its_deadline_replies_once_and_late_pixels_are_inert() { let mut u = Ui::new(Session::empty()); @@ -191,6 +204,8 @@ fn a_capture_after_its_deadline_replies_once_and_late_pixels_are_inert() { assert!(rx.try_recv().is_err(), "a late capture is inert"); } +/// Asserts the snapshot count budget rejects an over-budget request before issuing, and every +/// admitted capture still completes under its own id. #[test] fn a_snapshot_batch_is_capped_before_issuing_over_the_count_budget() { let mut u = Ui::new(Session::empty()); @@ -213,6 +228,7 @@ fn a_snapshot_batch_is_capped_before_issuing_over_the_count_budget() { rxs.clear(); } +/// Asserts an undersized readback is not accepted but reissued with one retry and no reply. #[test] fn an_undersized_readback_is_reissued_instead_of_accepted() { let mut u = Ui::new(Session::empty()); @@ -230,6 +246,7 @@ fn an_undersized_readback_is_reissued_instead_of_accepted() { assert!(!r.issued, "waits for the settled geometry again"); } +/// Asserts a one-pixel rounding difference is accepted as the same surface. #[test] fn a_one_pixel_rounding_difference_is_still_the_same_surface() { let mut u = Ui::new(Session::empty()); @@ -245,6 +262,7 @@ fn a_one_pixel_rounding_difference_is_still_the_same_surface() { assert!(u.app.capture.shots.is_empty(), "released"); } +/// Asserts an A→B→A tab switch invalidates the admitted capture with "document changed". #[test] fn a_document_a_to_b_to_a_switch_is_not_the_admitted_capture() { let mut s = Session::empty(); @@ -268,6 +286,8 @@ fn a_document_a_to_b_to_a_switch_is_not_the_admitted_capture() { assert!(u.app.capture.shots.is_empty(), "released"); } +/// Asserts a geometry change bumps the capture epoch and reissues instead of accepting old +/// pixels. #[test] fn a_geometry_change_reissues_instead_of_accepting_old_pixels() { let mut u = Ui::new(Session::empty()); @@ -288,6 +308,8 @@ fn a_geometry_change_reissues_instead_of_accepting_old_pixels() { assert!(!r.issued, "waits for the settled geometry again"); } +/// Asserts a translucent pixel round-trips through the PNG as straight RGBA without +/// darkening. #[test] fn a_translucent_pixel_round_trips_straight_rgba() { let img = egui::ColorImage::from_rgba_unmultiplied([1, 1], &[128, 64, 32, 128]); @@ -298,6 +320,8 @@ fn a_translucent_pixel_round_trips_straight_rgba() { assert_eq!(decoded.get_pixel(0, 0).0, [128, 64, 32, 128], "straight alpha is not darkened"); } +/// Asserts a capture is not issued before three completed real UI frames; the following +/// completed frame issues it. #[test] fn a_capture_is_not_issued_before_three_completed_real_ui_frames() { let mut u = Ui::new(Session::empty()); @@ -313,6 +337,8 @@ fn a_capture_is_not_issued_before_three_completed_real_ui_frames() { assert!(u.app.capture.shots[0].issued, "issued once three frames have presented their layout"); } +/// Asserts a discarded sizing pass does not count toward capture settling, and the next +/// completed frame issues it. #[test] fn a_discarded_sizing_pass_does_not_count_as_a_settled_capture_frame() { let mut u = Ui::new(Session::empty()); @@ -327,6 +353,8 @@ fn a_discarded_sizing_pass_does_not_count_as_a_settled_capture_frame() { assert!(u.app.capture.shots[0].issued, "the next completed frame issues the capture"); } +/// Asserts pixels delivered while hidden force a draining paint and are answered rather than +/// timed out. #[test] fn a_hidden_window_that_delivers_pixels_is_asked_to_paint_the_draining_pass() { let mut u = Ui::new(Session::empty()); @@ -354,6 +382,8 @@ fn a_hidden_window_that_delivers_pixels_is_asked_to_paint_the_draining_pass() { assert!(u.app.capture.shots.is_empty(), "released"); } +/// Asserts a capture for a window with unknown visibility is parked with its deadline and a +/// paint request, then times out once. #[test] fn a_capture_for_a_window_with_unknown_visibility_is_admitted_with_a_deadline() { let mut u = Ui::new(Session::empty()); @@ -386,6 +416,8 @@ fn a_capture_for_a_window_with_unknown_visibility_is_admitted_with_a_deadline() assert!(u.app.capture.shots.is_empty(), "released"); } +/// Asserts a completed capture is rejected when current geometry is unavailable, failing +/// closed. #[test] fn a_completed_capture_is_rejected_when_current_geometry_is_unavailable() { let mut u = Ui::new(Session::empty()); @@ -412,6 +444,7 @@ fn a_completed_capture_is_rejected_when_current_geometry_is_unavailable() { assert!(u.app.capture.shots.is_empty(), "released"); } +/// Asserts a tab switch while a capture settles invalidates it with "document changed". #[test] fn a_capture_belongs_to_the_document_admitted_not_the_one_showing_when_it_arrives() { let mut s = Session::empty(); @@ -435,6 +468,8 @@ fn a_capture_belongs_to_the_document_admitted_not_the_one_showing_when_it_arrive assert!(u.app.capture.shots.is_empty(), "released"); } +/// Asserts the capture budget covers the encoding and reply peaks, not just pixels, and an +/// over-budget resize is rejected before readback. #[test] fn the_capture_budget_counts_the_encoding_peak_not_only_the_pixels() { let fits = CaptureSpec { viewport: egui::ViewportId::ROOT, size_px: [4096, 4096], pixels_per_point: 1.0, zoom: 1.0 }; @@ -462,6 +497,8 @@ fn the_capture_budget_counts_the_encoding_peak_not_only_the_pixels() { assert!(!has_screenshot(&out), "over-budget growth is rejected before any readback command"); } +/// Asserts resizing settling captures rechecks aggregate reserved bytes, rejects the request +/// that no longer fits, and makes the survivor reserve its real cost. #[test] fn resizing_settling_captures_rechecks_the_aggregate_reserved_bytes() { let mut u = Ui::new(Session::empty()); @@ -481,6 +518,8 @@ fn resizing_settling_captures_rechecks_the_aggregate_reserved_bytes() { assert!(!has_screenshot(&out), "neither request was read back using the old small reservation"); } +/// Asserts an allowed resize restarts settling, issues only after three new-size frames, and +/// reserves the current cost. #[test] fn an_allowed_resize_restarts_settling_and_issues_with_the_current_reservation() { let mut u = Ui::new(Session::empty()); @@ -518,6 +557,8 @@ fn push_input(u: &mut Ui, id: u64) -> std::sync::mpsc::Receiver { rx } +/// Asserts injected input is queued, applied by the presenting pass, and acknowledged once +/// after that pass. #[test] fn injected_input_is_acknowledged_only_after_the_pass_that_applies_it() { let mut u = Ui::new(Session::empty()); @@ -534,6 +575,8 @@ fn injected_input_is_acknowledged_only_after_the_pass_that_applies_it() { assert!(!u.app.control_synthetic, "the fence ends with the frame"); } +/// Asserts a discarded pass neither acknowledges nor replays an injected batch; the presented +/// pass acknowledges once. #[test] fn a_discarded_pass_does_not_acknowledge_or_lose_an_injected_batch() { let mut u = Ui::new(Session::empty()); @@ -565,6 +608,8 @@ fn a_discarded_pass_does_not_acknowledge_or_lose_an_injected_batch() { assert!(app.input_acks.is_empty() && app.applied_acks.is_empty()); } +/// Asserts injected text is applied once across a discarded sizing pass and acknowledged +/// once. #[test] fn injected_text_lands_once_across_a_discarded_sizing_pass() { // egui reruns a discarded pass with the same `RawInput`. The custom editor applied the text in diff --git a/crates/ui-egui/src/credits_tests.rs b/crates/ui-egui/src/credits_tests.rs index 8b10a622..fb3993ad 100644 --- a/crates/ui-egui/src/credits_tests.rs +++ b/crates/ui-egui/src/credits_tests.rs @@ -51,10 +51,12 @@ impl Ui { u } + /// Store the About table's current view in test context data. fn set_view(&self, v: View) { self.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("credits_view"), v)); } + /// Read the stored About table view, defaulting when unset. fn view(&self) -> View { self.ctx.data(|d| d.get_temp::(egui::Id::unique("credits_view"))).unwrap_or_default() } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 3ec562a2..8d4d8fdb 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -179,6 +179,10 @@ pub fn keys(app: &mut FigApp, ctx: &Context) { } } +/// Draw the app's dialogs and cards: pending opens/imports/missing fonts, About, +/// Preferences, shortcuts, find/replace, batch rename, menu input, variables, dev compare, +/// branch, export preview, colour profile, plugin, live join/publish, online dialogs and +/// close confirmation. pub fn show(app: &mut FigApp, ctx: &Context) { // Dropped files waiting to open: their card, then (once it has shown) the files. crate::pending_open::pending_opens(app, ctx); diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index 4e724553..341e7cca 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -467,6 +467,8 @@ fn lead(u: &Ui, id: Id) -> Pos2 { u.ctx.read_response(id.with("lead")).unwrap_or_else(|| panic!("no field {id:?}")).rect.center() } +/// Asserts scrubbing the colour picker's R and alpha fields updates the colour live, each +/// scrub adding one undo step. #[test] fn scrubbing_colour_picker_fields_is_live_and_one_undo_step() { let (mut u, a, _) = setup(); @@ -490,6 +492,8 @@ fn scrubbing_colour_picker_fields_is_live_and_one_undo_step() { assert_eq!(undo_steps(&u), steps + 2, "alpha"); } +/// Asserts scrubbing a prototype delay sets the value once when released and adds one undo +/// step. #[test] fn scrubbing_a_prototype_delay_sets_it_once_when_let_go() { let mut s = Session::new(); @@ -514,6 +518,7 @@ fn scrubbing_a_prototype_delay_sets_it_once_when_let_go() { assert_eq!((ms, undo_steps(&u)), (800, steps + 1)); } +/// Asserts scrubbing the scale panel's width scales the layer live in one undo step. #[test] fn scrubbing_the_scale_panel_width_scales_live_in_one_undo_step() { let (mut u, a, _) = setup(); @@ -584,6 +589,8 @@ fn the_intensity_label_fits_whole_in_every_language() { out.textures_delta.clear(); } +/// Asserts a label too long for its number field is elided before the value, while the value +/// stays whole and both remain inside the field. #[test] fn a_label_too_long_for_its_field_ends_in_an_ellipsis_before_the_value() { let ctx = egui::Context::default(); @@ -780,6 +787,8 @@ fn a_deferred_field_save_keeps_its_document_when_the_next_input_switches_tabs() } } +/// Asserts a deferred field save does not retarget a closed, moved or foreign owner: the other +/// file stays untouched, Save as is not opened, and the stale request is consumed once. #[test] fn a_deferred_field_save_does_not_retarget_a_closed_moved_or_foreign_owner() { for change in ["closed", "moved", "foreign"] { diff --git a/crates/ui-egui/src/frame_labels.rs b/crates/ui-egui/src/frame_labels.rs index 4c4b0de1..a87fc9f4 100644 --- a/crates/ui-egui/src/frame_labels.rs +++ b/crates/ui-egui/src/frame_labels.rs @@ -58,6 +58,9 @@ pub fn turn_since(painter: &Painter, from: ShapeIdx, pivot: Pos2, angle: f32) { }); } +/// Recursively rotate shape geometry by `rot`/`angle` about `pivot`: leaves +/// no-op/callback/band shapes alone, rotates points and centers, adds the angle to +/// ellipses/rects/text, and rotates meshes. fn turn(shape: &mut Shape, rot: Rot2, angle: f32, pivot: Pos2) { let at = |p: &mut Pos2| *p = pivot + rot * (*p - pivot); match shape { diff --git a/crates/ui-egui/src/frame_presets.rs b/crates/ui-egui/src/frame_presets.rs index 0d1c4b91..9eb28162 100644 --- a/crates/ui-egui/src/frame_presets.rs +++ b/crates/ui-egui/src/frame_presets.rs @@ -10,6 +10,8 @@ use crate::icons::{self, Icon}; use crate::theme::{Tokens, regular, semibold}; use crate::widgets::{self, PAD}; +/// Draw the expandable frame-preset categories; choosing a preset runs `frame.create`, +/// switches to the Move tool and zooms to the new selection. pub fn panel(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); widgets::section_header(ui, tr("Frame"), &[], false); diff --git a/crates/ui-egui/src/guides.rs b/crates/ui-egui/src/guides.rs index 827597ac..3200ca42 100644 --- a/crates/ui-egui/src/guides.rs +++ b/crates/ui-egui/src/guides.rs @@ -49,6 +49,7 @@ struct Placed { line: Option<[Point; 2]>, } +/// Stable egui id for guide dragging. fn drag_id() -> Id { Id::unique("guide_drag") } diff --git a/crates/ui-egui/src/handles.rs b/crates/ui-egui/src/handles.rs index b7a575fa..d29d59ee 100644 --- a/crates/ui-egui/src/handles.rs +++ b/crates/ui-egui/src/handles.rs @@ -67,6 +67,7 @@ fn crop_corners(app: &FigApp, id: NodeId, r: (f64, f64, f64, f64)) -> Option<[Po ]) } +/// Stable egui id for handle dragging. fn drag_id() -> Id { Id::unique("figcraft_handle_drag") } diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index ebea682e..9c809b99 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -188,6 +188,8 @@ fn dropped(app: &mut FigApp, ctx: &egui::Context) { } } +/// Draw the Home sidebar: app icon and wordmark, file/template search (Escape clears it) +/// and section tabs; choosing a section clears the file query. fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) { let mut y = left.min.y + 20.0; crate::theme::paint_app_icon(ui, Rect::from_min_size(pos2(left.min.x + 12.0, y - 4.0), vec2(32.0, 32.0))); @@ -475,6 +477,8 @@ fn matches(template: &Template, query: &str, kind: Option) -> bool { || has(template.size)) } +/// Draw template search and kind filter, show no-match guidance and a "Show all templates" +/// button that clears the query and kind, and lay matching templates out in a grid. fn templates(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { heading(ui, t, tr("Templates")); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index cc19d6a9..ee25124a 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -248,6 +248,8 @@ fn a_finished_background_render_wakes_the_ui() { assert!(worker.poll().is_some(), "the woken frame finds the image"); } +/// Asserts a control request wakes an idle UI and is answered, and screenshots keep frames +/// coming until delivered before the UI rests again. #[test] fn control_requests_and_screenshots_wake_an_idle_ui() { let (mut h, _) = document(); @@ -341,6 +343,8 @@ fn cjk_names_are_looked_for_in_edits_not_on_every_pan() { assert_eq!(h.app.cjk_needed(&ctx, 0), crate::cjk::HANGUL); } +/// Asserts a hidden window answers edits and inspection at once, while a screenshot is +/// parked with a deadline and times out once instead of waiting for a frame. #[test] fn a_hidden_window_answers_at_once() { // #269, #720: minimised or covered, the window draws no frames, but the app's logic still runs: @@ -424,6 +428,8 @@ fn auto_save_writes_a_changed_file_then_rests() { let _ = std::fs::remove_dir_all(&dir); } +/// Asserts a hidden logic tick runs background work and advances the autosave deadline +/// without counting a UI frame, and is idempotent at the same time. #[test] fn a_hidden_logic_tick_runs_background_work_but_counts_no_ui_frame() { let (mut h, _) = document(); @@ -444,6 +450,7 @@ fn a_hidden_logic_tick_runs_background_work_but_counts_no_ui_frame() { assert_eq!(h.app.ui.next_autosave, deadline, "idempotent at the same monotonic time"); } +/// Asserts a discarded sizing pass counts one drawn frame, not one per pass. #[test] fn a_discarded_sizing_pass_sets_up_and_counts_one_frame_only() { let (mut h, _) = document(); diff --git a/crates/ui-egui/src/ime_tests.rs b/crates/ui-egui/src/ime_tests.rs index e699dda5..e6c2f698 100644 --- a/crates/ui-egui/src/ime_tests.rs +++ b/crates/ui-egui/src/ime_tests.rs @@ -48,6 +48,7 @@ fn ime_descriptor(u: &mut Ui, events: Vec) -> IMEOutput { u.frame_raw(RawInput { events, ..Default::default() }).platform_output.ime.expect("the IME stays on while editing") } +/// Return the available surrounding-text payload of an IME output, panicking when unavailable. fn available(ime: &IMEOutput) -> &IMESurroundingTextPayload { match &ime.surrounding_text { IMESurroundingText::Available(payload) => payload, @@ -55,6 +56,7 @@ fn available(ime: &IMEOutput) -> &IMESurroundingTextPayload { } } +/// Create a text layer with `text` and return its node id. fn text_layer(s: &mut Session, text: &str) -> NodeId { NodeId(s.execute("text.create", &json!({"text": text, "x": 40, "y": 40})).unwrap()["id"].as_u64().unwrap()) } @@ -103,6 +105,8 @@ fn pinyin_composes_inline_and_commits_once_with_the_ime_on_every_frame() { assert_eq!(text(&u, t), "Hi你好\n!"); } +/// Asserts an empty IME commit ends the composition, inserts nothing, keeps the editor +/// focused, and the next pass observes the committed text. #[test] fn an_empty_ime_commit_ends_the_composition_without_inserting_text() { // Safari's egui web integration sends `Commit("")` (not `Preedit("")`) when the last @@ -126,6 +130,8 @@ fn an_empty_ime_commit_ends_the_composition_without_inserting_text() { assert_eq!(text(&u, t), "Hello"); } +/// Asserts byte deletion before the paired commit replaces the exact UTF-8 range, and one undo +/// restores the pre-IME text. #[test] fn an_ime_replacing_text_deletes_in_bytes_before_the_paired_commit() { // 0.37 sends `DeleteSurroundingBytes` immediately before the commit that replaces it. The @@ -146,6 +152,8 @@ fn an_ime_replacing_text_deletes_in_bytes_before_the_paired_commit() { assert_eq!(text(&u, t), "ab\u{4F60}\u{597D}cd", "one undo restores the pre-IME text"); } +/// Asserts a caret click invalidates the whole stale IME batch (both byte and character +/// deletions) while a later direct commit still works. #[test] fn a_caret_click_rejects_the_stale_delete_and_commit_in_the_same_input_batch() { figcraft_text::scan_system_fonts(); @@ -185,6 +193,8 @@ fn a_caret_click_rejects_the_stale_delete_and_commit_in_the_same_input_batch() { } } +/// Asserts a control-channel document switch rejects the old composition's delete and commit +/// batch, while a direct commit to the new target remains valid. #[test] fn a_document_switch_rejects_the_old_compositions_delete_and_commit_batch() { figcraft_text::scan_system_fonts(); @@ -214,6 +224,8 @@ fn a_document_switch_rejects_the_old_compositions_delete_and_commit_batch() { assert_eq!(text(&u, b), "BravoX", "a direct commit to the new noncomposing target remains valid"); } +/// Asserts the first observation and ordinary typing are `Other`, while an accepted commit is +/// `InputMethod` with UTF-8 offsets, the same owner and generation. #[test] fn ime_cause_is_input_method_only_for_an_accepted_commit() { figcraft_text::scan_system_fonts(); @@ -240,6 +252,8 @@ fn ime_cause_is_input_method_only_for_an_accepted_commit() { assert_eq!(text(&u, t), "Hi你好x"); } +/// Asserts a frame mixing IME and application edits is `Other`, and a later pure commit is +/// `InputMethod` without lingering state. #[test] fn ime_cause_reports_mixed_application_and_ime_mutations_as_other() { figcraft_text::scan_system_fonts(); @@ -267,6 +281,7 @@ fn ime_cause_reports_mixed_application_and_ime_mutations_as_other() { assert_eq!(text(&u, t), "Hiあbうcえおdか"); } +/// Asserts a control-channel text edit is an application change (`Other`). #[test] fn a_control_channel_text_edit_is_an_application_change() { figcraft_text::scan_system_fonts(); @@ -283,6 +298,8 @@ fn a_control_channel_text_edit_is_an_application_change() { assert_eq!(available(&out).text, "Hiz"); } +/// Asserts a byte delete followed by a commit is `InputMethod` and describes the +/// post-mutation text and caret. #[test] fn ime_delete_then_commit_is_input_method_and_describes_the_post_mutation_text() { figcraft_text::scan_system_fonts(); @@ -302,6 +319,8 @@ fn ime_delete_then_commit_is_input_method_and_describes_the_post_mutation_text() assert_eq!(text(&u, t), "abXd"); } +/// Asserts preedit text is excluded from surrounding text and earns no receipt, while the +/// following commit is `InputMethod`. #[test] fn preedit_is_not_surrounding_text_but_the_commit_that_follows_is_input_method() { figcraft_text::scan_system_fonts(); @@ -322,6 +341,8 @@ fn preedit_is_not_surrounding_text_but_the_commit_that_follows_is_input_method() assert_eq!(available(&committed).cursor, 8); } +/// Asserts surrounding text is logical UTF-8 with byte offsets and keeps a reversed +/// selection. #[test] fn ime_surrounding_text_is_logical_utf8_and_keeps_a_reversed_selection() { figcraft_text::scan_system_fonts(); @@ -339,6 +360,8 @@ fn ime_surrounding_text_is_logical_utf8_and_keeps_a_reversed_selection() { assert!(payload.cursor < payload.anchor, "the reversed selection is not sorted"); } +/// Asserts empty text is available while a selection wider than the 4000-byte budget is +/// explicitly unavailable but still has a nonzero IME context generation. #[test] fn empty_text_is_available_while_a_too_wide_selection_is_explicitly_unavailable() { figcraft_text::scan_system_fonts(); @@ -361,6 +384,7 @@ fn empty_text_is_available_while_a_too_wide_selection_is_explicitly_unavailable( assert!(wide.context_generation > 0, "the owner stays known when only the text is unavailable"); } +/// Asserts replacing a too-wide selection with a commit is still an `InputMethod` receipt. #[test] fn a_commit_replacing_a_too_wide_selection_is_still_an_input_method_receipt() { figcraft_text::scan_system_fonts(); @@ -382,6 +406,8 @@ fn a_commit_replacing_a_too_wide_selection_is_still_an_input_method_receipt() { assert_eq!((available(&out).cursor, available(&out).anchor), (1, 1)); } +/// Asserts another text node is another owner, and re-entering the same node changes the +/// context generation while keeping the owner key. #[test] fn a_new_edit_session_and_another_text_node_are_new_ime_owners() { figcraft_text::scan_system_fonts(); @@ -405,6 +431,8 @@ fn a_new_edit_session_and_another_text_node_are_new_ime_owners() { assert_ne!(again.context_generation, first.context_generation, "re-entering is a replaced target"); } +/// Asserts an accepted commit survives a discarded rerun without inserting twice and keeps +/// its `InputMethod` cause, while a mixed rerun is `Other`. #[test] fn an_accepted_commit_survives_a_discarded_pass_without_reapplying_or_losing_its_cause() { figcraft_text::scan_system_fonts(); @@ -428,6 +456,9 @@ fn an_accepted_commit_survives_a_discarded_pass_without_reapplying_or_losing_its assert_eq!(mixed.change_cause, ImeChangeCause::Other); } +/// Asserts IME context state is scoped per viewport: each viewport has its own generation, +/// the root keeps its context across an aux pass, and a closed aux viewport does not inherit +/// the old generation. #[test] fn ime_context_state_is_scoped_per_viewport() { figcraft_text::scan_system_fonts(); @@ -480,6 +511,7 @@ fn ime_context_state_is_scoped_per_viewport() { assert_ne!(aux_again.context_generation, aux_ime.context_generation); } +/// Asserts the engine's IME surrounding-text byte limit equals egui's wire limit. #[test] fn the_engine_surrounding_limit_matches_the_wire_limit() { // A divergence would truncate or overrun the bounded payload silently. @@ -488,6 +520,8 @@ fn the_engine_surrounding_limit_matches_the_wire_limit() { assert_eq!(engine, wire); } +/// Asserts a frame observes the IME exactly once in both the editor pass and the Home +/// early-return pass. #[test] fn a_frame_observes_the_ime_once_even_when_it_ends_early() { figcraft_text::scan_system_fonts(); @@ -506,6 +540,8 @@ fn a_frame_observes_the_ime_once_even_when_it_ends_early() { assert_eq!(u.app.ime_observations - before, 1, "one idle observation on the home screen"); } +/// Asserts a double-click inside an existing edit selects the word without changing the IME +/// owner or generation. #[test] fn a_double_click_inside_the_edit_keeps_the_ime_generation() { figcraft_text::scan_system_fonts(); @@ -536,6 +572,8 @@ fn a_double_click_inside_the_edit_keeps_the_ime_generation() { assert_eq!(available(&after).text, "Hello world"); } +/// Asserts a `window.new` that moves the edited file leaves the root window emitting no IME +/// descriptor. #[test] fn a_window_new_that_moves_the_edited_file_leaves_the_root_ime_idle() { figcraft_text::scan_system_fonts(); @@ -560,6 +598,8 @@ fn a_window_new_that_moves_the_edited_file_leaves_the_root_ime_idle() { assert!(out.platform_output.ime.is_none(), "the root must not emit a descriptor for a file it no longer shows"); } +/// Asserts a CRLF-normalized commit is application-origin (`Other`), and bare-newline and +/// control-only commits insert nothing. #[test] fn a_transformed_commit_is_application_origin_and_a_bare_newline_does_nothing() { figcraft_text::scan_system_fonts(); @@ -582,6 +622,8 @@ fn a_transformed_commit_is_application_origin_and_a_bare_newline_does_nothing() assert!(matches!(control.surrounding_text, IMESurroundingText::Available(_))); } +/// Asserts character deletion keeps the selection for the paired commit, which replaces it +/// and reports `InputMethod`. #[test] fn ime_delete_surrounding_chars_keeps_the_selection_for_the_paired_commit() { figcraft_text::scan_system_fonts(); diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 42a9569e..0ce4fa7a 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -134,6 +134,8 @@ pub fn rail(app: &mut FigApp, ui: &mut Ui) { } } +/// Draw the left panel: versions when open, otherwise the file/Assets header with in-place +/// file rename and collapse control, followed by Pages and Layers or the Assets panel. pub fn left_panel(app: &mut FigApp, ui: &mut Ui) { if app.ui.versions.open { crate::versions::panel(app, ui); @@ -173,6 +175,8 @@ pub fn left_panel(app: &mut FigApp, ui: &mut Ui) { } } +/// Draw the page list with open/search/add controls, filtering, selection, rename, +/// reorder/drag and layer drop-to-page; Dev Mode allows opening pages but blocks mutations. fn pages(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); // Dev Mode only inspects: rows open pages, but don't add, rename, reorder, duplicate or delete. @@ -444,6 +448,7 @@ struct Sweep { ids: Vec, } +/// Stable egui id for the eye/padlock sweep state. fn sweep_id() -> Id { Id::unique("layer_toggle_sweep") } @@ -654,6 +659,7 @@ fn rename_layer(app: &mut FigApp, id: NodeId) { app.ui.layers_seen_sel = app.session.selection(); } +/// Stable egui id for a layer row's rename field. fn rename_id(id: NodeId) -> Id { Id::unique(("rename", id)) } @@ -722,6 +728,8 @@ fn search_field(ui: &mut Ui, t: &Tokens, w: f32, id: Id, hint: &str, text: &mut } } +/// Draw the layer tree with search, cached rows, reveal-selection expansion and scrolling, +/// rename/Tab navigation, selection and drag/drop, sweep toggles and Dev Mode restrictions. fn layers(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); // Dev Mode only inspects: rows select and expand, but don't rename, reorder, hide or lock. diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs index e801ad00..0f312499 100644 --- a/crates/ui-egui/src/layers_behaviour_tests.rs +++ b/crates/ui-egui/src/layers_behaviour_tests.rs @@ -312,6 +312,7 @@ fn pages_down_to(u: &mut Ui, to: f32) -> Vec { opened } +/// Asserts a 60-page list scrolls with the wheel to its last page, past its first pages. #[test] fn a_long_pages_list_scrolls_and_leaves_room_for_the_layers() { let mut s = Session::new(); @@ -383,6 +384,8 @@ fn double_click_checking_focus(u: &mut Ui, p: Pos2) { } } +/// Asserts double-clicking a page selects and renames it, with the keyboard in its name +/// field for the screen-reader tree. #[test] fn double_clicking_a_page_renames_it_with_the_focus_in_the_screen_reader_tree() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/layers_rename_tests.rs b/crates/ui-egui/src/layers_rename_tests.rs index 6ffa9696..c1c229eb 100644 --- a/crates/ui-egui/src/layers_rename_tests.rs +++ b/crates/ui-egui/src/layers_rename_tests.rs @@ -144,6 +144,8 @@ fn rename_without_a_name_renames_one_row_or_opens_the_dialog() { assert!(std::sync::Arc::ptr_eq(&doc, &s.ds().unwrap().doc)); } +/// Asserts a layer rename continues after its row is scrolled out of view and commits on +/// Enter. #[test] fn a_rename_scrolled_out_of_view_goes_on() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/layout_panel.rs b/crates/ui-egui/src/layout_panel.rs index eef37755..0874da2f 100644 --- a/crates/ui-egui/src/layout_panel.rs +++ b/crates/ui-egui/src/layout_panel.rs @@ -283,6 +283,8 @@ fn add_slot(ui: &mut Ui, label: &str, tip: &str, width: f32) -> egui::Response { resp } +/// Draw the 3×3 main/cross alignment grid, auto-gap modes, grid column/row gaps and the +/// horizontal baseline checkbox. fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { let t = Tokens::get(ui.ctx()); let grid_mode = l.mode == LayoutMode::Grid; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d3c17453..6f0effd8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -488,6 +488,7 @@ pub struct FrameClock { } impl Default for FrameClock { + /// Start the frame clock at the current web time with no injected test time. fn default() -> Self { Self { origin: web_time::Instant::now(), test_now: None } } @@ -722,6 +723,9 @@ pub struct UiState { } impl Default for UiState { + /// Seed the default UI state (panel widths, tabs, pages, Home, presenters, pickers and + /// dialogs off, and the rest of the transient UI fields); `FigApp::new` overrides panel + /// widths and the pages height from preferences. fn default() -> Self { Self { left_width: PANEL_DEFAULT, @@ -915,6 +919,9 @@ pub struct FigApp { pub type Done = Box; impl FigApp { + /// Construct `FigApp` from a session and services, taking panel widths and pages height + /// from preferences and initializing UI, canvas, clocks, capture/IME and transient channel + /// state. pub fn new(session: Session, services: Box) -> Self { // The main window's panels open as wide as they were left. let (left_width, right_width) = session.prefs.panel_widths; @@ -2044,6 +2051,9 @@ impl FigApp { } } + /// Draw the toast: start its lifetime on the frame **after** the first drawn frame, request + /// that frame and the frame that hides it, expire and remove it at the deadline, and place it + /// at the bottom centre. fn toast_ui(&mut self, ctx: &egui::Context) { let Some((msg, clock)) = &mut self.ui.toast else { return }; let now = ctx.input(|i| i.time); diff --git a/crates/ui-egui/src/live_join_tests.rs b/crates/ui-egui/src/live_join_tests.rs index 81eddc76..bc1db8ad 100644 --- a/crates/ui-egui/src/live_join_tests.rs +++ b/crates/ui-egui/src/live_join_tests.rs @@ -14,6 +14,7 @@ fn escape(h: &mut Harness) { } } +/// Whether the Join link field currently has focus. fn link_focused(h: &Harness) -> bool { h.ctx.memory(|m| m.has_focus(egui::Id::unique("live_join_link"))) } diff --git a/crates/ui-egui/src/live_server_tests.rs b/crates/ui-egui/src/live_server_tests.rs index 0bf1e631..014be152 100644 --- a/crates/ui-egui/src/live_server_tests.rs +++ b/crates/ui-egui/src/live_server_tests.rs @@ -20,6 +20,7 @@ fn key(h: &mut Harness, key: Key) { } } +/// Whether the widget with the given id currently has focus. fn focused(h: &Harness, id: &str) -> bool { h.ctx.memory(|m| m.has_focus(egui::Id::unique(id))) } diff --git a/crates/ui-egui/src/live_share_tests.rs b/crates/ui-egui/src/live_share_tests.rs index 06d902bd..d7fc61c8 100644 --- a/crates/ui-egui/src/live_share_tests.rs +++ b/crates/ui-egui/src/live_share_tests.rs @@ -385,6 +385,9 @@ fn without_a_code_the_link_is_shared_instead() { assert_eq!(script.lock().unwrap().copied.last().map(String::as_str), Some(PLAIN)); } +/// Asserts the join field accepts a typed code or invite link, joins on Enter, clears after +/// joining, shows failure/retry, rejects an invalid code with an error, and accepts local +/// codes and links. #[test] fn the_join_field_takes_a_code_or_a_link_and_enter_joins() { let (mut h, script) = harness(); @@ -450,6 +453,8 @@ fn the_home_screen_joins_with_a_code_and_opens_the_file() { assert_eq!(h.app.session.docs.len(), 2); } +/// Asserts the join dialog uses the same shared join field, focuses it, and closes once the +/// join starts. #[test] fn the_join_dialog_is_the_same_field() { let (mut h, script) = harness(); diff --git a/crates/ui-egui/src/live_tests.rs b/crates/ui-egui/src/live_tests.rs index eaff7354..63c3c42a 100644 --- a/crates/ui-egui/src/live_tests.rs +++ b/crates/ui-egui/src/live_tests.rs @@ -290,6 +290,8 @@ fn cursors_follow_pages_in_a_shared_document() { assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Hana left")); } +/// Asserts clicking a peer's avatar follows that peer, and Escape or scrolling the canvas +/// stops following. #[test] fn clicking_an_avatar_follows_and_escape_stops() { let (mut h, _) = Harness::new(); diff --git a/crates/ui-egui/src/menu_aim_tests.rs b/crates/ui-egui/src/menu_aim_tests.rs index ed167e12..6f7c4b8a 100644 --- a/crates/ui-egui/src/menu_aim_tests.rs +++ b/crates/ui-egui/src/menu_aim_tests.rs @@ -87,6 +87,7 @@ impl Ui { const TOPS: [&str; 6] = ["File", "Edit", "View", "Object", "Text", "Arrange"]; +/// Stable id for a top-level menu in the menu-aim test harness. fn top(name: &str) -> Id { Id::unique(("menu", name)) } @@ -145,6 +146,8 @@ fn a_reopened_main_menu_shows_no_stale_submenu() { assert!(u.tops_shown().is_empty(), "reopened with {:?} open", u.tops_shown()); } +/// Asserts leaving a top menu and returning to it does not restore the submenu that was open +/// before. #[test] fn a_submenu_one_level_down_reopens_closed() { let mut u = Ui::new(); diff --git a/crates/ui-egui/src/menu_input_tests.rs b/crates/ui-egui/src/menu_input_tests.rs index a50173bc..6fc65533 100644 --- a/crates/ui-egui/src/menu_input_tests.rs +++ b/crates/ui-egui/src/menu_input_tests.rs @@ -69,6 +69,8 @@ fn batch_rename_opens_its_existing_dialog() { assert!(ui.app.ui.last_error.is_none()); } +/// Asserts `file.rename` opens the left panel's inline file-name editor (not a dialog), +/// focuses it, and commits the typed name on Enter. #[test] fn rename_file_edits_the_file_name_in_place() { let mut ui = Ui::new(Session::new()); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e73d242d..e5ddb899 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -478,10 +478,12 @@ struct At { path: String, } +/// Scoped egui id for the current menu level. fn at_key(ui: &Ui) -> Id { ui.scope_id().with("menu_level") } +/// Return the current menu root/path, defaulting to the main menu root. fn level_at(ui: &Ui) -> At { ui.data(|d| d.get_temp::(at_key(ui))).unwrap_or(At { root: Id::unique("menus"), path: String::new() }) } @@ -817,6 +819,9 @@ const TOP_MENUS: &[&str] = &[ tr_noop("Help"), ]; +/// Draw the main and context menu trees: forget closed/stale submenu state, apply +/// control-channel Select-layer open/close, close on outside click or Escape, then draw the +/// colour picker and palette popups. pub fn popups(app: &mut FigApp, ctx: &egui::Context) { let t = Tokens::get(ctx); // A closed menu forgets its open submenus (and a context menu opened somewhere else starts @@ -1020,6 +1025,7 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { /// The context menu's Select layer submenu (its key in the context menu's tree). const SELECT_LAYER: &str = "Select layer"; +/// Stable egui id for the context menu's pending Select-layer open request. fn select_layer_ask_id() -> Id { Id::unique("ctx_select_layer_ask") } diff --git a/crates/ui-egui/src/panel_remove_tests.rs b/crates/ui-egui/src/panel_remove_tests.rs index a2e8d3ee..1608902d 100644 --- a/crates/ui-egui/src/panel_remove_tests.rs +++ b/crates/ui-egui/src/panel_remove_tests.rs @@ -64,10 +64,13 @@ fn remove_button(u: &mut Ui, list: &str, index: usize) -> Pos2 { .unwrap_or_else(|| panic!("no Remove button in {list} row {index} {row:?} {all:?}")) } +/// Read a temporary boolean open-settings flag keyed by `salt` and row index. fn flag(u: &Ui, salt: &'static str, i: usize) -> bool { u.ctx.data(|d| d.get_temp::(Id::unique((salt, i)))).unwrap_or(false) } +/// Asserts removing a fill keeps the open picker on the surviving paint (with its index +/// shifted), and removing the paint being edited closes the picker. #[test] fn removing_a_fill_keeps_the_picker_on_its_paint() { let mut s = Session::new(); @@ -93,6 +96,8 @@ fn removing_a_fill_keeps_the_picker_on_its_paint() { assert_eq!(u.app.ui.picker, None); } +/// Asserts removing effects or grids moves open settings and an open colour picker to the +/// surviving row, or closes them when their own row is removed. #[test] fn removing_effects_and_grids_keeps_open_settings_with_their_rows() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/panel_reorder_tests.rs b/crates/ui-egui/src/panel_reorder_tests.rs index 54caa687..ffeb948e 100644 --- a/crates/ui-egui/src/panel_reorder_tests.rs +++ b/crates/ui-egui/src/panel_reorder_tests.rs @@ -73,6 +73,8 @@ fn escape_cancels_a_row_drag() { assert_eq!(u.app.session.ds().unwrap().selection, [r], "the Escape only cancels the drag"); } +/// Asserts effect and layout-grid rows reorder by drag, while a mixed fill list exposes no +/// drag handle. #[test] fn effects_and_grids_drag_too_and_mixed_lists_dont() { let (mut u, r) = setup(); diff --git a/crates/ui-egui/src/perf_tests.rs b/crates/ui-egui/src/perf_tests.rs index 7b5d1212..eba5e68c 100644 --- a/crates/ui-egui/src/perf_tests.rs +++ b/crates/ui-egui/src/perf_tests.rs @@ -68,6 +68,8 @@ impl Bench { } } +/// Build and print a synthetic UI benchmark report for the given layer count; it makes no +/// assertions. fn report(layers: usize) { let mut b = Bench::new(layers); let mut rows: Vec<(&str, f64)> = Vec::new(); @@ -180,6 +182,7 @@ fn design_system(icons: usize, instances: usize) -> (Document, figcraft_engine:: figcraft_engine::synthetic_ds::design_system_with_instances(&spec, instances).unwrap() } +/// Build a test mouse-wheel event for the given vertical delta. fn wheel(d: f32) -> Event { Event::MouseWheel { unit: egui::MouseWheelUnit::Point, diff --git a/crates/ui-egui/src/plugin_panel_tests.rs b/crates/ui-egui/src/plugin_panel_tests.rs index 3b0f2746..53b1ba56 100644 --- a/crates/ui-egui/src/plugin_panel_tests.rs +++ b/crates/ui-egui/src/plugin_panel_tests.rs @@ -89,6 +89,9 @@ fn an_open_plugin_panel_draws_no_frames_while_idle() { Harness::new(session()).assert_idle("a plugin panel"); } +/// Asserts a plugin colour field's swatch opens the picker on that field, a picked colour +/// reaches the plugin, and a second plugin's panel opens while the first remains (two panels +/// present). #[test] fn a_colour_field_opens_the_colour_picker_and_two_panels_show_side_by_side() { let mut u = Ui::new(session()); diff --git a/crates/ui-egui/src/plugins_ui.rs b/crates/ui-egui/src/plugins_ui.rs index 83295359..26479c4d 100644 --- a/crates/ui-egui/src/plugins_ui.rs +++ b/crates/ui-egui/src/plugins_ui.rs @@ -245,6 +245,9 @@ const INSTALL: &str = "plugin.directory.install"; const MANAGE_W: f32 = 400.0; const ROW_TEXT_W: f32 = 300.0; +/// Draw the Plugins window with installed and browse tabs, dispatching remove, import, +/// browse, confirm and stop actions; closing the window cancels its directory requests and +/// clears confirmation. fn manage_window(app: &mut FigApp, ctx: &Context) { let t = Tokens::get(ctx); let mut open = true; @@ -509,6 +512,8 @@ fn summary(p: &InstalledPlugin) -> String { parts.join(" · ") } +/// Draw a plugin command's parameter form: close it when the plugin or command disappeared, +/// edit values and colour picks, run `plugin.run` on Run, and close on Cancel. fn form_window(app: &mut FigApp, ctx: &Context) { let Some(mut form) = app.ui.plugin_form.clone() else { return }; let Some((name, command)) = app diff --git a/crates/ui-egui/src/present_tests.rs b/crates/ui-egui/src/present_tests.rs index 4679c15d..93729922 100644 --- a/crates/ui-egui/src/present_tests.rs +++ b/crates/ui-egui/src/present_tests.rs @@ -137,6 +137,8 @@ fn preview_pane_opens_follows_edits_and_the_selection() { assert!(u.app.ui.preview.is_none()); } +/// Asserts the preview's device viewport and scroll range, wheel and drag scrolling, the +/// end clamp with spring-back, and that navigation resets scroll to the top. #[test] fn the_frame_scrolls_on_the_device_screen_by_wheel_and_drag() { let (mut u, a, b, _) = setup(); diff --git a/crates/ui-egui/src/present_view_tests.rs b/crates/ui-egui/src/present_view_tests.rs index 97fbba03..361a4caf 100644 --- a/crates/ui-egui/src/present_view_tests.rs +++ b/crates/ui-egui/src/present_view_tests.rs @@ -135,6 +135,8 @@ fn num(v: &Value) -> f64 { v.as_f64().unwrap() } +/// Asserts presenter fit/fit-width/fill/actual scaling, pan ranges, wheel and drag panning, +/// option carry-over to the next presentation, and R restarting the flow. #[test] fn presenter_scales_by_its_options_and_pans_what_overflows() { let (mut u, big) = big(); @@ -272,6 +274,7 @@ fn scrolling_keeps_its_momentum_and_springs_back_from_the_ends() { assert_eq!(u.preview()["flinging"], json!(false)); } +/// Asserts wheel momentum continues after input stops and then comes to rest. #[test] fn the_wheel_coasts_after_it_stops() { let (mut u, a, ..) = setup(); diff --git a/crates/ui-egui/src/proto_canvas.rs b/crates/ui-egui/src/proto_canvas.rs index 1faba513..b5d402b4 100644 --- a/crates/ui-egui/src/proto_canvas.rs +++ b/crates/ui-egui/src/proto_canvas.rs @@ -20,6 +20,7 @@ struct Drag { pos: Pos2, } +/// Stable egui id for prototype connection dragging. fn drag_id() -> Id { Id::unique("proto_canvas_drag") } @@ -59,6 +60,8 @@ struct Sources { ids: Arc>, } +/// Return the cached subtree nodes with interactions for `page`, recomputing only when the +/// document pointer or page changes. pub(crate) fn sources(ctx: &egui::Context, doc: &Arc, page: NodeId) -> Arc> { ctx.data_mut(|d| { let c = d.get_temp_mut_or_default::(Id::unique("proto_canvas_sources")); diff --git a/crates/ui-egui/src/proto_editor.rs b/crates/ui-egui/src/proto_editor.rs index cc3c352b..7163807e 100644 --- a/crates/ui-egui/src/proto_editor.rs +++ b/crates/ui-egui/src/proto_editor.rs @@ -129,6 +129,7 @@ fn unavailable(label: &str) -> &'static str { } } +/// Stable egui id for a node interaction's open action path. fn open_id(cx: &Cx) -> Id { Id::unique(("proto_open_action", cx.node, cx.k)) } diff --git a/crates/ui-egui/src/proto_editor_tests.rs b/crates/ui-egui/src/proto_editor_tests.rs index e6838a0a..63c152e1 100644 --- a/crates/ui-egui/src/proto_editor_tests.rs +++ b/crates/ui-egui/src/proto_editor_tests.rs @@ -78,6 +78,9 @@ fn condition(u: &Ui, btn: NodeId, block: usize) -> String { blocks[block].condition.as_ref().map(|c| c.display(&doc.variables)).unwrap_or_default() } +/// Asserts the interaction editor shows conditional blocks and nested actions, restarts a +/// condition when a variable of another type is picked, ignores a right-hand-side variable pick +/// for a complex condition, and one undo restores the previous condition. #[test] fn the_interaction_editor_shows_conditionals_and_picks_their_variables() { let (mut s, _, b, btn) = setup(); @@ -154,6 +157,8 @@ fn the_builder_takes_variable_operator_value_conditions() { } } +/// Asserts invalid typed conditions keep their draft and show errors without saving, a valid +/// one clears the error and saves, and Enter does not leak to shortcuts. #[test] fn typed_conditions_are_checked_and_errors_show_until_fixed() { let (mut s, _, b, btn) = setup(); diff --git a/crates/ui-egui/src/proto_panel.rs b/crates/ui-egui/src/proto_panel.rs index 972f068b..1b2e2df9 100644 --- a/crates/ui-egui/src/proto_panel.rs +++ b/crates/ui-egui/src/proto_panel.rs @@ -426,6 +426,8 @@ fn flows_overview(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { } } +/// Draw prototype settings: device and rotation, device colour, custom size, responsive +/// toggle and page background, running `proto.settings`. fn settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { if widgets::section_header(ui, tr("Prototype settings"), &[(Icon::Preview, tr("Open prototype preview"))], false).is_some() { app.run("proto.preview", json!({})); @@ -552,6 +554,7 @@ fn custom_size(app: &mut FigApp, ui: &mut Ui, model: Option) { }); } +/// Show, create or preview the flow starting at `frame` and edit its description. fn flow_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, frame: NodeId) { let flows = flows_of(doc, page); let mine = flows.iter().find(|f| f.start == frame).cloned(); @@ -579,6 +582,8 @@ fn flow_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, fra } } +/// List a node's interactions, add a default click navigation (or Back), open the editor +/// popover and remove interactions. fn interactions(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, node: &Node) { let t = Tokens::get(ui.ctx()); let id = node.id; @@ -696,6 +701,7 @@ fn animates(a: &Action) -> bool { ) } +/// Stable egui id for the prototype key-capture flag. fn capture_id() -> Id { Id::unique("proto_key_capture") } @@ -704,6 +710,8 @@ fn capturing(ctx: &egui::Context) -> bool { ctx.data(|d| d.get_temp::(capture_id())).unwrap_or(false) } +/// Draw an interaction's trigger controls: trigger picker, mouse/delay fields, key capture +/// and controller-button selection, patching the interaction. fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Interaction) { let t = Tokens::get(ui.ctx()); widgets::sublabels(ui, tr("Trigger"), None); @@ -1021,6 +1029,8 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: } } +/// Draw an interaction's animation controls: transition, direction, easing, duration and +/// custom easing fields, plus an animated curve preview. fn animation_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Interaction) { let t = Tokens::get(ui.ctx()); widgets::sublabels(ui, tr("Animation"), None); diff --git a/crates/ui-egui/src/proto_reorder_tests.rs b/crates/ui-egui/src/proto_reorder_tests.rs index 1f70f48d..d69be4f6 100644 --- a/crates/ui-egui/src/proto_reorder_tests.rs +++ b/crates/ui-egui/src/proto_reorder_tests.rs @@ -44,6 +44,7 @@ fn setup() -> (Ui, NodeId) { (u, btn) } +/// Stable id for a prototype node's open action path in the reorder tests. fn open_id(btn: NodeId) -> egui::Id { egui::Id::unique(("proto_open_action", btn, 0usize)) } diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index e2dca4a6..a084b9d0 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -78,6 +78,8 @@ impl FigApp { } } +/// Draw the right panel: people, present and share controls, Design/Prototype tabs and zoom +/// menu, then route the scroll area to comments, frame presets, design, prototype or inspect. pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let w = ui.available_width(); @@ -393,6 +395,8 @@ fn design(app: &mut FigApp, ui: &mut Ui) { crate::export_panel::section(app, ui, &doc, &sel); } +/// Draw the page section: background and colour profile, local variables/styles, and the +/// page export section. fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) { let t = Tokens::get(ui.ctx()); let Ok(page) = app.session.page() else { return }; @@ -455,6 +459,8 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) { crate::export_panel::page_section(app, ui, doc, page); } +/// Draw the selection header: type name (or Mixed), frame/group conversion, orientation +/// swap and frame presets, component/mask/boolean quick actions, and instance details. fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let t = Tokens::get(ui.ctx()); let label = match common(doc, sel, |n| n.kind.label()) { @@ -589,6 +595,8 @@ fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { ui.add_space(8.0); } +/// Draw alignment and distribution, X/Y and rotation with flip/rotate controls, the +/// absolute-position toggle and frame constraints. fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids = sel.to_vec(); let col = widgets::col_width(ui.available_width()); @@ -807,6 +815,8 @@ fn pretty(s: &str) -> String { } } +/// Draw visibility/blend/mask, opacity, uniform or per-corner radius, corner smoothing and +/// the blend-mode row. fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids = sel.to_vec(); let visible = common(doc, sel, |n| n.visible).unwrap_or(true); @@ -1080,6 +1090,8 @@ fn range_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, start: }); } +/// Draw a selected-text range fill, or the fill/stroke paint rows with add, style, reorder, +/// stroke align/weight, per-side weights and advanced stroke settings. fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: bool) { if fills && let Some((id, a, b)) = figcraft_engine::textedit::selected_range(&app.session) @@ -1641,6 +1653,8 @@ fn paint_label(p: &Paint) -> String { } } +/// Draw the effect list with add/style/type/toggle/remove/reorder, open settings, and keep an +/// open picker and settings flags aligned when rows move or are removed. fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids = sel.to_vec(); let list = common(doc, sel, |n| n.effects.clone()); @@ -1723,6 +1737,8 @@ fn fx_field(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, prop: &str, crate::variable_ui::number_field(app, ui, ids, &format!("effects/{i}/{prop}"), Id::unique(("fx", prop, i)), lead, Some(value), "", width) } +/// Draw type-specific effect fields for shadows, blurs, noise, texture and glass, using live +/// scrub edits that stay one undo step and bindable colour fields. fn effect_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Effect) { let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0; let set = |app: &mut FigApp, e2: Effect, scrub: Option| { @@ -1959,6 +1975,8 @@ fn fx_color(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Effect, } } +/// Draw the layout-grid list with add/style/type/toggle/remove/reorder, per-grid settings, +/// and keep open-settings flags aligned when rows move or are removed. fn grids(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids = sel.to_vec(); let list = common(doc, sel, |n| n.grids.clone()); diff --git a/crates/ui-egui/src/rtl_names_tests.rs b/crates/ui-egui/src/rtl_names_tests.rs index c88ab267..22f59817 100644 --- a/crates/ui-egui/src/rtl_names_tests.rs +++ b/crates/ui-egui/src/rtl_names_tests.rs @@ -34,6 +34,7 @@ fn caret_x(ctx: &egui::Context, text: &str, cursor: CCursor) -> f32 { ctx.fonts_mut(|f| f.layout_no_wrap(text.to_string(), regular(11.0), Color32::WHITE).pos_from_cursor(cursor).min.x) } +/// Read the primary character cursor of a test text field. fn cursor(ctx: &egui::Context, id: Id) -> CCursor { egui::TextEdit::load_state(ctx, id).unwrap().cursor.char_range().unwrap().primary } @@ -75,6 +76,8 @@ fn fonts() -> Option { Some(ctx) } +/// Asserts names keep their logical text while glyphs paint in visual order, including +/// ellipsis placement at the overflow end and mixed-direction rows. #[test] fn names_keep_logical_text_and_paint_in_visual_order() { let Some(ctx) = fonts() else { return }; @@ -137,6 +140,8 @@ fn names_keep_logical_text_and_paint_in_visual_order() { }); } +/// Asserts Arabic letters take joined contextual forms and truncation puts the ellipsis at +/// the visual end of a right-to-left row. #[test] fn arabic_joining_and_truncation_use_the_visual_end() { let Some(ctx) = fonts() else { return }; @@ -177,6 +182,8 @@ fn arabic_joining_and_truncation_use_the_visual_end() { }); } +/// Asserts the shaper mirrors right-to-left parentheses in both glyph order and the atlas +/// shapes chosen for them. #[test] fn rtl_brackets_are_mirrored_by_the_shaper() { let Some(ctx) = fonts() else { return }; @@ -211,6 +218,8 @@ fn rtl_brackets_are_mirrored_by_the_shaper() { }); } +/// Asserts Right/Left move the caret visually while logical offsets jump, Shift extends +/// visually, and typing inserts at the logical caret; Latin text keeps egui's own caret. #[test] fn rename_caret_moves_visually_with_logical_offsets() { let Some(ctx) = fonts() else { return }; @@ -266,6 +275,8 @@ fn rename_caret_moves_visually_with_logical_offsets() { assert_eq!(caret(&ctx, lid).0, 2); } +/// Asserts clicks map right-to-left visual positions to logical carets, so a click at the +/// visual left end leaves the caret at the logical end and typing appends there. #[test] fn clicks_place_the_logical_caret_by_what_is_shown() { let Some(ctx) = fonts() else { return }; diff --git a/crates/ui-egui/src/section_panel.rs b/crates/ui-egui/src/section_panel.rs index 586a5597..f3240d57 100644 --- a/crates/ui-egui/src/section_panel.rs +++ b/crates/ui-egui/src/section_panel.rs @@ -48,6 +48,9 @@ pub fn status_dropdown(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[Nod }); } +/// Draw section colour and hide-contents controls, Dev status, changed-reason Resolve, the +/// last-change note and Resize to fit; returns for an empty selection or one without a Dev +/// status. pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let sections = !sel.is_empty() && sel.iter().all(|i| is_section(doc, *i)); if sel.is_empty() || !sel.iter().all(|i| has_status(doc, *i)) { diff --git a/crates/ui-egui/src/select_all_tests.rs b/crates/ui-egui/src/select_all_tests.rs index a68a6854..d86de12d 100644 --- a/crates/ui-egui/src/select_all_tests.rs +++ b/crates/ui-egui/src/select_all_tests.rs @@ -45,6 +45,7 @@ fn selection(ctx: &egui::Context, te: Id) -> Option<(usize, usize)> { Some((a.min(b), a.max(b))) } +/// Asserts clicking a hex field focuses it and selects all six characters. #[test] fn a_click_into_a_hex_field_selects_it_all() { let ctx = egui::Context::default(); @@ -57,6 +58,7 @@ fn a_click_into_a_hex_field_selects_it_all() { assert_eq!(selection(&ctx, id.with("te")), Some((0, 6))); } +/// Asserts clicking an inline field selects it all and typing replaces the selection. #[test] fn a_click_into_an_inline_field_selects_it_all() { // A fill's hex in the Design panel, a variable's value, a rename. @@ -75,6 +77,7 @@ fn a_click_into_an_inline_field_selects_it_all() { assert_eq!(out.as_deref(), Some("red")); } +/// Asserts clicking a search box selects its whole query. #[test] fn a_click_into_a_search_box_selects_its_query() { let ctx = egui::Context::default(); @@ -87,6 +90,7 @@ fn a_click_into_a_search_box_selects_its_query() { assert_eq!(selection(&ctx, id), Some((0, 6))); } +/// Asserts Tab focus selects a field's whole value. #[test] fn tab_into_a_field_selects_it_all() { let ctx = egui::Context::default(); @@ -106,6 +110,7 @@ fn tab_into_a_field_selects_it_all() { assert_eq!(selection(&ctx, id), Some((0, 7))); } +/// Asserts a drag selects a non-empty proper subrange rather than the whole value. #[test] fn a_drag_selects_only_what_it_covers() { let ctx = egui::Context::default(); @@ -120,6 +125,7 @@ fn a_drag_selects_only_what_it_covers() { assert!(!(a == 0 && b == q.chars().count()), "not all of it: {a}..{b}"); } +/// Asserts re-clicking a field after focus was lost mid-edit selects all. #[test] fn a_field_left_mid_edit_selects_all_on_the_next_click() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/selection_colors.rs b/crates/ui-egui/src/selection_colors.rs index c699788b..682b37fe 100644 --- a/crates/ui-egui/src/selection_colors.rs +++ b/crates/ui-egui/src/selection_colors.rs @@ -24,6 +24,9 @@ struct Cache { colors: Arc>, } +/// Return the cached selection colours when the document and selection match. During an +/// interaction reuse an existing matching cache, or return an empty list without discovering +/// colours; otherwise discover and cache them. fn colors(ui: &Ui, document: u64, doc: &Arc, ids: &[NodeId], interacting: bool) -> Arc> { let key = Id::unique("selection_colors_cache"); if let Some(cache) = ui.data(|d| d.get_temp::(key)) @@ -49,6 +52,9 @@ pub(crate) fn open_picker(app: &mut FigApp, targets: Vec) { app.ui.picker = Some(PickerTarget::SelectionColor { targets, selection: ds.selection.clone(), document: ds.uid }); } +/// Draw the selection-colour groups (at least two): swatch opens the picker, hex and opacity +/// edits, style/variable detach/apply/bind, and “select layers with this color”; disabled +/// during an interaction. pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId]) { let Ok(ds) = app.session.ds() else { return }; let interacting = ds.interaction.is_some(); @@ -249,6 +255,8 @@ mod tests { assert!(!p.frame(vec![]).iter().any(|(s, _)| s == "Selection colors")); } + /// Asserts the panel reuses cached colours through a live interaction and refreshes the cache + /// after the commit. #[test] fn panel_keeps_cached_colors_during_interaction_and_refreshes_after_commit() { let (mut p, a, _) = Panel::new(); @@ -268,6 +276,8 @@ mod tests { assert!(!Arc::ptr_eq(&before.colors, &after.colors)); } + /// Asserts no cached colours are discovered during an interaction, and cancelling restores + /// the panel with the original colours. #[test] fn panel_defers_discovery_without_a_matching_cache_during_interaction() { let (mut p, a, _) = Panel::new(); diff --git a/crates/ui-egui/src/selection_spacing.rs b/crates/ui-egui/src/selection_spacing.rs index 7d1d27b4..414348bb 100644 --- a/crates/ui-egui/src/selection_spacing.rs +++ b/crates/ui-egui/src/selection_spacing.rs @@ -24,6 +24,8 @@ struct LayoutCache { reorder: Option>, } +/// Return one cached spacing/reorder calculation per document, page, selection and frame, +/// refreshing next frame after an edit and reusing an unchanged document snapshot. fn layout(app: &FigApp, ctx: &egui::Context, ids: &[NodeId]) -> Arc { let Ok(ds) = app.session.ds() else { return Arc::new(SelectionSpacing::default()) }; let key = Id::unique("selection_spacing_layout"); @@ -52,11 +54,13 @@ fn layout(app: &FigApp, ctx: &egui::Context, ids: &[NodeId]) -> Arc Option> { layout(app, ctx, ids); ctx.data(|d| d.get_temp::(Id::unique("selection_spacing_layout"))).and_then(|c| c.reorder) } +/// Stable egui id for spacing/reorder drag state. fn drag_id() -> Id { Id::unique("selection_spacing_drag") } @@ -107,6 +111,8 @@ fn start_drag(app: &mut FigApp, ctx: &egui::Context, ids: Vec, p: Pos2, } } +/// Draw horizontal and vertical spacing fields, running `arrange.spacing` with +/// `fromGesture`. pub fn panel(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId]) { let layout = layout(app, ui.ctx(), ids); if layout.horizontal.is_none() && layout.vertical.is_none() { diff --git a/crates/ui-egui/src/selection_spacing_tests.rs b/crates/ui-egui/src/selection_spacing_tests.rs index aaeaabc3..b4b384d0 100644 --- a/crates/ui-egui/src/selection_spacing_tests.rs +++ b/crates/ui-egui/src/selection_spacing_tests.rs @@ -43,6 +43,7 @@ fn drag(u: &mut Ui, p: Pos2, by: egui::Vec2, release: bool) { } } +/// Asserts typed and scrubbed spacing changes take effect with exactly one undo step. #[test] fn spacing_fields_type_scrub_and_keep_one_undo_step() { let (mut u, ids) = scene(true); @@ -88,6 +89,8 @@ fn canvas_gap_drag_keeps_selection_and_undo_redo_for_both_grid_axes() { } } +/// Asserts Escape rolls a spacing drag back, and releasing outside the window commits once +/// while keeping the selection. #[test] fn escape_rolls_back_and_an_outside_release_finishes_the_gesture() { let (mut u, ids) = scene(false); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 2abb1cfc..d47d9607 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -205,6 +205,9 @@ pub struct AfterFieldSave { } impl AfterFieldSave { + /// Run a deferred field-save binding only if the originating window still exists and owns + /// the document; temporarily activate that document, run the binding, then restore the + /// previously front tab. fn run(self, app: &mut FigApp) { // A closed file or one moved to another window cannot turn this into a save of the // current tab. The UiState normally stays in its window's pane; validate that too. @@ -299,6 +302,7 @@ pub(crate) fn revalidate_field_save(app: &FigApp, ctx: &Context, save: Option egui::Id { egui::Id::unique(("field_save_cancelled", ctx.viewport_id(), editor)) } @@ -644,12 +648,16 @@ pub(crate) struct ImeInputBaseline { } impl ImeInputBaseline { + /// Capture an IME baseline only while a text edit and an active preedit exist, recording + /// the IME target and the caret/anchor. pub(crate) fn capture(app: &FigApp) -> Option { let te = app.session.ds().ok()?.text_edit.as_ref()?; te.preedit.as_ref()?; Some(Self { target: ime_target(app)?, caret: te.caret, anchor: te.anchor }) } + /// Whether the IME target changed, editing or the preedit ended, or the caret/anchor moved + /// since the baseline was captured. fn interrupted(&self, app: &FigApp) -> bool { ime_target(app) != Some(self.target) || app @@ -661,6 +669,9 @@ impl ImeInputBaseline { } } +/// Apply a batch of text, IME, paste, copy, cut and key events: reject all IME events for an +/// interrupted composition, swallow text produced by a key that ran a shortcut, then finish +/// with triple-click line selection and preedit sync. fn text_keys(app: &mut FigApp, ctx: &Context, events: &[Event], ime_input: Option) { // Validate once before the batch: an accepted DeleteSurrounding deliberately clears preedit, // but its following Commit still belongs to the same valid transaction. An interrupted diff --git a/crates/ui-egui/src/style_picker_tests.rs b/crates/ui-egui/src/style_picker_tests.rs index f86ddd73..b732c11d 100644 --- a/crates/ui-egui/src/style_picker_tests.rs +++ b/crates/ui-egui/src/style_picker_tests.rs @@ -95,6 +95,8 @@ fn text_session() -> (Session, figcraft_doc::NodeId) { (s, t) } +/// Asserts hovering a text style previews it without an edit, leaving the popover restores +/// the value, and clicking applies one undo step and closes the picker. #[test] fn text_styles_preview_on_hover_and_apply_on_click() { let (s, t) = text_session(); diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index 92ff9f63..6f17910a 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -97,6 +97,8 @@ fn tab_drop_position(visible: &[(usize, Rect)], from: usize, pointer_x: f32) -> } } +/// Draw the top tab bar: Home, document and presenter tabs, new-tab and window controls, +/// open/close/reorder animation and tab closing, pausing the presenter when going Home. pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); egui::Panel::top("tab_bar").exact_size(HEIGHT).frame(egui::Frame::NONE.fill(t.tab_bar)).show(ui, |ui| { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 80d1399c..9a384294 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -173,6 +173,7 @@ impl Tokens { } } +/// Stable id for temporary theme tokens. fn tokens_id() -> egui::Id { egui::Id::unique("figcraft_tokens") } @@ -436,6 +437,7 @@ pub fn c32(c: figcraft_color::Color) -> Color32 { Color32::from_rgba_unmultiplied(r, g, b, a) } +/// Stable id for the current document-to-screen colour conversion. fn screen_conversion_id() -> egui::Id { egui::Id::unique("figcraft_screen_conversion") } diff --git a/crates/ui-egui/src/theme_startup_tests.rs b/crates/ui-egui/src/theme_startup_tests.rs index 1ae40f58..c128a597 100644 --- a/crates/ui-egui/src/theme_startup_tests.rs +++ b/crates/ui-egui/src/theme_startup_tests.rs @@ -84,6 +84,8 @@ fn unavailable_system_theme_uses_the_existing_light_fallback() { assert_eq!(Tokens::get(&ctx), Tokens::light()); } +/// Checks that the test graph enables egui's `default_fonts` and `monochrome_emoji_fonts` +/// features by asserting both a Latin and an emoji glyph are available. #[test] fn tests_have_the_explicit_default_and_monochrome_emoji_font_features() { // 0.37 egui has no default features, and kittest's own dev-features do not propagate to this diff --git a/crates/ui-egui/src/titlebar_tests.rs b/crates/ui-egui/src/titlebar_tests.rs index 8fa4ad47..fff303ef 100644 --- a/crates/ui-egui/src/titlebar_tests.rs +++ b/crates/ui-egui/src/titlebar_tests.rs @@ -81,6 +81,8 @@ fn window_buttons_minimise_maximise_and_close() { assert!(u.frame(vec![]).contains(&ViewportCommand::Close)); } +/// Asserts closing the custom titlebar over a presenter with unsaved changes asks with the +/// unsaved-changes dialog instead of emitting `Close`. #[test] fn titlebar_close_asks_over_the_presenter_when_a_file_is_unsaved() { let mut u = Ui::new(TitleBar::Custom); diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 7af3356a..8f8f6cc1 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -91,6 +91,7 @@ const BOARD_GROUPS: &[Group] = &[ Group::tools("comment", tr_noop("Comment"), &[(ToolId::Comment, Icon::Comment)]), ]; +/// Return the last tool index remembered for a toolbar group, falling back to `default`. fn remembered(ui: &Ui, key: &str, default: ToolId) -> ToolId { ui.data(|d| d.get_temp::(egui::Id::unique(("tb_last", key)))).and_then(|i| ToolId::ALL.get(i as usize).copied()).unwrap_or(default) } @@ -535,6 +536,8 @@ fn options_flyout(app: &mut FigApp, ui: &mut Ui, g: &Group, bottom_left: egui::P } } +/// Draw a tool group's flyout above its button, with keyboard/hover highlighting, remembered +/// selection when a tool is clicked, and Escape to close. fn flyout(app: &mut FigApp, ui: &mut Ui, g: &Group, bottom_left: egui::Pos2) { let t = Tokens::get(ui.ctx()); let item_h = 32.0; diff --git a/crates/ui-egui/src/type_settings_tests.rs b/crates/ui-egui/src/type_settings_tests.rs index cbde6cc1..421c1ce2 100644 --- a/crates/ui-egui/src/type_settings_tests.rs +++ b/crates/ui-egui/src/type_settings_tests.rs @@ -2,6 +2,8 @@ //! de paragraphe") stays inside the popover, which otherwise widened a little every frame. use egui::{Area, Id, Pos2, RawInput, Rect, vec2}; +/// Asserts a long type-settings row label does not widen the popover beyond 264 pt across +/// frames. #[test] fn a_long_label_does_not_widen_the_type_settings_popover() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index 33c0d5a0..f61aa183 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -1167,10 +1167,13 @@ const ROW: f32 = 26.0; /// The font list's tallest (it scrolls past that). const LIST_HEIGHT: f32 = 380.0; +/// Return the persisted list of recently used font families. fn recent_families(ctx: &egui::Context) -> Vec { ctx.data_mut(|d| d.get_persisted::>(Id::unique(RECENT_KEY))).unwrap_or_default() } +/// Remember a font family at the front of the recent list, removing an earlier +/// case-insensitive match and keeping at most eight entries. fn remember_family(ctx: &egui::Context, family: &str) { ctx.data_mut(|d| { let v = d.get_persisted_mut_or_default::>(Id::unique(RECENT_KEY)); @@ -1568,6 +1571,8 @@ mod variable_tests { assert!(p.app.ui.last_error.is_none(), "{:?}", p.app.ui.last_error); } + /// Asserts bound font family and style chips replace the dropdowns, a selected text range + /// offers no variable binding, and detaching restores the last values. #[test] fn bound_font_family_and_style_show_their_variables_and_detach() { let mut p = Panel::new(); diff --git a/crates/ui-egui/src/typography_scrub_tests.rs b/crates/ui-egui/src/typography_scrub_tests.rs index 1e629acf..9b95b8e5 100644 --- a/crates/ui-egui/src/typography_scrub_tests.rs +++ b/crates/ui-egui/src/typography_scrub_tests.rs @@ -44,6 +44,7 @@ impl Fields { out.textures_delta.clear(); out.platform_output.cursor_icon } + /// Screen position of a typography field's label/lead, used to start a scrub. fn lead(&self, key: &str) -> Pos2 { let rect = self.ctx.read_response(Id::unique(key).with("click")).unwrap().rect; Pos2::new(rect.min.x + 12.0, rect.center().y) @@ -157,6 +158,8 @@ fn typography_icon_scrub_changes_only_the_selected_characters_without_ending_tex assert_eq!(f.value("ty_ls"), 0.0); } +/// Asserts typography spacing fields accept typed percent units, commit on Enter, and +/// Escape restores the previous value without adding an undo step. #[test] fn typography_spacing_fields_still_accept_typed_units_and_escape_cancels() { let mut f = Fields::new(); diff --git a/crates/ui-egui/src/variable_ui.rs b/crates/ui-egui/src/variable_ui.rs index 776d8aae..fe822a1f 100644 --- a/crates/ui-egui/src/variable_ui.rs +++ b/crates/ui-egui/src/variable_ui.rs @@ -40,6 +40,7 @@ struct Picker { at: Pos2, } +/// Stable egui id for the variable picker. fn picker_id() -> Id { Id::unique("variable_picker") } diff --git a/crates/ui-egui/src/variables_panel.rs b/crates/ui-egui/src/variables_panel.rs index 2a9e3c04..372e3f04 100644 --- a/crates/ui-egui/src/variables_panel.rs +++ b/crates/ui-egui/src/variables_panel.rs @@ -173,6 +173,8 @@ pub fn show(app: &mut FigApp, ctx: &Context) { app.ui.variables = open.then_some(st); } +/// Draw the variables header: the title at the left, and the import/export/Close controls at the +/// right. fn header(app: &mut FigApp, ui: &mut Ui, open: &mut bool) { let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover()); @@ -253,6 +255,7 @@ fn extend(app: &mut FigApp, lib: &str, c: &str, st: &mut Editor) { } } +/// Stable egui id for the rename field of a `Rename` target. fn rename_id(target: &Rename) -> Id { Id::unique(("var_rename", target)) } @@ -281,6 +284,8 @@ fn unique(base: &str, taken: impl Fn(&str) -> bool) -> String { (1..10_000).map(|k| if k == 1 { base.to_string() } else { format!("{base} {k}") }).find(|n| !taken(n)).unwrap_or_else(|| base.to_string()) } +/// Draw the collections sidebar with library headings, collection rows and a create-collection +/// button. fn sidebar(app: &mut FigApp, ui: &mut Ui, views: &[View], local: &Variables, st: &mut Editor) { let t = Tokens::get(ui.ctx()); let (hr, _) = ui.allocate_exact_size(vec2(SIDEBAR_W, 40.0), Sense::hover()); @@ -505,6 +510,8 @@ fn group_of(name: &str) -> &str { name.rsplit_once('/').map(|(g, _)| g).unwrap_or("") } +/// Draw the variables table for the selected collection: search, library/extended notes, +/// grouped rows, mode columns and the create-variable row when editable. fn table(app: &mut FigApp, ui: &mut Ui, view: &View, st: &mut Editor) { let t = Tokens::get(ui.ctx()); // A sidebar click this frame picked another source: shown next frame. @@ -594,6 +601,8 @@ fn table(app: &mut FigApp, ui: &mut Ui, view: &View, st: &mut Editor) { }); } +/// Draw a collection's mode header row with rename, duplicate, reorder and delete actions +/// and an add-mode button when the collection is editable and not extended. fn header_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st: &mut Editor, width: f32) { let t = Tokens::get(ui.ctx()); // An extended collection has the modes of the one it extends. diff --git a/crates/ui-egui/src/vector_panel.rs b/crates/ui-egui/src/vector_panel.rs index 29ae8726..07dda3ff 100644 --- a/crates/ui-egui/src/vector_panel.rs +++ b/crates/ui-egui/src/vector_panel.rs @@ -72,6 +72,8 @@ fn width_point(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) { ui.add_space(8.0); } +/// Draw the selected points' position, dimensions, corner radius, mirroring and per-point +/// stroke cap/join controls; the width tool delegates to its own panel. pub fn points(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) { if ve.tool == vectoredit::VTool::Width { width_point(app, ui, doc, ve); diff --git a/crates/ui-egui/src/versions.rs b/crates/ui-egui/src/versions.rs index 3519b610..19eb6a04 100644 --- a/crates/ui-egui/src/versions.rs +++ b/crates/ui-egui/src/versions.rs @@ -403,6 +403,8 @@ fn current_row(ui: &mut Ui, more: bool, active: bool) -> bool { !active && resp.on_hover_text(tr("Back to the current version")).clicked() } +/// Draw one version row with timeline, name, time, up to three description lines and a +/// more/context menu; returns the chosen preview, restore, edit, duplicate or delete action. fn version_row(ui: &mut Ui, row: &Row, last: bool, previewing: bool) -> Option { let t = Tokens::get(ui.ctx()); let w = ui.available_width(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index f356b797..a5dd2c7b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -223,6 +223,9 @@ fn cancel_retargeted_field_save(ui: &mut Ui, id: Id) { ui.memory_mut(|m| m.surrender_focus(editor)); } +/// Implement the shared number field: label/icon lead, scrubbing, keyboard stepping and +/// typing, select-all when a click starts editing, bindable values and deferred-save +/// cancellation; returns an edit and/or bind event. fn number_field_impl( ui: &mut Ui, id: Id, diff --git a/crates/ui-egui/src/windows_tests.rs b/crates/ui-egui/src/windows_tests.rs index 55a7babd..0f5884dd 100644 --- a/crates/ui-egui/src/windows_tests.rs +++ b/crates/ui-egui/src/windows_tests.rs @@ -45,6 +45,8 @@ fn names(app: &FigApp) -> Vec { app.session.docs.iter().map(|d| d.doc.name.clone()).collect() } +/// Asserts closing a window in the presenter shows the unsaved-changes dialog and does not +/// dismiss the preview. #[test] fn closing_a_window_in_the_presenter_shows_the_unsaved_changes_dialog() { let mut session = Session::new(); @@ -104,6 +106,8 @@ fn windows_hold_tabs_and_close_with_their_files() { assert_eq!(app.session.ds().unwrap().doc.name, "A"); } +/// Asserts a hidden other window answers edits and inspection in its own file while the main +/// window keeps its file, and a capture parks with a deadline and times out once. #[test] fn another_hidden_window_answers_requests_that_need_no_frame_in_its_own_file() { let mut s = Session::empty(); @@ -180,6 +184,8 @@ fn a_request_for_a_window_on_screen_wakes_it_while_the_main_window_is_hidden() { assert!(woken.lock().unwrap().contains(&other), "the other window is asked to draw: {:?}", woken.lock().unwrap()); } +/// Asserts every direct UI frame clears the logic-tick marker so background work runs each +/// frame. #[test] fn every_direct_ui_frame_runs_background_work() { // Direct `App::ui` harnesses never call `App::logic`: the tick marker the UI pass consumes @@ -192,6 +198,8 @@ fn every_direct_ui_frame_runs_background_work() { } } +/// Asserts synthetic input for a child window reaches the pane that draws it, leaving the +/// main window's pane untouched. #[test] fn synthetic_input_for_a_child_window_reaches_the_pane_that_draws_it() { let mut s = Session::empty(); @@ -214,6 +222,8 @@ fn synthetic_input_for_a_child_window_reaches_the_pane_that_draws_it() { assert_eq!(crate::windows::take_input(&mut app, other), events, "the child's next frame takes its batch"); } +/// Asserts a child raw-input hook with no batch does not error or drain the root window's +/// pending acknowledgement. #[test] fn a_frame_without_a_batch_does_not_error_another_windows_acknowledgement() { let mut s = Session::empty(); @@ -238,6 +248,8 @@ fn a_frame_without_a_batch_does_not_error_another_windows_acknowledgement() { assert_eq!(app.input_acks[0].reply.id, json!(5)); } +/// Asserts a child window runs `viewport_setup` and its pane keeps the dark theme after the +/// swap back. #[test] fn a_child_window_runs_the_ui_setup_the_main_window_does() { // eframe calls `App::ui` only for the root window, so a child that draws while the root does @@ -263,6 +275,8 @@ fn a_child_window_runs_the_ui_setup_the_main_window_does() { assert_eq!(again, Some(json!(true))); } +/// Asserts a closed and later reused viewport does not inherit its old IME context +/// generation. #[test] fn an_emptied_window_forgets_its_ime_context() { // `windows::begin` ends the viewport when a window loses its last file; a later window that