From 669fe6734a6863f0061d1480f64e1de4db6a7f1c Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 25 Oct 2026 14:47:11 +0100 Subject: [PATCH] M-egui037: document UI lifecycle and regression contracts --- apps/deckcraft-web/src/web.rs | 5 ++ apps/deckcraft/src/main.rs | 3 ++ crates/engine/src/cmd/text.rs | 9 ++++ crates/engine/src/lib.rs | 4 ++ crates/fonts/build.rs | 39 ++++++++++++++++ crates/fonts/src/lib.rs | 2 + crates/ui-egui/src/backstage.rs | 4 ++ crates/ui-egui/src/canvas.rs | 4 ++ crates/ui-egui/src/capture.rs | 17 +++++++ crates/ui-egui/src/control.rs | 3 ++ crates/ui-egui/src/datetime_tests.rs | 1 + crates/ui-egui/src/dialogs.rs | 14 ++++++ crates/ui-egui/src/eyedropper.rs | 1 + crates/ui-egui/src/eyedropper_tests.rs | 1 + crates/ui-egui/src/ime_tests.rs | 49 ++++++++++++++++++++ crates/ui-egui/src/lib.rs | 28 +++++++++++ crates/ui-egui/src/opening.rs | 2 + crates/ui-egui/src/panes.rs | 9 ++++ crates/ui-egui/src/paragraph_dialog_tests.rs | 1 + crates/ui-egui/src/pattern_fill_tests.rs | 1 + crates/ui-egui/src/photo_album_tests.rs | 2 + crates/ui-egui/src/replacefonts_tests.rs | 3 ++ crates/ui-egui/src/ribbon.rs | 15 ++++++ crates/ui-egui/src/selpane.rs | 3 ++ crates/ui-egui/src/show.rs | 7 +++ crates/ui-egui/src/sorter.rs | 2 + crates/ui-egui/src/split_button_tests.rs | 7 +++ crates/ui-egui/src/squiggles.rs | 1 + crates/ui-egui/src/status.rs | 3 ++ crates/ui-egui/src/theme.rs | 8 ++++ crates/ui-egui/src/thumbs.rs | 2 + crates/ui-egui/src/toast.rs | 1 + crates/ui-egui/src/ui_soak_tests.rs | 2 + crates/ui-egui/src/uifonts.rs | 4 ++ crates/ui-egui/src/widgets.rs | 8 ++++ 35 files changed, 265 insertions(+) diff --git a/apps/deckcraft-web/src/web.rs b/apps/deckcraft-web/src/web.rs index e886f336..f8e68a35 100644 --- a/apps/deckcraft-web/src/web.rs +++ b/apps/deckcraft-web/src/web.rs @@ -11,6 +11,8 @@ const VIDEO_EXTS: &[&str] = &["mp4", "m4v", "mov", "webm"]; const CANVAS_ID: &str = "deckcraft_canvas"; const LOADING_ID: &str = "deckcraft_loading"; +/// Start the browser shell: install logging, locate the canvas, launch the eframe runner, +/// build the sample or blank deck, and replace the loading element when launch finishes. pub fn start() { eframe::WebLogger::init(log::LevelFilter::Info).ok(); wasm_bindgen_futures::spawn_local(async { @@ -75,10 +77,13 @@ struct WebShell { } impl eframe::App for WebShell { + /// Delegate one eframe logic pass to the wrapped application. fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.app.logic(ctx); } + /// Consume browser drops with fresh input, capture the identity before the asynchronous read, + /// and deliver the id, name, and bytes through the inbox. fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { // Browsers cannot read dropped files synchronously. Consume this pass's drops here, with // fresh input, and capture the document/slide before the asynchronous read finishes. diff --git a/apps/deckcraft/src/main.rs b/apps/deckcraft/src/main.rs index 8a028fab..b4afbb6b 100644 --- a/apps/deckcraft/src/main.rs +++ b/apps/deckcraft/src/main.rs @@ -42,6 +42,7 @@ impl eframe::App for App { apple_events::poll(&self.1, &mut self.0, ctx); self.0.logic(ctx); } + /// Forward native raw input to the wrapped application once per pass. fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { self.0.raw_input_hook(ctx, raw); } @@ -180,6 +181,8 @@ fn app_icon() -> Option { eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok() } +/// Install logging, parse startup arguments and environment, launch the native app, and +/// propagate an eframe startup failure. fn main() -> eframe::Result { // First, so every start-up record (and the engine's panic hook, installed with the first // Session) is captured; see `logging`. diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index fed79d4d..22a26ea1 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -12,6 +12,8 @@ use serde_json::{Value, json}; use super::*; use crate::{DocState, HistoryEntry, Result, Selection, Session, Target, TextSel, push_undo}; +/// Catalogue text-editing, selection, autocorrect, font, IME, and surrounding-deletion commands +/// with their parameter docs and enabled predicates. pub fn specs() -> Vec { vec![ cmd!(query "font.used", "Fonts in Presentation", [], None, "{} → [family] (theme fonts resolved)", has_doc, fonts_used), @@ -196,6 +198,7 @@ fn composition_range(body: &TextBody, c: Option<&crate::Composition>) -> Option< (start < end).then_some((c.start.0, start, end)) } +/// Return the composition character range only when its paragraph index equals `pi`. fn comp_in(comp: Option<(usize, usize, usize)>, pi: usize) -> Option<(usize, usize)> { comp.filter(|(p, _, _)| *p == pi).map(|(_, s, e)| (s, e)) } @@ -1422,6 +1425,9 @@ fn count_param(p: &Value, name: &str) -> usize { p.get(name).and_then(Value::as_u64).map_or(0, |v| usize::try_from(v).unwrap_or(usize::MAX)) } +/// Delete at most the requested character or byte counts around the selection or pre-edit, +/// preserving selection direction and composition boundaries and recording one undo step +/// outside composition. fn delete_surrounding(s: &mut Session, p: &Value) -> Result { let before = count_param(p, "before"); let after = count_param(p, "after"); @@ -1934,6 +1940,7 @@ mod ime_surrounding_tests { use crate::Session; + /// Test helper: insert and enter a rectangle containing `aé😀b`, returning its shape id. fn edit(s: &mut Session) -> u64 { let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 240, 60], "text": "aé😀b"})).unwrap(); let id = r["id"].as_u64().unwrap(); @@ -1941,11 +1948,13 @@ mod ime_surrounding_tests { id } + /// Test helper: return the identified shape's text, or an empty string when it is gone. fn text(s: &Session, id: u64) -> String { let id = deckcraft_model::ShapeId(u32::try_from(id).unwrap()); s.doc().unwrap().shape(id).and_then(|sh| sh.text.as_ref()).map(deckcraft_model::text::TextBody::text).unwrap_or_default() } + /// Test helper: set an in-paragraph text selection through the engine command path. fn select(s: &mut Session, anchor: usize, caret: usize) { s.execute("text.select", &json!({"anchor": [0, anchor], "caret": [0, caret]})).unwrap(); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 98bb3565..0eabc5fc 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -504,6 +504,8 @@ impl Default for Session { } impl Session { + /// Install the process panic hook and construct an empty session with default preferences, + /// clipboard, tool state, and no active document. pub fn new() -> Self { guard::install_panic_hook(); Session { @@ -613,6 +615,8 @@ impl Session { self.guarded(id, |s| s.execute_unguarded(id, params)) } + /// Resolve and run a command, settling composition first, checking eligibility, rolling back a + /// failed outer command, and committing the resulting undo entry or journal record. fn execute_unguarded(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?; // Any other edit (or a click, a key, undo) settles an input method's composition first. diff --git a/crates/fonts/build.rs b/crates/fonts/build.rs index a33c340e..6cf744f5 100644 --- a/crates/fonts/build.rs +++ b/crates/fonts/build.rs @@ -16,6 +16,8 @@ use std::path::PathBuf; /// CJK rebrand of Source Han on any platform). const EXCLUDED_FAMILIES: &[&str] = &["Source Sans 3", "Source Serif 4", "Source Han Sans", "Source Han Serif"]; +/// Reject source families prohibited by the asset policy, including Source Han and every +/// Noto CJK/Serif/Mono CJK variant. fn is_excluded_family(family: &str) -> bool { EXCLUDED_FAMILIES.contains(&family) || family.starts_with("Source Han Sans") @@ -34,12 +36,15 @@ struct FontInput { } impl FontInput { + /// Concatenate another input's generated source and embedded-face list into this one. fn append(&mut self, other: Self) { self.source.push_str(&other.source); self.faces.extend(other.faces); } } +/// Read font-input environment and target architecture, write `craft_fonts.rs`, report font-input +/// failures as warnings unless required, and always fail when the generated file cannot be written. fn main() { println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR"); println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED"); @@ -64,6 +69,7 @@ fn input_fonts(dir: Option<&std::path::Path>, wasm: bool, required: bool) -> Res load_fonts(dir, wasm, required).map(|input| input.source) } +/// Use no fonts when the directory is absent unless required; otherwise load the manifests. fn load_fonts(dir: Option<&std::path::Path>, wasm: bool, required: bool) -> Result { match dir { Some(dir) => craft_fonts(dir, wasm, required), @@ -72,6 +78,8 @@ fn load_fonts(dir: Option<&std::path::Path>, wasm: bool, required: bool) -> Resu } } +/// Load optional Latin input, required main input, and native-only Chinese input, then validate +/// the promised required faces after filtering. fn craft_fonts(dir: &std::path::Path, wasm: bool, required: bool) -> Result { let mut input = FontInput::default(); // The Latin presentation fonts are optional within craft-fonts (older checkouts lack them). @@ -126,6 +134,8 @@ fn craft_fonts(dir: &std::path::Path, wasm: bool, required: bool) -> Result Result { println!("cargo::rerun-if-changed={}", manifest.display()); let text = std::fs::read_to_string(manifest).map_err(|e| format!("{}: {e}", manifest.display()))?; @@ -199,6 +209,7 @@ mod tests { struct InputFixture(PathBuf); impl InputFixture { + /// Test helper: create a unique temporary fixture with a main manifest and asset file. fn new() -> Self { let path = std::env::temp_dir().join(format!("deck-font-input-{}-{}", std::process::id(), NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed))); std::fs::create_dir(&path).unwrap(); @@ -213,18 +224,22 @@ mod tests { Self(path) } + /// Test helper: replace the fixture's main manifest contents. fn main(&self, text: &str) { std::fs::write(self.0.join("fonts/manifest.txt"), text).unwrap(); } + /// Test helper: write the fixture's optional Latin manifest. fn latin(&self, text: &str) { std::fs::write(self.0.join("fonts/latin-manifest.txt"), text).unwrap(); } + /// Test helper: remove the optional Latin manifest. fn no_latin(&self) { let _ = std::fs::remove_file(self.0.join("fonts/latin-manifest.txt")); } + /// Test helper: install a valid Inter Regular/Bold Latin manifest. fn valid_latin(&self) { self.latin( "Inter | Regular | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n\ @@ -232,23 +247,28 @@ mod tests { ); } + /// Test helper: write the optional native Chinese manifest with the supplied rows. fn optional(&self, text: &str) { std::fs::write(self.0.join("fonts/simplified-chinese-manifest.txt"), text).unwrap(); } } impl Drop for InputFixture { + /// Test helper: best-effort removal of the temporary fixture directory. fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } + /// A required build without a font directory fails; an optional build embeds no fonts. #[test] fn required_input_cannot_silently_disappear() { assert!(input_fonts(None, false, true).unwrap_err().contains("CRAFT_FONTS_DIR")); assert_eq!(input_fonts(None, false, false).unwrap(), ""); } + /// A malformed optional Chinese manifest, or one naming a missing asset, leaves the main catalog + /// unchanged in optional builds, while a required build fails. #[test] fn broken_optional_manifest_preserves_the_main_fonts() { let fixture = InputFixture::new(); @@ -261,6 +281,7 @@ mod tests { assert!(input_fonts(Some(&fixture.0), false, true).is_err()); } + /// Adding or breaking native Chinese input must not change the wasm font initialiser bytes. #[test] fn native_opt_in_does_not_change_wasm_embedded_bytes() { let fixture = InputFixture::new(); @@ -274,6 +295,8 @@ mod tests { assert_eq!(input_fonts(Some(&fixture.0), true, true).unwrap(), before); } + /// Required native builds fail without the Chinese manifest; optional native and wasm builds + /// stay valid. #[test] fn required_native_input_requires_the_chinese_manifest() { let fixture = InputFixture::new(); @@ -284,6 +307,8 @@ mod tests { assert!(input_fonts(Some(&fixture.0), true, true).is_ok()); } + /// Every prohibited Source Han/Noto CJK family is excluded while permitted CJK, Latin, and + /// Hebrew faces survive. #[test] fn all_noto_cjk_families_are_excluded_from_every_platform() { for family in [ @@ -316,6 +341,8 @@ mod tests { assert!(native.contains("LXGW WenKai Lite"), "the valid Chinese row is retained"); } + /// A required native Chinese manifest must embed LXGW WenKai Lite Regular, not merely list a + /// nonempty manifest. #[test] fn required_native_chinese_manifest_must_emit_the_reviewed_face() { let fixture = InputFixture::new(); @@ -335,6 +362,8 @@ mod tests { assert!(native.contains("BIZ UDPGothic") && native.contains("Inter"), "the rest of the required catalog is retained"); } + /// A broken optional Latin manifest is skipped as a unit, preserving main and valid Chinese + /// faces; required builds still fail. #[test] fn optional_broken_latin_manifest_is_skipped_without_losing_core_or_chinese() { let fixture = InputFixture::new(); @@ -357,6 +386,8 @@ mod tests { } } + /// The reviewed Latin catalog keeps 43 permitted faces plus BIZ and LXGW natively, while wasm + /// remains a three-face subset. #[test] fn full_prior_latin_catalog_keeps_permitted_native_families() { let fixture = InputFixture::new(); @@ -387,6 +418,8 @@ mod tests { } } + /// Required builds without the Latin catalog fail because Inter Regular/Bold (or Variable) is + /// mandatory. #[test] fn required_builds_need_the_latin_catalog_for_inter() { let fixture = InputFixture::new(); @@ -398,6 +431,8 @@ mod tests { } } + /// A single Inter weight, non-Inter substitutes, or excluded faces cannot satisfy the required + /// Inter contract. #[test] fn required_builds_reject_partial_or_unrelated_faces() { let fixture = InputFixture::new(); @@ -420,6 +455,8 @@ mod tests { } } + /// BIZ UDPGothic Regular is required on every platform; required native builds additionally + /// need Bold. #[test] fn required_builds_need_biz_udpgothic_regular() { let fixture = InputFixture::new(); @@ -454,6 +491,8 @@ mod tests { assert_eq!(web.matches("CraftFont {").count(), 3, "wasm keeps Inter Regular/Bold and BIZ Regular only"); } + /// Required checks accept either static Inter Regular+Bold or a single Variable face, together + /// with BIZ. #[test] fn required_builds_accept_static_or_variable_inter_with_biz() { let fixture = InputFixture::new(); diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 7f7b583a..883ceb4e 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -409,6 +409,8 @@ mod tests { assert!(feature("liga").is_some()); } + /// When the Latin build input exists, the runtime catalogue carries the expected Latin faces; + /// required builds also assert their presence. #[test] fn craft_fonts_latin_when_present() { let required = option_env!("CRAFT_FONTS_REQUIRED").is_some(); diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 32ff0241..fd412028 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -180,6 +180,8 @@ pub struct Backstage { } impl Backstage { + /// Construct the Backstage view at a page with the current document identity, so a replaced deck + /// can be detected. pub fn new(page: Page, docs: (usize, Option)) -> Backstage { Backstage { page, @@ -437,6 +439,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { // ---------------------------------------------------------------- side menu +/// Draw one clickable Backstage navigation row and return its interaction response. fn nav_item(ui: &mut Ui, nav: Nav, bs: &Backstage, index: usize, t: &Tokens) -> egui::Response { let (rect, resp) = ui.allocate_exact_size(vec2(NAV_W, ITEM_H), Sense::click()); let selected = nav == Nav::Page(bs.page); @@ -1247,6 +1250,7 @@ fn preview(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, doc: &deckcraft_ }); } +/// Draw a labelled icon button and return whether it was clicked while enabled. fn icon_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> bool { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), if enabled { Sense::click() } else { Sense::hover() }); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index defa1744..09d84d6c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -143,6 +143,8 @@ fn trackpad_scroll(events: &[egui::Event]) -> egui::Vec2 { }) } +/// Paint the editing canvas, including rulers, selection handles, overlays, and current +/// zoom/scroll state. pub fn show(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let avail = ui.available_rect_before_wrap(); @@ -1718,6 +1720,8 @@ mod tests { pos2((p.x - r.min.x) / s, (p.y - r.min.y) / s) } + /// Test helper: inject a platform-command-modified line wheel event and run thirty harness + /// steps. fn ctrl_wheel(h: &mut Harness<'_, SlideApp>, lines: f32) { let wheel = Event::MouseWheel { unit: MouseWheelUnit::Line, diff --git a/crates/ui-egui/src/capture.rs b/crates/ui-egui/src/capture.rs index 523d13f7..34c036cd 100644 --- a/crates/ui-egui/src/capture.rs +++ b/crates/ui-egui/src/capture.rs @@ -175,6 +175,7 @@ pub struct CaptureSlot { } impl CaptureSlot { + /// Create a pending completion slot bound to one screenshot request token. fn new(token: u64) -> Self { Self { token, state: Mutex::new(SlotState::Pending) } } @@ -204,6 +205,7 @@ impl CaptureSlot { true } + /// Atomically replace the slot with `Pending` and return its previous state. fn take(&self) -> SlotResult { let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); match std::mem::replace(&mut *state, SlotState::Pending) { @@ -215,6 +217,7 @@ impl CaptureSlot { } } + /// Mark the slot cancelled so a later completion or drain cannot deliver an image. fn cancel(&self) { let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); *state = SlotState::Cancelled; @@ -284,10 +287,12 @@ pub struct Capture { } impl Capture { + /// Report whether the maximum number of screenshot requests is already admitted. pub fn is_full(&self) -> bool { self.requests.len() >= MAX_REQUESTS } + /// Return the number of currently admitted screenshot requests. pub fn pending(&self) -> usize { self.requests.len() } @@ -578,6 +583,7 @@ impl Capture { } impl Drop for Capture { + /// Cancel every outstanding completion when the capture registry is dropped. fn drop(&mut self) { for req in &self.requests { req.slot.cancel(); @@ -591,16 +597,20 @@ mod tests { use super::*; + /// Test helper: build a solid red `ColorImage` of the requested size. fn image(w: usize, h: usize) -> Arc { Arc::new(ColorImage::new([w, h], vec![egui::Color32::RED; w * h])) } + /// Test helper: admit one `ui.screenshot` request and return its token and reply receiver. fn setup(cap: &mut Capture, now: web_time::Instant, path: Option<&str>) -> (u64, std::sync::mpsc::Receiver) { let (req, rx) = crate::ControlRequest::new("ui.screenshot", json!({})); cap.admit(path.map(str::to_string), req.reply.clone(), Some(7), now).unwrap(); (cap.requests.last().unwrap().token, rx) } + /// Filling the registry to `MAX_REQUESTS` reports full; cancelling after a late completion + /// leaves no image. #[test] fn admission_is_bounded_and_late_cancel_keeps_no_image() { let now = web_time::Instant::now(); @@ -615,6 +625,7 @@ mod tests { assert!(matches!(slot.take(), SlotResult::Cancelled), "a completion after cancel retains nothing"); } + /// A completion carrying a different request token cannot complete the tracked slot. #[test] fn one_shot_token_correlation_ignores_a_foreign_completion() { let now = web_time::Instant::now(); @@ -627,6 +638,7 @@ mod tests { assert!(matches!(slot.take(), SlotResult::Ready(_))); } + /// Empty images are rejected; nonzero images are retained with their size. #[test] fn empty_images_are_rejected_and_other_sizes_are_kept_for_the_drain() { let now = web_time::Instant::now(); @@ -697,6 +709,7 @@ mod tests { assert!(!accepted([151, 76]), "a different framebuffer size is a resize, not a fit"); } + /// Pixel-memory budgets use checked geometry and reject zero, NaN, or overflowing sizes. #[test] fn budgets_use_checked_geometry_and_reject_zero_or_nan_sizes() { let g = Geometry { size: Vec2::new(100.0, 50.0), pixels_per_point: 2.0, viewport: ViewportId::ROOT, epoch: 1 }; @@ -707,6 +720,7 @@ mod tests { assert_eq!(Geometry { size: Vec2::new(f32::MAX, f32::MAX), pixels_per_point: f32::MAX, ..g }.image_bytes(), None); } + /// A changed geometry epoch retries instead of saving a stale image. #[test] fn a_geometry_epoch_change_retries_instead_of_saving() { let now = web_time::Instant::now(); @@ -731,6 +745,7 @@ mod tests { assert!(cap.requests[0].issued.is_none()); } + /// A document change after admission fails the request before any file is written. #[test] fn a_document_change_fails_before_writing() { let now = web_time::Instant::now(); @@ -751,6 +766,7 @@ mod tests { assert_eq!(reply["ok"], false); } + /// A timed-out capture replies with failure and releases its reservation. #[test] fn timeout_replies_once_and_releases_the_reservation() { let now = web_time::Instant::now(); @@ -769,6 +785,7 @@ mod tests { assert_eq!(rx.recv().unwrap()["ok"], false); } + /// An out-of-order completion is written to the path belonging to its own token. #[test] fn distinct_tokens_keep_distinct_paths_even_when_completed_out_of_order() { let now = web_time::Instant::now(); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 39de9a5b..8dc21d91 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -133,6 +133,7 @@ fn f(p: &Value, k: &str) -> Option { p.get(k).and_then(Value::as_f64).map(|v| v as f32) } +/// Dispatch one decoded control request and return an outcome for its caller to reply with. pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); @@ -410,6 +411,8 @@ pub fn tool_kind(name: &str, preset: Option<&str>) -> Option) -> Value { let [w, h] = image.size; let inline = path.is_none(); diff --git a/crates/ui-egui/src/datetime_tests.rs b/crates/ui-egui/src/datetime_tests.rs index 0a7dede1..8a92c637 100644 --- a/crates/ui-egui/src/datetime_tests.rs +++ b/crates/ui-egui/src/datetime_tests.rs @@ -39,6 +39,7 @@ fn date_run(h: &Harness<'_, SlideApp>) -> Option { .cloned() } +/// Insert opens date/time settings, and OK inserts exactly one field. #[test] fn insert_opens_the_dialog_and_ok_inserts_one_field() { let mut h = harness(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 71aece47..acbfc97b 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -77,6 +77,8 @@ fn title(id: &str) -> &'static str { } } +/// Render the active dialog, clearing it when dismissed and preserving or restoring application +/// state as its close path requires. pub fn show(app: &mut SlideApp, ctx: &egui::Context) { let Some(mut d) = app.dialog.take() else { return }; let mut open = true; @@ -182,6 +184,8 @@ fn restore_preferences(app: &mut SlideApp, d: &Dialog) { } } +/// Draw a labelled single-line field bound to `key`, falling back to `default`, and store the +/// edited value in the dialog. fn field(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, default: &str) { let mut v = d.get(key, default); ui.horizontal(|ui| { @@ -278,6 +282,8 @@ fn pick_album_picture(app: &mut SlideApp) -> Option { Some(json!({"name": name, "data": deckcraft_engine::cmd::base64_encode(&loaded)})) } +/// Draw an accessible album combo box, preserving its current/default selection and storing a +/// newly chosen key; the caller supplies the visible label. fn album_choice(ui: &mut Ui, d: &mut Dialog, id: &str, name: &str, key: &str, default: &str, choices: &[(&str, &str)]) { let cur = d.get(key, default); let label = choices.iter().find(|(k, _)| *k == cur).map(|(_, l)| *l).unwrap_or(default); @@ -564,6 +570,7 @@ fn seed_theme_colors(d: &mut Dialog) { d.fields.insert("seeded".into(), "1".into()); } +/// Draw one theme-colour row with its hex editor and store the value for the scheme slot. fn theme_color_row(ui: &mut Ui, d: &mut Dialog, slot: SchemeSlot) { let key = slot.xml_name(); let mut v = d.get(key, "000000"); @@ -670,6 +677,8 @@ fn theme_font_choices(app: &SlideApp, selected: &str) -> Vec { v } +/// Draw an accessible font combo box and update the selected family; the caller supplies the +/// visible label. fn font_combo(ui: &mut Ui, label: &str, choices: &[String], selected: &mut String) { let resp = egui::ComboBox::from_id_salt(label).selected_text(selected.as_str()).width(240.0).show_ui(ui, |ui| { for face in choices { @@ -2537,6 +2546,7 @@ fn size_position_value(d: &Dialog) -> Result { Ok(json!({"x": x, "y": y, "w": w, "h": h, "rot": rot, "lockAspect": field_of(d, "lock") == "1"})) } +/// Draw a labelled single-line text row bound to a dialog key and store the edited value. fn text_row(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str) { let mut v = field_of(d, key); ui.horizontal(|ui| { @@ -2602,6 +2612,8 @@ fn date_rows(lang: &str) -> Vec<(&'static str, String)> { rows } +/// Render language and format controls; OK applies `insert.dateTime`, and the return value says +/// whether the dialog should close. fn date_time_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.set_min_width(440.0); ui.label("Available formats:"); @@ -2904,6 +2916,8 @@ fn seed_font_fields(d: &mut Dialog) { seed_once(d, "colorSeed", shown); } +/// Draw a uniquely identified accessible choice combo box and store the selected key; +/// the caller supplies the visible label. fn choice(ui: &mut Ui, d: &mut Dialog, salt: &str, name: &str, key: &str, choices: &[(&str, &str)]) { let cur = d.get(key, ""); let label = choices.iter().find(|(v, _)| *v == cur).map(|(_, l)| *l).unwrap_or(cur.as_str()); diff --git a/crates/ui-egui/src/eyedropper.rs b/crates/ui-egui/src/eyedropper.rs index cbcd4f41..09a91a12 100644 --- a/crates/ui-egui/src/eyedropper.rs +++ b/crates/ui-egui/src/eyedropper.rs @@ -39,6 +39,7 @@ pub struct State { image: deckcraft_render::Image, } +/// Derive a unique egui id for an eyedropper menu target name. fn key(name: &str) -> Id { Id::unique(name) } diff --git a/crates/ui-egui/src/eyedropper_tests.rs b/crates/ui-egui/src/eyedropper_tests.rs index 650c0fc3..9c47e395 100644 --- a/crates/ui-egui/src/eyedropper_tests.rs +++ b/crates/ui-egui/src/eyedropper_tests.rs @@ -26,6 +26,7 @@ fn harness() -> Harness<'static, SlideApp> { h } +/// Test helper: stable unique id for the eyedropper test menu. fn menu() -> Id { Id::unique("test-menu") } diff --git a/crates/ui-egui/src/ime_tests.rs b/crates/ui-egui/src/ime_tests.rs index 2c4563a9..2c13e886 100644 --- a/crates/ui-egui/src/ime_tests.rs +++ b/crates/ui-egui/src/ime_tests.rs @@ -9,6 +9,7 @@ use serde_json::json; use crate::{Services, SlideApp}; use deckcraft_engine::Session; +/// Build the standard 1280×800 UI harness around a supplied session and run three frames. fn harness(session: Session) -> egui_kittest::Harness<'static, SlideApp> { let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1280.0, 800.0)).build_ui_state( |ui, app: &mut SlideApp| { @@ -868,6 +869,8 @@ fn text_of(h: &egui_kittest::Harness<'static, SlideApp>, id: u32) -> String { .unwrap_or_default() } +/// Open the title editor on `abcd`, select the text, and return the caret point used for +/// pointer interruption. fn pending_terminal_editor() -> (egui_kittest::Harness<'static, SlideApp>, egui::Pos2) { let mut h = harness(Session::with_new()); let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; @@ -884,6 +887,8 @@ fn pending_terminal_editor() -> (egui_kittest::Harness<'static, SlideApp>, egui: (h, point) } +/// Clear a pending pre-edit and assert that the visible text and undo length are unchanged and +/// no platform interrupt was requested. fn clear_pending_preedit(h: &mut egui_kittest::Harness<'_, SlideApp>) { let undo = undo_len(h.state()); h.event(preedit("Q")); @@ -897,6 +902,7 @@ fn clear_pending_preedit(h: &mut egui_kittest::Harness<'_, SlideApp>) { assert!(!h.output().platform_output.ime.as_ref().unwrap().should_interrupt_composition, "empty preedit is not a platform reset"); } +/// Build the terminal `DeleteSurrounding` event in character or byte units. fn pending_terminal_delete(bytes: bool) -> Event { Event::Ime(if bytes { ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 } @@ -905,6 +911,8 @@ fn pending_terminal_delete(bytes: bool) -> Event { }) } +/// A pointer interruption after clearing pre-edit rejects both later `DeleteSurrounding` and +/// commit forms. #[test] fn cleared_preedit_pointer_interruption_rejects_later_delete_and_terminal_commit() { for bytes in [false, true] { @@ -954,6 +962,8 @@ fn cleared_preedit_pointer_interruption_rejects_later_delete_and_terminal_commit } } +/// A key interruption after clearing pre-edit rejects a terminal event delivered in a different +/// batch. #[test] fn cleared_preedit_key_interruption_rejects_a_terminal_in_a_later_batch() { for bytes in [false, true] { @@ -977,6 +987,7 @@ fn cleared_preedit_key_interruption_rejects_a_terminal_in_a_later_batch() { } } +/// Replacing the editing target rejects stale terminal mutations from the old composition. #[test] fn cleared_preedit_target_replacement_rejects_later_terminal_mutations() { for bytes in [false, true] { @@ -1016,6 +1027,8 @@ fn cleared_preedit_target_replacement_rejects_later_terminal_mutations() { } } +/// Closing the last document clears pending terminal state, and a new document needs no +/// stale-composition fence. #[test] fn closing_the_last_document_clears_a_pending_preedit_terminal() { let (mut h, _) = pending_terminal_editor(); @@ -1050,6 +1063,7 @@ fn closing_the_last_document_clears_a_pending_preedit_terminal() { assert!(h.output().platform_output.ime.as_ref().is_some_and(|ime| ime.reset_request.is_none())); } +/// After a plain clear, both character- and byte-count terminal deletes are accepted. #[test] fn cleared_preedit_without_interruption_accepts_both_terminal_delete_forms() { for bytes in [false, true] { @@ -1065,6 +1079,8 @@ fn cleared_preedit_without_interruption_accepts_both_terminal_delete_forms() { } } +/// A clear accepts its own commit, then permits a direct commit and a fresh composition without +/// stale state. #[test] fn cleared_preedit_accepts_its_commit_then_direct_and_fresh_compositions() { let (mut h, _) = pending_terminal_editor(); @@ -1087,14 +1103,18 @@ fn cleared_preedit_accepts_its_commit_then_direct_and_fresh_compositions() { // Token-bound IME reset adoption (revision02): opaque id + ordered completion. // --------------------------------------------------------------------------- +/// Build the ordered reset-completion event for a request id. fn reset_completed(id: egui::ImeResetId) -> Event { Event::ImeResetCompleted { request_id: id } } +/// Return the custom descriptor's currently published reset id, asserting that a token-bound +/// request exists. fn published_reset(h: &egui_kittest::Harness<'static, SlideApp>) -> egui::ImeResetId { h.output().platform_output.ime.as_ref().and_then(|ime| ime.reset_request).expect("the custom descriptor carries a token-bound reset request") } +/// Read the reset phase recorded for the root viewport. fn root_phase(h: &egui_kittest::Harness<'static, SlideApp>) -> crate::ImeResetPhase { h.state().ime_reset_for(egui::ViewportId::ROOT) } @@ -1106,6 +1126,7 @@ fn viewport_input(viewport: egui::ViewportId) -> egui::RawInput { input } +/// Read the reset phase for an arbitrary viewport, defaulting to `Clear` when none is stored. fn phase_at(h: &egui_kittest::Harness<'static, SlideApp>, viewport: egui::ViewportId) -> crate::ImeResetPhase { h.state().ime_reset_for(viewport) } @@ -1156,6 +1177,8 @@ fn settled_title_reset(h: &mut egui_kittest::Harness<'static, SlideApp>) -> egui id } +/// Only the exact ordered completion releases the fence; a fresh suffix survives while a stale +/// prefix is discarded. #[test] fn ordered_reset_completion_is_exact_and_fresh_suffix_survives_stale_prefix() { let mut h = harness(Session::with_new()); @@ -1198,6 +1221,7 @@ fn ordered_reset_completion_is_exact_and_fresh_suffix_survives_stale_prefix() { assert_eq!(title_text(h.state()), "日YZW", "the exact new completion admits the same-batch suffix"); } +/// Waiting frames without the exact completion emit no replacement token and keep input blocked. #[test] fn no_completion_fails_closed_and_unchanged_waiting_frames_do_not_emit_new_requests() { let mut h = harness(Session::with_new()); @@ -1215,6 +1239,7 @@ fn no_completion_fails_closed_and_unchanged_waiting_frames_do_not_emit_new_reque assert_eq!(title_text(h.state()), "日Y", "the exact completion is the only release"); } +/// A discarded final `None` suspends the guard; returning to the same editor publishes a new id. #[test] fn discarded_final_none_suspends_and_the_same_editor_rearms_with_a_new_id() { let mut h = harness(Session::with_new()); @@ -1244,6 +1269,7 @@ fn discarded_final_none_suspends_and_the_same_editor_rearms_with_a_new_id() { assert_eq!(title_text(h.state()), "日Y"); } +/// A real modal without a discarded pass suspends the guard and publishes no speculative token. #[test] fn actual_modal_without_discard_suspends_and_queues_no_token() { let mut h = harness(Session::with_new()); @@ -1283,6 +1309,8 @@ fn actual_modal_without_discard_suspends_and_queues_no_token() { assert_eq!(title_text(h.state()), "日Y"); } +/// A modal-owned observe/finalizer pass rejects a matching custom descriptor before allocating a +/// token. #[test] fn modal_observe_and_finalizer_reject_a_matching_custom_descriptor_before_allocation() { let mut h = harness(Session::with_new()); @@ -1326,6 +1354,8 @@ fn modal_observe_and_finalizer_reject_a_matching_custom_descriptor_before_alloca assert!(ctx.output(|o| o.ime.as_ref().is_some_and(|ime| ime.reset_request.is_none())), "no token was added to the modal output"); } +/// Opening, Backstage, palette, and modal routes all report that the custom canvas cannot own +/// input. #[test] fn opening_backstage_palette_and_modal_routes_are_explicitly_ineligible() { let mut h = harness(Session::with_new()); @@ -1350,6 +1380,8 @@ fn opening_backstage_palette_and_modal_routes_are_explicitly_ineligible() { assert!(!h.state().ime_custom_canvas_route(&h.ctx), "a modal owns input"); } +/// Stock focused controls keep their own descriptor; Backstage/no-editor routes suspend and then +/// re-arm once on return. #[test] fn stock_focus_backstage_and_no_editor_routes_preserve_stock_output_and_rearm() { // A focused stock TextEdit keeps its own descriptor; the custom fence is suspended. @@ -1413,6 +1445,8 @@ fn stock_focus_backstage_and_no_editor_routes_preserve_stock_output_and_rearm() assert_eq!(title_text(h.state()), "日Y日Z日K"); } +/// Full logic+UI passes do not re-arm, while standalone logic suspends exactly once without +/// replaying input. #[test] fn full_logic_ui_passes_do_not_rearm_while_standalone_logic_suspends_once() { let mut h = harness(Session::with_new()); @@ -1445,6 +1479,8 @@ fn full_logic_ui_passes_do_not_rearm_while_standalone_logic_suspends_once() { assert!(h.output().platform_output.ime.as_ref().is_some_and(|ime| ime.reset_request.is_none())); } +/// A terminal change observed only in standalone logic never queues a token for a later stock +/// route. #[test] fn standalone_logic_changed_terminal_never_queues_a_token_for_the_next_stock_route() { let (mut h, _) = pending_terminal_editor(); @@ -1486,6 +1522,7 @@ fn standalone_logic_changed_terminal_never_queues_a_token_for_the_next_stock_rou assert_eq!(title_text(h.state()), "aYbcd", "the exact replacement admits the fresh suffix at the real caret"); } +/// The exact completion admits a fresh suffix without admitting the old terminal commit. #[test] fn cleared_preedit_exact_completion_admits_fresh_suffix_without_the_old_terminal_commit() { // Same batch: the exact completion is the platform-reset boundary; no old Commit, @@ -1545,6 +1582,7 @@ fn cleared_preedit_exact_completion_admits_fresh_suffix_without_the_old_terminal assert_eq!(undo_len(h.state()), undo + 1); } +/// Repeating the exact completion after `Clear` changes no composition or undo state. #[test] fn duplicate_exact_completion_after_clear_is_inert() { let mut h = harness(Session::with_new()); @@ -1582,6 +1620,8 @@ fn duplicate_exact_completion_after_clear_is_inert() { assert_eq!(title_text(h.state()), "日YZW"); } +/// The font-install early return suspends without a token, and returning to the editor re-arms +/// once. #[test] fn font_install_early_return_suspends_without_a_speculative_token_and_rearms_once() { let mut h = harness(Session::with_new()); @@ -1621,6 +1661,8 @@ fn font_install_early_return_suspends_without_a_speculative_token_and_rearms_onc assert_eq!(title_text(h.state()), format!("{before}Y")); } +/// The palette route retires the guard without a token; returning to the canvas publishes one +/// replacement. #[test] fn palette_route_retires_the_guard_without_a_speculative_token_and_rearms_once() { let mut h = harness(Session::with_new()); @@ -1650,6 +1692,7 @@ fn palette_route_retires_the_guard_without_a_speculative_token_and_rearms_once() assert_eq!(title_text(h.state()), format!("{before}Y")); } +/// A render-only viewport cannot mutate the root composition or bind the root reset guard. #[test] fn render_only_viewports_cannot_mutate_root_composition_or_bind_the_root_guard() { let (mut h, _) = pending_terminal_editor(); @@ -1697,6 +1740,7 @@ fn render_only_viewports_cannot_mutate_root_composition_or_bind_the_root_guard() assert!(matches!(root_phase(&h), crate::ImeResetPhase::Clear)); } +/// A suspended guard or request stamped for another viewport is never rebound to root. #[test] fn a_suspended_guard_or_request_stamped_for_another_viewport_is_never_rebound() { let mut h = harness(Session::with_new()); @@ -1724,6 +1768,7 @@ fn a_suspended_guard_or_request_stamped_for_another_viewport_is_never_rebound() assert_eq!(title_text(h.state()), "日Y"); } +/// Sorter and Notes Page stock routes allocate no custom reset token when a composition settles. #[test] fn sorter_and_notes_page_settles_never_allocate_for_a_stock_route() { for (view, command) in [(crate::ViewMode::Sorter, "view.sorter"), (crate::ViewMode::NotesPage, "view.notesPage")] { @@ -1787,6 +1832,8 @@ fn sorter_and_notes_page_settles_never_allocate_for_a_stock_route() { } } +/// A same-pass switch from custom canvas to stock palette retires the guard without publishing a +/// token. #[test] fn same_pass_custom_to_stock_palette_retires_the_guard_without_a_new_token() { let mut h = harness(Session::with_new()); @@ -1864,6 +1911,8 @@ fn same_pass_custom_to_stock_palette_retires_the_guard_without_a_new_token() { assert_eq!(title_text(h.state()), "日Y"); } +/// Closing and creating a deck before a pass keeps the old terminal fence until its exact reset +/// completion. #[test] fn close_new_then_edit_before_a_pass_keeps_the_old_terminal_fence_until_exact_reset() { let (mut h, _) = pending_terminal_editor(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f306f4e6..094d7aba 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -62,6 +62,7 @@ fn ime_in_ui_pass_key(viewport: egui::ViewportId) -> egui::Id { egui::Id::unique(("deckcraft-ime-in-ui-pass", viewport)) } +/// Stable key marking that the IME pass hooks are installed. fn ime_pass_hooks_key() -> egui::Id { egui::Id::unique(("deckcraft-ime-pass-hooks", "installed")) } @@ -741,6 +742,8 @@ struct ImeDescriptorKey { } impl ImeDescriptorKey { + /// Construct the exact custom-IME descriptor identity from owner, context generation, and + /// purpose. fn new(owner: egui::Id, context_generation: u64, purpose: egui::IMEPurpose) -> Self { Self { owner, context_generation, purpose } } @@ -765,6 +768,8 @@ enum ImeResetPhase { } impl ImeResetPhase { + /// Only `Clear` admits custom-canvas input; awaiting, suspended, and allocation-failed phases + /// remain fail-closed. fn blocks_custom_input(self) -> bool { !matches!(self, Self::Clear) } @@ -783,6 +788,8 @@ fn grid_flag(p: &Value, key: &str) -> Result, String> { } impl SlideApp { + /// Build application UI state, taking ownership of audio output and initialising media, dialogs, + /// synthetic input, and timing fields. pub fn new(session: Session, mut services: Services) -> Self { let audio_out = services.audio_out.take(); SlideApp { @@ -950,6 +957,8 @@ impl SlideApp { r } + /// Return the current document count plus the active document uid, so callers can detect a + /// replacement document rather than only an index change. pub(crate) fn docs_key(&self) -> (usize, Option) { // The document uid, not just its index: closing one presentation and opening another // must not look like the same document to the Backstage view or a queued input batch. @@ -1428,6 +1437,8 @@ impl SlideApp { Ok(Some(from)) } + /// Leave animation preview, stop editor-owned media, and create a show at the requested slide + /// with the current reading/presenter configuration; an out-of-range slide starts an ended show. pub fn start_show(&mut self, from: usize, reading: bool) { self.anim_preview = None; self.media.stop_owned(media::Owner::Editor); @@ -2178,6 +2189,7 @@ impl SlideApp { Some(ImeDestination { owner: target.owner, revision: st.revision, anchor, caret }) } + /// Use the recorded IME viewport only while it matches the current context viewport. fn ime_viewport_or(&self, ctx: &egui::Context) -> egui::ViewportId { self.ime_frame_viewport.filter(|v| *v == ctx.viewport_id()).unwrap_or_else(|| ctx.viewport_id()) } @@ -2188,6 +2200,7 @@ impl SlideApp { self.ime_reset.get(&viewport).copied().unwrap_or(ImeResetPhase::Clear) } + /// Store a non-clear reset phase for one viewport, or remove the entry when the phase is `Clear`. fn set_ime_reset(&mut self, viewport: egui::ViewportId, phase: ImeResetPhase) { if matches!(phase, ImeResetPhase::Clear) { let _ = self.ime_reset.remove(&viewport); @@ -2237,6 +2250,8 @@ impl SlideApp { ctx.viewport_id() == egui::ViewportId::ROOT } + /// Report whether the current pass is a full UI pass for the IME-owning viewport rather than + /// standalone logic. fn ime_in_ui_pass(&self, ctx: &egui::Context) -> bool { let key = ime_in_ui_pass_key(self.ime_viewport_or(ctx)); ctx.data(|d| d.get_temp::(key).unwrap_or(false)) @@ -2380,6 +2395,8 @@ impl SlideApp { } } + /// Request one replacement IME reset token and record `Awaiting` on success or + /// `AllocationFailed` when no token can be allocated. fn request_ime_rearm(&mut self, ctx: &egui::Context, viewport: egui::ViewportId, destination: ImeDestination, output_context: ImeDescriptorKey) { match ctx.request_ime_reset(viewport) { Some(id) => self.set_ime_reset(viewport, ImeResetPhase::Awaiting { id, viewport, destination, output_context }), @@ -3223,6 +3240,8 @@ impl SlideApp { self.finalize_ime_reset(&ctx, custom); } + /// Run the admitted root UI pass, including setup gating, panels, canvas, dialogs, overlays, and + /// show mode. fn ui_inner(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); self.ime_frame_viewport = Some(ctx.viewport_id()); @@ -3340,6 +3359,8 @@ impl SlideApp { self.pending_urls.push(url.to_string()); } + /// Drain pending control requests, executing ordinary requests and admitting screenshots for + /// later saving and reply. fn drain_control(&mut self, ctx: &egui::Context) { let Some(rx) = self.control_rx.take() else { return }; while let Ok(req) = rx.try_recv() { @@ -4821,6 +4842,7 @@ mod tests { assert_eq!(crate::canvas::page_flick(egui::vec2(4.0, 10.0)), None); } + /// Test helper: place the pointer over the canvas centre, inject wheel notches, and step once. fn mouse_wheel(harness: &mut egui_kittest::Harness<'static, SlideApp>, notches: &[f32]) { let centre = harness.state().canvas_rect.unwrap().center(); let input = harness.input_mut(); @@ -4929,6 +4951,8 @@ mod tests { assert_eq!(slide_at(h.state()), 0, "switching away without a hover still drops the fraction"); } + /// Wheel input over a zoomed canvas pans it, while the same input outside the canvas must not + /// navigate slides. #[test] fn mouse_wheel_over_a_zoomed_canvas_pans_and_outside_the_canvas_does_not_navigate() { let mut h = flick_harness(); @@ -5706,6 +5730,8 @@ mod tests { /// from the show state instead of inventing a paused-show policy. #[test] fn hidden_viewport_advances_show_and_presenter_clocks_from_logic() { + /// Nested helper in the hidden-show test: build root `RawInput` with the given time and + /// visibility flags. fn raw_at(time: f64, minimized: bool, occluded: bool) -> egui::RawInput { let mut raw = egui::RawInput { time: Some(time), ..Default::default() }; raw.viewports.insert( @@ -5768,6 +5794,8 @@ mod tests { /// Re-anchoring belongs to `show::ui`, which only runs on a real visible pass. #[test] fn overdue_visible_logic_does_not_reanchor_a_later_hidden_show() { + /// Nested helper in the overdue-logic test: build root `RawInput` with the given time and + /// visibility flags. fn raw_at(time: f64, minimized: bool, occluded: bool) -> egui::RawInput { let mut raw = egui::RawInput { time: Some(time), ..Default::default() }; raw.viewports.insert( diff --git a/crates/ui-egui/src/opening.rs b/crates/ui-egui/src/opening.rs index 3f5c41f3..5bbd0a35 100644 --- a/crates/ui-egui/src/opening.rs +++ b/crates/ui-egui/src/opening.rs @@ -191,6 +191,8 @@ mod tests { assert!(app.toast.as_ref().is_some_and(|t| t.text.contains("missing.pptx")), "the reason is shown"); } + /// Opening a path displays progress; cancelling closes the progress window without opening the + /// deck. #[test] fn the_progress_window_shows_and_cancel_closes_it() { use egui_kittest::kittest::Queryable; diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 0708713d..d7b42907 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -204,6 +204,7 @@ fn color_button( }); } +/// Render the shape-format pane with fill/line, effects, size/properties, text, and picture tabs. fn format_shape(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; @@ -936,6 +937,8 @@ fn background(app: &mut SlideApp, ui: &mut Ui) { }); } +/// Render picture-fill background controls and route transparency, tiling, and tile geometry +/// changes through the engine. fn picture_background_options(app: &mut SlideApp, ui: &mut Ui, pf: &deckcraft_model::style::PictureFill) { use deckcraft_model::style::PictureMode; let alpha = pf.alpha.unwrap_or(1.0); @@ -1018,6 +1021,7 @@ fn picture_background_options(app: &mut SlideApp, ui: &mut Ui, pf: &deckcraft_mo }); } +/// Render the animation pane and route its controls through the engine's animation commands. fn animation(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; @@ -1295,6 +1299,7 @@ fn focus_comment(app: &mut SlideApp, slide: usize, index: usize) { app.ui.comment_focus = Some((slide, index)); } +/// Render one comment card, preserving its slide/index identity and focus state through actions. fn comment_card(app: &mut SlideApp, ui: &mut Ui, slide: usize, index: usize, c: &deckcraft_model::Comment, focused: bool) { let t = Tokens::get(ui.ctx()); let stroke = if focused { Stroke::new(2.0, t.accent) } else { Stroke::NONE }; @@ -1373,6 +1378,8 @@ struct IdeaThumbs { tex: Vec<(String, egui::TextureHandle, u32, u32)>, } +/// Render per-slide Design Ideas and whole-presentation theme thumbnails; a click runs +/// `design.applyIdea` or `design.theme`. fn designer(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new("Pick a design for this slide. Ideas are generated on your device.").color(t.text_dim)); @@ -1621,6 +1628,7 @@ mod tests { h.query_all_by_role(egui::accesskit::Role::Button).filter(|n| n.accesskit_node().label().is_some_and(|l| l.trim() == "Color")).count() } + /// Fill colour and transparency controls appear only for fill kinds they can actually modify. #[test] fn fill_color_and_transparency_show_only_where_they_change_the_fill() { let gradient = json!({"gradient": {"stops": [[0, "accent1"], [1, "accent2"]], "angle": 90}}); @@ -1645,6 +1653,7 @@ mod tests { } } + /// The picture-fill transparency slider reflects and edits the picture fill's own transparency. #[test] fn picture_fill_transparency_slider_shows_the_picture_transparency() { let mut h = fill_pane(Some(picture_fill(0.4))); diff --git a/crates/ui-egui/src/paragraph_dialog_tests.rs b/crates/ui-egui/src/paragraph_dialog_tests.rs index 9b42cc73..02977307 100644 --- a/crates/ui-egui/src/paragraph_dialog_tests.rs +++ b/crates/ui-egui/src/paragraph_dialog_tests.rs @@ -60,6 +60,7 @@ fn paragraph_button_aligns_text_in_one_undo() { assert_eq!(h.state().session.active().unwrap().history.undo.last().unwrap().label, "Paragraph…"); } +/// Paragraph-dialog tab settings apply the chosen stop and return to the paragraph controls. #[test] fn tabs_from_paragraph_applies_a_stop_and_returns() { let mut h = harness(); diff --git a/crates/ui-egui/src/pattern_fill_tests.rs b/crates/ui-egui/src/pattern_fill_tests.rs index 334eb219..f7f5a784 100644 --- a/crates/ui-egui/src/pattern_fill_tests.rs +++ b/crates/ui-egui/src/pattern_fill_tests.rs @@ -136,6 +136,7 @@ fn pattern_gallery_shows_which_swatch_has_keyboard_focus() { assert!(outlined(&h), "the focused swatch is outlined"); } +/// A solid fill shows its transparency slider and does not expose a pattern gallery. #[test] fn shape_solid_fill_keeps_its_transparency_without_a_pattern_gallery() { let (mut s, _) = patterned_shape(); diff --git a/crates/ui-egui/src/photo_album_tests.rs b/crates/ui-egui/src/photo_album_tests.rs index 26c608da..b28a1c17 100644 --- a/crates/ui-egui/src/photo_album_tests.rs +++ b/crates/ui-egui/src/photo_album_tests.rs @@ -76,6 +76,8 @@ fn create_inserts_the_album_in_one_step() { assert_eq!(text, ["Text Box"]); } +/// Updating photo-album data reflows the already-open album without requiring it to be +/// reopened. #[test] fn update_reflows_the_open_album() { let data = deckcraft_engine::cmd::base64_encode(&png()); diff --git a/crates/ui-egui/src/replacefonts_tests.rs b/crates/ui-egui/src/replacefonts_tests.rs index 170e3d4a..449b4b46 100644 --- a/crates/ui-egui/src/replacefonts_tests.rs +++ b/crates/ui-egui/src/replacefonts_tests.rs @@ -7,6 +7,7 @@ use serde_json::json; use crate::{Services, SlideApp}; +/// Build the standard Replace Fonts UI harness with a fresh session. fn harness() -> Harness<'static, SlideApp> { let mut s = Session::with_new(); s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); @@ -50,6 +51,8 @@ fn reveal_ribbon_end(h: &mut Harness<'_, SlideApp>) { } } +/// The ribbon command opens Replace Fonts; its Replace button updates the document and leaves +/// the dialog open with a confirmation. #[test] fn ribbon_button_opens_the_dialog_and_replaces() { let mut h = harness(); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index fe9b129a..117e08e8 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -311,6 +311,8 @@ fn edge_cue(ui: &mut Ui, rect: Rect, glyph: &str, hint: &str, t: &Tokens) -> boo resp.clicked() } +/// Draw a large ribbon command button, honour its enabled state, run the command on click, and +/// attach its menu. fn big(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, p: Value) { let en = enabled(app, id); let r = big_button(ui, icon, label, en); @@ -481,6 +483,7 @@ fn layout_menu(app: &mut SlideApp, ui: &mut Ui, cmd: &str) { }); } +/// Render the Home Slides group: New Slide, Layout, Reset, and Section controls. fn slides_group(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { let (main, arrow) = big_button_ex(ui, Icon::NewSlide, "New\nSlide", enabled(app, "slide.new"), true, false); @@ -776,6 +779,7 @@ fn smartart_gallery( }); } +/// Render paragraph formatting controls using the current paragraph state. fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { let en = enabled(app, "format.bullets"); group(ui, |ui| { @@ -1000,6 +1004,7 @@ fn kurbo_flatten(path: &deckcraft_geom::BezPath, mut f: impl FnMut(Fl)) { }); } +/// Render drawing controls for shapes, text, arrange, quick styles, fill, and outline. fn drawing_group(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { let en = enabled(app, "shape.insert"); @@ -1589,6 +1594,8 @@ fn draw(app: &mut SlideApp, ui: &mut Ui) { // ---------- Design ---------- +/// Render the Design tab's themes, variants, colours, fonts, effects, background, and slide +/// size controls. fn design(app: &mut SlideApp, ui: &mut Ui) { let themes = deckcraft_model::theme::builtin_themes(); let cur = app.session.active().and_then(|d| d.doc.masters.first().map(|m| m.theme.name.clone())).unwrap_or_default(); @@ -1756,6 +1763,8 @@ pub fn paint_theme_tile(p: &egui::Painter, r: Rect, th: &deckcraft_model::Theme) // ---------- Transitions ---------- +/// Render transition preview, gallery, options, sound, and timing controls for the current +/// slide. fn transitions(app: &mut SlideApp, ui: &mut Ui) { let cur = app.session.active().and_then(|d| d.current_slide().and_then(|s| s.transition.clone())); let kind = cur.as_ref().map(|t| t.kind.clone()).unwrap_or_else(|| "none".into()); @@ -1985,6 +1994,7 @@ pub fn paint_transition_tile(p: &egui::Painter, r: Rect, (id, label): (&str, &st // ---------- Animations ---------- +/// Render animation preview, gallery, add/effect options, trigger, timing, and playback controls. fn animations(app: &mut SlideApp, ui: &mut Ui) { let has_sel = app.session.active().is_some_and(|d| !d.selection.shapes.is_empty()); let cur: Option = app.session.active().and_then(|d| { @@ -2509,6 +2519,7 @@ fn stroke_guide(p: &egui::Painter, g: &deckcraft_geom::warp::Guide, color: Color } } +/// Render the Shape Format tab's shape, fill, outline, effects, text, arrange, and size controls. fn shape_format(app: &mut SlideApp, ui: &mut Ui) { let sc = scheme(app); let sel = enabled(app, "shape.fill"); @@ -2628,6 +2639,7 @@ fn shape_format(app: &mut SlideApp, ui: &mut Ui) { }); } +/// Render ordering, align, group/ungroup, and rotate controls for the current selection. fn arrange_group(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { let sel = enabled(app, "arrange.bringForward"); @@ -2682,6 +2694,7 @@ fn arrange_group(app: &mut SlideApp, ui: &mut Ui) { }); } +/// Render width and height controls bound to the selected shape, plus the size-position dialog. fn size_group(app: &mut SlideApp, ui: &mut Ui) { let bx = app .session @@ -2713,6 +2726,8 @@ fn size_group(app: &mut SlideApp, ui: &mut Ui) { }); } +/// Render the Picture Format tab's corrections, colour, artistic effects, transparency, styles, +/// border, and picture actions. fn picture_format(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { let r = big_button(ui, Icon::RemoveBackground, "Remove\nBackground", enabled(app, "picture.removeBackground")); diff --git a/crates/ui-egui/src/selpane.rs b/crates/ui-egui/src/selpane.rs index 4f46cfe6..1e4c5464 100644 --- a/crates/ui-egui/src/selpane.rs +++ b/crates/ui-egui/src/selpane.rs @@ -105,6 +105,7 @@ fn row_id(id: u32) -> Id { Id::unique(("selection_pane_row", id)) } +/// Draw a small labelled icon button and return its interaction response. fn small_icon_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> egui::Response { let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(24.0, 22.0), if enabled { Sense::click() } else { Sense::hover() }); @@ -117,6 +118,8 @@ fn small_icon_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> egu resp.on_hover_text(label) } +/// Render the Selection Pane and route visibility, order, rename, and selection actions through +/// engine commands. pub fn show(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 68bf4d8c..2bb950a3 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -314,6 +314,8 @@ pub(crate) fn viewport_visible(ctx: &egui::Context) -> bool { } impl Show { + /// Initialise show state at the requested slide with reading/presenter modes; an out-of-range + /// slide starts an ended show. pub fn new(doc: &Presentation, from: usize, reading: bool, presenter: bool) -> Self { Show { instance: 0, @@ -829,6 +831,8 @@ fn keyboard(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64, keep } +/// Draw and advance the active show, handling hidden frames, timing, input, transitions, and +/// presenter/reading presentation. pub fn ui(app: &mut SlideApp, ui: &mut Ui) { let ctx = ui.ctx().clone(); let Some((doc_uid, doc)) = app.session.active().map(|d| (d.uid, d.doc.clone())) else { @@ -2094,6 +2098,7 @@ fn render_thumb(ctx: &egui::Context, show: &mut Show, doc: &Presentation, i: usi Some(tex) } +/// Render the presenter window and set `keep` false when the user ends or closes the show. fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64, keep: &mut bool) { let cur = show.state.slide; let next = show.state.peek_next(doc); @@ -3110,6 +3115,8 @@ mod tests { assert_eq!(st.slide, 0, "five Backs leave slide 2 (step {}, playing {})", st.step, st.playing); } + /// Click-target state follows show instance, reading/presenter modes, grid, and other layout + /// changes instead of retaining a stale target. #[test] fn input_state_tracks_the_modes_that_move_show_click_targets() { let session = deckcraft_engine::Session::with_new(); diff --git a/crates/ui-egui/src/sorter.rs b/crates/ui-egui/src/sorter.rs index f52805d5..4d39d62c 100644 --- a/crates/ui-egui/src/sorter.rs +++ b/crates/ui-egui/src/sorter.rs @@ -70,6 +70,8 @@ struct Header<'a> { first: Option, } +/// Render one slide-sorter section header, handle its collapse/drag interactions, and return the +/// occupied rectangle. fn section_header(app: &mut SlideApp, ui: &mut Ui, h: &Header<'_>) -> Rect { let t = Tokens::get(ui.ctx()); let width = ui.available_width(); diff --git a/crates/ui-egui/src/split_button_tests.rs b/crates/ui-egui/src/split_button_tests.rs index 084dc67b..3f91942b 100644 --- a/crates/ui-egui/src/split_button_tests.rs +++ b/crates/ui-egui/src/split_button_tests.rs @@ -34,6 +34,7 @@ fn harness(tab: &str) -> (Harness<'static, SlideApp>, [ShapeId; 2]) { (h, ids) } +/// Click the accessible button or chevron with the given name and run three harness steps. fn click(h: &mut Harness<'static, SlideApp>, name: &str) { h.get_by_role_and_label(Role::Button, name).click(); h.run_steps(3); @@ -52,6 +53,7 @@ fn both_halves_open_same_menu(tab: &str, name: &str, item: &str) { } } +/// Both the Home Layout label and chevron open and toggle the same layout menu. #[test] fn home_layout_opens_the_layout_menu_from_its_label_and_arrow() { both_halves_open_same_menu("Home", "Layout", "Title Slide"); @@ -91,17 +93,21 @@ fn home_section_menu_enters_wraps_and_closes_from_either_keyboard_trigger() { } } +/// Main and chevron halves of Shape Effects open the same effects menu on Home and Shape +/// Format. #[test] fn shape_effects_opens_the_effects_menu_from_its_label_and_arrow_on_both_tabs() { both_halves_open_same_menu("Home", "Shape Effects", "Format Shape…"); both_halves_open_same_menu("Shape Format", "Shape Effects", "Format Shape…"); } +/// Text Fill's label and chevron both open its colour menu. #[test] fn shape_format_text_fill_opens_its_colour_menu_from_its_label_and_arrow() { both_halves_open_same_menu("Shape Format", "Text Fill", "More Colors… #"); } +/// The Group main area executes grouping while only its chevron opens the group menu. #[test] fn shape_format_group_main_groups_and_its_arrow_opens_the_group_menu() { let (mut h, _) = harness("Shape Format"); @@ -116,6 +122,7 @@ fn shape_format_group_main_groups_and_its_arrow_opens_the_group_menu() { assert!(h.query_by_label("Ungroup").is_some(), "the arrow opens the Group actions"); } +/// The Rotate main area rotates while its chevron opens the rotate menu. #[test] fn shape_format_rotate_main_rotates_and_its_arrow_opens_the_rotate_menu() { let (mut h, ids) = harness("Shape Format"); diff --git a/crates/ui-egui/src/squiggles.rs b/crates/ui-egui/src/squiggles.rs index eebd9599..30a94a25 100644 --- a/crates/ui-egui/src/squiggles.rs +++ b/crates/ui-egui/src/squiggles.rs @@ -64,6 +64,7 @@ struct Done { ranges: Vec<(Tag, usize, usize)>, } +/// Return the shared application clock in milliseconds for spelling-squiggle animation timing. fn now_ms() -> f64 { crate::now_ms() } diff --git a/crates/ui-egui/src/status.rs b/crates/ui-egui/src/status.rs index 53ca3c39..0301af44 100644 --- a/crates/ui-egui/src/status.rs +++ b/crates/ui-egui/src/status.rs @@ -115,6 +115,7 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) { }); } +/// Draw a small status icon with a tooltip and return its interaction response. fn small_icon(ui: &mut Ui, icon: Icon, tip: &str, on: bool) -> egui::Response { let resp = icon_button(ui, icon, on); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tip)); @@ -151,6 +152,8 @@ fn shown_text(ui: &Ui, rect: Rect, id: &str, text: &str) { } } +/// Draw a labelled status icon, painting the text directly and exposing the toggle state as its +/// accessible selected state. fn labeled(ui: &mut Ui, icon: Icon, label: &str, on: bool) -> egui::Response { let t = Tokens::get(ui.ctx()); let w = 22.0 + ui.fonts_mut(|f| f.layout_no_wrap(label.into(), theme::font(12.0), t.text).size().x) + 8.0; diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index a9e03608..2e14bbaa 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -299,6 +299,8 @@ mod tests { out } + /// The UI font library supplies default text fonts and an explicit monochrome emoji face, + /// independent of kittest defaults. #[test] fn the_ui_library_has_default_fonts_and_explicit_monochrome_emoji_for_tests() { // `egui`'s `default_fonts` is no longer a default feature and kittest's own dev @@ -315,6 +317,9 @@ mod tests { assert!(ours.font_data.contains_key("Hack")); } + /// When the Chinese face is present (required builds guarantee it), exactly one LXGW WenKai + /// Lite Regular is embedded, and Chinese labels have glyph coverage and ink; builds without the + /// optional face skip this test. #[test] #[cfg(not(target_arch = "wasm32"))] fn native_chinese_fallback_draws_initial_document_labels() { @@ -359,6 +364,8 @@ mod tests { } } + /// When the face is present (required builds guarantee it), the native Hebrew fallback is exactly + /// one static Regular Noto Sans Hebrew face with glyph coverage and ink for language labels. #[test] #[cfg(not(target_arch = "wasm32"))] fn native_hebrew_fallback_is_one_regular_face_and_draws_language_labels() { @@ -399,6 +406,7 @@ mod tests { } } + /// Hebrew fallback preserves logical-order editing in mixed-direction text. #[test] #[cfg(not(target_arch = "wasm32"))] fn native_hebrew_fallback_keeps_mixed_text_editing_in_logical_order() { diff --git a/crates/ui-egui/src/thumbs.rs b/crates/ui-egui/src/thumbs.rs index b28992ca..0fe82166 100644 --- a/crates/ui-egui/src/thumbs.rs +++ b/crates/ui-egui/src/thumbs.rs @@ -8,6 +8,7 @@ use serde_json::json; use crate::SlideApp; use crate::theme::{self, Tokens}; +/// Render the slide thumbnail pane, including section headers, selection, and scroll state. pub fn show(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let in_master = app.session.active().is_some_and(|d| d.selection.target != Target::Slides); @@ -40,6 +41,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { } } +/// Render the scrollable thumbnail list, scrolling the current slide into view when it changes. fn list(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; diff --git a/crates/ui-egui/src/toast.rs b/crates/ui-egui/src/toast.rs index 59245b20..896f9049 100644 --- a/crates/ui-egui/src/toast.rs +++ b/crates/ui-egui/src/toast.rs @@ -39,6 +39,7 @@ pub fn note_missing_fonts(app: &mut SlideApp) { } } +/// Render and dismiss the active transient toast without disturbing the document. pub fn show(app: &mut SlideApp, ctx: &egui::Context) { let Some(toast) = &app.toast else { return }; if crate::now_ms() - toast.at > SHOW_MS { diff --git a/crates/ui-egui/src/ui_soak_tests.rs b/crates/ui-egui/src/ui_soak_tests.rs index 2cf551e3..edfd169f 100644 --- a/crates/ui-egui/src/ui_soak_tests.rs +++ b/crates/ui-egui/src/ui_soak_tests.rs @@ -83,6 +83,8 @@ fn harness() -> Harness<'static, SlideApp> { h } +/// Apply one deterministic random UI/document action and return a label describing it for the +/// soak log, which is used as context when a later assertion fails. fn act(h: &mut Harness<'_, SlideApp>, r: &mut Rng) -> String { match r.below(9) { 0..=2 => { diff --git a/crates/ui-egui/src/uifonts.rs b/crates/ui-egui/src/uifonts.rs index afa494d0..9d23f8fe 100644 --- a/crates/ui-egui/src/uifonts.rs +++ b/crates/ui-egui/src/uifonts.rs @@ -26,6 +26,8 @@ fn accepts_face(byte_len: usize, color: bool) -> bool { !color && byte_len <= MAX_FACE_BYTES } +/// Accept a face only when checked addition stays within the aggregate interface-font byte +/// budget. fn aggregate_accepts(total: usize, byte_len: usize) -> bool { total.checked_add(byte_len).is_some_and(|n| n <= MAX_AGGREGATE_BYTES) } @@ -199,6 +201,8 @@ mod tests { FontDb::global().face(family, "Regular") } + /// The aggregate font budget accepts the exact cap and rejects overflow or over-budget + /// additions. #[test] fn aggregate_font_budget_is_bounded_and_checked() { assert!(aggregate_accepts(0, MAX_FACE_BYTES), "the first face is accepted at the per-face limit"); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index b4819396..5bfbf041 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -35,6 +35,7 @@ pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> Respon /// whole label area, so no visible line of a one- or two-line label can open the menu. const SPLIT_ARROW_W: f32 = 14.0; +/// Return the chevron-only sub-rectangle at the right edge of a split button. fn split_arrow_rect(rect: Rect) -> Rect { Rect::from_min_max(pos2(rect.max.x - SPLIT_ARROW_W, rect.min.y + 38.0), rect.max) } @@ -129,6 +130,8 @@ pub fn small_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, selecte small_button_ex(ui, icon, label, enabled, selected, false).0 } +/// Draw a small named button, optionally with a separately named options chevron, and return +/// both click targets. pub fn small_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, selected: bool, split: bool) -> (Response, Option) { small_button_named(ui, icon, label, if label.is_empty() { "Options" } else { label }, enabled, selected, split) } @@ -233,6 +236,7 @@ pub fn split_color_popup<'a>(main: &'a Response, arrow: &'a Response) -> egui::P popup } +/// Derive the popup id from the main button response so a split menu follows that button. fn split_popup_id(main: &Response) -> egui::Id { main.id.with("split-menu") } @@ -632,6 +636,8 @@ mod tests { let label = egui::pos2(main_rect.center().x, main_rect.min.y + 45.0); assert!(main_rect.contains(label) && !arrow_rect.contains(label), "the visible label is outside the chevron strip"); + /// Nested large-split-button test helper: move and press/release the primary pointer at a + /// position. fn click(harness: &mut egui_kittest::Harness<'_, ()>, pos: egui::Pos2) { harness.input_mut().events.push(egui::Event::PointerMoved(pos)); harness.run(); @@ -695,6 +701,8 @@ mod tests { let label_pixels = pos2(text.max.x - 1.0, text.center().y); assert!(!arrow_rect.contains(label_pixels)); + /// Nested two-line-split-button test helper: move and press/release the primary pointer at a + /// position. fn click(harness: &mut egui_kittest::Harness<'_, ()>, pos: egui::Pos2) { harness.input_mut().events.push(egui::Event::PointerMoved(pos)); harness.run();