M-egui037: document UI lifecycle and regression contracts

This commit is contained in:
skylar taylor-barrick
2026-10-25 14:47:11 +01:00
parent 318c3da3ba
commit 669fe6734a
35 changed files with 265 additions and 0 deletions
+5
View File
@@ -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.
+3
View File
@@ -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<egui::IconData> {
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`.
+9
View File
@@ -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<CommandSpec> {
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<Value> {
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();
}
+4
View File
@@ -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<Value> {
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.
+39
View File
@@ -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<FontInput, String> {
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<FontInput, String> {
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<Font
Ok(input)
}
/// Parse one manifest, reject malformed rows, exclude banned families, apply the wasm filter,
/// canonicalise assets, and emit initialisers.
fn manifest(dir: &std::path::Path, manifest: &std::path::Path, wasm: bool) -> Result<FontInput, String> {
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();
+2
View File
@@ -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();
+4
View File
@@ -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<u64>)) -> 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() });
+4
View File
@@ -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,
+17
View File
@@ -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<ColorImage> {
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<ControlResponse>) {
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();
+3
View File
@@ -133,6 +133,7 @@ fn f(p: &Value, k: &str) -> Option<f32> {
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<deckcraft_engine::T
})
}
/// Encode and write a screenshot, or return inline base64 data, while enforcing the capture
/// budgets and reporting encoding and write errors.
pub fn save_screenshot(app: &mut SlideApp, image: &egui::ColorImage, path: Option<&str>) -> Value {
let [w, h] = image.size;
let inline = path.is_none();
+1
View File
@@ -39,6 +39,7 @@ fn date_run(h: &Harness<'_, SlideApp>) -> Option<deckcraft_model::Run> {
.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();
+14
View File
@@ -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<Value> {
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<String> {
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<Value, String> {
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());
+1
View File
@@ -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)
}
+1
View File
@@ -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")
}
+49
View File
@@ -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();
+28
View File
@@ -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<Option<bool>, 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<u64>) {
// 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::<bool>(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(
+2
View File
@@ -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;
+9
View File
@@ -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)));
@@ -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();
+1
View File
@@ -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();
+2
View File
@@ -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());
+3
View File
@@ -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();
+15
View File
@@ -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<deckcraft_model::Animation> = 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"));
+3
View File
@@ -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 };
+7
View File
@@ -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();
+2
View File
@@ -70,6 +70,8 @@ struct Header<'a> {
first: Option<u32>,
}
/// 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();
+7
View File
@@ -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");
+1
View File
@@ -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()
}
+3
View File
@@ -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;
+8
View File
@@ -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() {
+2
View File
@@ -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 };
+1
View File
@@ -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 {
+2
View File
@@ -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 => {
+4
View File
@@ -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");
+8
View File
@@ -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<Response>) {
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();