diff --git a/Cargo.lock b/Cargo.lock index 8a86b9863..062fa86ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5718,7 +5718,7 @@ dependencies = [ [[package]] name = "vectorcraft" -version = "0.16.0" +version = "0.17.0" dependencies = [ "arboard", "block2 0.6.2", @@ -5758,7 +5758,7 @@ dependencies = [ [[package]] name = "vectorcraft-affinity" -version = "0.16.0" +version = "0.17.0" dependencies = [ "crc32fast", "flate2", @@ -5769,7 +5769,7 @@ dependencies = [ [[package]] name = "vectorcraft-brush" -version = "0.16.0" +version = "0.17.0" dependencies = [ "kurbo 0.13.1", "serde", @@ -5781,7 +5781,7 @@ dependencies = [ [[package]] name = "vectorcraft-cad" -version = "0.16.0" +version = "0.17.0" dependencies = [ "image", "kurbo 0.13.1", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "vectorcraft-cli" -version = "0.16.0" +version = "0.17.0" dependencies = [ "log", "objc2-core-foundation", @@ -5819,7 +5819,7 @@ dependencies = [ [[package]] name = "vectorcraft-color" -version = "0.16.0" +version = "0.17.0" dependencies = [ "kurbo 0.13.1", "moxcms", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "vectorcraft-doc" -version = "0.16.0" +version = "0.17.0" dependencies = [ "chrono", "hayro-ccitt", @@ -5850,7 +5850,7 @@ dependencies = [ [[package]] name = "vectorcraft-effects" -version = "0.16.0" +version = "0.17.0" dependencies = [ "image", "kurbo 0.13.1", @@ -5866,7 +5866,7 @@ dependencies = [ [[package]] name = "vectorcraft-engine" -version = "0.16.0" +version = "0.17.0" dependencies = [ "craft-docpath", "crc32fast", @@ -5905,7 +5905,7 @@ dependencies = [ [[package]] name = "vectorcraft-eps" -version = "0.16.0" +version = "0.17.0" dependencies = [ "fax", "flate2", @@ -5929,7 +5929,7 @@ dependencies = [ [[package]] name = "vectorcraft-format" -version = "0.16.0" +version = "0.17.0" dependencies = [ "flate2", "proptest", @@ -5944,7 +5944,7 @@ dependencies = [ [[package]] name = "vectorcraft-geom" -version = "0.16.0" +version = "0.17.0" dependencies = [ "kurbo 0.13.1", "proptest", @@ -5956,7 +5956,7 @@ dependencies = [ [[package]] name = "vectorcraft-mcp" -version = "0.16.0" +version = "0.17.0" dependencies = [ "image", "log", @@ -5975,7 +5975,7 @@ dependencies = [ [[package]] name = "vectorcraft-metafile" -version = "0.16.0" +version = "0.17.0" dependencies = [ "image", "kurbo 0.13.1", @@ -5989,7 +5989,7 @@ dependencies = [ [[package]] name = "vectorcraft-pathops" -version = "0.16.0" +version = "0.17.0" dependencies = [ "kurbo 0.13.1", "linesweeper", @@ -6001,7 +6001,7 @@ dependencies = [ [[package]] name = "vectorcraft-pdf" -version = "0.16.0" +version = "0.17.0" dependencies = [ "aes", "chrono", @@ -6032,7 +6032,7 @@ dependencies = [ [[package]] name = "vectorcraft-plugins" -version = "0.16.0" +version = "0.17.0" dependencies = [ "serde", "serde_json", @@ -6046,7 +6046,7 @@ dependencies = [ [[package]] name = "vectorcraft-render" -version = "0.16.0" +version = "0.17.0" dependencies = [ "flate2", "gif", @@ -6073,14 +6073,14 @@ dependencies = [ [[package]] name = "vectorcraft-scriptcheck" -version = "0.16.0" +version = "0.17.0" dependencies = [ "proptest", ] [[package]] name = "vectorcraft-svg" -version = "0.16.0" +version = "0.17.0" dependencies = [ "craft-docpath", "flate2", @@ -6105,7 +6105,7 @@ dependencies = [ [[package]] name = "vectorcraft-testkit" -version = "0.16.0" +version = "0.17.0" dependencies = [ "fax", "image", @@ -6123,7 +6123,7 @@ dependencies = [ [[package]] name = "vectorcraft-text" -version = "0.16.0" +version = "0.17.0" dependencies = [ "harfrust 0.13.3", "icu_segmenter", @@ -6144,7 +6144,7 @@ dependencies = [ [[package]] name = "vectorcraft-three-d" -version = "0.16.0" +version = "0.17.0" dependencies = [ "vectorcraft-geom", "vectorcraft-pathops", @@ -6152,7 +6152,7 @@ dependencies = [ [[package]] name = "vectorcraft-tools" -version = "0.16.0" +version = "0.17.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6168,7 +6168,7 @@ dependencies = [ [[package]] name = "vectorcraft-trace" -version = "0.16.0" +version = "0.17.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6180,7 +6180,7 @@ dependencies = [ [[package]] name = "vectorcraft-ui-egui" -version = "0.16.0" +version = "0.17.0" dependencies = [ "bytemuck", "craft-keymap", @@ -6217,7 +6217,7 @@ dependencies = [ [[package]] name = "vectorcraft-web" -version = "0.16.0" +version = "0.17.0" dependencies = [ "eframe", "egui", @@ -7313,7 +7313,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.16.0" +version = "0.17.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index ba466c0fa..6e3d2fff8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"] [workspace.package] -version = "0.16.0" +version = "0.17.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" diff --git a/apps/vectorcraft-web/src/web.rs b/apps/vectorcraft-web/src/web.rs index 796680854..3339ddcfc 100644 --- a/apps/vectorcraft-web/src/web.rs +++ b/apps/vectorcraft-web/src/web.rs @@ -371,6 +371,8 @@ impl eframe::App for WebShell { app.finish_ime_logic(ctx); } + /// Forward host-gathered pointer events into this frame's raw input, then let the app apply its + /// shared input hook before egui runs. fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { raw.events.extend(self.host.pointers.borrow_mut().take()); if let Some(app) = &mut self.app { @@ -378,6 +380,8 @@ impl eframe::App for WebShell { } } + /// Read host paste and drop input once in the UI phase, hand files to the app's drop targets, run + /// the app UI, then poll control replies. fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { let ctx = ui.ctx().clone(); if let Some(app) = &mut self.app { diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 2a5eaf557..6cee2622c 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -91,6 +91,8 @@ impl eframe::App for App { file_access::unconfined(|| self.logic_frame(ctx, frame)); self.app.finish_ime_logic(ctx); } + /// Restore the borderless caption, apply platform menu and pen input, and hand the frame to the + /// app's raw-input hook while the keyboard user is unconfined. fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { // winit gives the window its caption back when it changes the window's flags (maximize, // restore, …): take it off again before the frame is laid out (#1758). diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 7b6065013..6d4b22fe2 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -443,6 +443,7 @@ impl Session { self.with_tool_cx(view, |t, cx| t.ime_session_matches_document(cx)) } + /// Whether the active tool currently owns keyboard text input, as the Type tool does while editing. pub fn tool_wants_text(&self) -> bool { self.tool.wants_text() } diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 7a0fcda6e..90bad0109 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -785,6 +785,7 @@ pub enum ImeSurrounding { } impl Default for ImeSurrounding { + /// Report surrounding text as unavailable because no document is active. fn default() -> Self { Self::Unavailable("no document is active") } diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index f982add4a..acbd0a621 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -73,6 +73,8 @@ type CachedLayout = ((u64, TextObject), Arc); /// A mutex (not an `AtomicU64`) keeps this available on wasm targets without 64-bit atomics. static NEXT_EDIT_SESSION: Mutex = Mutex::new(0); +/// Return the next process-wide edit-session id. It saturates at `u64::MAX` instead of wrapping to +/// zero; the final id can repeat only after 2^64 sessions. fn next_edit_session() -> u64 { let mut next = NEXT_EDIT_SESSION.lock().unwrap_or_else(PoisonError::into_inner); let session = next.checked_add(1).unwrap_or(u64::MAX); @@ -154,6 +156,8 @@ impl TypeTool { l } + /// The edited text used to find word and paragraph boundaries for selection: the typing session's + /// runs, or the document's current text when there is no live session or it has gone stale. fn plain(&self, cx: &ToolContext) -> String { let document = self.editing.and_then(|id| Self::text(cx, id)); match &self.typing { @@ -304,6 +308,8 @@ impl TypeTool { out } + /// Begin a new edit session for `id` at `caret`, assigning a fresh session id and clearing typing, + /// pre-edit and goal state. fn start_editing(&mut self, id: NodeId, caret: usize) { self.edit_session = next_edit_session(); self.editing = Some(id); @@ -946,6 +952,8 @@ impl Tool for TypeTool { let clip: String = self.clipboard.iter().map(|r| r.text.as_str()).collect(); !(self.preedit.is_none() && normalized.len() > 1 && normalized == clip && !cx.paste_plain_text) } + /// Whether the typing session still describes the edited document; a session ended by an outside + /// command reports false until a replacement commit starts a new one. fn ime_session_matches_document(&self, cx: &ToolContext) -> bool { match (self.editing, &self.typing) { (Some(id), Some(ty)) => Self::text(cx, id).is_none_or(|t| ty.cur == t.runs), @@ -955,6 +963,8 @@ impl Tool for TypeTool { _ => true, } } + /// Handle an editing key: ignore keys while an IME composes, remap arrows in vertical text, step + /// type attributes with Alt, and otherwise edit, navigate or end the session. fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec { // The IME owns the keys while it composes (the UI doesn't send them; this is a guard). if self.preedit.is_some() { @@ -1121,6 +1131,9 @@ impl Tool for TypeTool { self.preedit = Some(Preedit { range: start..end, active: active_chars.and_then(|r| char_range_to_bytes(&text, r)) }); out } + /// Delete the requested bytes or characters before and after the selection or marked text, + /// preserving the selection direction and composition and refusing a stale session; byte counts + /// round inward to UTF-8 boundaries. fn ime_delete_surrounding(&mut self, cx: &ToolContext, before: usize, after: usize, unit: SurroundingUnit) -> Vec { let Some(id) = self.editing else { return vec![] }; // The document changed under the session (undo, a panel edit): the ranges below would index @@ -1173,11 +1186,16 @@ impl Tool for TypeTool { } self.delete_ranges(cx, &ranges) } + /// Return the active Type tool's stable IME owner (document uid, target id, replacement epoch), + /// or `None` when nothing is being edited. fn ime_target(&self, cx: &ToolContext) -> Option<(u64, u64, u64)> { let id = self.editing?; Self::text(cx, id)?; Some((cx.revision.0, id.0, self.edit_session)) } + /// Return a bounded logical text window around the caret, anchor and optional pre-edit; a stale + /// typing session publishes the document's current text, and unusable or non-boundary offsets + /// return `Unavailable` rather than stale data. fn ime_surrounding(&self, cx: &ToolContext, max_bytes: usize) -> ImeSurrounding { let Some(id) = self.editing else { return ImeSurrounding::Unavailable("no text object is being edited"); @@ -1217,6 +1235,7 @@ impl Tool for TypeTool { }; ime_surrounding_window(&segments, preedit, len, caret, anchor, max_bytes) } + /// Whether an IME pre-edit is currently shown for this Type-tool session. fn composing(&self) -> bool { self.preedit.is_some() } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index df070ea61..2c82e5fca 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -131,6 +131,7 @@ pub(crate) fn end_document_drag(ctx: &egui::Context) { }); } +/// Temp-memory key for the active canvas drag state. fn drag_id() -> egui::Id { egui::Id::unique("canvas-drag") } @@ -3043,10 +3044,12 @@ fn ime_geometry(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) { /// out rather than spilling over its neighbours. const MIN_LABELLED_ARTBOARD: f32 = 24.0; +/// Temp-memory key recording an artboard-name press before its edit field opens. fn name_press_id() -> egui::Id { egui::Id::unique("canvas-artboard-name-press") } +/// Temp-memory key for the artboard name field currently being edited. fn name_edit_id() -> egui::Id { egui::Id::unique("canvas-artboard-name-edit") } @@ -4623,6 +4626,8 @@ mod tests { }) .unwrap_or_else(|| panic!("no {text:?} text")) } + /// Build raw input with a fixed 800 by 600 screen so headless frames keep a stable canvas texture + /// size. fn raw() -> egui::RawInput { egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() } } @@ -8658,6 +8663,8 @@ mod tests { assert_eq!(plain(&app, id), "雅がくらくか"); } + /// Reset to a blank document, select the Type tool and click document point (100, 100), returning + /// the screen position used by IME fixtures. fn type_at(app: &mut VectorcraftApp, ctx: &egui::Context) -> egui::Pos2 { app.session.prefs.placeholder_text = false; app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); diff --git a/crates/ui-egui/src/dialogs/graph_data.rs b/crates/ui-egui/src/dialogs/graph_data.rs index 296f60812..a74735363 100644 --- a/crates/ui-egui/src/dialogs/graph_data.rs +++ b/crates/ui-egui/src/dialogs/graph_data.rs @@ -336,6 +336,8 @@ fn import(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<(), String> { Ok(()) } +/// Draw the graph-data sheet: keyboard cell movement, block paste, import, transpose, switch-x/y +/// and revert actions, decimals and column width, and only the cells in view. fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let entry_id = egui::Id::unique("graph-data-entry"); if ui.memory(|m| m.has_focus(entry_id)) { diff --git a/crates/ui-egui/src/dialogs/revolve_gizmo.rs b/crates/ui-egui/src/dialogs/revolve_gizmo.rs index f882ddc4f..c9194d5d0 100644 --- a/crates/ui-egui/src/dialogs/revolve_gizmo.rs +++ b/crates/ui-egui/src/dialogs/revolve_gizmo.rs @@ -30,6 +30,7 @@ pub(crate) fn hint(d: &Dialog) -> &'static str { } } +/// Temp-memory key for the revolve gizmo drag state. fn drag_id() -> Id { Id::unique("revolve-gizmo-drag") } diff --git a/crates/ui-egui/src/dialogs/save_for_web.rs b/crates/ui-egui/src/dialogs/save_for_web.rs index 21494d783..02945cd3c 100644 --- a/crates/ui-egui/src/dialogs/save_for_web.rs +++ b/crates/ui-egui/src/dialogs/save_for_web.rs @@ -372,6 +372,7 @@ struct Cache { shots: Vec<(String, Result)>, } +/// Temp-memory key for the Save for Web preview render, original image and optimised shot textures. fn cache_id() -> egui::Id { egui::Id::unique("save-for-web-previews") } diff --git a/crates/ui-egui/src/dialogs/tests_accessibility.rs b/crates/ui-egui/src/dialogs/tests_accessibility.rs index a86087b26..42ee3256b 100644 --- a/crates/ui-egui/src/dialogs/tests_accessibility.rs +++ b/crates/ui-egui/src/dialogs/tests_accessibility.rs @@ -13,6 +13,7 @@ use crate::VectorcraftApp; type Harness = egui_kittest::Harness<'static, VectorcraftApp>; +/// Build a 300 by 300 document for a real-dialog accessibility harness. fn app() -> VectorcraftApp { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); @@ -38,6 +39,7 @@ fn strict(app: VectorcraftApp) -> Harness { h } +/// Export for Screens names its three text fields and exposes no raw widget ids. #[test] fn export_for_screens_names_the_form_text_fields() { let mut app = app(); @@ -50,6 +52,8 @@ fn export_for_screens_names_the_form_text_fields() { assert!(h.query_by_role_and_label(Role::TextInput, "prefix").is_none(), "no internal widget id"); } +/// Flatten Transparency names its preset name and three resolution or balance fields and roles the +/// balance control as a slider. #[test] fn flatten_transparency_names_its_preset_and_option_fields() { let mut app = app(); @@ -66,6 +70,7 @@ fn flatten_transparency_names_its_preset_and_option_fields() { let _ = h.get_by_role_and_label(Role::Slider, "Raster/Vector Balance:"); } +/// Document Setup names the top, bottom, left and right bleed inputs. #[test] fn document_setup_names_each_bleed_side() { let mut app = app(); @@ -76,6 +81,8 @@ fn document_setup_names_each_bleed_side() { } } +/// Split Into Grid keeps translated visible labels while naming row and column gutter and total +/// inputs by axis and context. #[test] fn split_into_grid_names_row_and_column_fields_with_context() { let mut app = app(); @@ -93,6 +100,7 @@ fn split_into_grid_names_row_and_column_fields_with_context() { assert_eq!(h.get_all_by_label("Total:").count(), 2, "the visible total labels are unchanged"); } +/// Artboard Options names the video ruler pixel aspect ratio input. #[test] fn artboard_options_names_the_video_pixel_aspect_ratio_field() { let mut app = app(); @@ -103,6 +111,7 @@ fn artboard_options_names_the_video_pixel_aspect_ratio_field() { let _ = h.get_by_role_and_label(Role::TextInput, "Video Ruler Pixel Aspect Ratio:"); } +/// A generic parameter dialog names its number and text inputs, combo boxes and checkbox. #[test] fn generic_parameter_dialog_names_numbers_text_and_choices() { let mut app = app(); @@ -130,6 +139,7 @@ fn generic_parameter_dialog_names_numbers_text_and_choices() { let _ = h.get_by_role_and_label(Role::CheckBox, "Flip:"); } +/// Slice Options names its multiline text-display area. #[test] fn slice_options_names_its_text_area() { let mut app = app(); diff --git a/crates/ui-egui/src/dialogs/tests_revolve.rs b/crates/ui-egui/src/dialogs/tests_revolve.rs index b8dee81b9..bbaea5255 100644 --- a/crates/ui-egui/src/dialogs/tests_revolve.rs +++ b/crates/ui-egui/src/dialogs/tests_revolve.rs @@ -56,6 +56,7 @@ impl Harness { self.texts.iter().find(|(s, _)| s == text).map(|(_, r)| *r).unwrap_or_else(|| panic!("missing text {text:?}")) } + /// Return the rectangle of the widget drawn under `Id::unique(id)` in the revolve test harness. fn response(&self, id: impl std::hash::Hash + std::fmt::Debug) -> Rect { self.ctx.read_response(Id::unique(id)).unwrap().rect } diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index b80be48c4..b92dbd161 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -42,6 +42,8 @@ fn seed_dock_panel_width(ui: &egui::Ui, panel: &str, width: f32) { ui.ctx().data_mut(|data| data.insert_persisted(ui.scope_id().with(panel), state)); } +/// Apply a requested dock width once by seeding the panel's 0.37 persisted state; ignore non-finite +/// or non-positive widths and clear the pending flag. pub(crate) fn apply_pending_dock_width(app: &mut VectorcraftApp, ui: &egui::Ui, panel: &str, clamp: fn(f32) -> f32) { if !app.ui.dock_width_pending { return; diff --git a/crates/ui-egui/src/edit_toolbar.rs b/crates/ui-egui/src/edit_toolbar.rs index 2a7989f68..caec027b6 100644 --- a/crates/ui-egui/src/edit_toolbar.rs +++ b/crates/ui-egui/src/edit_toolbar.rs @@ -333,6 +333,7 @@ pub(crate) fn button_id() -> egui::Id { egui::Id::unique("edit-toolbar-button") } +/// Stable id of the floating edit toolbar. pub(crate) fn toolbar_id() -> egui::Id { egui::Id::unique("edit-toolbar-toolbar") } diff --git a/crates/ui-egui/src/floating.rs b/crates/ui-egui/src/floating.rs index b6a489db6..68a3d55ac 100644 --- a/crates/ui-egui/src/floating.rs +++ b/crates/ui-egui/src/floating.rs @@ -125,6 +125,7 @@ pub(crate) fn root_groups_id() -> Id { Id::unique("floating-root-groups") } +/// Temp-memory key for the floating-panel move state. fn move_id() -> Id { Id::unique("floating-move") } @@ -150,6 +151,7 @@ pub(crate) fn icons_rect_id() -> Id { Id::unique("floating-icons-rect") } +/// Stable id of the floating tools zone used for dock and drop hit testing. pub(crate) fn tools_zone_id() -> Id { Id::unique("floating-tools-zone") } diff --git a/crates/ui-egui/src/ime.rs b/crates/ui-egui/src/ime.rs index 54d9fffc1..c0a93513a 100644 --- a/crates/ui-egui/src/ime.rs +++ b/crates/ui-egui/src/ime.rs @@ -43,6 +43,8 @@ struct OutputContext { } impl From<&IMEOutput> for OutputContext { + /// Copy the output's owner, context generation and purpose into the comparable publication + /// identity. fn from(output: &IMEOutput) -> Self { Self { owner: output.owner, generation: output.context_generation, purpose: output.purpose } } @@ -64,9 +66,12 @@ pub(crate) struct ResetGuard { pending: bool, } +/// Temp-memory key marking whether this viewport is currently inside a UI pass. fn mode_id(viewport: egui::ViewportId) -> Id { Id::unique(("vectorcraft.ime.in-ui-pass", viewport)) } +/// Temp-memory key for the pass number and descriptor identity observed during this viewport's UI +/// pass. fn output_id(viewport: egui::ViewportId) -> Id { Id::unique(("vectorcraft.ime.pass-output", viewport)) } @@ -133,6 +138,9 @@ fn route_live(app: &mut VectorcraftApp, ctx: &Context) -> bool { target(&mut app.session, view).is_some() && app.session.tool_ime_caret(view).is_some() } +/// Allocate a reset request on an eligible custom-editor route and replace the guard with the +/// confirmed destination, commit stamp and prior output identity; suspend instead when the route +/// cannot publish. fn request_guard(app: &mut VectorcraftApp, ctx: &Context, deferred: bool) -> bool { if !route_live(app, ctx) { suspend(app, ctx); @@ -163,11 +171,14 @@ fn request_guard(app: &mut VectorcraftApp, ctx: &Context, deferred: bool) -> boo true } +/// Temp-memory key for the test-only count of IME reset requests since the last take. #[cfg(test)] fn request_count_id() -> Id { Id::unique("vectorcraft.ime.test-request-count") } +/// Increment the test-only request counter for every reset allocation, including intermediates +/// superseded before publication. #[cfg(test)] fn note_test_request(ctx: &Context) { ctx.data_mut(|data| { @@ -202,10 +213,12 @@ pub(crate) fn suspend_if_route_lost(app: &mut VectorcraftApp, ctx: &Context) { } } +/// Whether this viewport has a guard, including a suspended guard that currently owns no request. pub(crate) fn blocked(app: &VectorcraftApp, ctx: &Context) -> bool { app.ime_resets.contains_key(&ctx.viewport_id()) } +/// Return the reset request awaiting native completion for this viewport; test helper. #[cfg(test)] pub(crate) fn pending_request(app: &VectorcraftApp, ctx: &Context) -> Option { app.ime_resets.get(&ctx.viewport_id()).and_then(|guard| guard.request) @@ -306,6 +319,8 @@ fn mark_pending(app: &mut VectorcraftApp, ctx: &Context) { app.ime_compositions.insert(viewport, IncomingComposition::Stale); } +/// Retire the guard's request and clear its deferred and pending flags while keeping the guard +/// blocked until an eligible route allocates a replacement. fn suspend(app: &mut VectorcraftApp, ctx: &Context) { if let Some(guard) = app.ime_resets.get_mut(&ctx.viewport_id()) { guard.request = None; @@ -378,6 +393,8 @@ pub(crate) fn controls_without_editor(app: &mut VectorcraftApp, ctx: &Context) { } } +/// Clear the previous pass's caret rectangle and published descriptor identity before this +/// viewport's UI runs. pub(crate) fn begin_ui(app: &mut VectorcraftApp, ctx: &Context) { app.ime_caret_rect = None; let id = output_id(ctx.viewport_id()); @@ -418,15 +435,21 @@ pub(crate) fn finish_ui(app: &mut VectorcraftApp, ctx: &Context) { } } +/// Whether this pass can publish a custom editor and the descriptor recorded during the pass is the +/// one actually published. fn eligible(app: &VectorcraftApp, ctx: &Context, custom: Option) -> bool { let actual = ctx.output(|output| output.ime.as_ref().map(OutputContext::from)); eligible_with(app, ctx, custom, actual) } +/// Require a custom descriptor, a matching actual output and an eligible route before an IME guard +/// may settle or re-arm. fn eligible_with(app: &VectorcraftApp, ctx: &Context, custom: Option, actual: Option) -> bool { custom.is_some() && actual == custom && pass_eligible(app, ctx) } +/// Whether a guard needs a new fence because it was suspended, deferred or pending, its destination +/// changed, or its published descriptor identity changed. fn needs_replacement(guard: &ResetGuard, destination: Option, custom: OutputContext) -> bool { guard.suspended || guard.deferred || guard.pending || guard.destination != destination || guard.output.is_some_and(|previous| previous != custom) } @@ -443,6 +466,8 @@ pub(crate) fn finish_logic(app: &mut VectorcraftApp, ctx: &Context) { } } +/// Read the current Type-tool target and its anchor and caret offsets, or `None` when no text edit +/// target or selection options are available. fn incoming_selection(app: &mut VectorcraftApp) -> Option { let view = app.view_info(); let target = target(&mut app.session, view)?; @@ -548,6 +573,9 @@ fn sync_incoming_unbound(app: &mut VectorcraftApp, ctx: &Context) { sync_incoming_impl(app, ctx, Fence::Unbound); } +/// Settle an incoming composition whose selection, composing state, session or context no longer +/// matches the recorded expectation, applying the fence policy; with no recorded composition, also +/// retire or replace a guard whose destination, commit stamp, context or pending state changed. fn sync_incoming_impl(app: &mut VectorcraftApp, ctx: &Context, fence: Fence) { let previous = app.ime_compositions.get(&ctx.viewport_id()).copied(); let expected = match previous { @@ -774,6 +802,8 @@ pub(crate) fn write_output(ctx: &Context, update: Option, rect mod tests { use super::*; + /// A changed context generation or IME purpose for the same owner requires a replacement reset, + /// while an unchanged descriptor keeps the existing request. #[test] fn same_owner_new_generation_or_purpose_requires_a_replacement_request() { let owner = Id::unique("vectorcraft-ime-output-context-test"); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3dbfa4a1a..9b2467e3f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1931,6 +1931,8 @@ impl VectorcraftApp { || self.pending_screenshots.iter().any(|p| p.slot.is_none()) } + /// Capture the viewport identity and geometry (content and inner rectangles, pixels per point and + /// zoom) whose change advances the screenshot epoch. fn geometry_stamp(ctx: &egui::Context) -> GeometryStamp { GeometryStamp { viewport: ctx.viewport_id(), @@ -2029,6 +2031,8 @@ impl VectorcraftApp { } } + /// Identify the document, modal dialog and focused widget that the current accepted input batch + /// belongs to. fn input_generation(&self) -> InputGeneration { InputGeneration { document: self.session.active().map_or(0, |document| document.uid), diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 0f3bda216..045f45774 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -25,6 +25,8 @@ struct RootGroupDrag { generation: u64, } +/// Restore the pre-drag panel layout and cancel the legacy docking drag when one is recorded; +/// return whether a drag was cancelled. fn cancel_legacy_drag(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { let key = egui::Id::unique("vectorcraft-panel-docking").with("legacy-origin"); let previous = ctx.data_mut(|data| data.remove_temp::(key)); @@ -925,6 +927,8 @@ fn tab_area_double_clicked( (at.x >= tabs && at.x < group.strip.right() - 24.0).then_some(group.shown) } +/// Draw the shared dock, finish or cancel a root-group drag, collapse groups, run panel commands +/// and report drop previews; return whether a dock layout exists. pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { let root_key = egui::Id::unique("vectorcraft-root-group-drag"); if let Some(drag) = ui.ctx().data(|data| data.get_temp::(root_key)) { diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index b05fe509f..d8780ddfb 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -970,6 +970,7 @@ fn two_stacked_groups_both_collapsed_are_both_as_short_as_their_tabs() { } } +/// Return the centre of a panel's tab in the shared dock test harness. fn tab_center(h: &Harness<'static, VectorcraftApp>, panel: &str) -> Pos2 { h.ctx.read_response(egui::Id::unique("vectorcraft-panel-docking").with(("tab", &panel.to_string()))).unwrap().rect.center() } diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index 44e92f0d8..04ca1bae6 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -1537,6 +1537,8 @@ mod tests { } } + /// Both stroke rows expose named Weight inputs, and the drop-shadow effect names its Blur input and + /// Color radio button. #[test] fn inline_stroke_weight_and_effect_fields_are_named() { use egui_kittest::kittest::Queryable; diff --git a/crates/ui-egui/src/panels/gradient.rs b/crates/ui-egui/src/panels/gradient.rs index b7c78f007..427cb5f5f 100644 --- a/crates/ui-egui/src/panels/gradient.rs +++ b/crates/ui-egui/src/panels/gradient.rs @@ -1211,6 +1211,7 @@ mod tests { assert_eq!((app.session.tool_id(), app.session.tool_options()), ("eyedropper", json!({"stop": "gradient", "brushKeyColor": false}))); } + /// The gradient panel names its Angle and Aspect Ratio inputs. #[test] fn angle_and_aspect_fields_are_named() { use egui_kittest::kittest::Queryable; diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 605201008..20a4bbfbb 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -126,10 +126,12 @@ struct ColumnDrag { ids: Vec, } +/// Stable id for the layers panel's per-name UI state. fn key(name: &str) -> egui::Id { egui::Id::unique(("layers-panel", name)) } +/// Temp-memory key for the layer rename editor. fn rename_id() -> egui::Id { egui::Id::unique("layers-rename") } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index d2296d770..33132c442 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -464,6 +464,7 @@ pub(crate) fn live_run(app: &mut VectorcraftApp, label: &str, cmd: &str, params: pub(crate) fn pstate(ctx: &egui::Context, key: &str) -> T { ctx.data(|d| d.get_temp::(egui::Id::unique(("panel-state", key)))).unwrap_or_default() } +/// Store per-panel UI state in egui temp memory under the `panel-state` namespace. pub(crate) fn set_pstate(ctx: &egui::Context, key: &str, v: T) { ctx.data_mut(|d| d.insert_temp(egui::Id::unique(("panel-state", key)), v)); } diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index a60b7f4b2..e8f8ed9f8 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -626,6 +626,7 @@ mod tests { assert_eq!(constrained(0.0, 50.0, Some(10.0), None), (10.0, 50.0)); } + /// The Transform panel names its Rotate and Shear inputs. #[test] fn rotate_and_shear_fields_are_named() { use egui_kittest::kittest::Queryable; diff --git a/crates/ui-egui/src/scrub.rs b/crates/ui-egui/src/scrub.rs index cfb8f6e35..832b82870 100644 --- a/crates/ui-egui/src/scrub.rs +++ b/crates/ui-egui/src/scrub.rs @@ -30,6 +30,7 @@ fn label_id() -> Id { Id::unique("numeric-field-label") } +/// Temp-memory key for the numeric-field scrub preference. fn enabled_id() -> Id { Id::unique("numeric-field-scrub-enabled") } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index ab1dc15be..6b7f8632f 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -498,6 +498,8 @@ fn apply_type_event( } } +/// Process one frame's events against the keymap at most once per context, viewport and frame, so +/// reruns cannot fire a shortcut or tool key twice. pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { // One UI pass takes a frame's events. A discarded rerun and a test helper that also calls // `logic` see the same events again; they must not fire a shortcut or tool key twice. The diff --git a/crates/ui-egui/src/sysclip.rs b/crates/ui-egui/src/sysclip.rs index 760890038..28135d45b 100644 --- a/crates/ui-egui/src/sysclip.rs +++ b/crates/ui-egui/src/sysclip.rs @@ -46,6 +46,7 @@ pub(crate) struct Publishing { } impl Publishing { + /// Whether the latest Copy or Cut still has formats waiting to be written to the system clipboard. pub(crate) fn is_pending(&self) -> bool { self.pending } diff --git a/crates/ui-egui/src/tests_lifecycle.rs b/crates/ui-egui/src/tests_lifecycle.rs index 8dbdd503d..19ede5501 100644 --- a/crates/ui-egui/src/tests_lifecycle.rs +++ b/crates/ui-egui/src/tests_lifecycle.rs @@ -8,6 +8,7 @@ use vectorcraft_engine::Session; use crate::VectorcraftApp; +/// Build a new 400 by 300 document with one selected rectangle for lifecycle fixtures. fn app() -> VectorcraftApp { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); @@ -16,10 +17,12 @@ fn app() -> VectorcraftApp { app } +/// Count child objects across every layer of the active document. fn objects(app: &VectorcraftApp) -> usize { app.session.active().map_or(0, |st| st.doc.layers.iter().map(|l| l.children().map_or(0, |c| c.len())).sum()) } +/// Return the number of undo entries in the active document's history. fn undo(app: &VectorcraftApp) -> usize { app.session.active().map_or(0, |st| st.history.undo.len()) } @@ -34,6 +37,8 @@ fn screen() -> egui::Rect { /// (which only pastes when the app copied text itself). const PASTE_SVG: &str = r#""#; +/// A discarded rerun must execute a duplicate paste exactly once while the host iteration still +/// ticks once. #[test] fn a_discarded_rerun_ticks_once_and_runs_a_duplicate_once() { let mut app = app(); @@ -75,6 +80,8 @@ fn a_discarded_rerun_ticks_once_and_runs_a_duplicate_once() { assert_eq!(undo(&app), before_undo + 1, "one undo step"); } +/// Logic-only iterations advance the host clock but never read the stale raw events a hidden window +/// replays. #[test] fn logic_only_iterations_tick_without_replaying_input() { let mut app = app(); @@ -103,6 +110,8 @@ fn logic_only_iterations_tick_without_replaying_input() { assert_eq!(undo(&app), before_undo, "no command ran hidden"); } +/// A visible frame consumes a paste once; later logic-only iterations with the same raw input must +/// not replay it. #[test] fn a_consumed_event_is_not_replayed_by_a_later_logic_iteration() { let mut app = app(); @@ -157,6 +166,8 @@ fn ime_frame( out.platform_output.ime } +/// Create a blank 400 by 300 document with one text object and return the app and node id; +/// placeholder text is disabled so the object is the only text. fn app_with_text(text: &str) -> (VectorcraftApp, u64) { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.session.prefs.placeholder_text = false; @@ -165,6 +176,7 @@ fn app_with_text(text: &str) -> (VectorcraftApp, u64) { (app, id) } +/// Read the plain text of the given text node in these lifecycle fixtures. fn plain(app: &VectorcraftApp, id: u64) -> String { match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind { vectorcraft_doc::NodeKind::Text(t) => t.plain_text(), @@ -172,6 +184,8 @@ fn plain(app: &VectorcraftApp, id: u64) -> String { } } +/// Send a pointer move and a primary press/release pair through the production tick then UI +/// lifecycle. fn click(app: &mut VectorcraftApp, ctx: &egui::Context, at: egui::Pos2) { let button = |pos: egui::Pos2, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; @@ -191,6 +205,7 @@ fn begin_typing(app: &mut VectorcraftApp, ctx: &egui::Context, id: u64) -> egui: at } +/// Build an IME pre-edit event whose active clause is an empty character range at `chars`. fn preedit(t: &str, chars: usize) -> Event { Event::Ime(egui::ImeEvent::Preedit { text: t.into(), active_range_chars: Some(chars..chars) }) } @@ -210,6 +225,7 @@ fn interruption_editor() -> (VectorcraftApp, egui::Context, u64, egui::Pos2) { (app, ctx, id, point) } +/// Build a one-character or one-byte IME delete-surrounding event according to `bytes`. fn surrounding_delete(bytes: bool) -> Event { Event::Ime(if bytes { egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 } @@ -218,10 +234,13 @@ fn surrounding_delete(bytes: bool) -> Event { }) } +/// Build the reset-completed event for the app's current pending IME request. fn reset_marker(app: &VectorcraftApp, ctx: &egui::Context) -> Event { Event::ImeResetCompleted { request_id: crate::ime::pending_request(app, ctx).expect("a real interruption requested a fence") } } +/// Click the given point, assert the resulting custom descriptor publishes the real reset request, +/// and return that request. fn pointer_reset(app: &mut VectorcraftApp, ctx: &egui::Context, point: egui::Pos2) -> egui::ImeResetId { let press = Event::PointerButton { pos: point, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }; let output = ime_frame(app, ctx, vec![Event::PointerMoved(point), press], |_| {}).expect("the destination still edits text"); @@ -232,10 +251,13 @@ fn pointer_reset(app: &mut VectorcraftApp, ctx: &egui::Context, point: egui::Pos request } +/// Build a pressed, non-repeat key event with the given key and modifiers. fn pressed(key: egui::Key, modifiers: egui::Modifiers) -> Event { Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers } } +/// A replacement fence admits an immediate fresh suffix without an old terminal, for empty and non- +/// empty pre-edits and for same- and next-batch ordering. #[test] fn reset_fence_without_an_old_terminal_admits_the_immediate_fresh_suffix() { for terminal_pending in [false, true] { @@ -261,6 +283,8 @@ fn reset_fence_without_an_old_terminal_admits_the_immediate_fresh_suffix() { } } +/// Only the newest reset request for this viewport, context and destination completes; old, foreign +/// and duplicate markers cannot retire it or admit input. #[test] fn newest_full_reset_id_rejects_old_foreign_and_duplicate_controls() { let (mut app, ctx, id, point) = interruption_editor(); @@ -308,6 +332,8 @@ fn newest_full_reset_id_rejects_old_foreign_and_duplicate_controls() { assert_eq!(plain(&app, id), "abQRcdé", "a duplicate marker cannot retire the new live preview"); } +/// A pressed navigation key retires the old fence, moves the caret once, and the final destination +/// publishes a replacement fence before its suffix is accepted. #[test] fn pressed_navigation_retires_the_old_fence_until_the_real_destination_is_known() { let (mut app, ctx, id, point) = interruption_editor(); @@ -334,6 +360,8 @@ fn pressed_navigation_retires_the_old_fence_until_the_real_destination_is_known( assert_eq!(plain(&app, id), "abがcdé"); } +/// A real permitted command key still runs while composing or after retirement; the cached +/// composing flag must not swallow Cmd+R. #[test] fn a_pressed_command_runs_after_composition_retirement_and_after_its_fence() { let (mut app, ctx, id, point) = interruption_editor(); @@ -353,6 +381,8 @@ fn a_pressed_command_runs_after_composition_retirement_and_after_its_fence() { assert_eq!(app.ui.view.rulers, !rulers); } +/// A request allocated in a discarded pass is retired; an unavailable final pass leaves no token, +/// and the same caret re-arms once when visible again. #[test] fn discarded_request_then_hidden_output_rearms_the_same_caret_once() { let (mut app, ctx, id, point) = interruption_editor(); @@ -397,6 +427,8 @@ fn discarded_request_then_hidden_output_rearms_the_same_caret_once() { assert_eq!(plain(&app, id), "abがcdé"); } +/// Stock text remains editable while custom fences retire; returning from stock focus and losing +/// window focus each allocate a fresh fence. #[test] fn stock_field_and_focus_loss_retire_custom_fences_without_consuming_stock_text() { let (mut app, ctx, id, point) = interruption_editor(); @@ -444,6 +476,8 @@ fn stock_field_and_focus_loss_retire_custom_fences_without_consuming_stock_text( assert_eq!(plain(&app, id), "aがbcdé"); } +/// Text, paste and cut events before a deferred pointer's real handler are dropped; the pointer +/// still moves the caret and publishes a replacement fence. #[test] fn a_deferred_pointer_drops_text_and_clipboard_until_its_real_handler_and_fence() { for paste in [false, true] { @@ -476,6 +510,8 @@ fn a_deferred_pointer_drops_text_and_clipboard_until_its_real_handler_and_fence( } } +/// A hidden route allocates no reset; an old marker and pointer suffix are inert and cannot lend a +/// token to a later stock field. #[test] fn hidden_pointer_and_marker_leave_no_token_for_a_later_stock_field() { let (mut app, ctx, id, point) = interruption_editor(); @@ -516,6 +552,8 @@ fn hidden_pointer_and_marker_leave_no_token_for_a_later_stock_field() { assert!(crate::ime::pending_request(&app, &ctx).is_none()); } +/// A hidden logic pass settles a changed composition without allocating, leaving no token for a +/// later stock field. #[test] fn hidden_logic_with_a_changed_composition_leaves_no_token_for_a_later_stock_field() { let (mut app, ctx, id, _) = interruption_editor(); @@ -542,6 +580,8 @@ fn hidden_logic_with_a_changed_composition_leaves_no_token_for_a_later_stock_fie assert!(stock.reset_request.is_none(), "hidden logic leaves no token for the stock field"); } +/// Home suspends before input, so an interrupted commit allocates no reset and its old marker +/// cannot rearm or leak into a stock field. #[test] fn home_with_an_interrupted_commit_allocates_no_reset_and_leaves_no_stock_token() { let (mut app, ctx, id, point) = interruption_editor(); @@ -573,6 +613,8 @@ fn home_with_an_interrupted_commit_allocates_no_reset_and_leaves_no_stock_token( assert!(crate::ime::pending_request(&app, &ctx).is_none(), "the old marker cannot rearm a retired fence"); } +/// A command batch that opens the palette allocates no speculative reset; the palette's stock field +/// receives no custom token. #[test] fn command_palette_opened_by_a_commit_batch_gets_no_speculative_reset() { let (mut app, ctx, id, point) = interruption_editor(); @@ -598,6 +640,8 @@ fn command_palette_opened_by_a_commit_batch_gets_no_speculative_reset() { assert_eq!(plain(&app, id), "axbcdé", "the application text lands at the fenced caret"); } +/// Presses that end on an invalid destination release no custom descriptor and allocate no reset +/// for the stock field. #[test] fn multiple_pointer_presses_ending_on_an_invalid_destination_leave_no_stock_token() { let (mut app, ctx, id, point) = interruption_editor(); @@ -627,6 +671,8 @@ fn multiple_pointer_presses_ending_on_an_invalid_destination_leave_no_stock_toke assert!(stock.reset_request.is_none(), "the invalidated final pointer leaves no stock token"); } +/// Cmd+X and Cmd+V in a deferred command batch are dropped until the replacement fence, leaving the +/// text and undo history unchanged. #[test] fn deferred_command_key_forms_drop_cmd_x_and_cmd_v_until_the_fence() { let (mut app, ctx, id, point) = interruption_editor(); @@ -655,6 +701,8 @@ fn deferred_command_key_forms_drop_cmd_x_and_cmd_v_until_the_fence() { assert_ne!(first, second); } +/// A live composition alone requests no reset; a stock field publishes no custom token, and +/// returning to Type accepts fresh composition. #[test] fn a_stock_field_with_an_active_composition_but_no_guard_gets_no_reset_request() { let (mut app, ctx, id, _) = interruption_editor(); @@ -687,6 +735,8 @@ fn a_stock_field_with_an_active_composition_but_no_guard_gets_no_reset_request() assert_eq!(plain(&app, id), "aがbcdé", "a clear adapter accepts fresh composition without a stale latch"); } +/// Opening a modal after the canvas suspends the fence; returning to the same caret allocates a new +/// fence and old input stays stale. #[test] fn a_modal_dialog_suspends_a_live_fence_and_the_same_caret_rearms() { let (mut app, ctx, id, point) = interruption_editor(); @@ -711,6 +761,8 @@ fn a_modal_dialog_suspends_a_live_fence_and_the_same_caret_rearms() { assert_eq!(plain(&app, id), "aがbcdé"); } +/// An absent pass starts a new context generation for the same owner and publishes a replacement +/// fence on return. #[test] fn a_same_owner_absent_pass_gets_a_new_generation_and_replacement_fence() { let (mut app, ctx, id, point) = interruption_editor(); @@ -731,6 +783,8 @@ fn a_same_owner_absent_pass_gets_a_new_generation_and_replacement_fence() { assert_eq!(plain(&app, id), "aがbcdé"); } +/// An occluded pass leaves the marker inert and keeps the guard suspended until a visible pass re- +/// arms it. #[test] fn geometry_loss_before_a_marker_keeps_the_fence_suspended_until_reactivation() { let (mut app, ctx, id, point) = interruption_editor(); @@ -757,6 +811,8 @@ fn geometry_loss_before_a_marker_keeps_the_fence_suspended_until_reactivation() assert_eq!(plain(&app, id), "aがbcdé"); } +/// Settling a preview through an application selection change allocates exactly one fence for the +/// post-settlement destination, with no preflight retry. #[test] fn settling_a_preview_without_a_pointer_allocates_one_request_after_the_commit() { let (mut app, ctx, id, _) = interruption_editor(); @@ -778,6 +834,8 @@ fn settling_a_preview_without_a_pointer_allocates_one_request_after_the_commit() assert_eq!(plain(&app, id), "aがbcdé"); } +/// Marked text left without a recorded composition triggers an interrupting descriptor whose +/// request-bound fence admits only fresh input. #[test] fn an_untokened_marked_text_interruption_binds_a_request() { let (mut app, ctx, id, _) = interruption_editor(); @@ -793,6 +851,8 @@ fn an_untokened_marked_text_interruption_binds_a_request() { assert_eq!(plain(&app, id), "abcdが"); } +/// Logic inside a full UI pass preserves the fence; repeated standalone logic keeps it blocked and +/// the first UI return re-arms once. #[test] fn full_pass_logic_keeps_its_fence_but_standalone_logic_rearms_once() { let (mut app, ctx, id, point) = interruption_editor(); @@ -834,6 +894,8 @@ fn full_pass_logic_keeps_its_fence_but_standalone_logic_rearms_once() { assert_eq!(plain(&app, id), "aがbcdé"); } +/// An application edit that preserves the caret but changes committed text invalidates the fence +/// before its raw suffix runs. #[test] fn same_caret_external_edit_invalidates_a_fence_before_its_raw_suffix() { let (mut app, ctx, id, point) = interruption_editor(); @@ -858,6 +920,8 @@ fn same_caret_external_edit_invalidates_a_fence_before_its_raw_suffix() { assert_eq!(plain(&app, id), "Aがbcdé"); } +/// An auxiliary viewport observes only its own guard; a replacement egui context rejects the old +/// request and publishes a new one. #[test] fn a_replaced_context_and_auxiliary_ui_cannot_reuse_the_root_fence() { let (mut app, ctx, id, point) = interruption_editor(); @@ -895,6 +959,8 @@ fn a_replaced_context_and_auxiliary_ui_cannot_reuse_the_root_fence() { assert_eq!(plain(&app, id), "aがbcdé"); } +/// A hidden logic pass neither interrupts an intact composition nor retries acknowledgement; non- +/// empty and old-terminal input stays rejected. #[test] fn hidden_logic_preserves_an_uninterrupted_composition_and_no_ack_stays_closed() { let (mut app, ctx, id, point) = interruption_editor(); @@ -917,6 +983,8 @@ fn hidden_logic_preserves_an_uninterrupted_composition_and_no_ack_stays_closed() } } +/// A real pointer interruption rejects following-batch IME deletes and commits without previewing +/// them, then admits fresh input only after its fence. #[test] fn pointer_interruption_rejects_next_batch_ime_mutations_and_allows_fresh_input() { for bytes in [false, true] { @@ -949,6 +1017,8 @@ fn pointer_interruption_rejects_next_batch_ime_mutations_and_allows_fresh_input( } } +/// Late composition input after clicking another text object cannot mutate either object; the +/// replacement target's fence admits only its own commit. #[test] fn target_switch_rejects_late_composition_input_before_mutating_the_new_text() { for bytes in [false, true] { @@ -986,6 +1056,8 @@ fn target_switch_rejects_late_composition_input_before_mutating_the_new_text() { } } +/// A composition's own delete plus commit, and a non-composing pointer commit, remain accepted +/// single undoable edits. #[test] fn valid_composition_delete_commit_and_noncomposing_pointer_commit_stay_accepted() { for bytes in [false, true] { @@ -1010,6 +1082,8 @@ fn valid_composition_delete_commit_and_noncomposing_pointer_commit_stay_accepted assert_eq!(plain(&app, id), "abcd"); } +/// A stale non-empty pre-edit stays rejected across idle frames until its matching fence; only +/// post-fence input changes text. #[test] fn a_stale_nonempty_preedit_stays_rejected_until_its_matching_fence() { let (mut app, ctx, id, point) = interruption_editor(); @@ -1034,6 +1108,8 @@ fn a_stale_nonempty_preedit_stays_rejected_until_its_matching_fence() { assert_eq!(plain(&app, id), text); } +/// An application caret change settles the preview at its old position and rejects old input +/// without changing the document. #[test] fn an_external_caret_change_keeps_preview_at_its_old_position_and_rejects_old_input() { let (mut app, ctx, id, _) = interruption_editor(); @@ -1051,6 +1127,8 @@ fn an_external_caret_change_keeps_preview_at_its_old_position_and_rejects_old_in assert_eq!(undo(&app), steps); } +/// An application edit while typing makes the payload and following commit `Other` and publishes +/// the document's current text, not the stale session. #[test] fn external_edit_while_typing_is_other_and_ime_commit_uses_the_document_text() { let (mut app, id) = app_with_text("Same"); @@ -1083,6 +1161,7 @@ fn external_edit_while_typing_is_other_and_ime_commit_uses_the_document_text() { assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("XYZy", 4, 4)); } +/// A dialog opened after the canvas takes IME ownership away from the Type tool on that pass. #[test] fn a_dialog_opened_after_the_canvas_takes_the_ime_away_from_the_type_tool() { let (mut app, id) = app_with_text("Same"); @@ -1103,6 +1182,8 @@ fn a_dialog_opened_after_the_canvas_takes_the_ime_away_from_the_type_tool() { assert!(during.is_none(), "the canvas must not own the IME while a dialog opened after it: {during:?}"); } +/// Home removes the Type tool owner, clears marked text and asks the system IME to discard its +/// composition. #[test] fn home_takes_down_a_live_composition() { let (mut app, id) = app_with_text("Same"); @@ -1121,6 +1202,8 @@ fn home_takes_down_a_live_composition() { assert!(!app.session.tool_composing(), "no stale composition survives Home"); } +/// Closing the active document removes the IME owner, clears marked text and asks the system IME to +/// discard its composition. #[test] fn closing_the_document_takes_down_a_live_composition() { let (mut app, id) = app_with_text("Same"); @@ -1137,6 +1220,8 @@ fn closing_the_document_takes_down_a_live_composition() { assert!(app.take_ime_discard(), "the system IME is told to drop its marked text"); } +/// A commit whose text the document rewrites through smart quotes or control filtering is reported +/// as `Other`, not `InputMethod`. #[test] fn an_ime_commit_the_document_rewrites_is_other() { let (mut app, id) = app_with_text("Same"); @@ -1158,6 +1243,8 @@ fn an_ime_commit_the_document_rewrites_is_other() { assert_eq!(filtered.change_cause, egui::output::ImeChangeCause::Other, "filtering is not an IME insertion"); } +/// Smart-quote replacement examines the character before the pre-edit, so the rewritten commit is +/// reported as `Other`. #[test] fn an_ime_quote_commit_uses_the_character_before_its_pre_edit() { let (mut app, id) = app_with_text("a"); @@ -1215,6 +1302,8 @@ fn ime_delete_surrounding_after_end_typing_keeps_the_next_commit_input_method() assert_eq!(committed.change_cause, egui::output::ImeChangeCause::InputMethod, "a caret move is not an application-side reset"); } +/// Reloading a document keeps its uid and text-node id but changes the edit-session replacement +/// epoch. #[test] fn a_reloaded_document_gets_a_new_ime_replacement_epoch() { let (mut app, id) = app_with_text("Same"); @@ -1372,10 +1461,12 @@ fn an_unavailable_ime_boundary_carries_the_document_revision() { assert!(after.unavailable_revision().is_some_and(|after| after > revision), "the document revision advanced"); } +/// Build a primary-pointer button event at `at` with the requested press state. fn primary_button(at: egui::Pos2, pressed: bool) -> Event { Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() } } +/// Build a left-arrow key event marked as a physical key press or release. fn left_key(pressed: bool) -> Event { Event::Key { key: egui::Key::ArrowLeft, physical_key: Some(egui::Key::ArrowLeft), pressed, repeat: false, modifiers: Default::default() } } @@ -1432,6 +1523,8 @@ fn a_pointer_before_same_batch_terminal_input_preserves_the_interrupted_text() { } } +/// Application navigation before a same-batch pending terminal is applied once; the old +/// delete/commit suffix is rejected and only input after the fence is admitted. #[test] fn application_navigation_before_same_batch_pending_terminal_rejects_its_mutations() { for bytes in [false, true] { @@ -1485,6 +1578,8 @@ fn terminal_before_a_same_batch_pointer_is_accepted_in_both_composition_states() } } +/// A terminal followed by application navigation keeps the valid commit and runs the navigation; an +/// active IME's own navigation does not interrupt it. #[test] fn terminal_before_navigation_is_accepted_and_active_ime_navigation_stays_owned() { for bytes in [false, true] { @@ -1505,6 +1600,8 @@ fn terminal_before_navigation_is_accepted_and_active_ime_navigation_stays_owned( } } +/// A pointer marker without an observed old composition does not reject a direct commit; terminal +/// text still executes before pointer placement. #[test] fn a_pointer_marker_without_an_old_composition_does_not_reject_a_direct_commit() { let (mut app, ctx, id, point) = interruption_editor(); @@ -1517,6 +1614,8 @@ fn a_pointer_marker_without_an_old_composition_does_not_reject_a_direct_commit() assert_eq!(plain(&app, id), "abcd"); } +/// A move within an already-held gesture before a same-batch terminal interrupts the old +/// composition; the retained move reaches the tool and only the live preview commits. #[test] fn a_held_pointer_move_before_same_batch_terminal_also_interrupts_the_old_composition() { for bytes in [false, true] { diff --git a/crates/ui-egui/src/tests_screenshots.rs b/crates/ui-egui/src/tests_screenshots.rs index b6ac2a2ee..204cf62ea 100644 --- a/crates/ui-egui/src/tests_screenshots.rs +++ b/crates/ui-egui/src/tests_screenshots.rs @@ -14,6 +14,8 @@ use crate::{Services, VectorcraftApp}; const SCREEN: egui::Vec2 = vec2(320.0, 200.0); +/// Build a screenshot test app with a write service, control channel and temporary directory on a +/// 100 by 100 document. fn app(tag: &str) -> (VectorcraftApp, std::sync::mpsc::Sender, std::path::PathBuf) { let dir = vectorcraft_testkit::temp_dir(tag); let services = @@ -24,12 +26,14 @@ fn app(tag: &str) -> (VectorcraftApp, std::sync::mpsc::Sender, s (app, tx, dir) } +/// Submit a `ui.screenshot` control request and return its reply receiver. fn request(tx: &std::sync::mpsc::Sender, params: serde_json::Value) -> std::sync::mpsc::Receiver { let (req, reply) = ControlRequest::new("ui.screenshot", params); tx.send(req).unwrap(); reply } +/// Run one tick-then-UI host frame at the requested screen size and discard texture deltas. fn frame_at(app: &mut VectorcraftApp, ctx: &egui::Context, size: egui::Vec2, events: Vec) -> egui::FullOutput { let raw = egui::RawInput { events, screen_rect: Some(Rect::from_min_size(Pos2::ZERO, size)), ..Default::default() }; app.begin_host_iteration(); @@ -41,6 +45,7 @@ fn frame_at(app: &mut VectorcraftApp, ctx: &egui::Context, size: egui::Vec2, eve out } +/// Run one host frame at the standard 320 by 200 screenshot size. fn frame(app: &mut VectorcraftApp, ctx: &egui::Context) -> egui::FullOutput { frame_at(app, ctx, SCREEN, vec![]) } @@ -85,6 +90,8 @@ fn admit(app: &mut VectorcraftApp, ctx: &egui::Context) { allow_due(app); } +/// One admitted capture completes once, writes the named file, replies with its size and releases +/// the reservation. #[test] fn a_request_saves_one_file_and_replies_once() { let (mut app, tx, dir) = app("screenshot-one"); @@ -108,6 +115,7 @@ fn a_request_saves_one_file_and_replies_once() { let _ = std::fs::remove_dir_all(dir); } +/// A timed-out request replies once and writes nothing; a later completion is inert. #[test] fn a_timed_out_request_writes_nothing_and_a_late_completion_is_inert() { let (mut app, tx, dir) = app("screenshot-timeout"); @@ -133,6 +141,8 @@ fn a_timed_out_request_writes_nothing_and_a_late_completion_is_inert() { let _ = std::fs::remove_dir_all(dir); } +/// A completion collected after the deadline is neither encoded nor written and replies once with +/// the no-frame error. #[test] fn a_completion_that_arrives_after_the_deadline_is_not_saved() { let (mut app, tx, dir) = app("screenshot-deadline"); @@ -157,6 +167,8 @@ fn a_completion_that_arrives_after_the_deadline_is_not_saved() { let _ = std::fs::remove_dir_all(dir); } +/// Admission refuses a fifth request without evicting the four pending ones and releases capacity +/// when one completes. #[test] fn admission_rejects_more_than_four_requests_without_evicting_one_and_releases_a_finished_one() { let (mut app, tx, dir) = app("screenshot-admission"); @@ -188,6 +200,8 @@ fn admission_rejects_more_than_four_requests_without_evicting_one_and_releases_a let _ = std::fs::remove_dir_all(dir); } +/// Pixels with a different size are stale, produce no reply or file, and a retry with the matching +/// image completes the request. #[test] fn an_image_of_another_size_is_stale_and_retried() { let (mut app, tx, dir) = app("screenshot-size"); @@ -213,6 +227,8 @@ fn an_image_of_another_size_is_stale_and_retried() { let _ = std::fs::remove_dir_all(dir); } +/// Changing and restoring the DPI still invalidates in-flight pixels by epoch; the retry for the +/// current geometry succeeds. #[test] fn a_geometry_change_and_change_back_still_rejects_the_old_pixels() { let (mut app, tx, dir) = app("screenshot-epoch"); @@ -243,6 +259,8 @@ fn a_geometry_change_and_change_back_still_rejects_the_old_pixels() { let _ = std::fs::remove_dir_all(dir); } +/// Switching documents while a capture is in flight fails the request instead of saving new- +/// document pixels. #[test] fn a_document_change_while_a_capture_is_in_flight_fails_instead_of_saving_it() { let (mut app, tx, dir) = app("screenshot-document"); @@ -266,6 +284,8 @@ fn a_document_change_while_a_capture_is_in_flight_fails_instead_of_saving_it() { let _ = std::fs::remove_dir_all(dir); } +/// Switching documents during settle fails at issuance without ever issuing a capture or keeping a +/// reservation. #[test] fn a_document_change_during_the_settle_fails_at_issuance_without_issuing_a_capture() { let (mut app, tx, dir) = app("screenshot-document-settle"); @@ -292,6 +312,7 @@ fn a_document_change_during_the_settle_fails_at_issuance_without_issuing_a_captu let _ = std::fs::remove_dir_all(dir); } +/// A second stale-sized completion answers the request once with an error and writes nothing. #[test] fn a_second_stale_completion_replies_once_with_an_error_and_writes_nothing() { let (mut app, tx, dir) = app("screenshot-stale-twice"); @@ -315,6 +336,8 @@ fn a_second_stale_completion_replies_once_with_an_error_and_writes_nothing() { let _ = std::fs::remove_dir_all(dir); } +/// A hidden window's logic pass requests a hidden paint instead of issuing the queued capture; the +/// capture remains pending. #[test] fn a_hidden_window_is_asked_to_paint_the_queued_capture() { let (mut app, tx, dir) = app("screenshot-hidden"); @@ -336,6 +359,7 @@ fn a_hidden_window_is_asked_to_paint_the_queued_capture() { let _ = std::fs::remove_dir_all(dir); } +/// An oversized claimed image is refused from the reply-encoding budget before any copy or encode. #[test] fn a_data_reply_over_the_encoding_budget_is_refused_before_encoding() { let (mut app, _tx, _dir) = app("screenshot-encoding"); @@ -347,6 +371,8 @@ fn a_data_reply_over_the_encoding_budget_is_refused_before_encoding() { assert!(answer["error"].as_str().unwrap_or_default().contains("reply-encoding limit"), "{answer}"); } +/// A path plus data capture writes the file and omits an oversized inline PNG; without a path the +/// same request fails. #[test] fn a_path_plus_data_capture_writes_the_file_and_omits_an_oversized_inline_png() { let (mut app, _tx, dir) = app("screenshot-path-data"); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 5b1a20e57..86a89ea11 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -16,12 +16,15 @@ use crate::{icons, scrub}; struct HashOnly(T); impl std::hash::Hash for HashOnly { + /// Hash only the wrapped id salt, deliberately omitting the debug-only identity that egui 0.37's + /// `AsIdSalt` requires. fn hash(&self, state: &mut H) { self.0.hash(state); } } impl std::fmt::Debug for HashOnly { + /// Format any `HashOnly` salt as the fixed text `widget id` so ids do not expose their value. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("widget id") } @@ -246,6 +249,8 @@ struct NextLabel { next_auto_id: egui::Id, } +/// Record a label's widget id, viewport, pass, position and next auto id so only the immediately +/// following field can take the label. fn remember_next_label(ui: &Ui, id: egui::Id) { let label = NextLabel { id, @@ -3483,6 +3488,7 @@ pub(crate) mod look_tests { assert!(lit(&hovered, row), "the row under the pointer is lit: {row:?}"); } + /// Each painted row label names its own numeric field rather than the field's internal salt. #[test] fn a_label_rows_input_is_named_by_the_row_label() { use egui_kittest::kittest::Queryable; @@ -3503,6 +3509,7 @@ pub(crate) mod look_tests { assert!(h.query_by_role_and_label(egui::accesskit::Role::TextInput, "plugin-angle").is_none(), "no internal widget id"); } + /// A label followed by a checkbox or other widget does not name a later field. #[test] fn a_label_does_not_name_a_field_after_another_widget() { use egui_kittest::kittest::Queryable; @@ -3559,6 +3566,7 @@ pub(crate) mod look_tests { } } + /// The Enter and focus guard's reconstructed id equals the id egui gives the ComboBox button. #[test] fn a_combos_keyboard_guard_reads_the_combos_own_button_id() { crate::i18n::set_current(crate::i18n::Lang::EN);