mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-28 20:47:15 -06:00
M-egui037: document UI lifecycle and regression contracts
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user