From 85c5d21465eca2356343cc0ac2eb31a95ee74d1e Mon Sep 17 00:00:00 2001 From: 00xJ0 <56056764+00xJ0@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:56:51 -0700 Subject: [PATCH 001/113] #1545: effects.apply answers with the applied instances (clip, index, effect) --- crates/engine/src/commands.rs | 13 ++++++++----- crates/engine/src/vfx_tests.rs | 35 ++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index c99e5c64b..7f5028366 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -2943,8 +2943,11 @@ fn build() -> Vec { } let (fw, fh) = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080)); let label = format!("Apply {}", def.name); - s.edit_sequence(&label, |q, _, _| { - let mut n = 0; + // Answer `{"applied":[{"clip","index","effect"}]}`: where each instance landed, so an + // agent can address it (`effects.setParam`, `effects.remove`) without a `sequence_inspect` + // round trip, as `timeline.place` answers with its clips (#1545). + let applied = s.edit_sequence(&label, |q, _, _| { + let mut applied = Vec::new(); for t in q.all_tracks_mut() { if (t.kind == TrackKind::Audio) != is_audio { continue; @@ -2956,12 +2959,12 @@ fn build() -> Vec { let pos = i.effects.iter().position(|e| e.def().is_some_and(|d| d.intrinsic)).unwrap_or(i.effects.len()); let pos = if is_audio { i.effects.len() } else { pos }; i.effects.insert(pos, inst); - n += 1; + applied.push(json!({"clip": i.id.0, "index": pos, "effect": def.id})); } } - Ok(n) + Ok(applied) })?; - Ok(Value::Null) + Ok(json!({"applied": applied})) }), cmd!("effects.remove", "Remove Effect", [], None, r#"{"clip":id,"index":n}"#, has_seq, |s, p| { let c = clip_p(p, "clip").ok_or_else(|| bad("effects.remove", "need `clip`"))?; diff --git a/crates/engine/src/vfx_tests.rs b/crates/engine/src/vfx_tests.rs index f1aeb5987..7922b6e9e 100644 --- a/crates/engine/src/vfx_tests.rs +++ b/crates/engine/src/vfx_tests.rs @@ -96,6 +96,41 @@ fn every_video_effect_applies_by_id_and_name_and_renders() { } } +/// #1545: `effects.apply` reports each instance it added (clip, index, effect id), like +/// `timeline.place` reports its clips, so agents need no `sequence_inspect` round trip. +#[test] +fn effects_apply_reports_where_each_instance_landed() { + let mut s = demo(); + let clip = first_v1_clip(&s); + let r = s.execute("effects.apply", json!({"clips": [clip], "effect": "gaussian_blur"})).unwrap(); + let applied = r["applied"].as_array().unwrap(); + assert_eq!(applied.len(), 1); + assert_eq!(applied[0]["clip"].as_u64(), Some(clip)); + assert_eq!(applied[0]["effect"], "gaussian_blur"); + let idx = applied[0]["index"].as_u64().unwrap() as usize; + let it = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(clip)).unwrap().1.clone(); + assert_eq!(it.effects.get(idx).map(|e| e.effect.as_str()), Some("gaussian_blur"), "index addresses the new instance"); + // the index is usable straight away + s.execute("effects.remove", json!({"clip": clip, "index": idx})).unwrap(); + assert!(s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(clip)).unwrap().1.effects.iter().all(|e| e.effect != "gaussian_blur")); + + // several clips: one entry per instance, each with its own clip id + let clips: Vec = s.active_sequence().unwrap().video_tracks[0].items.iter().map(|c| c.id.0).collect(); + assert!(clips.len() >= 2, "demo project has several V1 clips"); + let r = s.execute("effects.apply", json!({"clips": clips, "effect": "invert"})).unwrap(); + let got: Vec = r["applied"].as_array().unwrap().iter().map(|a| a["clip"].as_u64().unwrap()).collect(); + assert_eq!(got, clips); + + // audio effects report the (retargeted) audio clip, not the video clip that was passed + let video = first_v1_clip(&s); + let r = s.execute("effects.apply", json!({"clips": [video], "effect": "parametric_eq"})).unwrap(); + let a = &r["applied"].as_array().unwrap()[0]; + let target = a["clip"].as_u64().unwrap(); + let (track, item) = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(target)).unwrap(); + assert_eq!(s.active_sequence().unwrap().track(track).unwrap().kind, filmcraft_project::TrackKind::Audio); + assert_eq!(item.effects.get(a["index"].as_u64().unwrap() as usize).map(|e| e.effect.as_str()), Some("parametric_eq")); +} + #[test] fn new_effect_params_are_settable_and_keyframable() { let mut s = demo(); From 52df478479bab7b8782b35c625b6f96e9d529604 Mon Sep 17 00:00:00 2001 From: 00xJ0 <56056764+00xJ0@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:21:36 -0700 Subject: [PATCH 002/113] #1545: file.newProject answers with the project's name and revision --- crates/engine/src/commands.rs | 3 ++- crates/engine/src/file_tests.rs | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 7f5028366..1042f2a82 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1148,7 +1148,8 @@ fn build() -> Vec { s.revision += 1; s.saved_revision = s.revision; s.events.push(crate::Event::ProjectChanged { revision: s.revision }); - Ok(Value::Null) + // the project as `project_inspect` names it (#1545: an agent needs no extra round trip) + Ok(json!({"name": name, "revision": s.revision})) }), cmd!("file.openDemoProject", "Demo Project", ["File", "New"], None, "{}", always, |s, _| { let (p, seq) = crate::demo::demo_project(&s.media); diff --git a/crates/engine/src/file_tests.rs b/crates/engine/src/file_tests.rs index f474b268c..f6c6ad218 100644 --- a/crates/engine/src/file_tests.rs +++ b/crates/engine/src/file_tests.rs @@ -94,6 +94,19 @@ fn project_is_named_after_its_file() { let _ = std::fs::remove_dir_all(&d); } +/// #1545: `file.newProject` answers with the project it made (name and revision, as +/// `project_inspect` reports them) instead of `null`. +#[test] +fn new_project_answers_with_its_name_and_revision() { + let mut s = Session::default(); + let r = s.execute("file.newProject", json!({"name": "Trailer"})).unwrap(); + assert_eq!(r["name"], "Trailer"); + assert_eq!(r["revision"].as_u64(), Some(s.revision)); + assert_eq!(s.project.name, "Trailer"); + let r = s.execute("file.newProject", json!({})).unwrap(); + assert_eq!(r["name"], "Untitled", "the default name"); +} + /// Opening the demo after a saved project must not keep that path: Save would overwrite it. #[test] fn open_demo_project_clears_saved_path_so_save_does_not_overwrite() { From e8d20a39e74dae0e855ce050b85bcb1cbf31ded2 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 13:41:30 +0200 Subject: [PATCH 003/113] PNG Sequence writes RGB files when Include Alpha Channel is off (fixes #1863) --- crates/export/src/lib.rs | 13 ++++++++----- crates/export/src/settings.rs | 2 +- crates/export/src/settings_tests.rs | 27 ++++++++++++++++++++++++--- crates/export/src/still.rs | 4 ++-- 4 files changed, 35 insertions(+), 11 deletions(-) diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index ebea2550e..613e29135 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -1785,12 +1785,11 @@ impl Write for SharedBuf { /// Encode straight sRGB RGBA8 pixels as a PNG file (thumbnails, previews). pub fn encode_png(rgba: Vec, w: u32, h: u32) -> Result> { - encode_still(Format::PngSequence, rgba, w, h, false) + encode_still(Format::PngSequence, rgba, w, h, true) } -/// Encode one still of an image sequence (also Export Frame). PNG always carries the alpha -/// channel it is given; `alpha` keeps it in TIFF, Targa and OpenEXR too, which are otherwise written -/// as RGB. +/// Encode one still of an image sequence (also Export Frame). `alpha` keeps the alpha channel in +/// PNG, TIFF, Targa and OpenEXR, which are otherwise written as RGB. /// JPEG uses the default export quality ([`encode_still_with_quality`] picks another). pub fn encode_still(format: Format, rgba: Vec, w: u32, h: u32, alpha: bool) -> Result> { encode_still_with_quality(format, rgba, w, h, alpha, ExportSettings::default().quality) @@ -1820,9 +1819,13 @@ pub fn encode_still_with(format: Format, rgba: Vec, w: u32, h: u32, alpha: b Format::TgaSequence => return raster::tga(&rgba, w, h, alpha), Format::DpxSequence => return raster::dpx(&rgba, w, h), Format::ExrSequence => return raster::exr(&rgba, w, h, alpha, exr), - Format::PngSequence => { + Format::PngSequence if alpha => { image::ImageEncoder::write_image(image::codecs::png::PngEncoder::new(&mut out), &rgba, w, h, image::ExtendedColorType::Rgba8).map_err(enc)? } + Format::PngSequence => { + let rgb = raster::rgb(&rgba, w, h)?; + image::ImageEncoder::write_image(image::codecs::png::PngEncoder::new(&mut out), &rgb, w, h, image::ExtendedColorType::Rgb8).map_err(enc)? + } Format::TiffSequence | Format::BmpSequence => { let img = image::RgbaImage::from_raw(w, h, rgba).ok_or_else(|| ExportError::Encode("frame size".into()))?; let f = if format == Format::TiffSequence { image::ImageFormat::Tiff } else { image::ImageFormat::Bmp }; diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs index ab954adc9..84ca5bacd 100644 --- a/crates/export/src/settings.rs +++ b/crates/export/src/settings.rs @@ -624,7 +624,7 @@ impl ExportSettings { px * fps * bpp } Format::Mjpeg | Format::JpegSequence => px * 8.0 * (0.4 + self.quality as f64 / 100.0 * 2.0) * fps / 8.0, - Format::PngSequence => px * 4.0 * 0.45 * 8.0 * fps, + Format::PngSequence => px * if self.keeps_alpha() { 4.0 } else { 3.0 } * 0.45 * 8.0 * fps, Format::BmpSequence => px * 3.0 * 8.0 * fps, Format::TiffSequence => px * if self.keeps_alpha() { 4.0 } else { 3.0 } * 8.0 * fps, Format::TgaSequence => px * if self.keeps_alpha() { 4.0 } else { 3.0 } * 8.0 * fps, diff --git a/crates/export/src/settings_tests.rs b/crates/export/src/settings_tests.rs index 22be2d317..cfd028bf8 100644 --- a/crates/export/src/settings_tests.rs +++ b/crates/export/src/settings_tests.rs @@ -238,7 +238,8 @@ fn openexr_sequences_compress() { } } -/// `alpha` keeps straight alpha in PNG and TIFF sequences; off (the default) flattens over black (#160). +/// `alpha` keeps straight alpha in PNG and TIFF sequences; off (the default) flattens over black (#160) +/// and writes RGB files without an alpha channel (#1863). #[test] fn image_sequences_keep_alpha_when_asked() { let (p, seq, m) = matte([0.0, 0.0, 1.0, 0.5], 64, 36, None); @@ -248,16 +249,36 @@ fn image_sequences_keep_alpha_when_asked() { let mut s = ExportSettings { format: fmt, path: dir.path(&format!("a.{ext}")), alpha, ..Default::default() }; s.range = Some(TimeRange::new(Tick::ZERO, FrameRate::FPS_24.tick_of(2))); export(&p, seq, &s, &m, &Progress::default()).unwrap(); - let img = image::open(dir.0.join(format!("a000.{ext}"))).unwrap(); + let file = dir.0.join(format!("a000.{ext}")); + if fmt == Format::PngSequence { + let colour_type = std::fs::read(&file).unwrap()[25]; + assert_eq!(colour_type, if alpha { 6 } else { 2 }, "PNG colour type, alpha={alpha}"); + } + let img = image::open(&file).unwrap(); let px = img.to_rgba8().get_pixel(32, 18).0; + assert_eq!(img.color().has_alpha(), alpha, "{fmt:?} alpha channel written, alpha={alpha}"); if alpha { - assert!(img.color().has_alpha(), "{fmt:?} must be written with an alpha channel"); assert!((100..=160).contains(&px[3]) && px[2] > 200 && px[0] < 15, "{fmt:?} straight alpha: {px:?}"); } else { assert!(px[3] == 255 && px[2] > 100 && px[2] < 240 && px[0] < 15, "{fmt:?} flattened over black: {px:?}"); } } } + // the PNG size estimate counts the channels actually written, as TIFF's does + let rate = FrameRate::FPS_24; + let png = |alpha| ExportSettings { format: Format::PngSequence, alpha, ..Default::default() }.estimate_bytes(320, 180, rate, 48_000, rate.tick_of(1)); + assert_eq!(png(false) * 4, png(true) * 3, "PNG estimate: {} RGB vs {} RGBA", png(false), png(true)); +} + +/// Thumbnails (`encode_png`) and an 8-bit PNG Export Frame keep the alpha they are given: only the +/// sequence's `alpha` switch drops it (#1863). +#[test] +fn png_stills_keep_their_alpha_channel() { + let half = filmcraft_render::Image::filled(4, 2, [0.0, 0.0, 0.5, 0.5]); + let thumb = encode_png(half.to_rgba8(), 4, 2).unwrap(); + assert_eq!(thumb[25], 6, "encode_png colour type"); + let frame = still::encode(&half, still::StillFormat::Png, 8).unwrap(); + assert_eq!(frame[25], 6, "Export Frame PNG colour type"); } #[test] diff --git a/crates/export/src/still.rs b/crates/export/src/still.rs index c60c2159f..2af712461 100644 --- a/crates/export/src/still.rs +++ b/crates/export/src/still.rs @@ -77,8 +77,8 @@ pub fn encode(image: &Image, format: StillFormat, depth: u8) -> Result> StillFormat::Tiff => Format::TiffSequence, _ => Format::BmpSequence, }; - // 8-bit TIFF stays RGB, as Export Frame wrote it before; 16-bit output keeps alpha. - return crate::encode_still(f, rgba, w, h, false); + // 8-bit TIFF and BMP stay RGB, as Export Frame wrote them before; PNG and 16-bit output keep alpha. + return crate::encode_still(f, rgba, w, h, format == StillFormat::Png); } let capacity = samples.checked_mul(2).ok_or_else(bad)?; let mut bytes = Vec::with_capacity(capacity); From 32b857744094a6db55b7fe354c22c5a23062a5e3 Mon Sep 17 00:00:00 2001 From: Guilherme Barbosa Ferrarezi Date: Wed, 14 Oct 2026 08:56:17 -0300 Subject: [PATCH 004/113] #1216: a cap on the CPU threads an export uses (ExportSettings::max_threads) `ExportSettings::max_threads` (`maxThreads` in JSON, default 0 = all cores) makes `export()` run on a dedicated rayon pool of that size when it is below the pool the export is called in. Batch size, render-ahead, the renderer pool and the parallel work of the software encoders follow it because they ask rayon for the number of threads. A cap at or above the current pool changes nothing. The commands that resolve export settings accept it as a flat parameter or inside `settings`. The output does not depend on it. The decoders' and the H.264 encoder's own thread pools are not counted yet. --- crates/engine/src/export_tests.rs | 13 +++++ crates/engine/src/export_tools.rs | 3 ++ crates/export/src/determinism_tests.rs | 74 ++++++++++++++++++++++++++ crates/export/src/lib.rs | 30 ++++++++++- 4 files changed, 119 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/export_tests.rs b/crates/engine/src/export_tests.rs index d0cec7035..63cc218b7 100644 --- a/crates/engine/src/export_tests.rs +++ b/crates/engine/src/export_tests.rs @@ -815,3 +815,16 @@ fn caption_sidecars_keep_only_the_exported_interval() { assert!(doc.cues.is_empty(), "half-open export excludes cues touching either boundary: {doc:?}"); } } + +/// #1216: `maxThreads` reaches the export settings from the command parameters (CLI, MCP, control +/// channel), as a flat parameter or inside `settings`. +#[test] +fn max_threads_reaches_the_export_settings() { + let session = Session::default(); + let settings_of = |params: Value| crate::export_tools::settings_from_params(&session, ¶ms, "file.exportMedia").map(|(_, s)| s); + assert_eq!(settings_of(json!({"format": "mjpeg", "path": "out.mov", "maxThreads": 2})).unwrap().max_threads, 2); + assert_eq!(settings_of(json!({"format": "mjpeg", "path": "out.mov", "settings": {"maxThreads": 3}})).unwrap().max_threads, 3); + assert_eq!(settings_of(json!({"format": "mjpeg", "path": "out.mov"})).unwrap().max_threads, 0, "all cores unless asked"); + assert!(settings_of(json!({"format": "mjpeg", "path": "out.mov", "maxThreads": -1})).is_err(), "a negative count is refused"); + assert!(settings_of(json!({"format": "mjpeg", "path": "out.mov", "settings": {"maxThread": 2}})).is_err(), "a misspelt setting is refused, not ignored"); +} diff --git a/crates/engine/src/export_tools.rs b/crates/engine/src/export_tools.rs index 2fd68925b..5511141c5 100644 --- a/crates/engine/src/export_tools.rs +++ b/crates/engine/src/export_tools.rs @@ -235,6 +235,9 @@ pub fn settings_from_params(s: &Session, p: &Value, cmd: &str) -> Result<(Option settings.bitrate_kbps = v; settings.adaptive_bitrate = None; } + if let Some(v) = crate::commands::checked_u32_p(p, "maxThreads", cmd)? { + settings.max_threads = v; + } if let Some(v) = crate::commands::checked_u32_p(p, "maxBitrateKbps", cmd)? { settings.max_bitrate_kbps = Some(v); } diff --git a/crates/export/src/determinism_tests.rs b/crates/export/src/determinism_tests.rs index 2866cb9d4..44ff76daa 100644 --- a/crates/export/src/determinism_tests.rs +++ b/crates/export/src/determinism_tests.rs @@ -308,3 +308,77 @@ fn a_panic_while_rendering_ahead_is_an_error() { assert!(matches!(r, Err(ExportError::Encode(_))), "{:?}", r.map(|_| ())); }); } + +// ------------------------------------------------------------------ max_threads + +/// A provider that notes how many threads the pool it is asked on has, and which threads ask. +struct Noting<'a> { + inner: &'a SourceMap, + most: AtomicUsize, + threads: std::sync::Mutex>, +} + +impl<'a> Noting<'a> { + fn new(inner: &'a SourceMap) -> Self { + Self { inner, most: AtomicUsize::new(0), threads: Default::default() } + } +} + +impl SourceProvider for Noting<'_> { + fn source(&self, item: ItemId) -> Option { + self.most.fetch_max(rayon::current_num_threads(), Ordering::SeqCst); + self.threads.lock().unwrap().insert(std::thread::current().id()); + self.inner.source(item) + } +} + +/// Export the test project from a pool of `outer` threads with `max_threads` set, and report the +/// biggest pool a frame was asked on and how many different threads asked. +fn threads_used(outer: usize, max_threads: u32, name: &str) -> (usize, usize) { + let (p, seq, m) = project(); + let noting = Noting::new(&m); + let settings = ExportSettings { format: Format::Mjpeg, path: tmp(name), include_audio: false, max_threads, ..Default::default() }; + pool(outer).install(|| export(&p, seq, &settings, ¬ing, &Progress::default())).unwrap(); + (noting.most.load(Ordering::SeqCst), noting.threads.lock().unwrap().len()) +} + +#[test] +fn max_threads_caps_the_threads_an_export_runs_on() { + // uncapped, it renders on the pool it is called in + let (most, threads) = threads_used(8, 0, "cap-0.mov"); + assert_eq!(most, 8); + assert!(threads > 3, "{threads} threads asked for frames: the uncapped export should have used several"); + // capped, on a pool of that size + for cap in [1u32, 3] { + let (most, threads) = threads_used(8, cap, &format!("cap-{cap}.mov")); + assert_eq!(most, cap as usize, "cap {cap}"); + assert!(threads <= cap as usize, "cap {cap}: {threads} threads asked for frames"); + } +} + +#[test] +fn max_threads_never_adds_threads() { + // a cap at or above the pool the export is called in changes nothing + assert_eq!(threads_used(4, 4, "cap-same.mov").0, 4); + assert_eq!(threads_used(4, 100, "cap-large.mov").0, 4); +} + +#[test] +fn max_threads_does_not_change_the_file() { + for (format, ext) in [(Format::ProRes, "mov"), (Format::H264, "mp4"), (Format::Mjpeg, "mov")] { + let settings = |name: &str, max_threads: u32| ExportSettings { format, path: tmp(&format!("{name}.{ext}")), max_threads, ..Default::default() }; + let reference = export_with_cores(5, &settings(&format!("{}-uncapped", format.id()), 0)); + for cap in [1u32, 3] { + let capped = export_with_cores(5, &settings(&format!("{}-cap{cap}", format.id()), cap)); + assert!(reference == capped, "{}: a cap of {cap} thread(s) gives a different file", format.label()); + } + } +} + +#[test] +fn max_threads_is_a_setting_of_the_json_interface() { + let s: ExportSettings = serde_json::from_str(r#"{"format":"mjpeg","maxThreads":2}"#).unwrap(); + assert_eq!(s.max_threads, 2); + assert_eq!(ExportSettings::default().max_threads, 0, "all cores unless asked"); + assert_eq!(serde_json::to_value(&s).unwrap()["maxThreads"], serde_json::json!(2)); +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index ebea2550e..54001cfa2 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -325,6 +325,13 @@ pub struct ExportSettings { /// `progress.total`, `finished` and the final status; this call only adds to `done`. #[serde(default)] pub part_of_batch: bool, + /// Most CPU threads the export may use (0 = all of them, as before). Rendering, the software + /// encoders and the rest of the export's parallel work run on a pool of this size; a number + /// above what the machine has (or above the pool the export already runs in) changes nothing. + /// The output does not depend on it. Hardware encoders and the decoders' own threads are not + /// counted. + #[serde(default)] + pub max_threads: u32, /// ProRes flavour: `proxy`, `lt`, `standard`, `hq`, `4444` or `4444xq` (empty = HQ). #[serde(default)] pub prores_profile: String, @@ -558,6 +565,7 @@ impl Default for ExportSettings { bitrate_kbps: 20_000, burn_captions: false, part_of_batch: false, + max_threads: 0, prores_profile: String::new(), dnx_profile: String::new(), apv_profile: String::new(), @@ -1837,8 +1845,28 @@ pub fn encode_still_with(format: Format, rgba: Vec, w: u32, h: u32, alpha: b Ok(out.into_inner()) } -/// Run an export (blocking; call from a worker thread). +/// A pool of `max_threads` threads to run an export on, or `None` when it should run where it is: +/// no cap (0), a cap that is not below the pool the caller is in already, or no threads to spare +/// (the browser). +fn capped_pool(max_threads: u32) -> Option { + let cap = max_threads as usize; + if cap == 0 || cap >= rayon::current_num_threads() || cfg!(target_arch = "wasm32") { + return None; + } + rayon::ThreadPoolBuilder::new().num_threads(cap).thread_name(|i| format!("filmcraft-export-{i}")).build().ok() +} + +/// Run an export (blocking; call from a worker thread). With `settings.max_threads` set it runs on +/// a pool of that many threads, so its rendering and software encoding use at most that many cores. pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, sources: &dyn SourceProvider, progress: &Progress) -> Result { + match capped_pool(settings.max_threads) { + Some(pool) => pool.install(|| export_here(project, seq, settings, sources, progress)), + None => export_here(project, seq, settings, sources, progress), + } +} + +/// [`export`] on the threads it is called from. +fn export_here(project: &Arc, seq: ItemId, settings: &ExportSettings, sources: &dyn SourceProvider, progress: &Progress) -> Result { settings.validate()?; if stepped(settings.format) { let mut ex = Exporter::new(project.clone(), seq, settings, progress)?; From b474ca6ca488388eb6edc04da1db1c20c3a0217a Mon Sep 17 00:00:00 2001 From: Earl Co Date: Wed, 14 Oct 2026 19:48:23 +0200 Subject: [PATCH 005/113] edit: reject repeated clip identities in move plans Reject repeated source IDs before moving items, and normalize selection-derived plans so nudges and drags edit each selected identity once. Keep new-track intent per copy entry so repeated sources retain their requested destinations. Cover failed-move history preservation, corrected retry, repeated-copy destinations, and Frame Hold followed by dragging. Signed-off-by: Earl Co --- AGENTS.md | 8 + crates/edit/src/lib.rs | 6 + crates/edit/src/tests.rs | 15 + crates/engine/src/commands.rs | 47 +-- crates/engine/src/keyboard.rs | 4 + crates/engine/src/timeline_move_tests.rs | 310 ++++++++++++++++++ crates/ui-egui/src/panels/timeline.rs | 2 + .../ui-egui/tests/timeline_option_drag_ui.rs | 37 +++ 8 files changed, 408 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0683cb87a..b808459e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,6 +124,14 @@ See [§0](#0-never-crash). It outranks every other engineering rule. ### 3.2 The rest +**Invariant — move identity uniqueness:** A move plan lists each clip identity at most once. +The core move operation rejects repeated ids before mutation or id allocation; placing one identity +on multiple tracks makes selection and later identity-based editing ambiguous. Copies receive new ids +and retain each entry's requested destination. Destination track creation and the edit commit together. +In an idle session, a rejected duplicate move retains the project and its undo and redo history. +Selection-derived edit plans keep each identity once in first-occurrence order before expanding +linked partners; explicit move requests with repeated identities are still rejected. + See `CLAUDE.md`: pure Rust, dependency layering (`cargo xtask layers`), exact `Tick` time, everything is a command, everything is agent-drivable, and the quality gates (`cargo xtask ci`) before every commit. diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 2f2588d6c..5478030f0 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -509,6 +509,12 @@ pub fn close_gap(seq: &mut Sequence, track: TrackId, t: Tick) -> Result<()> { /// Move items by (track, time) offsets with overwrite (default drag) or insert (Cmd-drag) semantics. /// `moves`: (item, destination track, new start). pub fn move_items(seq: &mut Sequence, moves: &[(ClipId, TrackId, Tick)], insert_mode: bool, ctx: &mut EditCtx) -> Result<()> { + let mut seen = std::collections::HashSet::new(); + for (clip, _, _) in moves { + if !seen.insert(*clip) { + return Err(EditError::Other(format!("clip {} is listed more than once in this move", clip.0))); + } + } let mut work = seq.clone(); let mut placed = Vec::new(); for (c, dest, start) in moves { diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 9b42c106a..78b9dfaca 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -77,6 +77,21 @@ impl Fx { } } +#[test] +fn move_rejects_repeated_clip_ids_without_mutation() { + for insert_mode in [false, true] { + let mut fx = Fx::new(); + let (v1, v2) = (fx.v(0), fx.v(1)); + let c = fx.put(v1, 0, 10, 0); + let before = fx.seq.clone(); + let mut n = fx.next; + let result = move_items(&mut fx.seq, &[(c, v1, f(20)), (c, v2, f(20))], insert_mode, &mut Fx::ctx(&mut n)); + assert!(result.is_err(), "moving one identity to two tracks must be rejected: {result:?}"); + assert_eq!(fx.seq, before); + assert_eq!(n, fx.next, "a rejected move consumes no ids"); + } +} + #[test] fn overwrite_splits_underlying() { let mut fx = Fx::new(); diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index c99e5c64b..43c5ef401 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -344,10 +344,10 @@ pub(crate) fn item_p(p: &Value, k: &str) -> Option { /// as `copied`. fn move_clips(s: &mut Session, p: &Value) -> Result { const CMD: &str = "timeline.move"; - let mut listed: Vec<(ClipId, TrackId, Tick)> = Vec::new(); - let mut new_moves = Vec::new(); + let mut listed: Vec<(ClipId, TrackId, Tick, bool)> = Vec::new(); for m in p.get("moves").and_then(Value::as_array).into_iter().flatten() { - let track = if str_p(m, "track").is_some_and(|t| t.eq_ignore_ascii_case("new")) { + let new_track = str_p(m, "track").is_some_and(|t| t.eq_ignore_ascii_case("new")); + let track = if new_track { let clip = clip_p(m, "clip").ok_or_else(|| bad(CMD, "new track needs a clip"))?; let q = s.active_sequence().ok_or(EngineError::NoSequence)?; let (source, _) = q.find_item(clip).ok_or(edit::EditError::NoItem(clip))?; @@ -357,13 +357,12 @@ fn move_clips(s: &mut Session, p: &Value) -> Result { if m.get("time").and_then(Value::as_i64).is_none() { return Err(bad(CMD, "new track needs a time")); } - new_moves.push((clip, source)); Some(source) } else { track_p(s, m, "track", CMD)? }; if let (Some(clip), Some(track), Some(time)) = (clip_p(m, "clip"), track, m.get("time").and_then(Value::as_i64)) { - listed.push((clip, track, Tick(time))); + listed.push((clip, track, Tick(time), new_track)); } } if listed.is_empty() { @@ -371,32 +370,32 @@ fn move_clips(s: &mut Session, p: &Value) -> Result { } let follow = bool_p(p, "linked").unwrap_or(s.state.linked_selection); let q = s.active_sequence().ok_or(EngineError::NoSequence)?; - // (clip, destination track, new start, where a partner starts now). `time` is any i64: the - // sums are done in i128, where a few i64 values cannot overflow, and checked on the way back. - let mut plan: Vec<(ClipId, TrackId, i128, Option)> = Vec::new(); - for &(clip, track, time) in &listed { - plan.push((clip, track, i128::from(time.0), None)); + // (clip, destination track, new start, where a partner starts now, new track requested). + // `time` is any i64: sums use i128 and are checked on the way back. + let mut plan: Vec<(ClipId, TrackId, i128, Option, bool)> = Vec::new(); + for &(clip, track, time, new_track) in &listed { + plan.push((clip, track, i128::from(time.0), None, new_track)); let Some((_, it)) = q.find_item(clip) else { continue }; // `edit::move_items` reports it let Some(link) = it.link.filter(|_| follow) else { continue }; let offset = i128::from(time.0) - i128::from(it.start.0); for t in q.all_tracks().filter(|t| !t.locked) { for partner in t.items.iter().filter(|i| i.link == Some(link)) { if !listed.iter().any(|m| m.0 == partner.id) && !plan.iter().any(|m| m.0 == partner.id) { - plan.push((partner.id, t.id, i128::from(partner.start.0) + offset, Some(i128::from(partner.start.0)))); + plan.push((partner.id, t.id, i128::from(partner.start.0) + offset, Some(i128::from(partner.start.0)), false)); } } } } let late = plan.iter().map(|m| -m.2).max().unwrap_or(0).max(0); - let mut moves: Vec<(ClipId, TrackId, Tick)> = Vec::with_capacity(plan.len()); - for (clip, track, start, was) in plan { + let mut moves: Vec<(ClipId, TrackId, Tick, bool)> = Vec::with_capacity(plan.len()); + for (clip, track, start, was, new_track) in plan { // a partner that ends up where it is does not move (its listed clip only changed track): // re-placing it would drop the transitions at its edges if was == Some(start + late) { continue; } let start = i64::try_from(start + late).ok().map(Tick).filter(|t| *t <= Tick::MAX).ok_or_else(|| bad(CMD, "`time` is out of range"))?; - moves.push((clip, track, start)); + moves.push((clip, track, start, new_track)); } let ins = bool_p(p, "insert").unwrap_or(false); let copy = bool_p(p, "copy").unwrap_or(false); @@ -409,7 +408,7 @@ fn move_clips(s: &mut Session, p: &Value) -> Result { // Allocate destinations in the same transaction as the move: an invalid edit leaves // neither empty tracks nor history entries. Each source track keeps its clips together. let prepare = |q: &mut filmcraft_project::Sequence, ctx: &mut edit::EditCtx<'_>| -> Result> { - let sources: Vec<_> = q.all_tracks().filter(|t| new_moves.iter().any(|m| m.1 == t.id)).map(|t| (t.id, t.kind, t.channels)).collect(); + let sources: Vec<_> = q.all_tracks().filter(|t| moves.iter().any(|m| m.3 && m.1 == t.id)).map(|t| (t.id, t.kind, t.channels)).collect(); for kind in [TrackKind::Video, TrackKind::Audio] { let added = sources.iter().filter(|s| s.1 == kind).count(); if added > 0 && q.tracks(kind).len().saturating_add(added) > crate::sequence_tools::MAX_TRACKS { @@ -428,8 +427,9 @@ fn move_clips(s: &mut Session, p: &Value) -> Result { } Ok(moves .iter() - .map(|&(clip, track, time)| { - let dest = new_moves.iter().find(|m| m.0 == clip).and_then(|m| destinations.iter().find(|d| d.0 == m.1)).map_or(track, |d| d.1); + .map(|&(clip, track, time, new_track)| { + // A copy can list the same source again with a different destination. + let dest = destinations.iter().find(|d| new_track && d.0 == track).map_or(track, |d| d.1); (clip, dest, time) }) .collect()) @@ -575,12 +575,17 @@ pub fn linked_roll_pairs(seq: &filmcraft_project::Sequence, left: ClipId, right: pairs } -/// Expand a clip selection with linked partners (when linked selection is on). +/// Keep each selected identity once, then expand linked partners when linked selection is on. pub fn with_links(s: &Session, clips: &[ClipId]) -> Vec { - let Some(seq) = s.active_sequence() else { return clips.to_vec() }; - let mut out = clips.to_vec(); + let mut out = Vec::new(); + for c in clips { + if !out.contains(c) { + out.push(*c); + } + } + let Some(seq) = s.active_sequence() else { return out }; if s.state.linked_selection { - let links: Vec = clips.iter().filter_map(|c| seq.find_item(*c).and_then(|(_, i)| i.link)).collect(); + let links: Vec = out.iter().filter_map(|c| seq.find_item(*c).and_then(|(_, i)| i.link)).collect(); for t in seq.all_tracks() { for i in &t.items { if i.link.is_some_and(|l| links.contains(&l)) && !out.contains(&i.id) { diff --git a/crates/engine/src/keyboard.rs b/crates/engine/src/keyboard.rs index 5d344943f..622bd5300 100644 --- a/crates/engine/src/keyboard.rs +++ b/crates/engine/src/keyboard.rs @@ -226,7 +226,11 @@ fn nudge_track(s: &mut Session, up: bool) -> Result { let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; let mut moves = Vec::new(); let mut blocked = 0usize; + let mut seen = std::collections::HashSet::new(); for c in &s.state.selection { + if !seen.insert(*c) { + continue; + } let Some((tid, it)) = seq.find_item(*c) else { continue }; let Some(tr) = seq.track(tid) else { continue }; let list = seq.tracks(tr.kind); diff --git a/crates/engine/src/timeline_move_tests.rs b/crates/engine/src/timeline_move_tests.rs index f6aa640ff..3f4d32350 100644 --- a/crates/engine/src/timeline_move_tests.rs +++ b/crates/engine/src/timeline_move_tests.rs @@ -36,6 +36,316 @@ fn pair_in_an_empty_sequence(s: &mut Session, seconds: f64) -> (TrackId, TrackIt (q.video_tracks[0].id, v, q.audio_tracks[0].id, a) } +fn assert_selection_edit_matches_control(s: &mut Session, selection: Vec, command: &str, params: serde_json::Value) { + let before = s.project.clone(); + let undo = s.history.undo.len(); + let mut control = Session { project: s.project.clone(), state: s.state.clone(), history: s.history.clone(), media: s.media.clone(), ..Session::default() }; + control.state.selection = selection; + let expected = control.execute(command, params.clone()).unwrap(); + let actual = s.execute(command, params).unwrap(); + assert_eq!(actual, expected, "{command} returns the unique-selection result"); + assert_eq!(s.project, control.project, "{command} produces the complete unique-selection project"); + assert_eq!(s.history.undo.len(), undo + 1, "the edit is one undo step"); + assert_eq!(s.history.undo, control.history.undo); + assert_eq!(s.history.redo, control.history.redo); + assert_eq!(s.history.merge_key, control.history.merge_key); + let q = s.active_sequence().unwrap(); + let ids: Vec = q.all_tracks().flat_map(|t| t.items.iter().map(|i| i.id)).collect(); + assert_eq!(ids.iter().copied().collect::>().len(), ids.len(), "no identity was duplicated"); + q.check().unwrap(); + let after = s.project.clone(); + s.execute("edit.undo", json!({})).unwrap(); + control.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project, before, "undo restores the entire pre-edit project"); + assert_eq!(s.project, control.project); + assert_eq!(s.history.undo, control.history.undo); + assert_eq!(s.history.redo, control.history.redo); + s.active_sequence().unwrap().check().unwrap(); + s.execute("edit.redo", json!({})).unwrap(); + control.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.project, after, "redo restores the entire edited project"); + assert_eq!(s.project, control.project); + assert_eq!(s.history.undo, control.history.undo); + assert_eq!(s.history.redo, control.history.redo); + s.active_sequence().unwrap().check().unwrap(); +} + +#[test] +fn duplicate_selection_inputs_keep_first_occurrence_and_toggle_once() { + for linked in [false, true] { + let mut s = demo(); + let (_, v, _, a) = pair_in_an_empty_sequence(&mut s, 0.0); + s.execute("sequence.linkedSelection", json!({"on": linked})).unwrap(); + let before = s.project.clone(); + let history = s.history.clone(); + let ids = json!([a.id.0, a.id.0, v.id.0, a.id.0, v.id.0]); + let result = s.execute("timeline.select", json!({"clips": ids})).unwrap(); + assert_eq!(s.state.selection, vec![a.id, v.id], "explicit order precedes linked expansion"); + assert_eq!(result["selection"], json!([a.id.0, v.id.0])); + s.execute("timeline.select", json!({"clips": [v.id.0, v.id.0], "add": true})).unwrap(); + assert_eq!(s.state.selection, vec![a.id, v.id], "adding an identity twice does not reorder the selection"); + s.execute("timeline.select", json!({"clips": ids, "toggle": true})).unwrap(); + assert!(s.state.selection.is_empty(), "each selected identity toggles off once"); + s.execute("timeline.select", json!({"clips": ids, "toggle": true})).unwrap(); + assert_eq!(s.state.selection, vec![a.id, v.id], "each unselected identity toggles on once"); + s.execute("timeline.select", json!({"clips": []})).unwrap(); + s.execute("timeline.select", json!({"clips": [v.id.0, v.id.0], "add": true})).unwrap(); + assert_eq!(s.state.selection, if linked { vec![v.id, a.id] } else { vec![v.id] }); + assert_eq!(s.project, before, "selection admission does not edit the project"); + assert_eq!(s.history.undo, history.undo); + assert_eq!(s.history.redo, history.redo); + s.active_sequence().unwrap().check().unwrap(); + } +} + +#[test] +fn duplicate_selection_expansion_is_unique_without_an_active_sequence() { + let clips = [ClipId(9), ClipId(3), ClipId(9), ClipId(3), ClipId(5)]; + for linked in [false, true] { + let mut s = Session::default(); + s.state.linked_selection = linked; + for active in [None, Some(filmcraft_project::ItemId(u64::MAX))] { + s.state.active_sequence = active; + let before = serde_json::to_value(&s.state).unwrap(); + assert_eq!(crate::commands::with_links(&s, &clips), vec![ClipId(9), ClipId(3), ClipId(5)]); + assert_eq!(serde_json::to_value(&s.state).unwrap(), before); + } + } +} + +#[test] +fn duplicate_selection_public_admission_keeps_nudges_equivalent_to_unique_selection() { + for (command, linked) in [("timeline.nudgeRight", false), ("timeline.nudgeRight", true), ("timeline.nudgeUp", false)] { + let mut s = demo(); + let (_, v, _, a) = pair_in_an_empty_sequence(&mut s, 60.0); + s.execute("sequence.linkedSelection", json!({"on": linked})).unwrap(); + s.execute("timeline.select", json!({"clips": [v.id.0, v.id.0]})).unwrap(); + let selection = if linked { vec![v.id, a.id] } else { vec![v.id] }; + assert_eq!(s.state.selection, selection); + assert_selection_edit_matches_control(&mut s, selection.clone(), command, json!({})); + assert_eq!(s.state.selection, selection); + } +} + +#[test] +fn duplicate_selection_already_in_session_keeps_nudges_equivalent_to_unique_selection() { + for linked in [false, true] { + for command in ["timeline.nudgeRight", "timeline.nudgeUp"] { + let mut s = demo(); + let (_, v, a_track, a) = pair_in_an_empty_sequence(&mut s, 60.0); + s.execute("sequence.linkedSelection", json!({"on": linked})).unwrap(); + s.state.selection = vec![v.id, v.id, v.id]; + assert_selection_edit_matches_control(&mut s, vec![v.id], command, json!({})); + let q = s.active_sequence().unwrap(); + if command == "timeline.nudgeUp" { + assert_eq!(state(&s, v.id), (q.video_tracks[1].id, v.start)); + assert_eq!(state(&s, a.id), (a_track, a.start), "vertical nudges remain selected-only even with linked selection on"); + } else { + assert_eq!(state(&s, v.id).1, v.start + s.sequence_rate().tick_of(1)); + assert_eq!(state(&s, a.id).1, a.start + if linked { s.sequence_rate().tick_of(1) } else { Tick::ZERO }); + } + } + } +} + +#[test] +fn duplicate_selection_delete_matches_unique_selection_with_and_without_links() { + for linked in [false, true] { + for command in ["edit.clear", "edit.rippleDelete"] { + let mut s = demo(); + let (_, v, a_track, a) = pair_in_an_empty_sequence(&mut s, 60.0); + s.execute("sequence.linkedSelection", json!({"on": linked})).unwrap(); + if !linked { + s.execute("timeline.setTrack", json!({"track": a_track.0, "syncLock": false})).unwrap(); + } + s.state.selection = vec![v.id, v.id]; + assert_selection_edit_matches_control(&mut s, vec![v.id], command, json!({})); + assert!(s.active_sequence().unwrap().find_item(v.id).is_none()); + assert_eq!(s.active_sequence().unwrap().find_item(a.id).is_none(), linked); + assert!(s.state.selection.is_empty()); + } + } +} + +#[test] +fn duplicate_move_targets_are_rejected_and_retry_is_undoable() { + for insert in [false, true] { + let mut s = demo(); + let (_, v, _, _) = pair_in_an_empty_sequence(&mut s, 0.0); + let before = s.project.clone(); + let undo = s.history.undo.len(); + let at = s.sequence_rate().tick_of(48).0; + let result = s.execute( + "timeline.move", + json!({"moves": [{"clip": v.id.0, "track": "V1", "time": at}, {"clip": v.id.0, "track": "V2", "time": at}], "insert": insert, "linked": false}), + ); + assert!(result.is_err(), "duplicate move entries were accepted: {result:?}"); + assert_eq!(s.project, before); + assert_eq!(s.history.undo.len(), undo); + s.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": "V2", "time": at}], "insert": insert, "linked": false})).unwrap(); + let after = s.project.clone(); + assert_eq!(state(&s, v.id).1.0, at); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project, before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.project, after); + } +} + +fn reject_duplicate_new_track_move(s: &mut Session, clip: ClipId, insert: bool, linked: bool, new_first: bool) { + s.state.selection = vec![clip]; + let before = s.project.clone(); + let selection = s.state.selection.clone(); + let editor_state = serde_json::to_value(&s.state).unwrap(); + let history = s.history.clone(); + let journal = s.journal.clone(); + let at = s.sequence_rate().tick_of(48).0; + let tracks = if new_first { ["new", "V2"] } else { ["V2", "new"] }; + let result = s.execute( + "timeline.move", + json!({"moves": [{"clip": clip.0, "track": tracks[0], "time": at}, {"clip": clip.0, "track": tracks[1], "time": at}], "insert": insert, "linked": linked}), + ); + assert!(matches!(result, Err(EngineError::Edit(filmcraft_edit::EditError::Other(_)))), "duplicate move entries must be rejected: {result:?}"); + assert_eq!(s.project, before, "the project and its id allocator stay unchanged"); + assert_eq!(s.state.selection, selection); + assert_eq!(serde_json::to_value(&s.state).unwrap(), editor_state); + assert_eq!(s.history.undo.len(), history.undo.len(), "no oldest undo entry is evicted"); + assert_eq!(s.history.redo.len(), history.redo.len(), "no existing redo entry is lost"); + assert_eq!(s.history.undo, history.undo); + assert_eq!(s.history.redo, history.redo); + assert_eq!(s.history.merge_key, history.merge_key); + assert_eq!(s.history.limit, history.limit); + assert_eq!(s.journal, journal, "the failed command is not journaled"); +} + +#[test] +fn duplicate_new_track_move_preserves_redo_and_retry_is_undoable() { + for insert in [false, true] { + for linked in [false, true] { + for new_first in [false, true] { + let mut s = demo(); + let (_, v, _, _) = pair_in_an_empty_sequence(&mut s, 0.0); + s.execute("timeline.setTrack", json!({"track": "V2", "locked": true})).unwrap(); + let redo_project = s.project.clone(); + s.execute("edit.undo", json!({})).unwrap(); + assert!(!s.history.redo.is_empty()); + reject_duplicate_new_track_move(&mut s, v.id, insert, linked, new_first); + let before = s.project.clone(); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.project, redo_project, "the previously available redo still works"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project, before); + let at = s.sequence_rate().tick_of(48).0; + s.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": "new", "time": at}], "insert": insert, "linked": linked})).unwrap(); + let after = s.project.clone(); + assert_eq!(state(&s, v.id).1.0, at); + assert_eq!(s.active_sequence().unwrap().video_tracks.len(), 3); + s.active_sequence().unwrap().check().unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project, before, "one undo removes the new track and restores the move"); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.project, after); + } + } + } +} + +#[test] +fn duplicate_new_track_move_preserves_full_undo_history() { + for insert in [false, true] { + for linked in [false, true] { + for new_first in [false, true] { + let mut s = demo(); + let (_, v, _, _) = pair_in_an_empty_sequence(&mut s, 0.0); + assert_eq!(s.history.limit, 200); + while s.history.undo.len() < s.history.limit { + let step = s.history.undo.len(); + s.edit_merged("History step", &format!("history-{step}"), |project, _| { + project.root.name = format!("History {step}"); + Ok(()) + }) + .unwrap(); + } + assert_eq!(s.history.undo.len(), s.history.limit); + assert!(s.history.merge_key.is_some()); + reject_duplicate_new_track_move(&mut s, v.id, insert, linked, new_first); + let oldest = s.history.undo.first().unwrap().1.clone(); + for _ in 0..s.history.limit { + s.execute("edit.undo", json!({})).unwrap(); + } + assert_eq!(s.project, oldest, "every retained undo step remains usable"); + } + } + } +} + +#[test] +fn unique_new_track_moves_keep_linked_partner_behavior() { + for insert in [false, true] { + for linked in [false, true] { + let mut s = demo(); + let (_, v, a_track, a) = pair_in_an_empty_sequence(&mut s, 0.0); + let before = s.project.clone(); + let undo = s.history.undo.len(); + let to = s.sequence_rate().tick_of(48); + let result = + s.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": "new", "time": to.0}], "insert": insert, "linked": linked})).unwrap(); + let sequence = s.active_sequence().unwrap(); + assert_eq!(sequence.video_tracks.len(), 3); + assert_eq!(state(&s, v.id), (sequence.video_tracks[2].id, to)); + assert_eq!(state(&s, a.id), (a_track, if linked { to } else { a.start })); + assert_eq!(result["moved"], if linked { json!([v.id.0, a.id.0]) } else { json!([v.id.0]) }); + assert_eq!(s.history.undo.len(), undo + 1); + sequence.check().unwrap(); + let after = s.project.clone(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project, before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.project, after); + } + } +} + +#[test] +fn repeated_new_track_copy_destinations_get_distinct_ids() { + for insert in [false, true] { + for linked in [false, true] { + for new_first in [false, true] { + let mut s = demo(); + let (v_track, v, a_track, a) = pair_in_an_empty_sequence(&mut s, 0.0); + let before = s.project.clone(); + let undo = s.history.undo.len(); + let to = s.active_sequence().unwrap().duration() + s.sequence_rate().tick_of(48); + let tracks = if new_first { ["new", "V2"] } else { ["V2", "new"] }; + let result = s + .execute( + "timeline.move", + json!({"moves": [{"clip": v.id.0, "track": tracks[0], "time": to.0}, {"clip": v.id.0, "track": tracks[1], "time": to.0}], "insert": insert, "linked": linked, "copy": true}), + ) + .unwrap(); + let copies: Vec = result["copied"].as_array().unwrap().iter().map(|c| ClipId(c.as_u64().unwrap())).collect(); + assert_eq!(copies.len(), if linked { 3 } else { 2 }); + assert_eq!(copies.iter().copied().collect::>().len(), copies.len()); + assert!(!copies.contains(&v.id) && !copies.contains(&a.id)); + assert_eq!((state(&s, v.id), state(&s, a.id)), ((v_track, v.start), (a_track, a.start))); + assert_eq!(s.state.selection, copies); + let sequence = s.active_sequence().unwrap(); + assert_eq!(sequence.video_tracks.len(), 3); + assert!(copies.iter().all(|c| sequence.find_item(*c).is_some()), "every reported copy exists"); + assert!(copies.iter().any(|c| state(&s, *c) == (sequence.video_tracks[2].id, to))); + assert!(copies.iter().any(|c| state(&s, *c) == (sequence.video_tracks[1].id, to))); + assert_eq!(s.history.undo.len(), undo + 1); + sequence.check().unwrap(); + let after = s.project.clone(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project, before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.project, after); + } + } + } +} + /// `timeline.move` of one clip left its linked sound behind although Linked Selection was on. #[test] fn move_takes_linked_partners_along() { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index edb5646e4..98a72b4da 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2688,8 +2688,10 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & Drag::Move { clips, offset, track_delta, new_track, copy, .. } if offset != Tick::ZERO || track_delta != 0 || new_track.is_some() => { // #1104: dropped past the last track, the clips go onto a new one let to = |tid| new_track.map_or_else(|| json!(shift_track(seq, tid, track_delta).unwrap_or(tid).0), |_| json!("new")); + let mut seen = std::collections::HashSet::new(); let moves: Vec = clips .iter() + .filter(|c| seen.insert(**c)) .filter_map(|c| seq.find_item(*c).map(|(tid, it)| json!({"clip": c.0, "track": to(tid), "time": (it.start + offset).max(Tick::ZERO).0}))) .collect(); // Option held while dropping copies (Premiere: Option-drag duplicates) diff --git a/crates/ui-egui/tests/timeline_option_drag_ui.rs b/crates/ui-egui/tests/timeline_option_drag_ui.rs index 01840f43d..0593d8296 100644 --- a/crates/ui-egui/tests/timeline_option_drag_ui.rs +++ b/crates/ui-egui/tests/timeline_option_drag_ui.rs @@ -90,3 +90,40 @@ fn option_drag_copies_a_clip_and_a_plain_drag_moves_it() { assert_eq!(clip_count(&mut d), n); assert!(d.harness.state().session.active_sequence().unwrap().find_item(v2.id).unwrap().1.start > v2.start, "moved"); } + +#[test] +fn dragging_after_frame_hold_with_repeated_clip_arguments_edits_once() { + for copy in [false, true] { + let mut d = Driver::demo(); + let original = d.harness.state().session.active_sequence().unwrap().video_tracks[1].items[0].clone(); + d.exec("clip.frameHold", json!({"clips": [original.id.0, original.id.0], "time": original.start.0})); + let before = d.harness.state().session.project.clone(); + let undo = d.harness.state().session.history.undo.len(); + let n = clip_count(&mut d); + let r = d.rect(&format!("timeline.clip.{}", original.id.0)); + let (x, y) = (r[0] + 10.0, r[1] + r[3] / 2.0); + d.ok("ui.drag", json!({"from": {"x": x, "y": y}, "to": {"x": x + 240.0, "y": y}, "steps": 12, "modifiers": {"alt": copy}})); + d.frames(3); + let s = &d.harness.state().session; + let q = s.active_sequence().unwrap(); + if copy { + assert_eq!(s.state.selection.len(), 1, "one selected clip produces one copy"); + let copied = s.state.selection[0]; + assert_ne!(copied, original.id); + assert!(q.find_item(copied).unwrap().1.start > original.start); + assert_eq!(q.find_item(original.id).unwrap().1.start, original.start); + } else { + assert!(q.find_item(original.id).unwrap().1.start > original.start, "a repeated selection must still drag"); + } + let ids: Vec<_> = q.all_tracks().flat_map(|t| t.items.iter().map(|i| i.id)).collect(); + assert_eq!(ids.len(), n + usize::from(copy)); + assert_eq!(ids.iter().copied().collect::>().len(), ids.len()); + q.check().unwrap(); + assert_eq!(s.history.undo.len(), undo + 1); + let after = s.project.clone(); + d.exec("edit.undo", json!({})); + assert_eq!(d.harness.state().session.project, before); + d.exec("edit.redo", json!({})); + assert_eq!(d.harness.state().session.project, after); + } +} From 869e40701ce2dbbbff30834664a5f57946881834 Mon Sep 17 00:00:00 2001 From: Sam Sandweiss Date: Wed, 14 Oct 2026 22:49:08 -0700 Subject: [PATCH 006/113] fix(engine): Unlink a selection that also holds unlinked clips (#1103) The Timeline's context menu offers Unlink when any picked clip is linked, but clip.link only unlinked when every selected clip was, so a title or a music clip in the selection turned Unlink into a re-link and nothing seemed to happen. It now unlinks when any selected clip is linked, and a partner left alone in its link is unlinked too. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 15 +++++++++++++- crates/engine/src/explicit_targets_tests.rs | 23 +++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 58d943b83..7d929e226 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1865,11 +1865,24 @@ fn build() -> Vec { }), cmd!("clip.link", "Link", ["Clip"], Some("Cmd+L"), r#"{"clips":[id]?}"#, has_selection, |s, p| { let sel = clips_p(s, p); - let linked = sel.iter().all(|c| s.active_sequence().and_then(|q| q.find_item(*c)).is_some_and(|(_, i)| i.link.is_some())); + // Unlink when any selected clip is linked, as the Timeline's context menu says (#1103): + // a title or a music clip among the selection must not turn Unlink into a re-link + let linked = sel.iter().any(|c| s.active_sequence().and_then(|q| q.find_item(*c)).is_some_and(|(_, i)| i.link.is_some())); s.edit_sequence(if linked { "Unlink" } else { "Link" }, |q, ctx, _| { if linked { + let mut links = Vec::new(); for c in &sel { if let Some((_, i)) = q.find_item_mut(*c) { + links.extend(i.link.take()); + } + } + // a partner left alone in its link (one half unlinked on its own) is unlinked + // too: a one-member link would read as "linked" with nothing to follow + for l in links { + let members: Vec = q.all_tracks().flat_map(|t| t.items.iter()).filter(|i| i.link == Some(l)).map(|i| i.id).collect(); + if let [lonely] = members[..] + && let Some((_, i)) = q.find_item_mut(lonely) + { i.link = None; } } diff --git a/crates/engine/src/explicit_targets_tests.rs b/crates/engine/src/explicit_targets_tests.rs index 2abbcee12..4a80f9261 100644 --- a/crates/engine/src/explicit_targets_tests.rs +++ b/crates/engine/src/explicit_targets_tests.rs @@ -307,3 +307,26 @@ fn link_leaves_clips_without_a_partner_unlinked() { assert_eq!(clip(&s, v1).unwrap().link, None, "v1 has no overlapping audio in the selection"); assert_eq!(clip(&s, a2).unwrap().link, None, "a2 has no overlapping video in the selection"); } + +/// #1103: Unlink with a selection that also holds an unlinked clip (a title, a music clip) unlinks +/// the linked ones, as the Timeline's context menu offers, instead of re-linking them. +#[test] +fn unlink_with_an_unlinked_clip_in_the_selection_still_unlinks() { + let mut s = demo(); + let (v, a) = (v1(&s), a1(&s)); + let (v0, a0, v1, a1) = (v[0].id, a[0].id, v[1].id, a[1].id); + assert!(clip(&s, v0).unwrap().link.is_some() && clip(&s, v1).unwrap().link.is_some(), "the demo's clips start linked"); + // make one clip unlinked on its own: its partner goes with it (no one-member link stays) + s.execute("clip.link", json!({"clips": [a1.0]})).unwrap(); + assert_eq!(clip(&s, a1).unwrap().link, None); + assert_eq!(clip(&s, v1).unwrap().link, None, "the partner left alone is unlinked too"); + s.execute("clip.link", json!({"clips": [v0.0, a0.0]})).unwrap(); + s.execute("clip.link", json!({"clips": [v0.0, a0.0]})).unwrap(); + assert!(clip(&s, v0).unwrap().link.is_some(), "v0 and a0 linked again"); + // v0 + a0 linked, v1 not: Unlink + let r = s.execute("clip.link", json!({"clips": [v0.0, a0.0, v1.0]})).unwrap(); + assert_eq!(r["linked"], false); + for c in [v0, a0, v1] { + assert_eq!(clip(&s, c).unwrap().link, None, "{c:?}"); + } +} From 1a946e764b562456bdce4d8f7457ec16ba53e4d3 Mon Sep 17 00:00:00 2001 From: Sam Sandweiss Date: Wed, 14 Oct 2026 22:49:07 -0700 Subject: [PATCH 007/113] fix(engine): Close Gap with no gap selected closes the sequence's gaps (#1986) Sequence > Close Gap needed a selected gap and otherwise did nothing from the menu. As in Premiere, with no gap selected it now closes every span where no track has anything, between In and Out (the whole sequence when they are not set), in one undo step. A selected gap still closes alone (#1135). Simplify Sequence's gap search is shared with it. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 34 +++++++++-- crates/engine/src/gap_selection_tests.rs | 74 +++++++++++++++++++++++- crates/engine/src/sequence_extras.rs | 60 ++++++++++++------- 3 files changed, 141 insertions(+), 27 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 58d943b83..920defd85 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -2136,12 +2136,14 @@ fn build() -> Vec { remove_transitions(s, &ids, "Clear Transition") }), cmd!("sequence.closeGap", "Close Gap", ["Sequence"], None, r#"{"track":"V1"|id?,"time":ticks?}"#, has_seq, |s, p| { - // Shortcuts and menus have no parameters. Resolve that form from the selected gap - // (#1135); explicit requests keep their existing track/time validation. + // Shortcuts and menus have no parameters. That form closes the selected gap (#1135), + // or with none selected every gap between In and Out, as Premiere's Sequence ▸ Close + // Gap does (#1986); explicit requests keep their existing track/time validation. if p.as_object().is_some_and(|p| p.is_empty()) { - let gap = s.state.gap_selection.ok_or_else(|| bad("sequence.closeGap", "select a gap or provide `track`"))?; - ripple_delete_gap(s, gap)?; - return Ok(Value::Null); + return match s.state.gap_selection { + Some(gap) => ripple_delete_gap(s, gap).map(|_| Value::Null), + None => close_sequence_gaps(s), + }; } let tr = track_p(s, p, "track", "sequence.closeGap")?.ok_or_else(|| bad("sequence.closeGap", "need `track`"))?; let t = time_p(s, p, "").unwrap_or(s.playhead()); @@ -4037,6 +4039,28 @@ fn ripple_delete_gap(s: &mut Session, g: crate::GapSelection) -> Result { Ok(json!({"closed": g.range.duration.0})) } +/// `sequence.closeGap` with no gap selected: every span where no track has anything, between In +/// and Out (the whole sequence without them), closes in one undo step (#1986). +fn close_sequence_gaps(s: &mut Session) -> Result { + let within = in_out_range(s)?; + let gaps = s.active_sequence().map(|q| crate::sequence_extras::sequence_gaps(q, Some(within))).unwrap_or_default(); + let Some(first) = gaps.first().copied() else { + return Err(bad("sequence.closeGap", "no gaps to close")); + }; + s.edit_sequence("Close Gap", |q, _, st| { + // a locked track would stay put while everything else moved past it + if q.all_tracks().any(|t| t.locked && t.items.iter().any(|i| i.start >= first.end())) { + return Err(edit::EditError::Locked.into()); + } + for g in gaps.iter().rev() { + crate::sequence_extras::close_sequence_gap(q, *g, st.ripple_sequence_markers); + } + Ok(()) + })?; + let closed = gaps.iter().fold(Tick::ZERO, |sum, g| sum + g.duration); + Ok(json!({"gaps": gaps.len(), "closed": closed.0})) +} + /// `sequence.closeGap`: ripple the gap on `track` at `t` closed (one undo step, "Ripple Delete"). fn close_gap(s: &mut Session, tr: TrackId, t: Tick) -> Result<()> { s.edit_sequence("Ripple Delete", |q, _, st| { diff --git a/crates/engine/src/gap_selection_tests.rs b/crates/engine/src/gap_selection_tests.rs index 19abd7c4c..74d62d329 100644 --- a/crates/engine/src/gap_selection_tests.rs +++ b/crates/engine/src/gap_selection_tests.rs @@ -211,7 +211,6 @@ fn close_gap_rejects_invalid_context_and_explicit_parameters_without_editing() { two_clips(&mut s, [0, 96]); let before = s.project.clone(); let undo = s.history.undo.len(); - assert!(s.execute("sequence.closeGap", json!({})).is_err(), "a shortcut needs a selected gap"); s.execute("timeline.selectGap", json!({"track":"V1","frame":60})).unwrap(); for params in [json!({"track":null}), json!({"track":false}), json!({"track":"V99","frame":60}), json!({"time":1})] { assert!(s.execute("sequence.closeGap", params).is_err(), "invalid explicit requests must not fall back to the selected gap"); @@ -232,3 +231,76 @@ fn close_gap_rejects_invalid_context_and_explicit_parameters_without_editing() { assert!(std::sync::Arc::ptr_eq(&before, &s.project)); assert_eq!(s.history.undo.len(), undo); } + +/// 48-frame clips (pictures on V1, linked sounds on A1) placed at `frames`, in that order. +fn clips_at(s: &mut Session, frames: &[i64]) -> Vec<(ClipId, ClipId)> { + s.execute("file.openDemoProject", json!({})).unwrap(); + s.execute("file.newSequence", json!({"name": "Gaps", "video": 1, "audio": 1})).unwrap(); + let rate = s.sequence_rate(); + let item = s.project.items.values().find(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap(); + for f in frames { + s.execute("timeline.place", json!({"item": item.0, "frame": f, "sourceIn": rate.tick_of(48).0, "duration": rate.tick_of(48).0})).unwrap(); + } + s.execute("edit.deselectAll", json!({})).unwrap(); + let q = s.active_sequence().unwrap(); + (0..frames.len()).map(|n| (q.video_tracks[0].items[n].id, q.audio_tracks[0].items[n].id)).collect() +} + +/// #1986: Sequence ▸ Close Gap with no gap selected closes every gap in the sequence, on picture and +/// sound alike, in one undo step (Premiere's Close Gap). +#[test] +fn close_gap_without_a_selected_gap_closes_every_gap_in_one_undo_step() { + let mut s = Session::default(); + let clips = clips_at(&mut s, &[24, 96, 200]); + let rate = s.sequence_rate(); + let at = |f: i64| rate.tick_of(f); + let (zero, f48, f96) = (Tick::ZERO, at(48), at(96)); + let before = serde_json::to_value(&s.project).unwrap(); + let undo = s.history.undo.len(); + let r = s.execute("sequence.closeGap", json!({})).unwrap(); + assert_eq!(r["gaps"], json!(3), "the leading gap and the two between the clips: {r}"); + for ((picture, sound), start) in clips.iter().zip([zero, f48, f96]) { + assert_eq!(range(&s, *picture).0, start); + assert_eq!(range(&s, *sound).0, start, "sound stays with its picture"); + } + assert_eq!(s.history.undo.len(), undo + 1, "one undo step"); + s.undo(); + assert_eq!(serde_json::to_value(&s.project).unwrap(), before); +} + +/// With In and Out set, only the gaps that lie between them close. +#[test] +fn close_gap_without_a_selected_gap_only_closes_gaps_between_in_and_out() { + let mut s = Session::default(); + let clips = clips_at(&mut s, &[0, 96, 200]); + let rate = s.sequence_rate(); + let at = |f: i64| rate.tick_of(f); + s.execute("markers.markIn", json!({"time": at(140).0})).unwrap(); + s.execute("markers.markOut", json!({"time": at(260).0})).unwrap(); + let r = s.execute("sequence.closeGap", json!({})).unwrap(); + assert_eq!(r["gaps"], json!(1), "{r}"); + assert_eq!(range(&s, clips[0].0).0, Tick::ZERO); + assert_eq!(range(&s, clips[1].0).0, at(96), "the gap before In stays"); + assert_eq!(range(&s, clips[2].0).0, at(144), "the gap between In and Out closed"); +} + +/// Nothing to close, or a locked track that would be left behind: an error and no edit. +#[test] +fn close_gap_without_a_selected_gap_refuses_without_editing() { + let mut s = Session::default(); + clips_at(&mut s, &[0, 48]); + let before = s.project.clone(); + let undo = s.history.undo.len(); + assert!(s.execute("sequence.closeGap", json!({})).is_err(), "no gaps"); + assert!(std::sync::Arc::ptr_eq(&before, &s.project)); + assert_eq!(s.history.undo.len(), undo); + + let mut s = Session::default(); + clips_at(&mut s, &[0, 96]); + s.execute("timeline.setTrack", json!({"track":"A1","locked":true})).unwrap(); + let before = s.project.clone(); + let undo = s.history.undo.len(); + assert!(s.execute("sequence.closeGap", json!({})).is_err(), "A1 is locked and has a clip after the gap"); + assert!(std::sync::Arc::ptr_eq(&before, &s.project)); + assert_eq!(s.history.undo.len(), undo); +} diff --git a/crates/engine/src/sequence_extras.rs b/crates/engine/src/sequence_extras.rs index 093ce0f29..54449fa0c 100644 --- a/crates/engine/src/sequence_extras.rs +++ b/crates/engine/src/sequence_extras.rs @@ -413,27 +413,8 @@ pub fn simplify_sequence(proj: &filmcraft_project::Project, q: &mut Sequence, o: // close gaps: spans where no track has anything let mut closed = Tick::ZERO; if o.close_gaps { - let mut spans: Vec = q.all_tracks().flat_map(|t| t.items.iter().map(|i| i.range())).collect(); - spans.extend(q.caption_tracks.iter().flat_map(|t| t.captions.iter().map(|c| c.range()))); - spans.sort_by_key(|r| r.start); - let mut gaps: Vec = Vec::new(); - let mut cursor = Tick::ZERO; - for r in &spans { - if r.start > cursor { - gaps.push(TimeRange::from_bounds(cursor, r.start)); - } - cursor = cursor.max(r.end()); - } - for g in gaps.iter().rev() { - for t in q.video_tracks.iter_mut().chain(q.audio_tracks.iter_mut()) { - edit::shift_track_from(t, g.end(), Tick::ZERO - g.duration); - } - for t in &mut q.caption_tracks { - for c in t.captions.iter_mut().filter(|c| c.start >= g.end()) { - c.start -= g.duration; - } - } - crate::sequence_tools::ripple_markers(&mut q.markers, g.end(), Tick::ZERO - g.duration); + for g in sequence_gaps(q, None).iter().rev() { + close_sequence_gap(q, *g, true); closed += g.duration; } } @@ -456,6 +437,43 @@ pub fn simplify_sequence(proj: &filmcraft_project::Project, q: &mut Sequence, o: json!({"removedClips": removed_clips, "removedEffects": removed_effects, "movedClips": moved, "closedGaps": closed.0, "removedTracks": removed_tracks}) } +/// Spans where no track has anything (clips or captions), in time order: the gaps before, between +/// and inside the material, never the open time after the last clip. With `within`, only the gaps +/// that lie entirely inside it (Close Gap between In and Out). +pub(crate) fn sequence_gaps(q: &Sequence, within: Option) -> Vec { + let mut spans: Vec = q.all_tracks().flat_map(|t| t.items.iter().map(|i| i.range())).collect(); + spans.extend(q.caption_tracks.iter().flat_map(|t| t.captions.iter().map(|c| c.range()))); + spans.sort_by_key(|r| r.start); + let mut gaps: Vec = Vec::new(); + let mut cursor = Tick::ZERO; + for r in &spans { + if r.start > cursor { + gaps.push(TimeRange::from_bounds(cursor, r.start)); + } + cursor = cursor.max(r.end()); + } + if let Some(w) = within { + gaps.retain(|g| g.start >= w.start && g.end() <= w.end()); + } + gaps +} + +/// Ripple one of [`sequence_gaps`] closed: everything after it, on every track, moves left by its +/// length (it is empty on all of them, so nothing can collide). Close later gaps first. +pub(crate) fn close_sequence_gap(q: &mut Sequence, g: TimeRange, markers: bool) { + for t in q.video_tracks.iter_mut().chain(q.audio_tracks.iter_mut()) { + edit::shift_track_from(t, g.end(), Tick::ZERO - g.duration); + } + for t in &mut q.caption_tracks { + for c in t.captions.iter_mut().filter(|c| c.start >= g.end()) { + c.start -= g.duration; + } + } + if markers { + crate::sequence_tools::ripple_markers(&mut q.markers, g.end(), Tick::ZERO - g.duration); + } +} + fn simplify(s: &mut Session, p: &Value) -> Result { let o = SimplifyOptions::from_params(p)?; let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?; From 6cb14e7c441018d8be5429a49da1823cef4295db Mon Sep 17 00:00:00 2001 From: Wbunker Date: Sat, 17 Oct 2026 09:15:54 -0400 Subject: [PATCH 008/113] fix(interchange): OTIO export stops writing nested-sequence copies past a limit OTIO has no references, so every use of a nested sequence is written in full. Compound clips that each hold several uses of the next make that exponential: a 3 KB FCPXML whose media nest 12 deep with 3 uses each imports as 14 items and then makes the OTIO export run for minutes and grow to gigabytes (depth 5 is 216 MB, about 7x more per level), until memory runs out. The other formats reference nested sequences and stay instant. The exporter counts clips written inside nested sequences; after 25 000 (about 250 MB of JSON, far above real projects) further nested sequences are written as gaps, with one warning. The depth-12 document now exports in seconds under a 4 GB memory cap. Regression tests lower the limit to keep the document tiny: without the check all 112 stacks are written; an ordinary project is untouched. Co-Authored-By: Claude Sonnet 5.5 --- crates/interchange/README.md | 4 ++ crates/interchange/src/otio.rs | 80 +++++++++++++++++++++++++++++++++- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/crates/interchange/README.md b/crates/interchange/README.md index edaff851f..5acdb8a19 100644 --- a/crates/interchange/README.md +++ b/crates/interchange/README.md @@ -85,6 +85,10 @@ OMF carries no video at all. ## Tests +- `src/otio.rs` (unit tests): OTIO repeats a nested sequence for every use, so compound clips that each hold + several uses of the next are exponential (a 3 KB FCPXML nested 12 deep made the export run for minutes and grow to + gigabytes). After 25 000 clips written inside nested sequences the rest are gaps, with one warning; an ordinary + project is far below that and unchanged. - `tests/aaf_omf.rs`: AAF round trips at 25, 29.97 DF, 23.976 and 59.94 DF (clips, gaps, two-sided and one-sided transitions, links, markers, start timecodes, constant and keyframed gain, media markers and timecode); 512-byte sectors; breakout to mono; embedded trimmed audio diff --git a/crates/interchange/src/otio.rs b/crates/interchange/src/otio.rs index 421bea985..dbf010247 100644 --- a/crates/interchange/src/otio.rs +++ b/crates/interchange/src/otio.rs @@ -113,17 +113,27 @@ fn color_label(s: &str) -> Label { // Export // --------------------------------------------------------------------------------------------- +/// Most clips an export writes inside nested sequences. OTIO has no references: every use of a nested +/// sequence is written in full, so a compound clip that holds three uses of another, nested ten deep, +/// is 3^10 copies, and a 3 KB document made the export run for minutes and grow to gigabytes. Each +/// clip is about 10 KB of JSON, so this bounds the extra output near 250 MB; real projects use a +/// few thousand. Past it, further nested sequences are written as gaps, with a warning. +const MAX_NESTED_ITEMS: usize = 25_000; + struct Exp<'a, 'r> { p: &'a Project, opts: &'a ExportOptions, report: &'r mut Report, stack: Vec, + /// Clips written inside nested sequences so far, and the limit for it (see [`MAX_NESTED_ITEMS`]). + nested_items: usize, + nested_limit: usize, } pub(crate) fn export(p: &Project, seq_id: ItemId, opts: &ExportOptions, report: &mut Report) -> Result { let seq = p.sequence(seq_id).ok_or(Error::NoSequence(seq_id))?; let rate = seq.settings.frame_rate; - let mut x = Exp { p, opts, report, stack: vec![seq_id] }; + let mut x = Exp { p, opts, report, stack: vec![seq_id], nested_items: 0, nested_limit: MAX_NESTED_ITEMS }; let name = opts.name.clone().unwrap_or_else(|| p.item(seq_id).map(|i| i.name.clone()).unwrap_or_default()); let stack = x.stack_json(seq_id, &name, None); let tl = json!({ @@ -247,6 +257,9 @@ impl Exp<'_, '_> { } fn item_json(&mut self, it: &TrackItem, kind: TrackKind, rate: FrameRate) -> Value { + if self.stack.len() > 1 { + self.nested_items = self.nested_items.saturating_add(1); + } let base = base_item(self.p, it.item); let mut effects = Vec::new(); if let Some(h) = it.frame_hold { @@ -269,6 +282,17 @@ impl Exp<'_, '_> { self.report.warn("recursive nested sequence skipped"); return json!({"OTIO_SCHEMA": "Gap.1", "name": "", "source_range": tr(Tick::ZERO, it.duration, rate), "effects": [], "markers": [], "enabled": true, "metadata": {}}); } + if self.nested_items >= self.nested_limit { + // once: later nested sequences hit this too, and the message would repeat + if self.nested_items < usize::MAX { + self.nested_items = usize::MAX; + self.report.warn(format!( + "more than {} clips written inside nested sequences: the rest are gaps (OTIO repeats a nested sequence for every use)", + self.nested_limit + )); + } + return json!({"OTIO_SCHEMA": "Gap.1", "name": "", "source_range": tr(Tick::ZERO, it.duration, rate), "effects": [], "markers": [], "enabled": true, "metadata": {}}); + } self.stack.push(base); let mut s = self.stack_json(base, &it.name, Some((it.source_in, it.duration, rate))); self.stack.pop(); @@ -823,4 +847,58 @@ mod tests { let back = read_rt(&rt(t, FrameRate::FPS_24)).unwrap(); assert!((back - t).abs() <= Tick(1)); } + + /// A tree of compound clips: `uses` uses of the next level in each, `levels` media levels deep, leaf clips in the last. + fn nested_tree(levels: usize, uses: usize) -> String { + let mut xml = String::from( + r#" +"#, + ); + for level in 0..levels { + let inner: String = (0..uses) + .map(|i| { + if level + 1 < levels { + format!(r#""#, level + 1) + } else { + format!(r#""#) + } + }) + .collect(); + xml.push_str(&format!(r#"{inner}"#)); + } + xml.push_str(r#""#); + xml + } + + /// OTIO repeats a nested sequence for every use, so 10 uses nested 3 deep is 111 copies and a 3 KB + /// document nested 12 deep made the export run for minutes and grow to gigabytes. Past the limit on clips + /// written inside nested sequences the rest are gaps, with one warning. The test lowers the limit so it + /// stays tiny: without the check all 112 stacks are written. + #[test] + fn nested_copies_stop_at_the_limit() { + let (imported, _) = crate::import(nested_tree(3, 10).as_bytes(), crate::Format::Fcpxml, None).expect("import succeeds"); + let seq = imported.sequences[0]; + let opts = ExportOptions::default(); + let write = |limit: usize| { + let mut report = Report::default(); + let mut x = Exp { p: &imported.project, opts: &opts, report: &mut report, stack: vec![seq], nested_items: 0, nested_limit: limit }; + let stacks = x.stack_json(seq, "t", None).to_string().matches("\"Stack.1\"").count(); + (stacks, report) + }; + let (all, report) = write(usize::MAX - 1); + assert_eq!(all, 112); + assert!(!report.mentions("nested sequences"), "{report}"); + let (capped, report) = write(200); + assert!(capped < 60, "{capped} stacks"); + assert!(report.mentions("clips written inside nested sequences"), "{report}"); + assert_eq!(report.warnings().filter(|w| w.message.contains("inside nested sequences")).map(|w| w.count).sum::(), 1); + } + + /// An ordinary project with nested sequences is far below the real limit and exports untouched. + #[test] + fn ordinary_nesting_is_not_capped() { + let (imported, _) = crate::import(nested_tree(3, 4).as_bytes(), crate::Format::Fcpxml, None).expect("import succeeds"); + let (_, report) = crate::export(&imported.project, imported.sequences[0], crate::Format::Otio, &ExportOptions::default()).expect("export succeeds"); + assert!(!report.mentions("inside nested sequences"), "{report}"); + } } From b1df3b2ef96175466f16845fcdaeca10275ff718 Mon Sep 17 00:00:00 2001 From: irgendwasmitfelix Date: Sun, 18 Oct 2026 19:59:01 +0200 Subject: [PATCH 009/113] #2343: saving a project creates its previews folder only when there are previews PreviewStore::move_to created `FilmCraft Previews//` on every first save, even with nothing rendered. Create it only when there are files to take along; render and render_audio already create it on demand. Fixes #2343 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/previews.rs | 7 +++++-- crates/engine/src/previews_tests.rs | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/previews.rs b/crates/engine/src/previews.rs index 5b75c02f4..7ef9e6f19 100644 --- a/crates/engine/src/previews.rs +++ b/crates/engine/src/previews.rs @@ -219,17 +219,20 @@ impl PreviewStore { self.bump(); } - /// Move to `dir`, taking this store's preview files along (first save of a project). + /// Move to `dir`, taking this store's preview files along (first save of a project). The folder is + /// created only when there are files to take along: a render creates it when it needs it (#2343). pub fn move_to(&self, dir: PathBuf) { let old = self.dir(); if old.as_deref() == Some(dir.as_path()) { return; } + let files = self.files.read().unwrap_or_else(|e| e.into_inner()).clone(); if let Some(old) = &old + && !files.is_empty() && (old.starts_with(crate::temp_dir()) || self.temp_root().is_some_and(|r| old.starts_with(r))) { let _ = std::fs::create_dir_all(&dir); - for name in self.files.read().unwrap_or_else(|e| e.into_inner()).iter() { + for name in &files { let (a, b) = (old.join(name), dir.join(name)); if !b.exists() && std::fs::rename(&a, &b).is_err() { let _ = std::fs::copy(&a, &b).map(|_| std::fs::remove_file(&a)); diff --git a/crates/engine/src/previews_tests.rs b/crates/engine/src/previews_tests.rs index dfc6b7a48..a21ca0d78 100644 --- a/crates/engine/src/previews_tests.rs +++ b/crates/engine/src/previews_tests.rs @@ -224,6 +224,27 @@ fn previews_survive_save_and_open() { let _ = std::fs::remove_dir_all(&folder); } +#[test] +fn saving_without_previews_creates_no_previews_folder() { + // #2343: a project with nothing rendered leaves only the file it was asked to write + let mut s = Session::default(); + let folder = tmp("save-empty"); + std::fs::create_dir_all(&folder).unwrap(); + let path = folder.join("Film.fcproj").to_string_lossy().to_string(); + s.execute("file.save", json!({"path": path})).unwrap(); + assert_eq!(s.previews.dir().unwrap(), folder.join("FilmCraft Previews").join("Film")); + assert!(!folder.join("FilmCraft Previews").exists()); + // the first render creates it + let (mut r, _) = session("save-empty-render"); + r.execute("file.save", json!({"path": path})).unwrap(); + assert!(!folder.join("FilmCraft Previews").exists()); + let v = r.execute("sequence.renderEffectsInToOut", json!({"wait": true})).unwrap(); + job_ok(&r, &v); + assert!(folder.join("FilmCraft Previews").join("Film").is_dir()); + assert_eq!(states(&r), vec!["green", "none"]); + let _ = std::fs::remove_dir_all(&folder); +} + #[test] fn render_selection_and_audio_previews() { let (mut s, a) = session("sel"); From a073371bc67ff014301cbdd47eac7b4f66c455f5 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:36:44 +0200 Subject: [PATCH 010/113] Fix TTML/DFXP export overflow for cue times near i64::MAX Found while testing crates/captions/src/ttml.rs:58 (clock) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/captions/src/ttml.rs | 5 ++--- crates/captions/tests/ttml_clock_overflow.rs | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 crates/captions/tests/ttml_clock_overflow.rs diff --git a/crates/captions/src/ttml.rs b/crates/captions/src/ttml.rs index bf6b2f65b..8636c4340 100644 --- a/crates/captions/src/ttml.rs +++ b/crates/captions/src/ttml.rs @@ -18,7 +18,7 @@ use std::collections::{HashMap, HashSet}; use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; -use crate::{Cue, Document, Error, Result, TICKS_PER_MS}; +use crate::{Cue, Document, Error, Result}; /// Which TTML flavour to write. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -55,8 +55,7 @@ fn esc(s: &str) -> String { } fn clock(t: Tick) -> String { - let ms = (t.0.max(0) + TICKS_PER_MS / 2) / TICKS_PER_MS; - format!("{:02}:{:02}:{:02}.{:03}", ms / 3_600_000, (ms / 60_000) % 60, (ms / 1000) % 60, ms % 1000) + crate::format_clock(t, '.') } /// Time expression for `t`: frames at `rate` when it is on a frame boundary, else clock time. diff --git a/crates/captions/tests/ttml_clock_overflow.rs b/crates/captions/tests/ttml_clock_overflow.rs new file mode 100644 index 000000000..23a3870ad --- /dev/null +++ b/crates/captions/tests/ttml_clock_overflow.rs @@ -0,0 +1,18 @@ +//! TTML / DFXP export must not overflow for cue times near `i64::MAX` (e.g. from an MCC with a +//! huge timecode). + +use filmcraft_captions::{Cue, Document, Format, WriteOptions, write}; +use filmcraft_time::Tick; + +fn doc(end: Tick) -> Document { + Document { cues: vec![Cue { start: Tick(0), end, text: "x".into(), ..Cue::default() }], ..Document::default() } +} + +#[test] +fn ttml_and_dfxp_write_survive_tick_max() { + for format in [Format::Ttml, Format::Dfxp] { + let out = write(&doc(Tick(i64::MAX)), format, WriteOptions::default()); + let s = String::from_utf8(out).unwrap(); + assert!(s.contains("end=\""), "{format:?}: {s}"); + } +} From dd2d3111d68855ef4262d32b5e5f66724f48a622 Mon Sep 17 00:00:00 2001 From: irgendwasmitfelix Date: Sun, 18 Oct 2026 23:07:35 +0200 Subject: [PATCH 011/113] #2340: uncompressed QuickTime video (raw 24-bit RGB, 2vuy, v210) decodes MOV / MP4 files with uncompressed video were reported unreadable: the fourccs were recognised as video but no decoder factory took them (the raw decoder was wired up for AVI only). A raw_factory now decodes `raw ` at depth 24 (packed RGB, unpadded rows), `2vuy` (UYVY 4:2:2) and `v210` (10-bit 4:2:2, shown at 8 bits), and the media info names them. fl32 audio in these files already played. Fixes #2340 Co-Authored-By: Claude Opus 5.5 --- crates/codecs/src/lib.rs | 3 +- crates/codecs/src/mp4.rs | 3 ++ crates/codecs/src/tests.rs | 27 +++++++++++++++++ crates/codecs/src/video.rs | 61 +++++++++++++++++++++++++++++++++++++- 4 files changed, 92 insertions(+), 2 deletions(-) diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 9a8bebdbf..f3ae47b72 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -77,7 +77,7 @@ impl From for filmcraft_media::MediaError { } /// Our own (pure-Rust) decoders, in the order they are tried. -const BUILTIN_FACTORIES: [VideoDecoderFactory; 10] = [ +const BUILTIN_FACTORIES: [VideoDecoderFactory; 11] = [ video::h264_factory, video::hevc_factory, video::vp8_factory, @@ -88,6 +88,7 @@ const BUILTIN_FACTORIES: [VideoDecoderFactory; 10] = [ video::dnx_factory, video::mjpeg_factory, video::mpeg2_factory, + video::raw_factory, ]; fn factories() -> &'static RwLock> { diff --git a/crates/codecs/src/mp4.rs b/crates/codecs/src/mp4.rs index 6e33c027e..b4936b484 100644 --- a/crates/codecs/src/mp4.rs +++ b/crates/codecs/src/mp4.rs @@ -580,6 +580,9 @@ fn codec_label(c: &CodecConfig) -> String { CodecConfig::Hevc(_) => "HEVC".into(), CodecConfig::Vp9(c) => format!("VP9 (Profile {})", c.profile), CodecConfig::Unknown { fourcc, .. } if fourcc.0 == *b"vp08" => "VP8".into(), + CodecConfig::Unknown { fourcc, .. } if fourcc.0 == *b"raw " => "Uncompressed RGB".into(), + CodecConfig::Unknown { fourcc, .. } if fourcc.0 == *b"2vuy" => "Uncompressed YUV 4:2:2".into(), + CodecConfig::Unknown { fourcc, .. } if fourcc.0 == *b"v210" => "Uncompressed YUV 4:2:2 10-bit".into(), CodecConfig::Av1(c) => format!("AV1 ({} Profile)", ["Main", "High", "Professional"].get(c.seq_profile as usize).unwrap_or(&"Main")), CodecConfig::Apv(a) => match filmcraft_apv::Profile::from_idc(a.profile_idc) { Some(p) => p.name().into(), diff --git a/crates/codecs/src/tests.rs b/crates/codecs/src/tests.rs index 52af0574c..9c8ea8c19 100644 --- a/crates/codecs/src/tests.rs +++ b/crates/codecs/src/tests.rs @@ -236,6 +236,33 @@ fn mp4_flac_audio() { assert!(peak > 0.05 && peak < 1.0, "audio peak {peak}"); } +/// #2340: uncompressed QuickTime video (`raw ` 24-bit RGB, `2vuy`, `v210`) decodes, with fl32 +/// audio, instead of being reported unreadable. +#[test] +fn quicktime_uncompressed_video() { + let src = ["-f", "lavfi", "-i", "color=c=0x3060C0:s=64x36:r=24:d=0.5", "-f", "lavfi", "-i", "sine=frequency=1000:sample_rate=48000:d=0.5"]; + for (name, vcodec) in [ + ("raw_rgb24.mov", ["-c:v", "rawvideo", "-pix_fmt", "rgb24"]), + ("raw_2vuy.mov", ["-c:v", "rawvideo", "-pix_fmt", "uyvy422"]), + ("raw_v210.mov", ["-c:v", "v210", "-pix_fmt", "yuv422p10le"]), + ] { + let args: Vec<&str> = src.iter().copied().chain(vcodec).chain(["-c:a", "pcm_f32be", "-shortest"]).collect(); + let Some(b) = fixture(name, &args) else { return }; + let src = crate::open_bytes(name, b).unwrap_or_else(|e| panic!("{name}: {e}")); + let f = src.video_frame(FrameRequest { time: Tick(TICKS_PER_SECOND / 4), scale: 1.0 }).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!((f.width, f.height), (64, 36), "{name}"); + assert!(src.info().video.as_ref().is_some_and(|v| v.codec.starts_with("Uncompressed")), "{name}: {:?}", src.info().video); + let rgba = f.to_rgba8(); + let px = &rgba[(18 * 64 + 32) * 4..][..3]; + for (got, want) in px.iter().zip([0x30u8, 0x60, 0xc0]) { + assert!((*got as i32 - want as i32).abs() <= 6, "{name}: {px:?}"); + } + let a = src.audio(12_000, 4800, 48_000).expect("fl32 audio"); + let peak = a.channels[0].iter().fold(0f32, |m, s| m.max(s.abs())); + assert!(peak > 0.05 && peak < 1.0, "{name}: audio peak {peak}"); + } +} + #[test] fn matroska_prores_pcm() { check_mkv("blue_prores_pcm.mkv", &["-c:v", "prores_ks", "-profile:v", "2"], &["-c:a", "pcm_s16le"]); diff --git a/crates/codecs/src/video.rs b/crates/codecs/src/video.rs index 28b3f4c66..7a4a2ef55 100644 --- a/crates/codecs/src/video.rs +++ b/crates/codecs/src/video.rs @@ -1094,6 +1094,13 @@ fn esds_object_type(raw: &[u8]) -> Option { raw.get(p).copied() } +/// Uncompressed QuickTime / MP4 video (`raw ` 24-bit RGB, `2vuy`, `v210`, #2340). +pub fn raw_factory(e: &SampleEntry) -> Option>> { + let v = e.video.as_ref()?; + let format = RawFormat::from_mov(e.format.0, v.depth)?; + Some(RawVideoDecoder::new(format, u32::from(v.width), u32::from(v.height), false).map(|d| Box::new(d) as Box)) +} + pub fn mpeg2_factory(e: &SampleEntry) -> Option>> { match &e.codec { CodecConfig::Unknown { fourcc, raw } if is_mpeg2_entry(&fourcc.0, raw) => { @@ -1168,6 +1175,11 @@ pub enum RawFormat { Nv12, /// Luma only (`Y800`, `GREY`, `Y8 `). Gray8, + /// 24-bit R, G, B; rows unpadded (QuickTime `raw ` at depth 24). + Rgb24, + /// 10-bit 4:2:2 Y'CbCr, six pixels in four little-endian words, rows padded to 128 bytes + /// (QuickTime `v210`); shown at 8 bits. + V210, } impl RawFormat { @@ -1193,13 +1205,24 @@ impl RawFormat { }) } + /// The layout of a QuickTime / MP4 video sample entry (`raw ` RGB at depth 24, `2vuy`, `v210`). + pub fn from_mov(fourcc: [u8; 4], depth: u16) -> Option { + match (&fourcc, depth) { + (b"raw ", 24) => Some(RawFormat::Rgb24), + (b"2vuy", _) => Some(RawFormat::Uyvy), + (b"v210", _) => Some(RawFormat::V210), + _ => None, + } + } + pub fn label(self) -> &'static str { match self { - RawFormat::Bgr24 => "Uncompressed RGB 24-bit", + RawFormat::Bgr24 | RawFormat::Rgb24 => "Uncompressed RGB 24-bit", RawFormat::Bgrx32 => "Uncompressed RGB 32-bit", RawFormat::Yuy2 | RawFormat::Uyvy => "Uncompressed YUV 4:2:2", RawFormat::I420 | RawFormat::Yv12 | RawFormat::Nv12 => "Uncompressed YUV 4:2:0", RawFormat::Gray8 => "Uncompressed greyscale", + RawFormat::V210 => "Uncompressed YUV 4:2:2 10-bit", } } } @@ -1254,6 +1277,42 @@ impl RawVideoDecoder { } VideoFrame::rgba8(self.width, self.height, rgba) } + RawFormat::Rgb24 => { + let stride = w * 3; + if s.len() < stride * h { + return Err(short()); + } + let mut rgba = Vec::with_capacity(w * h * 4); + for px in s.get(..stride * h).unwrap_or_default().as_chunks::<3>().0 { + rgba.extend_from_slice(&[px[0], px[1], px[2], 255]); + } + VideoFrame::rgba8(self.width, self.height, rgba) + } + RawFormat::V210 => { + // 6 pixels per 16 bytes; each row padded to a multiple of 128 bytes (48 pixels) + let stride = w.div_ceil(48) * 128; + if s.len() < stride * h { + return Err(short()); + } + let (mut y, mut u, mut v) = (Vec::with_capacity(w * h), Vec::with_capacity(cw * h), Vec::with_capacity(cw * h)); + for row in s.chunks_exact(stride).take(h) { + // the row's 10-bit samples in order: Cb Y Cr Y Cb Y Cr Y …, kept at 8 bits + let mut comps = row.as_chunks::<4>().0.iter().flat_map(|b| { + let word = u32::from_le_bytes(*b); + [word & 0x3ff, (word >> 10) & 0x3ff, (word >> 20) & 0x3ff].map(|c| (c >> 2) as u8) + }); + for x in 0..cw { + let (Some(cb), Some(y0), Some(cr), Some(y1)) = (comps.next(), comps.next(), comps.next(), comps.next()) else { break }; + u.push(cb); + v.push(cr); + y.push(y0); + if 2 * x + 1 < w { + y.push(y1); + } + } + } + self.yuv([y, u, v], filmcraft_frame::Chroma::C422) + } RawFormat::Yuy2 | RawFormat::Uyvy => { let stride = cw * 4; if s.len() < stride * h { From 7cd5acfcf9a34246d1e6891f8a0267a74cac91a6 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:52:52 +0200 Subject: [PATCH 012/113] Keep '#' inside a quoted .cube TITLE when parsing Found while testing crates/color/src/lut.rs:236 (parse_cube strips '#' comments before reading TITLE); writer at to_cube ~line 290 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/color/src/lut.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/color/src/lut.rs b/crates/color/src/lut.rs index 67590eb12..a8aa19a68 100644 --- a/crates/color/src/lut.rs +++ b/crates/color/src/lut.rs @@ -226,7 +226,14 @@ impl Lut { return Err(err("keyword after table data")); } match key { - "TITLE" => title = line[5..].trim().trim_matches('"').to_string(), + "TITLE" => { + // a '#' inside a quoted title is part of the title, not a comment + let rest = raw.trim_start()[5..].trim_start(); + title = match rest.strip_prefix('"').and_then(|q| q.split_once('"')) { + Some((t, _)) => t.to_string(), + None => line[5..].trim().trim_matches('"').to_string(), + }; + } "LUT_1D_SIZE" => { size1 = it.next().and_then(|v| v.parse().ok()).ok_or_else(|| err("bad LUT_1D_SIZE"))?; if !(2..=65536).contains(&size1) { @@ -555,6 +562,15 @@ mod tests { } } + #[test] + fn cube_title_with_hash_roundtrips() { + let mut c = Lut3d::identity(2); + c.title = "Look #2".into(); + assert_eq!(Lut3d::parse_cube(&c.to_cube()).unwrap().title, "Look #2"); + let text = c.to_cube().replace("TITLE \"Look #2\"", "TITLE \"Look\" # note"); + assert_eq!(Lut::parse_cube(&text).unwrap().title, "Look"); + } + #[test] fn cube_roundtrip_3d_1d_and_shaper() { let mut c = wild(17); From b4330d75c3f62bd2dd651e8aa441a270517eadde Mon Sep 17 00:00:00 2001 From: nuclearspike Date: Wed, 14 Oct 2026 14:18:34 -0600 Subject: [PATCH 013/113] fix(media): preserve Counting Leader timing across rates --- crates/engine/src/clip_ops.rs | 7 +- crates/engine/src/clip_ops_tests.rs | 27 +++++++ crates/media/src/generators.rs | 110 +++++++++++++++++++++++++++- 3 files changed, 138 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/clip_ops.rs b/crates/engine/src/clip_ops.rs index 8a85b9eee..3cfc08e07 100644 --- a/crates/engine/src/clip_ops.rs +++ b/crates/engine/src/clip_ops.rs @@ -929,8 +929,11 @@ fn consolidate_duplicates(s: &mut Session, _: &Value) -> Result { let mut groups: BTreeMap> = BTreeMap::new(); for it in s.project.items.values() { if let ItemKind::Media(m) = &it.kind { - // same file (or generator), interpretation and streams - let shape = (m.info.video.is_some(), m.info.has_audio(), m.info.duration); + // A generator's source frame rate can change its output (including a + // counting leader's one-frame beep), even when its duration matches. + // File media keeps its existing duplicate grouping. + let generator_rate = if matches!(&m.media, MediaRef::Generator(_)) { m.info.video.as_ref().map(|v| v.frame_rate) } else { None }; + let shape = (m.info.video.is_some(), m.info.has_audio(), m.info.duration, generator_rate); let key = serde_json::to_string(&(&m.media, &m.interpret, m.offline, shape)).unwrap_or_default(); groups.entry(key).or_default().push(it.id); } diff --git a/crates/engine/src/clip_ops_tests.rs b/crates/engine/src/clip_ops_tests.rs index 262b1ec80..cb16796b1 100644 --- a/crates/engine/src/clip_ops_tests.rs +++ b/crates/engine/src/clip_ops_tests.rs @@ -228,6 +228,33 @@ fn remove_unused_and_consolidate_duplicates() { assert!(s.project.item(dup).is_some()); } +#[test] +fn consolidate_keeps_counting_leaders_with_distinct_source_rates() { + let mut s = Session::default(); + s.execute("file.newProject", json!({"name": "Leader rates"})).unwrap(); + s.execute("file.newSequence", json!({"name": "24 fps", "fps": 24, "video": 1, "audio": 1})).unwrap(); + let leader_24 = ItemId(s.execute("file.newCountingLeader", json!({"seconds": 3})).unwrap()["item"].as_u64().unwrap()); + s.execute("file.newSequence", json!({"name": "60 fps", "fps": 60, "video": 1, "audio": 1})).unwrap(); + let leader_60 = ItemId(s.execute("file.newCountingLeader", json!({"seconds": 3})).unwrap()["item"].as_u64().unwrap()); + assert_ne!(leader_24, leader_60); + s.execute("timeline.place", json!({"item": leader_60.0, "track": "V1", "audioTrack": "A1", "seconds": 0.0})).unwrap(); + let before = s.media.full_res_source(&s.project, leader_60, &*s.services).unwrap().audio(0, 2_010, 48_000).unwrap(); + assert!(before.channels.iter().all(|channel| channel[800..].iter().all(|sample| *sample == 0.0))); + + let result = s.execute("edit.consolidateDuplicates", json!({})).unwrap(); + assert_eq!(result["removed"], json!([]), "leaders with different source rates are not duplicates"); + assert!(s.project.item(leader_60).is_some()); + let sequence = s.active_sequence().unwrap(); + assert_eq!(sequence.audio_tracks[0].items[0].item, leader_60); + let after = s.media.full_res_source(&s.project, leader_60, &*s.services).unwrap().audio(0, 2_010, 48_000).unwrap(); + assert_eq!(after.channels, before.channels); + + s.execute("project.select", json!({"items": [leader_60.0]})).unwrap(); + let duplicate = ItemId(s.execute("edit.duplicate", json!({})).unwrap()["items"][0].as_u64().unwrap()); + let result = s.execute("edit.consolidateDuplicates", json!({})).unwrap(); + assert_eq!(result["removed"], json!([duplicate.0]), "equal-rate copies should still consolidate"); +} + #[test] fn new_sequence_from_clip_bin_from_selection_and_offline_file() { let mut s = demo(); diff --git a/crates/media/src/generators.rs b/crates/media/src/generators.rs index 97d42d1de..6ead187f8 100644 --- a/crates/media/src/generators.rs +++ b/crates/media/src/generators.rs @@ -3,6 +3,9 @@ //! //! Every generator is a pure function of (settings, media time, output scale): deterministic and //! resolution-independent, so low-resolution playback renders directly at the reduced size. +//! +//! Counting Leader beeps last one source frame at each second mark. Use exact audio sample +//! positions and the rational source frame rate for the gate, without rounded timestamp boundaries. use std::f32::consts::{PI, TAU}; use std::sync::Arc; @@ -162,13 +165,14 @@ impl MediaSource for GeneratorSource { let mut out = AudioBuffer::silence(sample_rate, 2, frames); let sr = sample_rate as f32; let total = self.info.duration.to_units_floor(sample_rate as i64); + let frame_rate = self.info.frame_rate().sane(); for i in 0..frames { let n = start + i as i64; if n < 0 || n >= total { continue; } let t = n as f32 / sr; - let (l, r) = audio_sample(&self.generator, t, n); + let (l, r) = audio_sample(&self.generator, t, n, sample_rate, frame_rate); out.channels[0][i] = l; out.channels[1][i] = r; } @@ -188,7 +192,7 @@ fn hash(n: i64) -> f32 { (x >> 40) as f32 / (1u64 << 24) as f32 * 2.0 - 1.0 } -fn audio_sample(g: &Generator, t: f32, n: i64) -> (f32, f32) { +fn audio_sample(g: &Generator, t: f32, n: i64, sample_rate: u32, frame_rate: FrameRate) -> (f32, f32) { match g { Generator::BarsAndTone => { let s = (TAU * 1000.0 * t).sin() * db(-20.0); @@ -200,8 +204,9 @@ fn audio_sample(g: &Generator, t: f32, n: i64) -> (f32, f32) { } Generator::CountingLeader => { // 1 kHz beep for one frame at each second mark (the "2-pop" style beep). - let frac = t.fract(); - let s = if frac < 1.0 / 24.0 { (TAU * 1000.0 * t).sin() * db(-20.0) } else { 0.0 }; + let in_frame = sample_rate != 0 + && i128::from(n.rem_euclid(i64::from(sample_rate))) * i128::from(frame_rate.num) < i128::from(sample_rate) * i128::from(frame_rate.den); + let s = if in_frame { (TAU * 1000.0 * t).sin() * db(-20.0) } else { 0.0 }; (s, s) } Generator::Demo(scene) => demo_audio(*scene, t, n), @@ -569,6 +574,103 @@ mod tests { assert!((peak - db(-20.0)).abs() < 1e-3); } + #[test] + fn counting_leader_beeps_last_exactly_one_source_frame() { + let sample_rate = 48_000; + for rate in [FrameRate::FPS_24, FrameRate::FPS_25, FrameRate::FPS_30, FrameRate::FPS_50, FrameRate::FPS_60, FrameRate::FPS_23_976, FrameRate::FPS_29_97] + { + let source = GeneratorSource::new(Generator::CountingLeader, 64, 36, rate, Tick(3 * TICKS_PER_SECOND)); + let frame_samples = (sample_rate as u64 * rate.den as u64).div_ceil(rate.num as u64) as usize; + for start in [0, sample_rate as i64, 2 * sample_rate as i64] { + let audio = source.audio(start, frame_samples + 8, sample_rate).unwrap(); + for channel in &audio.channels { + assert!(channel[..frame_samples].iter().any(|s| s.abs() > 0.05), "{rate:?}, start {start}: missing beep"); + assert_ne!(channel[frame_samples - 1], 0.0, "{rate:?}, start {start}: beep stops before one frame"); + assert!(channel[frame_samples..].iter().all(|s| *s == 0.0), "{rate:?}, start {start}: beep extends beyond one frame"); + } + } + } + } + + #[test] + fn counting_leader_custom_rate_stops_at_exact_sample_boundary() { + let sample_rate = 41_000; + let source = GeneratorSource::new(Generator::CountingLeader, 64, 36, FrameRate { num: 41, den: 1 }, Tick(3 * TICKS_PER_SECOND)); + for start in [0, 41_000, 82_000] { + let audio = source.audio(start, 1_008, sample_rate).unwrap(); + for channel in &audio.channels { + assert_ne!(channel[999], 0.0, "missing final in-frame sample"); + assert!(channel[1_000..].iter().all(|s| *s == 0.0), "sample exactly at frame boundary must be silent"); + } + } + } + + #[test] + fn counting_leader_gate_is_independent_of_request_boundaries() { + for sample_rate in [41_000u32, 44_100, 48_000] { + for rate in [FrameRate::FPS_24, FrameRate::FPS_60, FrameRate::FPS_23_976, FrameRate::FPS_29_97, FrameRate { num: 41, den: 1 }] { + let source = GeneratorSource::new(Generator::CountingLeader, 64, 36, rate, Tick(3 * TICKS_PER_SECOND)); + let frame_samples = (u64::from(sample_rate) * rate.den as u64).div_ceil(rate.num as u64) as usize; + let start = i64::from(sample_rate) - 3; + let audio = source.audio(start, frame_samples + 8, sample_rate).unwrap(); + for channel in &audio.channels { + assert!(channel[..3].iter().all(|s| *s == 0.0), "{rate:?}, {sample_rate}: previous second should be silent"); + assert!(channel[3..3 + frame_samples].iter().any(|s| s.abs() > 0.05), "{rate:?}, {sample_rate}: missing beep"); + assert!(channel[3 + frame_samples..].iter().all(|s| *s == 0.0), "{rate:?}, {sample_rate}: beep exceeds frame"); + } + let first = source.audio(start, 5, sample_rate).unwrap(); + let second = source.audio(start + 5, frame_samples + 3, sample_rate).unwrap(); + for c in 0..2 { + let split: Vec<_> = first.channels[c].iter().chain(&second.channels[c]).copied().collect(); + assert_eq!(split, audio.channels[c], "{rate:?}, {sample_rate}: split requests change samples"); + } + let end = source.audio(i64::from(sample_rate) + frame_samples as i64 - 2, 6, sample_rate).unwrap(); + assert!(end.channels.iter().all(|channel| channel[2..].iter().all(|s| *s == 0.0)), "{rate:?}, {sample_rate}: end-crossing block leaks"); + } + } + } + + #[test] + fn counting_leader_malformed_rates_use_default_frame_gate() { + let sample_rate = 48_000u32; + let default_rate = FrameRate::default(); + let frame_samples = (u64::from(sample_rate) * default_rate.den as u64).div_ceil(default_rate.num as u64); + let start = frame_samples as i64 - 2; + for rate in [FrameRate { num: 41, den: 0 }, FrameRate { num: 0, den: 0 }, FrameRate { num: i64::MIN, den: 1 }, FrameRate { num: 1, den: i64::MIN }] { + let source = GeneratorSource::new(Generator::CountingLeader, 64, 36, rate, Tick(3 * TICKS_PER_SECOND)); + let audio = source.audio(start, 8, sample_rate).unwrap(); + for channel in &audio.channels { + assert_ne!(channel[1], 0.0, "{rate:?}: missing final fallback-frame sample"); + assert!(channel[2..].iter().all(|sample| *sample == 0.0), "{rate:?}: fallback gate exceeds one frame"); + } + let fallback = GeneratorSource::new(Generator::CountingLeader, 64, 36, default_rate, Tick(3 * TICKS_PER_SECOND)); + assert_eq!(audio.channels, fallback.audio(start, 8, sample_rate).unwrap().channels, "{rate:?}: changed fallback"); + } + } + + #[test] + fn counting_leader_gate_handles_zero_and_hostile_rates() { + let invalid = [FrameRate { num: 0, den: 0 }, FrameRate { num: i64::MIN, den: 1 }, FrameRate { num: 1, den: i64::MIN }, FrameRate { num: 41, den: 0 }]; + let rates = + invalid.into_iter().chain([FrameRate { num: 1, den: i64::MAX }, FrameRate { num: i64::MAX, den: 1 }, FrameRate { num: i64::MAX, den: i64::MAX }]); + for rate in rates { + let source = GeneratorSource::new(Generator::CountingLeader, 64, 36, rate, Tick(3 * TICKS_PER_SECOND)); + for sample_rate in [0, 1, 48_000, u32::MAX] { + for start in [-2, 0, 1] { + let audio = std::panic::catch_unwind(|| source.audio(start, 8, sample_rate)).unwrap().unwrap(); + assert!(audio.channels.iter().flatten().all(|s| s.is_finite()), "{rate:?}, {sample_rate}: non-finite audio"); + if sample_rate == 0 { + assert!(audio.channels.iter().flatten().all(|s| *s == 0.0)); + } + if invalid.contains(&rate) { + let fallback = GeneratorSource::new(Generator::CountingLeader, 64, 36, FrameRate::default(), Tick(3 * TICKS_PER_SECOND)); + assert_eq!(audio.channels, fallback.audio(start, 8, sample_rate).unwrap().channels, "{rate:?}: changed fallback"); + } + } + } + } + } + #[test] fn all_scenes_render() { for s in DemoScene::ALL { From 7cb72e6d3173591e344310c8ae8b40bffa635ad9 Mon Sep 17 00:00:00 2001 From: nuclearspike Date: Mon, 19 Oct 2026 00:50:20 -0600 Subject: [PATCH 014/113] fix(media): restrict Counting Leader rate identity and verify full beep frame --- crates/engine/src/clip_ops.rs | 9 +++++---- crates/media/src/generators.rs | 25 ++++++++++++++++++------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/clip_ops.rs b/crates/engine/src/clip_ops.rs index 3cfc08e07..9aa372224 100644 --- a/crates/engine/src/clip_ops.rs +++ b/crates/engine/src/clip_ops.rs @@ -929,10 +929,11 @@ fn consolidate_duplicates(s: &mut Session, _: &Value) -> Result { let mut groups: BTreeMap> = BTreeMap::new(); for it in s.project.items.values() { if let ItemKind::Media(m) = &it.kind { - // A generator's source frame rate can change its output (including a - // counting leader's one-frame beep), even when its duration matches. - // File media keeps its existing duplicate grouping. - let generator_rate = if matches!(&m.media, MediaRef::Generator(_)) { m.info.video.as_ref().map(|v| v.frame_rate) } else { None }; + let generator_rate = if matches!(&m.media, MediaRef::Generator(filmcraft_media::Generator::CountingLeader)) { + m.info.video.as_ref().map(|v| v.frame_rate) + } else { + None + }; let shape = (m.info.video.is_some(), m.info.has_audio(), m.info.duration, generator_rate); let key = serde_json::to_string(&(&m.media, &m.interpret, m.offline, shape)).unwrap_or_default(); groups.entry(key).or_default().push(it.id); diff --git a/crates/media/src/generators.rs b/crates/media/src/generators.rs index 6ead187f8..50bdf21a3 100644 --- a/crates/media/src/generators.rs +++ b/crates/media/src/generators.rs @@ -580,12 +580,18 @@ mod tests { for rate in [FrameRate::FPS_24, FrameRate::FPS_25, FrameRate::FPS_30, FrameRate::FPS_50, FrameRate::FPS_60, FrameRate::FPS_23_976, FrameRate::FPS_29_97] { let source = GeneratorSource::new(Generator::CountingLeader, 64, 36, rate, Tick(3 * TICKS_PER_SECOND)); + let carrier = GeneratorSource::new(Generator::Tone { hz: 1000.0, db: -20.0 }, 64, 36, rate, Tick(3 * TICKS_PER_SECOND)); let frame_samples = (sample_rate as u64 * rate.den as u64).div_ceil(rate.num as u64) as usize; for start in [0, sample_rate as i64, 2 * sample_rate as i64] { let audio = source.audio(start, frame_samples + 8, sample_rate).unwrap(); - for channel in &audio.channels { - assert!(channel[..frame_samples].iter().any(|s| s.abs() > 0.05), "{rate:?}, start {start}: missing beep"); - assert_ne!(channel[frame_samples - 1], 0.0, "{rate:?}, start {start}: beep stops before one frame"); + let expected = carrier.audio(start, frame_samples, sample_rate).unwrap(); + for (channel, carrier_channel) in audio.channels.iter().zip(&expected.channels) { + assert!(carrier_channel.iter().any(|s| s.abs() > 0.05), "{rate:?}, start {start}: carrier should be audible"); + assert_eq!( + &channel[..frame_samples], + carrier_channel.as_slice(), + "{rate:?}, start {start}: in-frame beep differs from the ungated carrier" + ); assert!(channel[frame_samples..].iter().all(|s| *s == 0.0), "{rate:?}, start {start}: beep extends beyond one frame"); } } @@ -596,10 +602,12 @@ mod tests { fn counting_leader_custom_rate_stops_at_exact_sample_boundary() { let sample_rate = 41_000; let source = GeneratorSource::new(Generator::CountingLeader, 64, 36, FrameRate { num: 41, den: 1 }, Tick(3 * TICKS_PER_SECOND)); + let carrier = GeneratorSource::new(Generator::Tone { hz: 1000.0, db: -20.0 }, 64, 36, FrameRate { num: 41, den: 1 }, Tick(3 * TICKS_PER_SECOND)); for start in [0, 41_000, 82_000] { let audio = source.audio(start, 1_008, sample_rate).unwrap(); - for channel in &audio.channels { - assert_ne!(channel[999], 0.0, "missing final in-frame sample"); + let expected = carrier.audio(start, 1_000, sample_rate).unwrap(); + for (channel, carrier_channel) in audio.channels.iter().zip(&expected.channels) { + assert_eq!(&channel[..1_000], carrier_channel.as_slice(), "custom-rate beep stops before the frame boundary"); assert!(channel[1_000..].iter().all(|s| *s == 0.0), "sample exactly at frame boundary must be silent"); } } @@ -636,11 +644,14 @@ mod tests { let default_rate = FrameRate::default(); let frame_samples = (u64::from(sample_rate) * default_rate.den as u64).div_ceil(default_rate.num as u64); let start = frame_samples as i64 - 2; + let carrier = GeneratorSource::new(Generator::Tone { hz: 1000.0, db: -20.0 }, 64, 36, default_rate, Tick(3 * TICKS_PER_SECOND)); + let expected = carrier.audio(start, 2, sample_rate).unwrap(); + assert!(expected.channels.iter().all(|channel| channel.iter().any(|sample| sample.abs() > 0.05))); for rate in [FrameRate { num: 41, den: 0 }, FrameRate { num: 0, den: 0 }, FrameRate { num: i64::MIN, den: 1 }, FrameRate { num: 1, den: i64::MIN }] { let source = GeneratorSource::new(Generator::CountingLeader, 64, 36, rate, Tick(3 * TICKS_PER_SECOND)); let audio = source.audio(start, 8, sample_rate).unwrap(); - for channel in &audio.channels { - assert_ne!(channel[1], 0.0, "{rate:?}: missing final fallback-frame sample"); + for (channel, carrier_channel) in audio.channels.iter().zip(&expected.channels) { + assert_eq!(&channel[..2], carrier_channel.as_slice(), "{rate:?}: fallback beep stops before the frame boundary"); assert!(channel[2..].iter().all(|sample| *sample == 0.0), "{rate:?}: fallback gate exceeds one frame"); } let fallback = GeneratorSource::new(Generator::CountingLeader, 64, 36, default_rate, Tick(3 * TICKS_PER_SECOND)); From e593ea6fa3a5bacb1bcbdfdc3419722d20faa23e Mon Sep 17 00:00:00 2001 From: Michael Date: Mon, 19 Oct 2026 20:59:59 +0800 Subject: [PATCH 015/113] fix(engine): clip.audioChannels no longer resets channels on a channels-less call (#1845) audio_channels() took the timeline-clip branch whenever either no project item was selected OR `channels` was given, even though the branch only ever reads `channels`. With no item selected and no `channels` key (e.g. a stray `format` sent while the project selection happens to be empty), it still ran that branch, and `unwrap_or_default()` silently reset the selected clips' source_channels to `[]`. Only `channels` now decides which branch runs; the combination this command can't act on (no item selected, no `channels`) is now a clip.audioChannels error instead of a silent reset. Fixes one case from #1845's repro (format-only call resets an existing channel map); the issue's other reported behaviors (dialog UX, downmixed nested-channel selection, Breakout to Mono channel counts) are separate and not addressed here. cargo test -p filmcraft-engine: 648/649 pass; the one failure (export_tests::every_builtin_preset_exports_and_ffprobe_confirms, APV) reproduces identically on a clean upstream/main checkout before this change and looks like a local ffprobe/APV support gap, not a regression from this patch. Co-Authored-By: Claude Sonnet 5 --- crates/engine/src/clip_ops.rs | 10 +++++++++- crates/engine/src/clip_ops_tests.rs | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/clip_ops.rs b/crates/engine/src/clip_ops.rs index 8a85b9eee..f7d97b29b 100644 --- a/crates/engine/src/clip_ops.rs +++ b/crates/engine/src/clip_ops.rs @@ -1234,7 +1234,15 @@ fn parse_format(f: &str) -> Option { fn audio_channels(s: &mut Session, p: &Value) -> Result { let items: Vec = items_p(s, p).into_iter().filter(|i| s.project.item(*i).and_then(|it| it.as_media()).is_some_and(|m| m.info.has_audio())).collect(); - if items.is_empty() || p.get("channels").is_some() { + // `channels` is the only thing the timeline-clip branch below reads, so it alone decides + // which branch runs: no project item selected used to fall into it too (on the theory that + // there was nothing else to do), but a `channels`-less call with no item selected (e.g. a + // stray `format` with an empty project selection, #1845) then reset the selected clips' + // `source_channels` to `[]` via `unwrap_or_default()` instead of being rejected. + if items.is_empty() && p.get("channels").is_none() { + return Err(bad("clip.audioChannels", "select a clip with audio, or a project item to set its format")); + } + if p.get("channels").is_some() { // timeline audio clips: which source channels each plays let clips = match p.get("clips").and_then(Value::as_array) { Some(a) if a.iter().all(Value::is_u64) => a.iter().filter_map(|v| v.as_u64().map(ClipId)).collect(), diff --git a/crates/engine/src/clip_ops_tests.rs b/crates/engine/src/clip_ops_tests.rs index 262b1ec80..12d195929 100644 --- a/crates/engine/src/clip_ops_tests.rs +++ b/crates/engine/src/clip_ops_tests.rs @@ -379,6 +379,24 @@ fn modify_audio_channels_and_breakout_to_mono() { assert!(s.execute("clip.breakoutToMono", json!({})).is_err(), "no audio"); } +/// #1845: with no project item selected, a `channels`-less call (a stray `format` included) +/// used to fall into the timeline-clip branch anyway and reset `source_channels` to `[]` via +/// `unwrap_or_default()`, instead of being rejected. The map set by the previous `channels` call +/// must survive a later `format`-only call under the same (empty) project selection. +#[test] +fn audio_channels_rejects_a_channel_less_call_with_no_item_selected() { + let mut s = demo(); + s.state.project_selection.clear(); + let a1 = s.active_sequence().unwrap().audio_tracks[0].items[0].id; + s.state.selection = vec![a1]; + s.execute("clip.audioChannels", json!({"channels": [1]})).unwrap(); + assert_eq!(clip(&s, a1.0).source_channels, vec![1]); + assert!(s.execute("clip.audioChannels", json!({"format": null})).is_err(), "nothing to do: no item selected and no `channels`"); + assert_eq!(clip(&s, a1.0).source_channels, vec![1], "the existing map must survive a rejected call"); + assert!(s.execute("clip.audioChannels", json!({})).is_err(), "same with no params at all"); + assert_eq!(clip(&s, a1.0).source_channels, vec![1]); +} + #[test] fn modify_timecode_and_reset() { let mut s = demo(); From 2a19266ba884d777ce236ba225acf117652b517a Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 21:50:08 +0000 Subject: [PATCH 016/113] UI.9: draw panels with craft-ui shared docking (Frame profile) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit craft-ui's DockArea draws the frames, tab strips, floating windows and drag previews and owns the gestures. FilmCraft keeps its panels, the ≡ panel menu, the Timeline's sequence tabs (drawn in the Timeline's group header), maximize keys, the wheel and middle click on tabs, and the Timeline restored after close. The layout is a craft-ui Layout; saved workspaces are craft-ui Workspaces, and entries written before are still read. window.dockPanel, window.floatPanel and window.closePanel run as craft-ui DockCommands, and the window.panel.* commands route too. Automation ids for tabs, drag bars, floating handles and dividers come from DockArea::hit_rects. craft-ui is pinned to storytold/craft-libs#187. --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/ui-egui/src/dock.rs | 1009 +++++++++------------ crates/ui-egui/src/dock_shared.rs | 131 --- crates/ui-egui/src/dock_ui.rs | 758 ++++++++-------- crates/ui-egui/src/lib.rs | 19 +- crates/ui-egui/src/panels/keyboard.rs | 9 +- crates/ui-egui/src/panels/mod.rs | 2 +- crates/ui-egui/src/panels/workspaces.rs | 13 +- crates/ui-egui/src/state.rs | 8 +- crates/ui-egui/tests/meters_ui.rs | 16 +- crates/ui-egui/tests/monitor_geometry.rs | 16 +- crates/ui-egui/tests/nest_editing_ui.rs | 4 +- crates/ui-egui/tests/panels_ui.rs | 21 +- crates/ui-egui/tests/sequence_tabs_ui.rs | 4 +- crates/ui-egui/tests/shared_docking_ui.rs | 316 ++++--- crates/ui-egui/tests/timeline_drop_ui.rs | 4 +- crates/ui-egui/tests/ui_control_input.rs | 26 +- crates/ui-egui/tests/ui_dock_resize.rs | 36 +- crates/ui-egui/tests/ui_keyboard_focus.rs | 24 +- crates/ui-egui/tests/workspaces_ui.rs | 2 +- 21 files changed, 1075 insertions(+), 1347 deletions(-) delete mode 100644 crates/ui-egui/src/dock_shared.rs diff --git a/Cargo.lock b/Cargo.lock index 43b781b41..e2eb13f25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1019,7 +1019,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09 [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +source = "git+https://github.com/storytold/craft-libs?rev=787b70627461a3c988157ed776e8f6051cbc94c3#787b70627461a3c988157ed776e8f6051cbc94c3" dependencies = [ "egui", "serde", diff --git a/Cargo.toml b/Cargo.toml index 625558e91..dfa2509bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,7 +74,7 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l # ui # Shared frontend components; keep this dependency above the engine boundary. -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "787b70627461a3c988157ed776e8f6051cbc94c3", features = ["serde"] } egui = "0.36" eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index d144bd57e..929f9da6c 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -1,11 +1,12 @@ -//! Premiere-style docking: a tree of splits and tab groups. Panels are rounded frames separated by -//! thin gutters; each group has a tab strip (active tab bright, with a panel menu "≡"); the focused -//! panel gets a blue outline. Gutters drag to resize; workspaces are serialized trees. +//! FilmCraft's panels and workspaces. craft-ui's docking (Frame profile) owns the frame tree, +//! the tab strips, floating windows and gestures (`dock_ui.rs` draws it); this module names the +//! panels, builds the built-in workspaces and draws the Timeline's sequence tabs. use egui::{Align2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde::{Deserialize, Serialize}; -use craft_ui::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis, SplitSize as SharedSplitSize, split_rect}; +use craft_ui::docking::{Action, Floating, Node}; +use craft_ui::layout::{SplitAxis, SplitSize}; use crate::icons::{self, Icon}; use crate::theme::Tokens; @@ -133,48 +134,19 @@ impl PanelKind { } } -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -pub enum SplitSize { - Ratio(f32), - /// First child has a fixed size in points. - FixedA(f32), - /// Second child has a fixed size in points. - FixedB(f32), -} +/// A FilmCraft panel arrangement: craft-ui's frame tree and floating windows. +pub type Layout = craft_ui::docking::Layout; +/// What a saved workspace stores: its layout, closed panels and lock. +pub type Workspace = craft_ui::docking::Workspace; -impl SplitSize { - fn shared(self) -> SharedSplitSize { - match self { - Self::Ratio(value) => SharedSplitSize::Ratio(value), - Self::FixedA(value) => SharedSplitSize::FixedFirst(value), - Self::FixedB(value) => SharedSplitSize::FixedSecond(value), - } - } +fn tabs(p: &[PanelKind], active: usize) -> Node { + Node::Tabs { panels: p.to_vec(), active } } - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub enum DockNode { - /// `vertical`: children stacked top/bottom; else side by side. - Split { - vertical: bool, - size: SplitSize, - a: Box, - b: Box, - }, - Tabs { - panels: Vec, - active: usize, - }, +fn hsplit(size: SplitSize, a: Node, b: Node) -> Node { + Node::Split { axis: SplitAxis::Horizontal, size, first: Box::new(a), second: Box::new(b) } } - -fn tabs(p: &[PanelKind], active: usize) -> DockNode { - DockNode::Tabs { panels: p.to_vec(), active } -} -fn hsplit(size: SplitSize, a: DockNode, b: DockNode) -> DockNode { - DockNode::Split { vertical: false, size, a: Box::new(a), b: Box::new(b) } -} -fn vsplit(size: SplitSize, a: DockNode, b: DockNode) -> DockNode { - DockNode::Split { vertical: true, size, a: Box::new(a), b: Box::new(b) } +fn vsplit(size: SplitSize, a: Node, b: Node) -> Node { + Node::Split { axis: SplitAxis::Vertical, size, first: Box::new(a), second: Box::new(b) } } pub const WORKSPACES: [&str; 9] = ["Editing", "Assembly", "Color", "Effects", "Audio", "Captions and Graphics", "Learning", "Review", "All Panels"]; @@ -191,15 +163,19 @@ pub struct WorkspacePrefs { pub current: String, } -/// A named layout. `layout` is a [`DockNode`] kept as JSON, so one unreadable entry (a panel a -/// newer version added) doesn't lose the others. +/// A named workspace. `workspace` is a craft-ui [`Workspace`] kept as JSON, so one unreadable +/// entry (a panel a newer version added) doesn't lose the others. Files written before +/// FilmCraft used craft-ui workspaces hold a frame tree in `layout` and floating groups in +/// `floating` instead; [`saved_workspace`] reads them, and saving writes `workspace`. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct SavedWorkspace { pub name: String, + #[serde(skip_serializing_if = "serde_json::Value::is_null")] + pub workspace: serde_json::Value, + #[serde(skip_serializing_if = "serde_json::Value::is_null")] pub layout: serde_json::Value, - /// Kept raw for the same per-workspace failure isolation as the dock tree. - #[serde(default, skip_serializing_if = "serde_json::Value::is_null")] + #[serde(skip_serializing_if = "serde_json::Value::is_null")] pub floating: serde_json::Value, } @@ -254,63 +230,145 @@ pub fn find(prefs: &WorkspacePrefs, query: &str) -> Option { names(prefs).into_iter().find(|n| slug(n) == slug(query)) } -/// The saved layout of a workspace: the user's saved version, else the built-in default. -pub fn saved_layout(prefs: &WorkspacePrefs, name: &str) -> DockNode { - saved_docking(prefs, name).root -} - -pub fn saved_docking(prefs: &WorkspacePrefs, name: &str) -> Layout { +/// A workspace as saved: the user's saved version, else the built-in default. An entry that +/// does not read or validate falls back to the default and stays in the file untouched. +pub fn saved_workspace(prefs: &WorkspacePrefs, name: &str) -> Workspace { let saved = prefs.saved.iter().find(|saved| saved.name == name).and_then(|saved| { - let mut layout = Layout { - root: serde_json::from_value(saved.layout.clone()).ok()?, - floating: if saved.floating.is_null() { vec![] } else { serde_json::from_value(saved.floating.clone()).ok()? }, + let workspace = if saved.workspace.is_null() { + Workspace::new(legacy::layout(&saved.layout, &saved.floating)?) + } else { + serde_json::from_value::(saved.workspace.clone()).ok()? }; - if layout.floating.is_empty() { - migrate_legacy_effects(&mut layout.root); - } - layout.validate().ok().map(|()| layout) + workspace.validate().ok().map(|()| workspace) }); - saved.unwrap_or_else(|| Layout { root: workspace(name), floating: vec![] }) + saved.unwrap_or_else(|| Workspace::new(workspace(name))) } -/// Older Effects workspaces stored the same Effect Controls panel twice: selected beside -/// Source, and an inactive duplicate beside Essential Graphics. Remove only that known -/// duplicate shape; unrelated malformed or ambiguous saved layouts still fail validation. -fn migrate_legacy_effects(root: &mut DockNode) { - let Ok(shared) = crate::dock_shared::encode(root, &[]) else { return }; - if shared.panels().iter().filter(|panel| ***panel == PanelKind::EffectControls).count() != 2 { - return; +/// The saved layout of a workspace (see [`saved_workspace`]). +pub fn saved_docking(prefs: &WorkspacePrefs, name: &str) -> Layout { + saved_workspace(prefs, name).layout +} + +/// The frame tree and floating groups FilmCraft wrote before its workspaces were craft-ui +/// [`Workspace`]s. +mod legacy { + use super::{Floating, Layout, Node, PanelKind, SplitAxis, SplitSize}; + use serde::Deserialize; + + const MAX_DEPTH: usize = 32; + + #[derive(Deserialize)] + enum Tree { + /// `vertical`: children stacked top/bottom; else side by side. + Split { + vertical: bool, + size: Size, + a: Box, + b: Box, + }, + Tabs { + panels: Vec, + active: usize, + }, } - fn selected_source(node: &DockNode) -> bool { - match node { - DockNode::Tabs { panels, active } => { - panels.contains(&PanelKind::Source) && panels.contains(&PanelKind::AudioClipMixer) && panels.get(*active) == Some(&PanelKind::EffectControls) - } - DockNode::Split { a, b, .. } => selected_source(a) || selected_source(b), + + #[derive(Deserialize)] + enum Size { + Ratio(f32), + FixedA(f32), + FixedB(f32), + } + + #[derive(Deserialize)] + struct Group { + panels: Vec, + active: usize, + rect: [f32; 4], + } + + fn node(tree: Tree, depth: usize) -> Option> { + if depth > MAX_DEPTH { + return None; } + Some(match tree { + Tree::Tabs { panels, active } => Node::Tabs { panels, active }, + Tree::Split { vertical, size, a, b } => Node::Split { + axis: if vertical { SplitAxis::Vertical } else { SplitAxis::Horizontal }, + size: match size { + Size::Ratio(value) => SplitSize::Ratio(value), + Size::FixedA(value) => SplitSize::FixedFirst(value), + Size::FixedB(value) => SplitSize::FixedSecond(value), + }, + first: Box::new(node(*a, depth + 1)?), + second: Box::new(node(*b, depth + 1)?), + }, + }) } - fn remove_inactive(node: &mut DockNode) { - match node { - DockNode::Tabs { panels, active } if panels.as_slice() == [PanelKind::EffectControls, PanelKind::EssentialGraphics] && *active == 1 => { - panels.remove(0); - *active = 0; - } - DockNode::Split { a, b, .. } => { - remove_inactive(a); - remove_inactive(b); - } - DockNode::Tabs { .. } => {} + + pub(super) fn layout(root: &serde_json::Value, floating: &serde_json::Value) -> Option { + let tree: Tree = serde_json::from_value(root.clone()).ok()?; + let floating: Vec = if floating.is_null() { vec![] } else { serde_json::from_value(floating.clone()).ok()? }; + let mut root = match node(tree, 0)? { + Node::Tabs { panels, .. } if panels.is_empty() => None, + root => Some(root), + }; + if floating.is_empty() + && let Some(root) = root.as_mut() + { + migrate_effects(root); } + Some(Layout { root, floating: floating.into_iter().map(|group| Floating::single(group.panels, group.active, group.rect)).collect() }) } - if selected_source(root) { - remove_inactive(root); + + /// Effects workspaces saved by early versions held the Effect Controls panel twice: selected + /// beside Source, and an inactive duplicate beside Essential Graphics. Remove only that known + /// duplicate shape; other malformed saved layouts still fail validation. + fn migrate_effects(root: &mut Node) { + fn count(node: &Node) -> usize { + match node { + Node::Tabs { panels, .. } => panels.iter().filter(|panel| **panel == PanelKind::EffectControls).count(), + Node::Split { first, second, .. } => count(first) + count(second), + _ => 0, + } + } + fn selected_source(node: &Node) -> bool { + match node { + Node::Tabs { panels, active } => { + panels.contains(&PanelKind::Source) + && panels.contains(&PanelKind::AudioClipMixer) + && panels.get(*active) == Some(&PanelKind::EffectControls) + } + Node::Split { first, second, .. } => selected_source(first) || selected_source(second), + _ => false, + } + } + fn remove_inactive(node: &mut Node) { + match node { + Node::Tabs { panels, active } if panels.as_slice() == [PanelKind::EffectControls, PanelKind::EssentialGraphics] && *active == 1 => { + panels.remove(0); + *active = 0; + } + Node::Split { first, second, .. } => { + remove_inactive(first); + remove_inactive(second); + } + _ => {} + } + } + if count(root) == 2 && selected_source(root) { + remove_inactive(root); + } } } /// The default layout of a named workspace. -pub fn workspace(name: &str) -> DockNode { +pub fn workspace(name: &str) -> Layout { + Layout { root: Some(workspace_root(name)), floating: vec![] } +} + +fn workspace_root(name: &str) -> Node { use PanelKind::*; - use SplitSize::*; + use SplitSize::{FixedFirst as FixedA, FixedSecond as FixedB, Ratio}; let bottom_editing = || hsplit(FixedA(64.0), tabs(&[Tools], 0), hsplit(FixedB(116.0), tabs(&[Timeline], 0), tabs(&[AudioMeters], 0))); match name { "Assembly" => vsplit( @@ -385,187 +443,72 @@ pub fn workspace(name: &str) -> DockNode { } } -impl DockNode { - /// Every panel in the tree. - pub fn panels(&self, out: &mut Vec) { - match self { - DockNode::Split { a, b, .. } => { - a.panels(out); - b.panels(out); - } - DockNode::Tabs { panels, .. } => out.extend(panels.iter().copied()), - } - } - pub fn contains(&self, p: PanelKind) -> bool { - let mut v = Vec::new(); - self.panels(&mut v); - v.contains(&p) - } - /// Make `p` the active tab of its group. Returns false if not present. - pub fn activate(&mut self, p: PanelKind) -> bool { - crate::dock_shared::apply_tree(self, craft_ui::docking::Action::Activate { panel: p }) - } - pub fn is_visible(&self, p: PanelKind) -> bool { - match self { - DockNode::Split { a, b, .. } => a.is_visible(p) || b.is_visible(p), - DockNode::Tabs { panels, active } => panels.get(*active) == Some(&p), - } - } - /// Close a panel (remove its tab; empty groups collapse their split). - pub fn close(&mut self, p: PanelKind) { - crate::dock_shared::apply_tree(self, craft_ui::docking::Action::Close { panel: p }); - } +/// FilmCraft's queries and changes on a [`Layout`] beyond craft-ui's actions. +pub trait LayoutExt { + /// Whether `panel` is the shown tab of its frame or floating window. + fn is_visible(&self, panel: PanelKind) -> bool; + /// The shown panel of each frame in layout order, then of each floating window. + fn visible_panels(&self) -> Vec; /// Put the Timeline panel back where the workspaces keep it (left of the audio meters) after /// it was closed, so opening a sequence always has somewhere to show it. - pub fn restore_timeline(&mut self) { - if self.contains(PanelKind::Timeline) { - return; - } - fn beside_meters(n: &mut DockNode) -> bool { - match n { - DockNode::Split { a, b, .. } => beside_meters(a) || beside_meters(b), - DockNode::Tabs { panels, .. } if panels.as_slice() == [PanelKind::AudioMeters] => { - *n = hsplit(SplitSize::FixedB(116.0), tabs(&[PanelKind::Timeline], 0), tabs(&[PanelKind::AudioMeters], 0)); - true + fn restore_timeline(&mut self); + /// Add a panel as a tab next to `near` (else Project, Program or the first docked panel). + fn open_near(&mut self, panel: PanelKind, near: PanelKind); +} + +impl LayoutExt for Layout { + fn is_visible(&self, panel: PanelKind) -> bool { + self.panel_state(&panel) == craft_ui::docking::PanelState::Front + } + + fn visible_panels(&self) -> Vec { + fn walk(node: &Node, out: &mut Vec) { + match node { + Node::Tabs { panels, active } => out.extend(panels.get(*active).copied()), + Node::Split { first, second, .. } => { + walk(first, out); + walk(second, out); } - DockNode::Tabs { .. } => false, + _ => {} } } - if !beside_meters(self) { + let mut out = Vec::new(); + if let Some(root) = &self.root { + walk(root, &mut out); + } + out.extend(self.floating.iter().flat_map(|window| window.groups.iter().filter_map(|group| group.panels.get(group.active).copied()))); + out + } + + fn restore_timeline(&mut self) { + if self.contains(&PanelKind::Timeline) { + return; + } + fn beside_meters(n: &mut Node) -> bool { + match n { + Node::Split { first, second, .. } => beside_meters(first) || beside_meters(second), + Node::Tabs { panels, .. } if panels.as_slice() == [PanelKind::AudioMeters] => { + *n = hsplit(SplitSize::FixedSecond(116.0), tabs(&[PanelKind::Timeline], 0), tabs(&[PanelKind::AudioMeters], 0)); + true + } + _ => false, + } + } + if !self.root.as_mut().is_some_and(beside_meters) { self.open_near(PanelKind::Timeline, PanelKind::Project); } } - /// Add a panel as a tab next to `near` (or into the first group). - pub fn open_near(&mut self, p: PanelKind, near: PanelKind) { - if crate::dock_shared::encode(self, &[]).is_err() { - return; - } - let anchor = [near, PanelKind::Project, PanelKind::Program].into_iter().find(|panel| self.contains(*panel)).or_else(|| { - let mut panels = Vec::new(); - self.panels(&mut panels); - panels.first().copied() - }); - crate::dock_shared::apply_tree(self, craft_ui::docking::Action::Open { panel: p, anchor }); + + fn open_near(&mut self, panel: PanelKind, near: PanelKind) { + let docked = Layout { root: self.root.clone(), floating: vec![] }; + let anchor = [near, PanelKind::Project, PanelKind::Program] + .into_iter() + .find(|candidate| docked.contains(candidate)) + .or_else(|| docked.panels().first().map(|first| **first)); + let _ = self.apply(Action::Open { panel, anchor }); } } -/// A floating group retains the same panel identities as docked tabs. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct Floating { - pub panels: Vec, - pub active: usize, - /// [x, y, width, height] in logical points. - pub rect: [f32; 4], -} - -/// App persistence shape; all mutations pass through the shared docking model. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct Layout { - pub root: DockNode, - #[serde(default)] - pub floating: Vec, -} - -impl Layout { - pub fn apply(&mut self, action: craft_ui::docking::Action) -> Result<(), String> { - crate::dock_shared::apply(self, action) - } - pub fn validate(&self) -> Result<(), String> { - crate::dock_shared::encode(&self.root, &self.floating)?.validate().map_err(|error| error.to_string()) - } - pub fn contains(&self, panel: PanelKind) -> bool { - self.root.contains(panel) || self.floating.iter().any(|group| group.panels.contains(&panel)) - } -} - -/// One laid-out tab group. -pub struct Group { - pub path: String, - pub rect: Rect, - pub content: Rect, - pub panels: Vec, - pub active: usize, -} - -/// Actions produced by interacting with the dock chrome. -#[derive(Debug, Clone, PartialEq)] -pub enum DockAction { - Activate(PanelKind), - BeginDrag(PanelKind), - Focus(PanelKind), - Close(PanelKind), - PanelMenu(PanelKind, egui::Pos2), - /// A sequence tab of the Timeline panel was clicked / closed (project item id). - OpenSequence(u64), - CloseSequence(u64), - /// A sequence tab was dragged past a neighbour: its new place among the sequence tabs. - MoveSequence(u64, usize), -} - -/// Lay out the tree into group rects (with `gap` gutters) and handle gutter dragging. -pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, path: &str, out: &mut Vec, reg: &mut crate::automation::Registry) { - let mut actions = Vec::new(); - layout_with_actions(ui, node, rect, t, path, out, reg, &mut actions); - for action in actions { - crate::dock_shared::apply_tree(node, action); - } -} - -/// Lay out chrome while returning mutations for the application's UI command route. -pub fn layout_with_actions( - ui: &mut egui::Ui, - node: &mut DockNode, - rect: Rect, - t: &Tokens, - path: &str, - out: &mut Vec, - reg: &mut crate::automation::Registry, - actions: &mut Vec>, -) { - layout_inner(ui, node, rect, t, path, &[], out, reg, actions); -} - -fn layout_inner( - ui: &mut egui::Ui, - node: &mut DockNode, - rect: Rect, - t: &Tokens, - path: &str, - tree_path: &[bool], - out: &mut Vec, - reg: &mut crate::automation::Registry, - actions: &mut Vec>, -) { - match node { - DockNode::Tabs { panels, active } => { - let tab_h = if panels.len() == 1 && panels[0].compact() { 8.0 } else { t.tab_h }; - let content = Rect::from_min_max(pos2(rect.min.x, rect.min.y + tab_h), rect.max); - out.push(Group { path: path.to_string(), rect, content, panels: panels.clone(), active: *active }); - } - DockNode::Split { vertical, size, a, b } => { - let axis = if *vertical { SplitAxis::Vertical } else { SplitAxis::Horizontal }; - let Some(split) = split_rect(rect, axis, size.shared(), t.gap, 20.0) else { return }; - let id = egui::Id::new(("dock-gutter", path.to_string())); - let handle_style = ResizeHandleStyle { hovered: t.focus.gamma_multiply(0.4), dragged: t.focus.gamma_multiply(0.9), focused: t.focus }; - let label = if *vertical { tl!("Height:") } else { tl!("Width:") }; - let response = ResizeHandle::new(id, axis, split.divider, label, handle_style).value(split.first_size(), 0.0..=split.available()).show(ui); - reg.add_response(&format!("dock.gutter.{path}"), &response.response, "gutter"); - if response.delta != 0.0 - && let Some(resized) = split.resized(size.shared(), response.delta, 40.0) - { - actions.push(craft_ui::docking::Action::ResizeSplit { path: tree_path.to_vec(), size: resized }); - } - let mut first_path = tree_path.to_vec(); - first_path.push(false); - let mut second_path = tree_path.to_vec(); - second_path.push(true); - layout_inner(ui, a, split.first, t, &format!("{path}a"), &first_path, out, reg, actions); - layout_inner(ui, b, split.second, t, &format!("{path}b"), &second_path, out, reg, actions); - } - } -} - -/// Draw a group's frame + tab strip. Returns actions (tab clicks, panel menu, focus). /// The sequences open in the Timeline panel, in tab order: `(project item id, name)`. #[derive(Debug, Clone, Default)] pub struct SeqTabs { @@ -573,317 +516,210 @@ pub struct SeqTabs { pub active: Option, } -pub fn draw_group_chrome( - ui: &mut egui::Ui, - g: &Group, - focused: PanelKind, - seqs: &SeqTabs, - t: &Tokens, - reg: &mut crate::automation::Registry, -) -> Vec { - let chrome = draw_group_chrome_details(ui, g, seqs, t, reg); - paint_group_focus(ui, g, focused, t); - chrome.actions +impl SeqTabs { + /// The Timeline panel's tab label: its active sequence. + pub fn label(&self) -> String { + let name = self.open.iter().find(|(id, _)| self.active == Some(*id)).map(|(_, n)| n.clone()); + name.unwrap_or_else(|| tl!("Timeline: (no sequences)").into()) + } } -#[derive(Default)] -pub struct Chrome { - pub actions: Vec, - pub tabs: Vec<(PanelKind, Rect)>, +/// What the sequence tabs ask for. +#[derive(Debug, Clone, PartialEq)] +pub enum SeqAction { + Open(u64), + Close(u64), + /// A sequence tab was dragged past a neighbour: its new place among the sequence tabs. + Move(u64, usize), + /// A right click: show that sequence and open the Timeline's panel menu there. + Menu(u64, egui::Pos2), } -pub fn draw_group_chrome_details(ui: &mut egui::Ui, g: &Group, seqs: &SeqTabs, t: &Tokens, reg: &mut crate::automation::Registry) -> Chrome { +fn sequence_widths(ui: &egui::Ui, seqs: &SeqTabs) -> Vec { + seqs.open + .iter() + .map(|(id, name)| { + let label = ui.painter().layout_no_wrap(name.clone(), Tokens::ui(12.0), egui::Color32::WHITE).size().x; + label + 16.0 + if seqs.active == Some(*id) { 16.0 } else { 0.0 } + }) + .collect() +} + +/// The width [`sequence_tabs`] needs to show every open sequence. +pub fn sequence_tabs_width(ui: &egui::Ui, seqs: &SeqTabs) -> f32 { + sequence_widths(ui, seqs).iter().map(|w| w + 8.0).sum::() + 12.0 +} + +/// Draw the Timeline panel's sequence tabs in `strip`: one tab per open sequence, × on the active +/// one, drag to reorder, middle-click to close, the wheel to step through them, and a » list +/// when they don't fit. +pub fn sequence_tabs(ui: &mut egui::Ui, strip: Rect, seqs: &SeqTabs, t: &Tokens, reg: &mut crate::automation::Registry) -> Vec { let mut actions = Vec::new(); - let mut tab_rects: Vec<(PanelKind, Rect)> = Vec::new(); - let painter = ui.painter().clone(); - painter.rect_filled(g.rect, t.radius, t.panel_bg); - let active_panel = g.panels.get(g.active).copied(); - let compact = g.panels.len() == 1 && g.panels[0].compact(); - // A narrow header grip moves the entire panel, including Timeline panels whose tabs - // separately reorder open sequences. - if let Some(panel) = active_panel { - let grip = Rect::from_min_size(g.rect.min, vec2(if compact { g.rect.width() } else { 10.0 }, if compact { 8.0 } else { t.tab_h })); - let response = ui.interact(grip, egui::Id::new(("panel-drag", g.path.clone())), Sense::drag()); - reg.add_response(&format!("panel.drag.{}", panel.id()), &response, "Move panel"); - if response.drag_started() { - actions.push(DockAction::BeginDrag(panel)); + let painter = ui.painter_at(strip); + let active_at = seqs.open.iter().position(|(id, _)| seqs.active == Some(*id)).unwrap_or(0); + let text_y = strip.center().y; + // Which tabs fit. The active tab is always shown: tabs before it are left out until it + // fits, then as many after it as there is room for. The » list reaches the rest. + let widths = sequence_widths(ui, seqs); + let span = |from: usize, to: usize| widths.get(from..=to).map_or(0.0, |w| w.iter().sum::() + 8.0 * w.len() as f32); + let room = strip.width() - 12.0; + let all_fit = span(0, seqs.open.len().saturating_sub(1)) <= room; + let room = if all_fit { room } else { room - 24.0 }; + let mut first = 0; + while first < active_at && span(first, active_at) > room { + first += 1; + } + let mut last = active_at.max(first); + while last + 1 < seqs.open.len() && span(first, last + 1) <= room { + last += 1; + } + let list_id = egui::Id::new("timeline-tab-list"); + let more = (!all_fit).then(|| Rect::from_min_size(pos2(strip.max.x - 22.0, strip.center().y - 10.0), vec2(18.0, 20.0))); + let mut list_opened = false; + if let Some(r) = more { + let resp = ui.interact(r, egui::Id::new("timeline-tab-overflow"), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, resp.enabled(), "more tabs")); + reg.add_response("timeline.tabs.more", &resp, "more tabs"); + let c = if resp.hovered() { t.tab_text_active } else { t.tab_text }; + icons::paint(&painter, r.shrink(4.0).translate(vec2(-2.0, 0.0)), Icon::ChevronRight, c); + icons::paint(&painter, r.shrink(4.0).translate(vec2(2.0, 0.0)), Icon::ChevronRight, c); + paint_widget_focus(&painter, &resp, t); + if resp.clicked() { + let open = ui.ctx().data(|d| d.get_temp::(list_id)).unwrap_or(false); + ui.ctx().data_mut(|d| d.insert_temp(list_id, !open)); + list_opened = !open; } } - if compact { - // grip dots - let c = pos2(g.rect.center().x, g.rect.min.y + 4.0); - for dx in [-4.0, 0.0, 4.0] { - painter.circle_filled(c + vec2(dx, 0.0), 1.0, t.text_faint); + // The wheel over the strip steps through the tabs: down = next, up = previous, one tab per + // notch (trackpad scrolling adds up to a notch first), stopping at the ends. + let wheel_id = egui::Id::new("timeline-tab-wheel"); + if ui.rect_contains_pointer(strip) && !seqs.open.is_empty() { + const NOTCH: f32 = 40.0; + let total = ui.ctx().data(|d| d.get_temp::(wheel_id)).unwrap_or(0.0) + crate::panels::timeline::wheel_input(ui.ctx()).delta.y; + let steps = (total / NOTCH).trunc(); + ui.ctx().data_mut(|d| d.insert_temp(wheel_id, total - steps * NOTCH)); + let to = (active_at as i64).saturating_sub(steps as i64).clamp(0, seqs.open.len() as i64 - 1) as usize; + if let Some((id, _)) = seqs.open.get(to).filter(|_| to != active_at) { + actions.push(SeqAction::Open(*id)); } } else { - let strip = Rect::from_min_size(g.rect.min, vec2(g.rect.width(), t.tab_h)); - // the Timeline panel shows one tab per open sequence (its own tab names the sequence) - struct Tab { - panel: PanelKind, - seq: Option, - title: String, - active: bool, + ui.ctx().data_mut(|d| d.remove::(wheel_id)); + } + let mut x = strip.min.x + 12.0; + let mut seq_rects: Vec<(u64, Rect)> = Vec::new(); + let mut dragged: Option = None; + for (i, (id, name)) in seqs.open.iter().enumerate().take(last + 1).skip(first) { + let is_active = seqs.active == Some(*id); + let galley = painter.layout_no_wrap(name.clone(), Tokens::ui(12.0), if is_active { t.tab_text_active } else { t.tab_text }); + let close_w = if is_active { 16.0 } else { 0.0 }; + let w = widths.get(i).copied().unwrap_or(0.0); + let tab_rect = Rect::from_min_size(pos2(x, strip.min.y), vec2(w, strip.height())); + // a sequence tab keeps its identity when the tabs are reordered under the pointer + let resp = ui.interact(tab_rect, egui::Id::new(("tab-seq", *id)), Sense::click_and_drag()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, resp.enabled(), is_active, name)); + ui.ctx().accesskit_node_builder(resp.id, |node| { + node.set_role(egui::accesskit::Role::Tab); + node.set_selected(is_active); + }); + paint_widget_focus(&painter, &resp, t); + seq_rects.push((*id, tab_rect)); + if resp.dragged() { + dragged = Some(*id); } - let mut tabs: Vec = Vec::new(); - for (i, p) in g.panels.iter().enumerate() { - let active = i == g.active; - if *p == PanelKind::Timeline && active && !seqs.open.is_empty() { - tabs.extend(seqs.open.iter().map(|(id, name)| Tab { panel: *p, seq: Some(*id), title: name.clone(), active: seqs.active == Some(*id) })); - } else if *p == PanelKind::Timeline { - let name = seqs.open.iter().find(|(id, _)| seqs.active == Some(*id)).map(|(_, n)| n.clone()); - tabs.push(Tab { panel: *p, seq: None, title: name.unwrap_or_else(|| tl!("Timeline: (no sequences)").into()), active }); - } else { - tabs.push(Tab { panel: *p, seq: None, title: crate::i18n::t(p.title()).to_string(), active }); - } + // Escape puts a dragged tab back where it was (egui ends the drag on Escape, #580) + let home = egui::Id::new(("tab-seq-home", *id)); + if resp.drag_started() { + ui.data_mut(|d| d.insert_temp(home, i)); } - let activate = |tab: &Tab, actions: &mut Vec| match tab.seq { - Some(id) => actions.push(DockAction::OpenSequence(id)), - None => actions.push(DockAction::Activate(tab.panel)), - }; - let active_at = tabs.iter().position(|tab| tab.active).unwrap_or(0); - let text_y = strip.min.y + 16.0; - // Which tabs fit. The active tab is always shown: tabs before it are left out until it - // fits, then as many after it as there is room for. Tabs that are left out are reached - // through the » list at the right end of the strip. - let widths: Vec = tabs - .iter() - .map(|tab| { - let label = painter.layout_no_wrap(tab.title.clone(), Tokens::ui(12.0), t.tab_text).size().x; - label + 16.0 + if tab.active { 20.0 } else { 0.0 } + if tab.active && tab.seq.is_some() { 16.0 } else { 0.0 } - }) - .collect(); - let span = |from: usize, to: usize| widths.get(from..=to).map_or(0.0, |w| w.iter().sum::() + 8.0 * w.len() as f32); - let room = strip.width() - 12.0; - let all_fit = span(0, tabs.len().saturating_sub(1)) <= room; - let room = if all_fit { room } else { room - 24.0 }; - let mut first = 0; - while first < active_at && span(first, active_at) > room { - first += 1; - } - let mut last = active_at.max(first); - while last + 1 < tabs.len() && span(first, last + 1) <= room { - last += 1; - } - let list_id = egui::Id::new(("tab-list", g.path.clone())); - let more = (!all_fit).then(|| Rect::from_min_size(pos2(strip.max.x - 22.0, strip.min.y + 6.0), vec2(18.0, 20.0))); - let mut list_opened = false; - if let Some(r) = more { - let resp = ui.interact(r, egui::Id::new(("tab-overflow", g.path.clone())), Sense::click()); - let name = if g.panels.contains(&PanelKind::Timeline) { "timeline.tabs.more".to_string() } else { format!("panel.tabs.more.{}", g.path) }; - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, resp.enabled(), "more tabs")); - reg.add_response(&name, &resp, "more tabs"); - let c = if resp.hovered() { t.tab_text_active } else { t.tab_text }; - icons::paint(&painter, r.shrink(4.0).translate(vec2(-2.0, 0.0)), Icon::ChevronRight, c); - icons::paint(&painter, r.shrink(4.0).translate(vec2(2.0, 0.0)), Icon::ChevronRight, c); - paint_widget_focus(&painter, &resp, t); - if resp.clicked() { - let open = ui.ctx().data(|d| d.get_temp::(list_id)).unwrap_or(false); - ui.ctx().data_mut(|d| d.insert_temp(list_id, !open)); - list_opened = !open; - } - } - // The wheel over the strip steps through the tabs: down = next, up = previous, one tab - // per wheel notch (trackpad scrolling adds up to a notch first), stopping at the ends. - let wheel_id = egui::Id::new(("tab-wheel", g.path.clone())); - if ui.rect_contains_pointer(strip) && !tabs.is_empty() { - const NOTCH: f32 = 40.0; - let total = ui.ctx().data(|d| d.get_temp::(wheel_id)).unwrap_or(0.0) + crate::panels::timeline::wheel_input(ui.ctx()).delta.y; - let steps = (total / NOTCH).trunc(); - ui.ctx().data_mut(|d| d.insert_temp(wheel_id, total - steps * NOTCH)); - let to = (active_at as i64).saturating_sub(steps as i64).clamp(0, tabs.len() as i64 - 1) as usize; - if let Some(tab) = tabs.get(to).filter(|_| to != active_at) { - activate(tab, &mut actions); - actions.push(DockAction::Focus(tab.panel)); - } - } else { - ui.ctx().data_mut(|d| d.remove::(wheel_id)); - } - let mut x = strip.min.x + 12.0; - // sequence tabs as drawn (for dragging one past its neighbours), and the one being dragged - let mut seq_rects: Vec<(u64, Rect)> = Vec::new(); - let mut dragged: Option = None; - for (i, tab) in tabs.iter().enumerate().take(last + 1).skip(first) { - let p = &tab.panel; - let is_active = tab.active; - let galley = painter.layout_no_wrap(tab.title.clone(), Tokens::ui(12.0), if is_active { t.tab_text_active } else { t.tab_text }); - let close_w = if is_active && tab.seq.is_some() { 16.0 } else { 0.0 }; - let w = widths.get(i).copied().unwrap_or(0.0); - let tab_rect = Rect::from_min_size(pos2(x, strip.min.y), vec2(w, t.tab_h)); - // a sequence tab keeps its identity when the tabs are reordered under the pointer - let resp = match tab.seq { - Some(id) => ui.interact(tab_rect, egui::Id::new(("tab-seq", g.path.clone(), id)), Sense::click_and_drag()), - None => ui.interact(tab_rect, egui::Id::new(("tab", g.path.clone(), i)), Sense::click_and_drag()), - }; - if tab.seq.is_none() && resp.drag_started() { - actions.push(DockAction::BeginDrag(*p)); - } - if let Some((_, rect)) = tab_rects.iter_mut().find(|(panel, _)| panel == p) { - *rect = rect.union(tab_rect); - } else { - tab_rects.push((*p, tab_rect)); - } - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, resp.enabled(), is_active, &tab.title)); - ui.ctx().accesskit_node_builder(resp.id, |node| { - node.set_role(egui::accesskit::Role::Tab); - node.set_selected(is_active); - }); - paint_widget_focus(&painter, &resp, t); - if let Some(id) = tab.seq { - seq_rects.push((id, tab_rect)); - if resp.dragged() { - dragged = Some(id); - } - // Escape puts a dragged tab back where it was (egui ends the drag on Escape, #580) - let home = egui::Id::new(("tab-seq-home", id)); - if resp.drag_started() - && let Some(at) = seqs.open.iter().position(|(s, _)| *s == id) - { - ui.data_mut(|d| d.insert_temp(home, at)); - } - if resp.drag_stopped() - && let Some(at) = ui.data_mut(|d| d.remove_temp::(home)) - && ui.input(|i| i.key_pressed(egui::Key::Escape)) - { - actions.push(DockAction::MoveSequence(id, at)); - } - } - if let Some(id) = tab.seq { - reg.add_response(&format!("timeline.tab.{id}"), &resp, &tab.title); - } - if tab.seq.is_none() || is_active { - reg.add_response(&format!("panel.tab.{}", p.id()), &resp, p.title()); - } - let label_x = tab_rect.min.x + 8.0 + close_w; - let label_w = galley.size().x; - let col = if is_active || resp.hovered() { t.tab_text_active } else { t.tab_text }; - painter.galley_with_override_text_color(pos2(label_x, text_y - galley.size().y / 2.0), galley, col); - let mut closed = false; - if let Some(id) = tab.seq.filter(|_| is_active) { - // × closes the sequence's tab (the sequence stays in the project) - let cr = Rect::from_center_size(pos2(tab_rect.min.x + 10.0, text_y), vec2(8.0, 8.0)); - let cresp = ui.interact(cr.expand(4.0), egui::Id::new(("tab-close", g.path.clone(), id)), Sense::click()); - cresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, cresp.enabled(), "close sequence")); - reg.add_response(&format!("timeline.tab.{id}.close"), &cresp, "close sequence"); - let cc = if cresp.hovered() { t.tab_text_active } else { t.tab_text }; - painter.line_segment([cr.left_top(), cr.right_bottom()], Stroke::new(1.2, cc)); - painter.line_segment([cr.right_top(), cr.left_bottom()], Stroke::new(1.2, cc)); - paint_widget_focus(&painter, &cresp, t); - if cresp.clicked() { - actions.push(DockAction::CloseSequence(id)); - closed = true; - } - } - if is_active { - let mr = Rect::from_center_size(pos2(label_x + label_w + 12.0, text_y), vec2(12.0, 10.0)); - let mresp = ui.interact(mr.expand(3.0), egui::Id::new(("tab-menu", g.path.clone())), Sense::click()); - mresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, mresp.enabled(), "panel menu")); - reg.add_response(&format!("panel.menu.{}", p.id()), &mresp, "panel menu"); - let mc = if mresp.hovered() { t.tab_text_active } else { t.tab_text }; - icons::paint(&painter, Rect::from_center_size(mr.center(), vec2(16.0, 16.0)), Icon::Hamburger, mc); - // 1 pt underline spanning label + ≡, 23 pt below the frame top - let uy = strip.min.y + 23.0; - painter.line_segment([pos2(label_x, uy), pos2(mr.max.x, uy)], Stroke::new(1.0, t.tab_text_active)); - paint_widget_focus(&painter, &mresp, t); - if mresp.clicked() { - actions.push(DockAction::PanelMenu(*p, mr.left_bottom())); - } - } - if (resp.clicked() || resp.drag_started()) && !closed { - activate(tab, &mut actions); - actions.push(DockAction::Focus(*p)); - } - // a right-click shows the tab and opens its panel menu (as in Premiere) - if resp.secondary_clicked() { - activate(tab, &mut actions); - actions.push(DockAction::Focus(*p)); - let at = resp.interact_pointer_pos().unwrap_or(tab_rect.left_bottom()); - actions.push(DockAction::PanelMenu(*p, at)); - } - if resp.middle_clicked() { - // a sequence tab closes its sequence; the Timeline panel itself stays - match tab.seq.or(seqs.active.filter(|_| *p == PanelKind::Timeline)) { - Some(id) => actions.push(DockAction::CloseSequence(id)), - None => actions.push(DockAction::Close(*p)), - } - } - x += w + 8.0; - } - // Dragging a sequence tab sideways: it changes places with a neighbour when the pointer - // passes that neighbour's middle. - if let Some(id) = dragged - && let Some(px) = ui.input(|i| i.pointer.latest_pos()).map(|p| p.x) - && let Some(at) = seq_rects.iter().position(|(s, _)| *s == id) - && let Some(index) = seqs.open.iter().position(|(s, _)| *s == id) + if resp.drag_stopped() + && let Some(at) = ui.data_mut(|d| d.remove_temp::(home)) + && ui.input(|input| input.key_pressed(egui::Key::Escape)) { - let before = at.checked_sub(1).and_then(|k| seq_rects.get(k)); - let after = seq_rects.get(at + 1); - if before.is_some_and(|(_, r)| px < r.center().x) { - actions.push(DockAction::MoveSequence(id, index.saturating_sub(1))); - } else if after.is_some_and(|(_, r)| px > r.center().x) { - actions.push(DockAction::MoveSequence(id, index + 1)); - } + actions.push(SeqAction::Move(*id, at)); } - // the » list: every tab of the group, the shown one ticked - let list_open = ui.ctx().data(|d| d.get_temp::(list_id)).unwrap_or(false); - if let Some(r) = more.filter(|_| list_open) { - let mut picked = None; - let area = egui::Area::new(list_id.with("area")).order(egui::Order::Foreground).pivot(Align2::RIGHT_TOP).fixed_pos(r.right_bottom()).show( - ui.ctx(), - |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.set_min_width(160.0); - // many open sequences: the list scrolls instead of leaving the window - egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { - for tab in &tabs { - let label = if tab.active { format!("✓ {}", tab.title) } else { tab.title.clone() }; - let resp = ui.button(label); - match tab.seq { - Some(id) => reg.add_response(&format!("timeline.tabs.list.{id}"), &resp, &tab.title), - None => reg.add_response(&format!("panel.tabs.list.{}", tab.panel.id()), &resp, &tab.title), - } - if resp.clicked() { - picked = Some(tab); - } + reg.add_response(&format!("timeline.tab.{id}"), &resp, name); + let label_x = tab_rect.min.x + 8.0 + close_w; + let col = if is_active || resp.hovered() { t.tab_text_active } else { t.tab_text }; + let label_w = galley.size().x; + painter.galley_with_override_text_color(pos2(label_x, text_y - galley.size().y / 2.0), galley, col); + let mut closed = false; + if is_active { + // × closes the sequence's tab (the sequence stays in the project) + let cr = Rect::from_center_size(pos2(tab_rect.min.x + 10.0, text_y), vec2(8.0, 8.0)); + let cresp = ui.interact(cr.expand(4.0), egui::Id::new(("tab-close", *id)), Sense::click()); + cresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, cresp.enabled(), "close sequence")); + reg.add_response(&format!("timeline.tab.{id}.close"), &cresp, "close sequence"); + let cc = if cresp.hovered() { t.tab_text_active } else { t.tab_text }; + painter.line_segment([cr.left_top(), cr.right_bottom()], Stroke::new(1.2, cc)); + painter.line_segment([cr.right_top(), cr.left_bottom()], Stroke::new(1.2, cc)); + paint_widget_focus(&painter, &cresp, t); + if cresp.clicked() { + actions.push(SeqAction::Close(*id)); + closed = true; + } + let uy = strip.max.y - 2.0; + painter.line_segment([pos2(label_x, uy), pos2(label_x + label_w, uy)], Stroke::new(1.0, t.tab_text_active)); + } + if (resp.clicked() || resp.drag_started()) && !closed && !is_active { + actions.push(SeqAction::Open(*id)); + } + if resp.middle_clicked() { + actions.push(SeqAction::Close(*id)); + } + if resp.secondary_clicked() { + actions.push(SeqAction::Menu(*id, resp.interact_pointer_pos().unwrap_or(tab_rect.left_bottom()))); + } + x += w + 8.0; + } + // Dragging a sequence tab sideways: it changes places with a neighbour when the pointer + // passes that neighbour's middle. + if let Some(id) = dragged + && let Some(px) = ui.input(|i| i.pointer.latest_pos()).map(|p| p.x) + && let Some(at) = seq_rects.iter().position(|(s, _)| *s == id) + && let Some(index) = seqs.open.iter().position(|(s, _)| *s == id) + { + let before = at.checked_sub(1).and_then(|k| seq_rects.get(k)); + let after = seq_rects.get(at + 1); + if before.is_some_and(|(_, r)| px < r.center().x) { + actions.push(SeqAction::Move(id, index.saturating_sub(1))); + } else if after.is_some_and(|(_, r)| px > r.center().x) { + actions.push(SeqAction::Move(id, index + 1)); + } + } + // the » list: every open sequence, the shown one ticked + let list_open = ui.ctx().data(|d| d.get_temp::(list_id)).unwrap_or(false); + if let Some(r) = more.filter(|_| list_open) { + let mut picked = None; + let area = + egui::Area::new(list_id.with("area")).order(egui::Order::Foreground).pivot(Align2::RIGHT_TOP).fixed_pos(r.right_bottom()).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(160.0); + // many open sequences: the list scrolls instead of leaving the window + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + for (id, name) in &seqs.open { + let label = if seqs.active == Some(*id) { format!("✓ {name}") } else { name.clone() }; + let resp = ui.button(label); + reg.add_response(&format!("timeline.tabs.list.{id}"), &resp, name); + if resp.clicked() { + picked = Some(*id); } - }); + } }); - }, - ); - if let Some(tab) = picked { - activate(tab, &mut actions); - actions.push(DockAction::Focus(tab.panel)); - } - if picked.is_some() || (!list_opened && area.response.clicked_elsewhere()) { - ui.ctx().data_mut(|d| d.remove::(list_id)); - } - } else if list_open { + }); + }); + if let Some(id) = picked { + actions.push(SeqAction::Open(id)); + } + if picked.is_some() || (!list_opened && area.response.clicked_elsewhere()) { ui.ctx().data_mut(|d| d.remove::(list_id)); } + } else if list_open { + ui.ctx().data_mut(|d| d.remove::(list_id)); } - // Activating presses focus their tab immediately, rather than the group's old active panel. - // Otherwise entering an inspector can briefly reactivate a stale Project/Timeline selection. - // Middle-click closes a tab, so it keeps the active panel as the focus target. - if let Some(p) = active_panel - && ui.rect_contains_pointer(g.rect) - && ui.input(|i| i.pointer.any_pressed()) - { - let panel = ui - .input(|i| i.pointer.interact_pos().filter(|_| i.pointer.primary_pressed() || i.pointer.secondary_pressed())) - .and_then(|pos| tab_rects.iter().find(|(_, rect)| rect.contains(pos)).map(|(panel, _)| *panel)) - .unwrap_or(p); - actions.push(DockAction::Focus(panel)); - } - Chrome { actions, tabs: tab_rects } + actions } -/// Paint once after the group's contents so child backgrounds cannot hide its focus (#1075). -pub(crate) fn paint_group_focus(ui: &egui::Ui, group: &Group, focused: PanelKind, tokens: &Tokens) { - if group.panels.get(group.active).copied() == Some(focused) { - ui.painter().rect_stroke(group.rect, 0.0, Stroke::new(1.0, tokens.focus), StrokeKind::Inside); - } -} - -fn paint_widget_focus(painter: &egui::Painter, response: &egui::Response, tokens: &Tokens) { +pub(crate) fn paint_widget_focus(painter: &egui::Painter, response: &egui::Response, tokens: &Tokens) { if response.has_focus() { painter.rect_stroke(response.rect.shrink(2.0), 2.0, Stroke::new(1.5, tokens.focus), StrokeKind::Inside); } @@ -898,68 +734,53 @@ pub fn placeholder(ui: &mut egui::Ui, rect: Rect, t: &Tokens, text: &str) { mod tests { use super::*; - #[test] - fn split_first_never_panics_on_tiny_areas() { - let split_first = |size: SplitSize, avail: f32| { - split_rect(Rect::from_min_size(egui::Pos2::ZERO, vec2(avail, 100.0)), SplitAxis::Horizontal, size.shared(), 0.0, 20.0).unwrap().first_size() - }; - for avail in [0.0, 1.0, 15.0, 20.0, 35.0, 39.9, 40.0, 100.0, 2000.0] { - for size in [ - SplitSize::Ratio(0.0), - SplitSize::Ratio(0.5), - SplitSize::Ratio(1.0), - SplitSize::FixedA(300.0), - SplitSize::FixedB(300.0), - SplitSize::FixedA(0.0), - ] { - let f = split_first(size, avail); - assert!((0.0..=avail).contains(&f), "{size:?} in {avail}: {f}"); - } - } - assert_eq!(split_first(SplitSize::FixedA(300.0), 1000.0), 300.0); - assert_eq!(split_first(SplitSize::FixedB(300.0), 1000.0), 700.0); - assert_eq!(split_first(SplitSize::Ratio(0.0), 1000.0), 20.0); - } - #[test] fn workspaces_contain_core_panels() { for w in WORKSPACES { let d = workspace(w); - assert!(d.contains(PanelKind::Timeline), "{w}"); - assert!(d.contains(PanelKind::Program), "{w}"); + d.validate().unwrap(); + assert!(d.contains(&PanelKind::Timeline), "{w}"); + assert!(d.contains(&PanelKind::Program), "{w}"); } } - #[test] - fn close_and_open() { - let mut d = workspace("Editing"); - d.close(PanelKind::Metadata); - assert!(!d.contains(PanelKind::Metadata)); - d.open_near(PanelKind::LumetriColor, PanelKind::Program); - assert!(d.is_visible(PanelKind::LumetriColor)); - let s = serde_json::to_string(&d).unwrap(); - let back: DockNode = serde_json::from_str(&s).unwrap(); - assert_eq!(back, d); - } - /// #284: with Project and Program closed (or every panel closed) a panel still opens. #[test] fn open_near_falls_back_to_the_first_group() { let mut d = workspace("Editing"); - let mut all = Vec::new(); - d.panels(&mut all); - for p in all { - d.close(p); + for p in d.panels().into_iter().copied().collect::>() { + d.apply(Action::Close { panel: p }).unwrap(); } - assert_eq!(d, DockNode::Tabs { panels: vec![], active: 0 }); + assert_eq!(d.root, None); d.open_near(PanelKind::Program, PanelKind::Project); assert!(d.is_visible(PanelKind::Program)); d.restore_timeline(); assert!(d.is_visible(PanelKind::Timeline)); // a tree without Project or Program: the panel joins the first group - let mut d = hsplit(SplitSize::Ratio(0.5), tabs(&[PanelKind::Effects], 0), tabs(&[PanelKind::Markers], 0)); + let mut d = Layout { root: Some(hsplit(SplitSize::Ratio(0.5), tabs(&[PanelKind::Effects], 0), tabs(&[PanelKind::Markers], 0))), floating: vec![] }; d.open_near(PanelKind::Info, PanelKind::Source); assert!(d.is_visible(PanelKind::Info)); - assert_eq!(d, hsplit(SplitSize::Ratio(0.5), tabs(&[PanelKind::Effects, PanelKind::Info], 1), tabs(&[PanelKind::Markers], 0))); + assert_eq!(d.root, Some(hsplit(SplitSize::Ratio(0.5), tabs(&[PanelKind::Effects, PanelKind::Info], 1), tabs(&[PanelKind::Markers], 0)))); + } + + /// A workspace file written before FilmCraft saved craft-ui workspaces still opens, and a + /// saved workspace reads back as written. + #[test] + fn saved_workspaces_read_earlier_files_and_round_trip() { + let legacy = serde_json::json!({"saved": [{"name": "Mine", "layout": {"Split": {"vertical": false, "size": {"FixedA": 300.0}, + "a": {"Tabs": {"panels": ["Project", "Info"], "active": 1}}, "b": {"Tabs": {"panels": ["Timeline"], "active": 0}}}}, + "floating": [{"panels": ["Effects"], "active": 0, "rect": [10.0, 20.0, 300.0, 200.0]}]}, + {"name": "Broken", "layout": {"Tabs": {"panels": ["NoSuchPanel"], "active": 0}}}], "current": "Mine"}); + let prefs: WorkspacePrefs = serde_json::from_value(legacy).unwrap(); + let mine = saved_docking(&prefs, "Mine"); + assert!(mine.is_visible(PanelKind::Info) && mine.is_visible(PanelKind::Timeline) && mine.is_visible(PanelKind::Effects)); + assert!(matches!(mine.root, Some(Node::Split { size: SplitSize::FixedFirst(300.0), .. }))); + assert_eq!(saved_docking(&prefs, "Broken"), workspace("Broken")); + let saved = SavedWorkspace { name: "Mine".into(), workspace: serde_json::to_value(Workspace::new(mine.clone())).unwrap(), ..Default::default() }; + let prefs = WorkspacePrefs { saved: vec![saved], current: "Mine".into() }; + let text = serde_json::to_string(&prefs).unwrap(); + assert!(!text.contains("\"layout\":{\"Split\""), "{text}"); + let back: WorkspacePrefs = serde_json::from_str(&text).unwrap(); + assert_eq!(saved_docking(&back, "Mine"), mine); } } diff --git a/crates/ui-egui/src/dock_shared.rs b/crates/ui-egui/src/dock_shared.rs deleted file mode 100644 index 2b64351db..000000000 --- a/crates/ui-egui/src/dock_shared.rs +++ /dev/null @@ -1,131 +0,0 @@ -//! Convert the persisted workspace schema to shared docking transactions. - -use craft_ui::docking::{Action, Floating as SharedFloating, Layout as SharedLayout, Location, Node, Placement, Zone}; -use craft_ui::layout::{SplitAxis, SplitSize as SharedSplitSize}; - -use crate::dock::{DockNode, Floating, Layout, PanelKind, SplitSize}; - -const MAX_DEPTH: usize = 32; -const MAX_NODES: usize = 4096; -const MAX_PANELS: usize = 128; - -fn encode_node(node: &DockNode, depth: usize, count: &mut usize) -> Result, String> { - *count = count.saturating_add(1); - if depth > MAX_DEPTH || *count > MAX_NODES { - return Err("workspace dock tree exceeds supported limits".into()); - } - Ok(match node { - DockNode::Tabs { panels, active } => { - if panels.len() > MAX_PANELS { - return Err("too many workspace tabs".into()); - } - Node::Tabs { panels: panels.clone(), active: *active } - } - DockNode::Split { vertical, size, a, b } => Node::Split { - axis: if *vertical { SplitAxis::Vertical } else { SplitAxis::Horizontal }, - size: match *size { - SplitSize::Ratio(value) => SharedSplitSize::Ratio(value), - SplitSize::FixedA(value) => SharedSplitSize::FixedFirst(value), - SplitSize::FixedB(value) => SharedSplitSize::FixedSecond(value), - }, - first: Box::new(encode_node(a, depth.saturating_add(1), count)?), - second: Box::new(encode_node(b, depth.saturating_add(1), count)?), - }, - }) -} - -fn decode_node(node: Node) -> Result { - Ok(match node { - Node::Tabs { panels, active } => DockNode::Tabs { panels, active }, - Node::Stack { .. } => return Err("FilmCraft does not use stacked panels".into()), - Node::Split { axis, size, first, second } => DockNode::Split { - vertical: axis == SplitAxis::Vertical, - size: match size { - SharedSplitSize::Ratio(value) => SplitSize::Ratio(value), - SharedSplitSize::FixedFirst(value) => SplitSize::FixedA(value), - SharedSplitSize::FixedSecond(value) => SplitSize::FixedB(value), - }, - a: Box::new(decode_node(*first)?), - b: Box::new(decode_node(*second)?), - }, - }) -} - -pub(crate) fn encode(root: &DockNode, floating: &[Floating]) -> Result, String> { - if floating.len() > MAX_PANELS || floating.iter().any(|group| group.panels.len() > MAX_PANELS) { - return Err("too many floating workspace panels".into()); - } - let root = if matches!(root, DockNode::Tabs { panels, active: 0 } if panels.is_empty()) { None } else { Some(encode_node(root, 0, &mut 0)?) }; - Ok(SharedLayout { - root, - floating: floating.iter().map(|group| SharedFloating { panels: group.panels.clone(), active: group.active, rect: group.rect }).collect(), - }) -} - -pub(crate) fn decode(layout: SharedLayout) -> Result { - Ok(Layout { - root: match layout.root { - Some(root) => decode_node(root)?, - None => DockNode::Tabs { panels: vec![], active: 0 }, - }, - floating: layout.floating.into_iter().map(|group| Floating { panels: group.panels, active: group.active, rect: group.rect }).collect(), - }) -} - -pub(crate) fn apply(layout: &mut Layout, action: Action) -> Result<(), String> { - let mut shared = encode(&layout.root, &layout.floating)?; - shared.validate().map_err(|error| error.to_string())?; - check_last_docked(&shared, &action)?; - shared.apply(action).map_err(|error| error.to_string())?; - let proposed = decode(shared)?; - encode(&proposed.root, &proposed.floating)?; - *layout = proposed; - Ok(()) -} - -pub(crate) fn apply_tree(root: &mut DockNode, action: Action) -> bool { - let Ok(mut shared) = encode(root, &[]) else { return false }; - if shared.apply(action).is_err() { - return false; - } - let Ok(layout) = decode(shared) else { return false }; - if encode(&layout.root, &[]).is_err() { - return false; - } - *root = layout.root; - true -} - -/// Dock a panel along one edge of the whole workspace. The shared model restores a panel beside a -/// complete sibling subtree, so naming every remaining docked panel targets the root itself. -pub(crate) fn dock_at_edge(layout: &mut SharedLayout, panel: PanelKind, zone: Zone) -> Result<(), String> { - let size = match zone { - Zone::Left | Zone::Top => SharedSplitSize::Ratio(0.25), - Zone::Right | Zone::Bottom => SharedSplitSize::Ratio(0.75), - Zone::Center => return Err("a workspace edge needs a left, right, top or bottom zone".into()), - }; - let mut proposed = layout.clone(); - proposed.apply(Action::Close { panel }).map_err(|error| error.to_string())?; - let sibling: Vec = SharedLayout { root: proposed.root.clone(), floating: vec![] }.panels().into_iter().copied().collect(); - let anchor = *sibling.first().ok_or("another docked panel is required to dock beside")?; - let location = Location { floating: None, anchor: Some(anchor), placement: Placement::Split(zone), size: Some(size), stack: None, sibling }; - proposed.restore(panel, &location).map_err(|error| error.to_string())?; - *layout = proposed; - Ok(()) -} - -/// Keep one docked destination available for dragging floating panels back into the workspace. -pub(crate) fn check_last_docked(layout: &SharedLayout, action: &Action) -> Result<(), String> { - let panel = match action { - Action::Float { panel, .. } => Some(panel), - Action::Move { panel, anchor, .. } if layout.floating.iter().any(|group| group.panels.contains(anchor)) => Some(panel), - _ => None, - }; - if let Some(panel) = panel { - let docked = SharedLayout { root: layout.root.clone(), floating: vec![] }; - if docked.contains(panel) && docked.panels().len() <= 1 { - return Err("the last docked panel must remain in the workspace".into()); - } - } - Ok(()) -} diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 98951de89..3327ba4f9 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -1,26 +1,32 @@ -//! FilmCraft's panel chrome and document tabs over the shared docking transactions. +//! FilmCraft's panels in craft-ui's shared docking, Frame profile. craft-ui draws the frames, tab +//! strips, floating windows and drag previews and owns the gestures; FilmCraft draws the panel +//! bodies, the ≡ panel menu and the Timeline's sequence tabs, and maps its control commands onto +//! craft-ui's `DockCommand`. -use craft_ui::docking::{Action, DropTarget, Placement, Zone, resolve_drop}; -use egui::{Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use craft_ui::docking::{ + Action, Divider, DockArea, DockCommand, DockContent, DockEvent, DockStyle, FloatSpace, Hit, Layout, Placement, Profile, ResizeEdge, Workspace, Zone, +}; +use egui::{Rect, Sense, Stroke, StrokeKind, vec2}; use serde_json::{Value, json}; -use crate::dock::{self, DockAction, DockNode, Group, PanelKind}; +use crate::dock::{self, PanelKind, SeqAction, SeqTabs}; +use crate::icons::{self, Icon}; +use crate::theme::Tokens; use crate::{FilmcraftApp, panels}; -/// Single source for rendering and hit testing: saved offscreen positions remain -/// reachable, and every panel has enough room for its chrome. -fn floating_rect(group: &dock::Floating, screen: Rect) -> Rect { - let rect = Rect::from_min_size(pos2(group.rect[0], group.rect[1]), vec2(group.rect[2].max(160.0), group.rect[3].max(100.0))); - rect.translate(vec2( - (screen.min.x - rect.min.x).max(0.0) + (screen.max.x - 40.0 - rect.min.x).min(0.0), - (screen.min.y - rect.min.y).max(0.0) + (screen.max.y - 30.0 - rect.min.y).min(0.0), - )) +/// The dock area's egui id; `DockArea::hit_rects` and `float_space` read by it. +pub fn area_id() -> egui::Id { + egui::Id::new("filmcraft-docking") } -fn drag_id() -> egui::Id { - egui::Id::new("dock-panel-drag") +/// Where each panel body was drawn last frame, and on which layer. +fn bodies_id() -> egui::Id { + area_id().with("bodies") } +/// Width of the ≡ panel menu button at the end of each tab strip. +const MENU_W: f32 = 26.0; + pub(crate) fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Value) -> Option> { if id == "window.dockAction" { return Some((|| { @@ -30,181 +36,171 @@ pub(crate) fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, param Ok(Value::Null) })()); } - if !matches!(id, "window.dockPanel" | "window.floatPanel" | "window.closePanel") { - return None; + if matches!(id, "window.dockPanel" | "window.floatPanel" | "window.closePanel") { + return Some(alias(app, ctx, id, params)); } - Some((|| { - let panel = match params.get("panel") { - None => app.ui.focused, - Some(value) => value.as_str().and_then(PanelKind::from_name).ok_or("unknown panel")?, - }; - let action = match id { - "window.closePanel" => Action::Close { panel }, - "window.floatPanel" => { - let rect = match params.get("rect") { - None => default_float_rect(ctx.content_rect()), - Some(value) => serde_json::from_value::<[f32; 4]>(value.clone()).map_err(|_| "rect must contain four finite numbers")?, - }; - Action::Float { panel, rect } - } - _ => { - let zone_name = match params.get("zone") { - None => "center", - Some(value) => value.as_str().ok_or("zone must be a string")?, - }; - let zone = match zone_name.to_ascii_lowercase().as_str() { - "left" => Zone::Left, - "right" => Zone::Right, - "top" => Zone::Top, - "bottom" => Zone::Bottom, - "center" | "tab" => Zone::Center, - _ => return Err("unknown docking zone".into()), - }; - let Some(anchor) = params.get("anchor") else { - if params.get("before").is_some() { - return Err("before needs an anchor panel".into()); - } - app.dock_to_edge(panel, zone)?; - return Ok(json!({"panel": panel.id()})); - }; - let anchor = anchor.as_str().and_then(PanelKind::from_name).ok_or("a known anchor panel is required")?; - let placement = match params.get("before") { - Some(value) => Placement::Tab { before: Some(value.as_str().and_then(PanelKind::from_name).ok_or("unknown before panel")?) }, - None => Placement::Split(zone), - }; - Action::Move { panel, anchor, placement } - } - }; - app.apply_dock(action)?; - Ok(json!({"panel": panel.id()})) - })()) + if id.starts_with("window.panel.") && DockCommand::::IDS.contains(&id) { + return Some((|| { + let mut params = if params.is_object() { params.clone() } else { json!({}) }; + params["command"] = json!(id); + let command: DockCommand = serde_json::from_value(params).map_err(|error| format!("invalid docking command: {error}"))?; + app.run_dock_command(ctx, command)?; + Ok(Value::Null) + })()); + } + None } -pub(crate) fn default_float_rect(screen: Rect) -> [f32; 4] { - let center = screen.center(); - [center.x - 210.0, center.y - 160.0, 420.0, 320.0] +/// FilmCraft's own docking commands, as craft-ui `window.panel.*` commands. `panel` defaults to +/// the focused panel. +fn alias(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Value) -> Result { + let panel = match params.get("panel") { + None => app.ui.focused, + Some(value) => value.as_str().and_then(PanelKind::from_name).ok_or("unknown panel")?, + }; + let command = match id { + "window.closePanel" => json!({"command": "window.panel.close", "panel": panel}), + "window.floatPanel" => match params.get("rect") { + None => json!({"command": "window.panel.float", "panel": panel}), + Some(value) => { + let rect = serde_json::from_value::<[f32; 4]>(value.clone()).map_err(|_| "rect must contain four finite numbers")?; + json!({"command": "window.panel.layout", "action": Action::Float { panel, rect }}) + } + }, + _ => { + let zone = match params.get("zone") { + None => "center", + Some(value) => value.as_str().ok_or("zone must be a string")?, + }; + match params.get("anchor") { + Some(anchor) => { + let anchor = anchor.as_str().and_then(PanelKind::from_name).ok_or("a known anchor panel is required")?; + let mut command = json!({"command": "window.panel.dock", "panel": panel, "anchor": anchor, "zone": zone.to_ascii_lowercase()}); + if let Some(before) = params.get("before") { + command["before"] = json!(before.as_str().and_then(PanelKind::from_name).ok_or("unknown before panel")?); + } + command + } + None if params.get("before").is_some() => return Err("before needs an anchor panel".into()), + // Without an anchor a side zone docks along that edge of the whole workspace. + None => { + let edge = match zone.to_ascii_lowercase().as_str() { + "left" => Some(Zone::Left), + "right" => Some(Zone::Right), + "top" => Some(Zone::Top), + "bottom" => Some(Zone::Bottom), + "center" | "tab" => None, + _ => return Err("unknown docking zone".into()), + }; + match edge { + None => json!({"command": "window.panel.dock", "panel": panel}), + Some(edge) => { + let docked = Layout { root: app.ui.dock.root.clone(), floating: vec![] }; + let anchor = + docked.panels().into_iter().copied().find(|other| *other != panel).ok_or("another docked panel is required to dock beside")?; + json!({"command": "window.panel.layout", "action": Action::Move { panel, anchor, placement: Placement::Edge(edge) }}) + } + } + } + } + } + }; + let command: DockCommand = serde_json::from_value(command).map_err(|error| format!("invalid docking command: {error}"))?; + app.run_dock_command(ctx, command)?; + Ok(json!({"panel": panel.id()})) +} + +/// Keep one docked destination available for dragging floating panels back into the workspace. +fn check_last_docked(layout: &Layout, action: &Action) -> Result<(), String> { + let panel = match action { + Action::Float { panel, .. } => Some(panel), + Action::Move { panel, anchor, .. } if layout.floating.iter().any(|window| window.contains(anchor)) => Some(panel), + _ => None, + }; + if let Some(panel) = panel { + let docked = Layout { root: layout.root.clone(), floating: vec![] }; + if docked.contains(panel) && docked.panels().len() <= 1 { + return Err("the last docked panel must remain in the workspace".into()); + } + } + Ok(()) } impl FilmcraftApp { + /// A layout action from the dock area, through the command path the control channel uses. fn dock_command(&mut self, ctx: &egui::Context, action: Action) -> Result<(), String> { let value = serde_json::to_value(action).map_err(|error| error.to_string())?; crate::menus::invoke(self, ctx, "window.dockAction", json!({"action": value})).map(|_| ()) } + /// Run a craft-ui docking command on the current layout, all or nothing. Workspaces are + /// FilmCraft's own (Window ▸ Workspaces). + pub(crate) fn run_dock_command(&mut self, ctx: &egui::Context, command: DockCommand) -> Result<(), String> { + if command.is_app_owned() { + return Err("use the window.workspace.* commands for workspaces".into()); + } + let space = craft_ui::docking::float_space(ctx, area_id()).unwrap_or_else(|| FloatSpace::new(ctx.content_rect())); + let actions = command.to_actions(&Workspace::new(self.ui.dock.clone()), false, &space).map_err(|error| error.to_string())?; + self.apply_dock_all(actions) + } + pub fn apply_dock(&mut self, action: Action) -> Result<(), String> { - let relocating = matches!(&action, Action::Move { .. } | Action::Float { .. }); - let reveal = match &action { - Action::Activate { panel } => Some(*panel), - _ => None, - }; - // a panel closed while floating remembers where; closed while docked, it forgets (#1985) - let closing = match &action { - Action::Close { panel } => Some((*panel, self.ui.floating.iter().find(|g| g.panels.contains(panel)).map(|g| g.rect))), - _ => None, - }; - self.commit_dock(|shared| { - crate::dock_shared::check_last_docked(shared, &action)?; - shared.apply(action).map_err(|error| error.to_string()) - })?; + self.apply_dock_all(vec![action]) + } + + fn apply_dock_all(&mut self, actions: Vec>) -> Result<(), String> { + let mut layout = self.ui.dock.clone(); + let mut relocating = false; + let mut reveal = None; + let mut closed_floating = self.ui.closed_floating.clone(); + for action in actions { + relocating |= matches!(&action, Action::Move { .. } | Action::Float { .. } | Action::MoveGroup { .. } | Action::MoveWindow { .. }); + if let Action::Activate { panel } = &action { + reveal = Some(*panel); + } + // a panel closed while floating remembers where; closed while docked, it forgets (#1985) + for panel in layout.closed_by(&action).map_err(|error| error.to_string())? { + closed_floating.retain(|(p, _)| *p != panel); + if let Some(window) = layout.floating.iter().find(|window| window.contains(&panel)) { + closed_floating.push((panel, window.rect)); + } + } + check_last_docked(&layout, &action)?; + layout.apply(action).map_err(|error| error.to_string())?; + } + self.ui.dock = layout; + self.ui.closed_floating = closed_floating; if let Some(panel) = reveal { self.pending_panel_raise = Some(panel); } - if let Some((panel, rect)) = closing { - self.ui.closed_floating.retain(|(p, _)| *p != panel); - if let Some(rect) = rect { - self.ui.closed_floating.push((panel, rect)); - } - } - if relocating - || self.ui.keys.maximized.is_some_and(|panel| !self.ui.dock.contains(panel) && !self.ui.floating.iter().any(|group| group.panels.contains(&panel))) - { + if relocating || self.ui.keys.maximized.is_some_and(|panel| !self.ui.dock.contains(&panel)) { self.ui.keys.maximized = None; } Ok(()) } - /// Dock a panel along an edge of the whole workspace rather than beside one group. - pub fn dock_to_edge(&mut self, panel: PanelKind, zone: Zone) -> Result<(), String> { - self.commit_dock(|shared| crate::dock_shared::dock_at_edge(shared, panel, zone))?; - self.ui.keys.maximized = None; - Ok(()) - } - - fn commit_dock(&mut self, change: impl FnOnce(&mut craft_ui::docking::Layout) -> Result<(), String>) -> Result<(), String> { - let mut shared = crate::dock_shared::encode(&self.ui.dock, &self.ui.floating)?; - shared.validate().map_err(|error| error.to_string())?; - change(&mut shared)?; - let layout = crate::dock_shared::decode(shared)?; - crate::dock_shared::encode(&layout.root, &layout.floating)?; - self.ui.dock = layout.root; - self.ui.floating = layout.floating; - Ok(()) - } - - fn panel_body(&mut self, ui: &mut egui::Ui, panel: PanelKind, content: Rect) { - self.auto.add(&format!("panel.{}", panel.id()), content, panel.title()); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(content).id_salt(("panel", panel.id()))); - child.set_clip_rect(content.intersect(ui.clip_rect())); - panels::show(self, &mut child, panel, content); - } - - /// OS drag positions come from the host cursor hook, so egui pointer capture alone - /// cannot decide whether a Timeline is covered. Use the same geometry and layer - /// ordering as our renderer before any panel can dispatch an edit. + /// OS drag positions come from the host cursor hook, so egui pointer capture alone cannot + /// decide whether a Timeline is covered: the Timeline body drawn last frame must hold `pos`, + /// with no floating window above it there. pub(crate) fn timeline_drop_target(&self, ctx: &egui::Context, pos: egui::Pos2) -> bool { if !ctx.content_rect().contains(pos) { return false; } - if let Some(panel) = - self.ui.keys.maximized.filter(|panel| self.ui.dock.contains(*panel) || self.ui.floating.iter().any(|group| group.panels.contains(panel))) - { - return panel == PanelKind::Timeline; - } - for index in self.floating_order(ctx).into_iter().rev() { - if let Some(group) = self.ui.floating.get(index) - && floating_rect(group, ctx.content_rect()).contains(pos) - { - return group.panels.get(group.active) == Some(&PanelKind::Timeline); - } - } - true - } - - fn floating_order(&self, ctx: &egui::Context) -> Vec { + let Some(bodies) = ctx.data(|data| data.get_temp::>(bodies_id())) else { return true }; + let Some((_, _, timeline)) = bodies.iter().find(|(panel, rect, _)| *panel == PanelKind::Timeline && rect.contains(pos)) else { return false }; let layers = ctx.memory(|memory| memory.layer_ids().collect::>()); - let mut order: Vec<_> = (0..self.ui.floating.len()).collect(); - order.sort_by_key(|index| { - self.ui - .floating - .get(*index) - .and_then(|group| group.panels.first()) - .and_then(|panel| { - let id = egui::Id::new(("floating-panel", panel.id())); - layers.iter().position(|layer| layer.id == id) - }) - .unwrap_or(usize::MAX) - }); - order + let rank = |layer: &egui::LayerId| layers.iter().position(|other| other == layer); + !self.ui.dock.floating.iter().any(|window| { + let Some(first) = window.panels().next() else { return false }; + let layer = egui::LayerId::new(egui::Order::Middle, area_id().with(("floating", *first))); + layer != *timeline && rank(&layer) > rank(timeline) && ctx.memory(|memory| memory.area_rect(layer.id)).is_some_and(|rect| rect.contains(pos)) + }) } pub(crate) fn dock_area(&mut self, ui: &mut egui::Ui, body: Rect) { let t = self.tokens; let ctx = ui.ctx().clone(); - if let Some(panel) = self.pending_panel_raise.take() - && let Some(owner) = self.ui.floating.iter().find(|group| group.panels.contains(&panel)).and_then(|group| group.panels.first()) - { - ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, egui::Id::new(("floating-panel", owner.id())))); - } - let maximized = - self.ui.keys.maximized.filter(|panel| self.ui.dock.contains(*panel) || self.ui.floating.iter().any(|group| group.panels.contains(panel))); - let mut dock = match maximized { - Some(panel) => DockNode::Tabs { panels: vec![panel], active: 0 }, - None => std::mem::replace(&mut self.ui.dock, DockNode::Tabs { panels: vec![], active: 0 }), - }; - let mut groups = vec![]; - let mut layout_actions = Vec::new(); - dock::layout_with_actions(ui, &mut dock, body, &t, "", &mut groups, &mut self.auto, &mut layout_actions); - let seqs = dock::SeqTabs { + let seqs = SeqTabs { open: self .session .state @@ -220,253 +216,251 @@ impl FilmcraftApp { .collect(), active: self.session.state.active_sequence.map(|id| id.0), }; - let mut actions = vec![]; - let mut targets = vec![]; - for group in &groups { - let chrome = dock::draw_group_chrome_details(ui, group, &seqs, &t, &mut self.auto); - if let Some(target) = target(group, chrome.tabs, true) { - targets.push(target); - } - actions.extend(chrome.actions); + let style = dock_style(ui, &t); + let mut area = DockArea::new(area_id()).maximized(self.ui.keys.maximized.filter(|panel| self.ui.dock.contains(panel))); + if let Some(panel) = self.pending_panel_raise.take() { + area = area.raise_panel(panel); } - if maximized.is_none() { - self.ui.dock = dock; + let layout = self.ui.dock.clone(); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(body).id_salt("filmcraft-dock-area")); + child.set_clip_rect(body.intersect(ui.clip_rect())); + let seq_width = dock::sequence_tabs_width(ui, &seqs); + let mut content = FilmContent { app: self, seqs, seq_width, tokens: t, seq_actions: Vec::new(), menu: None, bodies: Vec::new() }; + let output = area.show_content(&mut child, &layout, &style, &mut content); + let FilmContent { seq_actions, mut menu, bodies, .. } = content; + ctx.data_mut(|data| data.insert_temp(bodies_id(), bodies)); + if let Some(panel) = output.focused { + self.ui.focused = panel; } - for group in &groups { - if let Some(panel) = group.panels.get(group.active).copied() { - self.panel_body(ui, panel, group.content); + if let Some(error) = output.error { + self.ui.status = error.to_string(); + } + for event in output.events { + if let DockEvent::ToggleMaximize { panel } = event { + panels::keyboard::toggle_maximize(self, panel); } } - for group in &groups { - dock::paint_group_focus(ui, group, self.ui.focused, &t); - } - let floating_order = self.floating_order(&ctx); - for index in floating_order.into_iter().filter(|_| maximized.is_none()) { - if let Some(target) = self.floating_group(&ctx, index, &seqs, &mut actions) { - targets.push(target); + for action in output.actions { + if let Err(error) = self.dock_command(&ctx, action) { + self.ui.status = error; } } - for action in layout_actions { - let _ = self.dock_command(&ctx, action); - } - for action in actions { - match action { - DockAction::Activate(panel) => { - let _ = self.dock_command(&ctx, Action::Activate { panel }); - } - DockAction::Close(panel) => { - let _ = self.dock_command(&ctx, Action::Close { panel }); - } - DockAction::Focus(panel) => self.ui.focused = panel, - DockAction::BeginDrag(panel) => { - ctx.data_mut(|data| data.insert_temp(drag_id(), panel)); - } - DockAction::OpenSequence(id) => { - let _ = self.dock_command(&ctx, Action::Activate { panel: PanelKind::Timeline }); - if self.session.state.active_sequence.map(|item| item.0) != Some(id) - && let Err(error) = self.session.execute("sequence.open", json!({"item": id})) - { - self.ui.status = error.to_string(); + for action in seq_actions { + let result = match action { + SeqAction::Open(id) => { + let _ = self.apply_dock(Action::Activate { panel: PanelKind::Timeline }); + self.ui.focused = PanelKind::Timeline; + if self.session.state.active_sequence.map(|item| item.0) == Some(id) { + Ok(Value::Null) + } else { + self.session.execute("sequence.open", json!({"item": id})) } } - DockAction::CloseSequence(id) => { - if let Err(error) = self.session.execute("sequence.close", json!({"item": id})) { - self.ui.status = error.to_string(); + SeqAction::Close(id) => self.session.execute("sequence.close", json!({"item": id})), + SeqAction::Move(id, index) => self.session.execute("sequence.moveTab", json!({"item": id, "index": index})), + SeqAction::Menu(id, pos) => { + menu = Some((PanelKind::Timeline, pos)); + self.ui.focused = PanelKind::Timeline; + if self.session.state.active_sequence.map(|item| item.0) == Some(id) { + Ok(Value::Null) + } else { + self.session.execute("sequence.open", json!({"item": id})) } } - DockAction::MoveSequence(id, index) => { - if let Err(error) = self.session.execute("sequence.moveTab", json!({"item": id, "index": index})) { - self.ui.status = error.to_string(); - } - } - DockAction::PanelMenu(panel, pos) => { - let frame = ctx.cumulative_frame_nr(); - ctx.data_mut(|data| { - data.insert_temp(egui::Id::new("panel-menu"), (panel, pos)); - data.insert_temp(egui::Id::new("panel-menu-opened"), frame); - }); - } + }; + if let Err(error) = result { + self.ui.status = error.to_string(); } } - self.panel_drag(&ctx, body, &targets); + if let Some((panel, pos)) = menu { + let frame = ctx.cumulative_frame_nr(); + ctx.data_mut(|data| { + data.insert_temp(egui::Id::new("panel-menu"), (panel, pos)); + data.insert_temp(egui::Id::new("panel-menu-opened"), frame); + }); + } + let hits = DockArea::::hit_rects(&ctx, area_id()); + self.tab_strip_pointer(&ctx, &hits); + self.register_dock_hits(&hits); panels::panel_menu_popup(self, ui); self.cancel_value_drag(ui.ctx()); } - fn floating_group(&mut self, ctx: &egui::Context, index: usize, seqs: &dock::SeqTabs, actions: &mut Vec) -> Option> { - let floating = self.ui.floating.get(index)?.clone(); - let panel = *floating.panels.first()?; - let rect = floating_rect(&floating, ctx.content_rect()); - let t = self.tokens; - let id = egui::Id::new(("floating-panel", panel.id())); - let mut output = None; - egui::Area::new(id).order(egui::Order::Middle).fixed_pos(rect.min).constrain(false).interactable(true).show(ctx, |ui| { - let (rect, _) = ui.allocate_exact_size(rect.size(), Sense::hover()); - let compact = floating.panels.len() == 1 && panel.compact(); - let header = if compact { 8.0 } else { t.tab_h }; - let strip = Rect::from_min_size(rect.min, vec2(rect.width(), header)); - let response = ui.interact(strip, id.with("move"), Sense::drag()); - self.auto.add_response(&format!("panel.float.{index}.move"), &response, "Move floating panel"); - let mut next = if response.dragged() { rect.translate(response.drag_delta()) } else { rect }; - let group = Group { - path: format!("f{index}"), - rect, - content: Rect::from_min_max(pos2(rect.min.x, rect.min.y + header), rect.max), - panels: floating.panels.clone(), - active: floating.active, + /// Automation ids for craft-ui's gesture sources: `panel.tab.`, `panel.drag.` + /// (the group's drag bar), `panel.tabs.more.`, `panel.float..move` and `.resize`, + /// and `dock.gutter.` (`a`/`b` per split level). + fn register_dock_hits(&mut self, hits: &[(Hit, Rect)]) { + let window = |panel: &PanelKind| self.ui.dock.floating.iter().position(|window| window.contains(panel)); + let mut found = Vec::new(); + for (hit, rect) in hits { + let entry = match hit { + Hit::Tab(panel) => (format!("panel.tab.{}", panel.id()), panel.title()), + Hit::DragBar(panel) => (format!("panel.drag.{}", panel.id()), "Move panel"), + Hit::Overflow(panel) => (format!("panel.tabs.more.{}", panel.id()), "more tabs"), + Hit::Title(panel) => match window(panel) { + Some(index) => (format!("panel.float.{index}.move"), "Move floating panel"), + None => continue, + }, + Hit::Resize(panel, ResizeEdge::BottomRight) => match window(panel) { + Some(index) => (format!("panel.float.{index}.resize"), "Resize floating panel"), + None => continue, + }, + Hit::Divider(Divider::Split(path)) => { + (format!("dock.gutter.{}", path.iter().map(|second| if *second { 'b' } else { 'a' }).collect::()), "gutter") + } + _ => continue, }; - let chrome = dock::draw_group_chrome_details(ui, &group, seqs, &t, &mut self.auto); - actions.extend(chrome.actions); - if let Some(active) = group.panels.get(group.active).copied() { - self.panel_body(ui, active, group.content); - } - ui.painter().rect_stroke(rect, t.radius, Stroke::new(1.0, t.focus), StrokeKind::Outside); - let grip = Rect::from_min_max(rect.max - vec2(14.0, 14.0), rect.max); - let response = ui.interact(grip, id.with("resize"), Sense::click_and_drag()); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, response.enabled(), tl!("Resize floating panel"))); - self.auto.add_response(&format!("panel.float.{index}.resize"), &response, "Resize floating panel"); - for step in 0..3 { - let offset = 4.0 + step as f32 * 4.0; - ui.painter() - .line_segment([pos2(rect.max.x - offset, rect.max.y - 2.0), pos2(rect.max.x - 2.0, rect.max.y - offset)], Stroke::new(1.0, t.text_faint)); - } - if response.hovered() || response.dragged() { - ctx.set_cursor_icon(egui::CursorIcon::ResizeNwSe); - } - let delta = floating_resize_delta(ui, &response); - if response.has_focus() { - ui.painter().rect_stroke(grip, 2.0, Stroke::new(1.5, t.focus), StrokeKind::Inside); - } - if delta != egui::Vec2::ZERO { - next = Rect::from_min_size(next.min, (next.size() + delta).max(vec2(160.0, 100.0))); - } - if next != rect { - let _ = self.dock_command(ctx, Action::MoveFloating { panel, rect: [next.min.x, next.min.y, next.width(), next.height()] }); - } - dock::paint_group_focus(ui, &group, self.ui.focused, &t); - output = target(&group, chrome.tabs, false); - }); - output + found.push((entry, *rect)); + } + for ((id, label), rect) in found { + self.auto.add(&id, rect, label); + } } +} - fn panel_drag(&mut self, ctx: &egui::Context, body: Rect, targets: &[DropTarget]) { - let Some(panel) = ctx.data(|data| data.get_temp::(drag_id())) else { return }; - let canceled = - ctx.input(|input| input.key_pressed(egui::Key::Escape) || !input.focused || (!input.pointer.primary_down() && !input.pointer.primary_released())); - if canceled || (!self.ui.dock.contains(panel) && !self.ui.floating.iter().any(|group| group.panels.contains(&panel))) { - ctx.data_mut(|data| data.remove::(drag_id())); +impl FilmcraftApp { + /// Over a tab strip the wheel steps through the group's tabs (down = next, one tab per notch, + /// trackpad scrolling adding up to a notch, stopping at the ends), and a middle click on a tab + /// closes it; on the Timeline's tab it closes the active sequence and keeps the panel. + fn tab_strip_pointer(&mut self, ctx: &egui::Context, hits: &[(Hit, Rect)]) { + let wheel_id = area_id().with("tab-wheel"); + let pos = ctx.pointer_hover_pos(); + let over = hits.iter().find_map(|(hit, rect)| match hit { + Hit::Tab(panel) | Hit::DragBar(panel) | Hit::Overflow(panel) if pos.is_some_and(|pos| rect.contains(pos)) => { + Some((matches!(hit, Hit::Tab(_)), *panel)) + } + _ => None, + }); + let Some((on_tab, panel)) = over else { + ctx.data_mut(|data| data.remove::(wheel_id)); + return; + }; + if on_tab && ctx.input(|input| input.pointer.button_clicked(egui::PointerButton::Middle)) { + let result = if panel == PanelKind::Timeline && self.session.state.active_sequence.is_some() { + self.session.execute("sequence.close", json!({})).map(drop).map_err(|error| error.to_string()) + } else { + self.dock_command(ctx, Action::Close { panel }) + }; + if let Err(error) = result { + self.ui.status = error; + } return; } - let Some(pos) = ctx.pointer_latest_pos() else { return }; - let force_float = ctx.input(|input| input.modifiers.command); - let edge = (!force_float).then(|| workspace_edge(body, pos, targets)).flatten(); - let proposal = (!force_float && edge.is_none()).then(|| resolve_drop(&panel, pos, targets)).flatten(); - let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("dock-drop"))); + let Some((panels, active)) = group_of(&self.ui.dock, panel) else { return }; + const NOTCH: f32 = 40.0; + let total = ctx.data(|data| data.get_temp::(wheel_id)).unwrap_or(0.0) + panels::timeline::wheel_input(ctx).delta.y; + let steps = (total / NOTCH).trunc(); + ctx.data_mut(|data| data.insert_temp(wheel_id, total - steps * NOTCH)); + let to = (active as i64).saturating_sub(steps as i64).clamp(0, (panels.len() as i64 - 1).max(0)) as usize; + if let Some(next) = panels.get(to).filter(|_| to != active).copied() { + let _ = self.dock_command(ctx, Action::Activate { panel: next }); + self.ui.focused = next; + } + } +} + +/// The panels of the tab group holding `panel`, and its active index. +fn group_of(layout: &Layout, panel: PanelKind) -> Option<(Vec, usize)> { + fn find(node: &craft_ui::docking::Node, panel: PanelKind) -> Option<(Vec, usize)> { + match node { + craft_ui::docking::Node::Tabs { panels, active } if panels.contains(&panel) => Some((panels.clone(), *active)), + craft_ui::docking::Node::Split { first, second, .. } => find(first, panel).or_else(|| find(second, panel)), + _ => None, + } + } + layout.root.as_ref().and_then(|root| find(root, panel)).or_else(|| { + layout.floating.iter().flat_map(|window| &window.groups).find(|group| group.panels.contains(&panel)).map(|group| (group.panels.clone(), group.active)) + }) +} + +fn dock_style(ui: &egui::Ui, t: &Tokens) -> DockStyle { + let mut style = DockStyle::from_ui(ui); + style.profile = Profile::Frame; + style.tab_height = t.tab_h; + style.gap = t.gap; + style.background = t.panel_bg; + style.tab_background = t.tab_bg; + style.active_background = t.panel_bg; + style.floating_background = t.panel_bg; + style.text = t.tab_text_active; + style.inactive_text = t.tab_text; + style.accent = t.focus; + style.font = Tokens::ui(12.0); + style +} + +/// FilmCraft's side of the dock area for one frame. +struct FilmContent<'a> { + app: &'a mut FilmcraftApp, + seqs: SeqTabs, + /// The width the sequence tabs take in the Timeline's tab strip. + seq_width: f32, + tokens: Tokens, + seq_actions: Vec, + /// The ≡ button clicked: its panel and where its menu opens. + menu: Option<(PanelKind, egui::Pos2)>, + bodies: Vec<(PanelKind, Rect, egui::LayerId)>, +} + +impl DockContent for FilmContent<'_> { + fn body(&mut self, ui: &mut egui::Ui, panel: &PanelKind) { let t = self.tokens; - if let Some((_, _, preview)) = edge { - painter.rect_filled(preview.shrink(2.0), t.radius, t.focus.gamma_multiply(0.2)); - painter.rect_stroke(preview.shrink(2.0), t.radius, Stroke::new(2.0, t.focus), StrokeKind::Inside); + let rect = ui.max_rect(); + self.bodies.push((*panel, rect, ui.layer_id())); + self.app.auto.add(&format!("panel.{}", panel.id()), rect, panel.title()); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).id_salt(("panel", panel.id()))); + child.set_clip_rect(rect.intersect(ui.clip_rect())); + panels::show(self.app, &mut child, *panel, rect); + // Painted after the body so child backgrounds cannot hide the focus outline (#1075). + if self.app.ui.focused == *panel { + ui.painter().rect_stroke(rect, 0.0, Stroke::new(1.0, t.focus), StrokeKind::Inside); } - if let Some(proposal) = &proposal { - painter.rect_filled(proposal.preview.shrink(2.0), t.radius, t.focus.gamma_multiply(0.2)); - painter.rect_stroke(proposal.preview.shrink(2.0), t.radius, Stroke::new(2.0, t.focus), StrokeKind::Inside); - if let Some(mark) = proposal.insertion { - painter.rect_filled(mark, 0.0, t.focus); - } + } + + fn logical_label(&self, panel: &PanelKind) -> String { + crate::i18n::t(panel.title()).to_string() + } + + /// With no sequence open, the Timeline's tab says so. + fn label(&mut self, _ui: &egui::Ui, panel: &PanelKind, logical: &str, _font: &egui::FontId, _color: egui::Color32) -> egui::WidgetText { + if *panel == PanelKind::Timeline && self.seqs.open.is_empty() { self.seqs.label().into() } else { logical.into() } + } + + /// A floated panel is at most 420 by 320 points: panel bodies fill their frame, so their + /// drawn height says nothing about the size they need. + fn floating_size(&self, _panel: &PanelKind) -> Option { + Some(vec2(420.0, 320.0)) + } + + /// Tools and the audio meters are narrow strips without a tab strip. + fn bare(&self, panel: &PanelKind) -> bool { + panel.compact() + } + + /// The ≡ menu, and the Timeline's sequence tabs before it while the Timeline is shown. + fn group_header_width(&self, _panels: &[PanelKind], active: &PanelKind, _floating: bool) -> f32 { + if *active == PanelKind::Timeline && !self.seqs.open.is_empty() { self.seq_width + MENU_W } else { MENU_W } + } + + /// The ≡ panel menu of the group's shown panel; for the Timeline, its sequence tabs first. + fn group_header(&mut self, ui: &mut egui::Ui, _panels: &[PanelKind], active: &PanelKind, _floating: bool) { + let t = self.tokens; + let header = ui.max_rect(); + let menu = Rect::from_min_max(egui::pos2((header.max.x - MENU_W).max(header.min.x), header.min.y), header.max); + if *active == PanelKind::Timeline && !self.seqs.open.is_empty() { + let strip = Rect::from_min_max(header.min, egui::pos2(menu.min.x, header.max.y)); + self.seq_actions.extend(dock::sequence_tabs(ui, strip, &self.seqs, &t, &mut self.app.auto)); } - let title = painter.layout_no_wrap(panel.title().to_owned(), crate::theme::Tokens::ui(12.0), Color32::WHITE); - let ghost = Rect::from_min_size(pos + vec2(12.0, 10.0), title.size() + vec2(16.0, 8.0)); - painter.rect_filled(ghost, 4.0, t.focus.gamma_multiply(0.85)); - painter.galley(ghost.min + vec2(8.0, 4.0), title, Color32::WHITE); - ctx.set_cursor_icon(egui::CursorIcon::Grabbing); - ctx.request_repaint(); - if ctx.input(|input| input.pointer.primary_released()) { - ctx.data_mut(|data| data.remove::(drag_id())); - if let Some((_, zone, _)) = edge { - if crate::menus::invoke(self, ctx, "window.dockPanel", json!({"panel": panel.id(), "zone": zone})).is_ok() { - self.ui.focused = panel; - } - return; - } - let action = match proposal { - Some(proposal) => Some(Action::Move { panel, anchor: proposal.anchor, placement: proposal.placement }), - None if force_float || !targets.iter().any(|target| target.rect.contains(pos)) => { - Some(Action::Float { panel, rect: [pos.x - 40.0, pos.y - 12.0, 420.0, 320.0] }) - } - None => None, - }; - if let Some(action) = action - && self.dock_command(ctx, action).is_ok() - { - self.ui.focused = panel; - } + let rect = Rect::from_center_size(menu.center(), vec2(18.0, 18.0)); + let response = ui.interact(rect, egui::Id::new(("panel-menu-button", active.id())), Sense::click()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, response.enabled(), "panel menu")); + self.app.auto.add_response(&format!("panel.menu.{}", active.id()), &response, "panel menu"); + let color = if response.hovered() { t.tab_text_active } else { t.tab_text }; + icons::paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), Icon::Hamburger, color); + dock::paint_widget_focus(ui.painter(), &response, &t); + if response.clicked() { + self.app.ui.focused = *active; + self.menu = Some((*active, rect.left_bottom())); } } } - -/// Width of the band along the workspace border that docks beside the whole layout. -const EDGE_BAND: f32 = 8.0; - -/// The outermost drop zone: near the workspace border a drop splits the root, not the group -/// under the pointer. A floating group over the border (it rejects splits) keeps the drop. -fn workspace_edge(body: Rect, pos: egui::Pos2, targets: &[DropTarget]) -> Option<(Zone, &'static str, Rect)> { - if !body.is_finite() || !body.contains(pos) || targets.iter().any(|target| !target.allow_splits && target.rect.contains(pos)) { - return None; - } - let band = EDGE_BAND.min(body.width() / 4.0).min(body.height() / 4.0); - let edges = [ - (pos.x - body.left(), Zone::Left, "left"), - (body.right() - pos.x, Zone::Right, "right"), - (pos.y - body.top(), Zone::Top, "top"), - (body.bottom() - pos.y, Zone::Bottom, "bottom"), - ]; - let (distance, zone, name) = edges.into_iter().min_by(|a, b| a.0.total_cmp(&b.0))?; - if distance > band { - return None; - } - let size = body.size() * 0.25; - let preview = match zone { - Zone::Left => Rect::from_min_size(body.min, vec2(size.x, body.height())), - Zone::Right => Rect::from_min_max(pos2(body.right() - size.x, body.top()), body.max), - Zone::Top => Rect::from_min_size(body.min, vec2(body.width(), size.y)), - Zone::Bottom | Zone::Center => Rect::from_min_max(pos2(body.left(), body.bottom() - size.y), body.max), - }; - Some((zone, name, preview)) -} - -fn target(group: &Group, mut tabs: Vec<(PanelKind, Rect)>, allow_splits: bool) -> Option> { - let strip = Rect::from_min_max(group.rect.min, pos2(group.rect.max.x, group.content.min.y)); - // A compact grip still identifies the source group to the shared resolver. - for panel in &group.panels { - if !tabs.iter().any(|(candidate, _)| candidate == panel) { - tabs.push((*panel, Rect::NOTHING)); - } - } - Some(DropTarget { anchor: *group.panels.get(group.active)?, rect: group.rect, tab_strip: Some(strip), tabs, allow_splits, accepts_tabs: true }) -} - -/// A corner is a two-axis resize control; focused arrows belong to it and Tab moves on. -fn floating_resize_delta(ui: &mut egui::Ui, response: &egui::Response) -> egui::Vec2 { - if response.drag_started() { - response.request_focus(); - } - let mut delta = if response.dragged() { response.drag_delta() } else { egui::Vec2::ZERO }; - if response.has_focus() { - ui.memory_mut(|memory| { - memory.set_focus_lock_filter(response.id, egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() }) - }); - ui.input_mut(|input| { - for (key, amount) in [ - (egui::Key::ArrowLeft, vec2(-8.0, 0.0)), - (egui::Key::ArrowRight, vec2(8.0, 0.0)), - (egui::Key::ArrowUp, vec2(0.0, -8.0)), - (egui::Key::ArrowDown, vec2(0.0, 8.0)), - ] { - if input.consume_key(egui::Modifiers::NONE, key) { - delta += amount; - } - } - }); - } - if response.enabled() { delta } else { egui::Vec2::ZERO } -} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c838e6fe9..2db18de6f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -37,7 +37,6 @@ pub mod control; pub mod crash; pub mod credits; pub mod dock; -mod dock_shared; mod dock_ui; /// Thumbnail textures kept before the least recently used are freed (each is up to 320 px wide). const MAX_THUMB_TEXTURES: usize = 512; @@ -73,7 +72,7 @@ use filmcraft_time::Tick; use serde_json::{Value, json}; pub use control::ControlRequest; -use dock::PanelKind; +use dock::{LayoutExt as _, PanelKind}; use frames::{FrameKey, FrameServer, Target}; use native_dialogs::FileDialog; use state::UiState; @@ -854,9 +853,7 @@ impl FilmcraftApp { pub fn set_workspace(&mut self, name: &str) { self.ui.workspace = name.to_string(); - let layout = dock::saved_docking(&self.workspaces, name); - self.ui.dock = layout.root; - self.ui.floating = layout.floating; + self.ui.dock = dock::saved_docking(&self.workspaces, name); self.pending_panel_raise = None; self.ui.keys.maximized = None; if self.workspaces.current != name { @@ -874,20 +871,20 @@ impl FilmcraftApp { pub fn show_panel(&mut self, p: PanelKind) { // closed while floating: it floats again where it was (#1985); taken before the Timeline's // own restore below docks it - let open = self.ui.dock.contains(p) || self.ui.floating.iter().any(|group| group.panels.contains(&p)); + let open = self.ui.dock.contains(&p); let refloat = match self.ui.closed_floating.iter().position(|(k, _)| *k == p) { Some(i) if !open => Some(self.ui.closed_floating.remove(i).1), _ => None, }; - if p == PanelKind::Timeline && !self.ui.floating.iter().any(|group| group.panels.contains(&p)) { + if p == PanelKind::Timeline { self.ui.dock.restore_timeline(); } - if !self.ui.dock.contains(p) && !self.ui.floating.iter().any(|group| group.panels.contains(&p)) { + if !self.ui.dock.contains(&p) { let near = match p { // a side panel beside Properties (or Essential Sound), never over the Program monitor PanelKind::TextToSpeech => [PanelKind::Properties, PanelKind::EssentialSound, PanelKind::EssentialGraphics] .into_iter() - .find(|k| self.ui.dock.contains(*k)) + .find(|k| self.ui.dock.contains(k)) .unwrap_or(PanelKind::Program), PanelKind::LumetriColor | PanelKind::EssentialGraphics | PanelKind::EssentialSound | PanelKind::Properties => PanelKind::Program, PanelKind::Source @@ -1804,9 +1801,7 @@ impl FilmcraftApp { filmcraft_engine::Event::OpenSequence(_) => { // show the sequence with its own view (or fitted, the first time) self.timeline_view_of = None; - if !self.ui.floating.iter().any(|group| group.panels.contains(&PanelKind::Timeline)) { - self.ui.dock.restore_timeline(); - } + self.ui.dock.restore_timeline(); let _ = self.apply_dock(craft_ui::docking::Action::Activate { panel: PanelKind::Timeline }); } filmcraft_engine::Event::OpenSource(_) => { diff --git a/crates/ui-egui/src/panels/keyboard.rs b/crates/ui-egui/src/panels/keyboard.rs index bdf744196..91ce44441 100644 --- a/crates/ui-egui/src/panels/keyboard.rs +++ b/crates/ui-egui/src/panels/keyboard.rs @@ -235,7 +235,7 @@ pub fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Val /// Maximize `p`, or restore the layout when a panel is maximized (also the panel menu's Maximize / /// Restore Frame). A maximized panel that has since been closed counts as restored. pub(crate) fn toggle_maximize(app: &mut FilmcraftApp, p: PanelKind) -> Value { - let current = app.ui.keys.maximized.filter(|q| app.ui.dock.contains(*q) || app.ui.floating.iter().any(|g| g.panels.contains(q))); + let current = app.ui.keys.maximized.filter(|q| app.ui.dock.contains(q)); app.ui.keys.maximized = if current.is_some() { None } else { Some(p) }; if let Some(p) = app.ui.keys.maximized { let _ = app.apply_dock(craft_ui::docking::Action::Activate { panel: p }); @@ -255,11 +255,8 @@ fn panel_under_pointer(app: &FilmcraftApp, ctx: &egui::Context) -> Option Vec { - let mut all = Vec::new(); - app.ui.dock.panels(&mut all); - let mut visible: Vec<_> = all.into_iter().filter(|p| app.ui.dock.is_visible(*p) && !p.compact()).collect(); - visible.extend(app.ui.floating.iter().filter_map(|group| group.panels.get(group.active).copied()).filter(|panel| !panel.compact())); - visible + use crate::dock::LayoutExt as _; + app.ui.dock.visible_panels().into_iter().filter(|p| !p.compact()).collect() } fn cycle_panel(app: &mut FilmcraftApp, next: bool) -> Value { diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index ff18ab0f2..3b7d33dfe 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -309,7 +309,7 @@ pub fn panel_menu_popup(app: &mut FilmcraftApp, ui: &mut egui::Ui) { let _ = crate::menus::invoke(app, ui.ctx(), "window.floatPanel", serde_json::json!({"panel": p.id()})); close = true; } - let maximized = app.ui.keys.maximized.is_some_and(|q| app.ui.dock.contains(q) || app.ui.floating.iter().any(|g| g.panels.contains(&q))); + let maximized = app.ui.keys.maximized.is_some_and(|q| app.ui.dock.contains(&q)); let r = ui.button(if maximized { tl!("Restore Frame") } else { tl!("Maximize Frame") }); app.auto.add(&format!("panel.menu.{}.maximize", p.id()), r.rect, if maximized { "Restore Frame" } else { "Maximize Frame" }); if r.clicked() { diff --git a/crates/ui-egui/src/panels/workspaces.rs b/crates/ui-egui/src/panels/workspaces.rs index 5678bd171..89ac924a1 100644 --- a/crates/ui-egui/src/panels/workspaces.rs +++ b/crates/ui-egui/src/panels/workspaces.rs @@ -56,17 +56,14 @@ pub fn route(app: &mut FilmcraftApp, id: &str, params: &Value) -> Option Result<(), String> { - crate::dock::Layout { root: app.ui.dock.clone(), floating: app.ui.floating.clone() }.validate()?; - let layout = serde_json::to_value(&app.ui.dock).map_err(|e| e.to_string())?; - let floating = if app.ui.floating.is_empty() { serde_json::Value::Null } else { serde_json::to_value(&app.ui.floating).map_err(|e| e.to_string())? }; + let workspace = crate::dock::Workspace::new(app.ui.dock.clone()); + workspace.validate().map_err(|e| e.to_string())?; + let workspace = serde_json::to_value(&workspace).map_err(|e| e.to_string())?; let mut next = app.workspaces.clone(); let saved = &mut next.saved; match saved.iter_mut().find(|s| s.name == name) { - Some(s) => { - s.layout = layout; - s.floating = floating; - } - None => saved.push(dock::SavedWorkspace { name: name.to_string(), layout, floating }), + Some(s) => *s = dock::SavedWorkspace { name: name.to_string(), workspace, ..Default::default() }, + None => saved.push(dock::SavedWorkspace { name: name.to_string(), workspace, ..Default::default() }), } next.current = name.to_string(); app.set_workspaces(next) diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index e0710236c..212a60a78 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; -use crate::dock::{DockNode, PanelKind}; +use crate::dock::PanelKind; use crate::icons::Icon; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -433,9 +433,8 @@ pub struct UiState { pub tool: Tool, pub mode: Mode, pub workspace: String, - pub dock: DockNode, - #[serde(default)] - pub floating: Vec, + /// The panel arrangement in use: craft-ui's frame tree and floating windows. + pub dock: crate::dock::Layout, /// Where each panel closed while floating was ([x, y, width, height]): opening it again /// brings it back there, floating, instead of docked (#1985). #[serde(default)] @@ -963,7 +962,6 @@ impl Default for UiState { tool: Tool::Selection, mode: Mode::Edit, workspace: "Editing".into(), - floating: vec![], closed_floating: vec![], dock: crate::dock::workspace("Editing"), focused: PanelKind::Timeline, diff --git a/crates/ui-egui/tests/meters_ui.rs b/crates/ui-egui/tests/meters_ui.rs index 8d835ecfb..cbd24fd7e 100644 --- a/crates/ui-egui/tests/meters_ui.rs +++ b/crates/ui-egui/tests/meters_ui.rs @@ -5,10 +5,12 @@ use std::sync::mpsc::{Sender, channel}; use std::sync::{Arc, Mutex}; +use craft_ui::docking::Node; +use craft_ui::layout::SplitSize; use egui_kittest::Harness; use filmcraft_engine::Session; use filmcraft_ui_egui::control::ControlRequest; -use filmcraft_ui_egui::dock::{DockNode, PanelKind, SplitSize}; +use filmcraft_ui_egui::dock::PanelKind; use filmcraft_ui_egui::{AudioOut, FilmcraftApp}; use serde_json::{Value, json}; @@ -90,17 +92,17 @@ impl Driver { /// Give the Audio Meters panel `width` points (it sits in a fixed-width split beside the /// Timeline). fn meters_width(&mut self, width: f32) { - fn set(n: &mut DockNode, width: f32) -> bool { + fn set(n: &mut Node, width: f32) -> bool { match n { - DockNode::Split { size, b, .. } if matches!(&**b, DockNode::Tabs { panels, .. } if panels.as_slice() == [PanelKind::AudioMeters]) => { - *size = SplitSize::FixedB(width); + Node::Split { size, second, .. } if matches!(&**second, Node::Tabs { panels, .. } if panels.as_slice() == [PanelKind::AudioMeters]) => { + *size = SplitSize::FixedSecond(width); true } - DockNode::Split { a, b, .. } => set(a, width) || set(b, width), - DockNode::Tabs { .. } => false, + Node::Split { first, second, .. } => set(first, width) || set(second, width), + _ => false, } } - assert!(set(&mut self.harness.state_mut().ui.dock, width)); + assert!(self.harness.state_mut().ui.dock.root.as_mut().is_some_and(|root| set(root, width))); self.frames(3); } /// The level the left bar shows, in dB. diff --git a/crates/ui-egui/tests/monitor_geometry.rs b/crates/ui-egui/tests/monitor_geometry.rs index 700657e19..6ecaa81d4 100644 --- a/crates/ui-egui/tests/monitor_geometry.rs +++ b/crates/ui-egui/tests/monitor_geometry.rs @@ -6,7 +6,7 @@ use egui_kittest::kittest::{NodeT, Queryable}; use filmcraft_engine::Session; use filmcraft_ui_egui::{ FilmcraftApp, - dock::{self, DockNode, PanelKind, SeqTabs}, + dock::PanelKind, panels::monitor::{self, Which}, theme::{self, ThemeKind}, }; @@ -25,14 +25,12 @@ fn harness_at_scale(width: f32, kind: ThemeKind, which: Which, scale: f32) -> Ha app.set_theme(ui.ctx(), kind); app.auto.begin_frame(); let panel = if which == Which::Source { PanelKind::Source } else { PanelKind::Program }; - let mut node = DockNode::Tabs { panels: vec![panel], active: 0 }; - let mut groups = Vec::new(); - dock::layout(ui, &mut node, ui.max_rect(), &app.tokens, "monitor", &mut groups, &mut app.auto); - for group in groups { - // Exercise the actual parent chrome, including its themed panel background. - dock::draw_group_chrome(ui, &group, panel, &SeqTabs::default(), &app.tokens, &mut app.auto); - monitor::show(app, ui, group.content, which); - } + // The panel frame as docking draws it: a tab strip over the themed panel background. + let frame = ui.max_rect(); + ui.painter().rect_filled(frame, 0.0, app.tokens.panel_bg); + let content = egui::Rect::from_min_max(egui::pos2(frame.min.x, frame.min.y + app.tokens.tab_h), frame.max); + let _ = panel; + monitor::show(app, ui, content, which); let source = filmcraft_ui_egui::panels::dragged_source(ui); ui.ctx().data_mut(|data| data.insert_temp(egui::Id::new("observed-source-drag"), source)); }, diff --git a/crates/ui-egui/tests/nest_editing_ui.rs b/crates/ui-egui/tests/nest_editing_ui.rs index 4ad6e6abb..33394004b 100644 --- a/crates/ui-egui/tests/nest_editing_ui.rs +++ b/crates/ui-egui/tests/nest_editing_ui.rs @@ -97,14 +97,14 @@ fn reveal_in_project_shows_the_clips_item_in_the_project_panel() { }; let bin = d.app().session.project.root.parent_of(item).expect("the demo keeps its footage in a bin").0; // the Project panel is closed, its bins are collapsed and a search hides everything - d.app().ui.dock.close(PanelKind::Project); + d.app().ui.dock.apply(craft_ui::docking::Action::Close { panel: PanelKind::Project }).unwrap(); d.app().ui.expanded_bins.clear(); d.app().ui.project_search = "no such thing".into(); d.frames(2); assert!(!d.has(&format!("project.item.{}", item.0))); d.exec("clip.revealInProject", json!({"clip": clip})); d.frames(3); - assert!(d.app().ui.dock.is_visible(PanelKind::Project), "{mode:?}"); + assert!(filmcraft_ui_egui::dock::LayoutExt::is_visible(&d.app().ui.dock, PanelKind::Project), "{mode:?}"); assert!(d.app().ui.project_search.is_empty()); assert_eq!(d.app().session.state.project_selection, vec![item]); if mode == ViewMode::List { diff --git a/crates/ui-egui/tests/panels_ui.rs b/crates/ui-egui/tests/panels_ui.rs index 483bee1c9..de79a9fba 100644 --- a/crates/ui-egui/tests/panels_ui.rs +++ b/crates/ui-egui/tests/panels_ui.rs @@ -183,7 +183,7 @@ fn every_panel_opens_from_the_window_menu() { /// and closing every panel still leaves a dock that Window ▸ can reopen panels into. #[test] fn panel_menu_maximize_restores_and_an_empty_dock_reopens() { - use filmcraft_ui_egui::dock::{DockNode, PanelKind}; + use filmcraft_ui_egui::dock::PanelKind; let mut d = Driver::demo(); let layout = d.app().ui.dock.clone(); d.click("panel.menu.Program"); @@ -198,12 +198,10 @@ fn panel_menu_maximize_restores_and_an_empty_dock_reopens() { assert_eq!(d.app().ui.dock, layout, "the layout survives maximize / restore"); assert!(d.has("panel.Timeline")); - let mut all = Vec::new(); - layout.panels(&mut all); - for p in all { + for p in layout.panels() { d.ok("ui.panel.close", json!({"panel": p.id()})); } - assert_eq!(d.app().ui.dock, DockNode::Tabs { panels: vec![], active: 0 }); + assert_eq!(d.app().ui.dock.root, None); d.ok("ui.menu.invoke", json!({"id": "window.panel.Program"})); d.ok("ui.menu.invoke", json!({"id": "window.panel.Timeline"})); d.frames(3); @@ -215,16 +213,17 @@ fn panel_menu_maximize_restores_and_an_empty_dock_reopens() { /// content the wheel leaves the tabs alone. #[test] fn wheel_over_a_tab_strip_switches_tabs() { - use filmcraft_ui_egui::dock::{DockNode, PanelKind}; + use craft_ui::docking::Node; + use filmcraft_ui_egui::dock::PanelKind; fn shown(d: &mut Driver) -> PanelKind { - fn find(n: &DockNode) -> Option { + fn find(n: &Node) -> Option { match n { - DockNode::Tabs { panels, active } if panels.contains(&PanelKind::Project) => panels.get(*active).copied(), - DockNode::Tabs { .. } => None, - DockNode::Split { a, b, .. } => find(a).or_else(|| find(b)), + Node::Tabs { panels, active } if panels.contains(&PanelKind::Project) => panels.get(*active).copied(), + Node::Split { first, second, .. } => find(first).or_else(|| find(second)), + _ => None, } } - find(&d.app().ui.dock).unwrap() + d.app().ui.dock.root.as_ref().and_then(find).unwrap() } fn wheel(d: &mut Driver, dy: f64) { let id = format!("panel.tab.{}", shown(d).id()); diff --git a/crates/ui-egui/tests/sequence_tabs_ui.rs b/crates/ui-egui/tests/sequence_tabs_ui.rs index d2f73b675..a2702c2ac 100644 --- a/crates/ui-egui/tests/sequence_tabs_ui.rs +++ b/crates/ui-egui/tests/sequence_tabs_ui.rs @@ -91,7 +91,7 @@ impl Driver { } fn has_timeline(&mut self) -> bool { - self.app().ui.dock.contains(filmcraft_ui_egui::dock::PanelKind::Timeline) + self.app().ui.dock.contains(&filmcraft_ui_egui::dock::PanelKind::Timeline) } } @@ -170,7 +170,7 @@ fn timeline_has_a_tab_per_open_sequence() { assert!(d.has_timeline(), "closing the last sequence keeps the Timeline panel"); assert!(d.has("panel.tab.Timeline")); // reopening from the project brings it back, even if the panel itself was closed - d.app().ui.dock.close(filmcraft_ui_egui::dock::PanelKind::Timeline); + d.app().ui.dock.apply(craft_ui::docking::Action::Close { panel: filmcraft_ui_egui::dock::PanelKind::Timeline }).unwrap(); d.frames(2); assert!(!d.has_timeline()); d.exec("sequence.open", json!({"item": main})); diff --git a/crates/ui-egui/tests/shared_docking_ui.rs b/crates/ui-egui/tests/shared_docking_ui.rs index 7d087bcb4..95ce475ec 100644 --- a/crates/ui-egui/tests/shared_docking_ui.rs +++ b/crates/ui-egui/tests/shared_docking_ui.rs @@ -1,34 +1,55 @@ //! Shared docking preserves FilmCraft document state and legacy workspace files. -use craft_ui::docking::{Action, Placement, Zone}; +use craft_ui::docking::{Action, Node, Placement, Zone}; +use craft_ui::layout::{SplitAxis, SplitSize}; use egui::{Event, Modifiers, PointerButton, Rect, pos2, vec2}; use egui_kittest::Harness; use egui_kittest::kittest::{NodeT, Queryable}; use filmcraft_engine::Session; use filmcraft_ui_egui::{ FilmcraftApp, - dock::{self, DockNode, Layout, PanelKind, SplitSize}, + dock::{self, Layout, LayoutExt, PanelKind}, menus, }; use serde_json::json; -fn layout() -> Layout { - Layout { - root: DockNode::Split { - vertical: false, - size: SplitSize::FixedA(400.0), - a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project, PanelKind::Info, PanelKind::History], active: 1 }), - b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Source, PanelKind::Program], active: 1 }), - }, - floating: vec![], +fn tabs(panels: &[PanelKind], active: usize) -> Node { + Node::Tabs { panels: panels.to_vec(), active } +} + +fn split(vertical: bool, size: SplitSize, a: Node, b: Node) -> Node { + Node::Split { axis: if vertical { SplitAxis::Vertical } else { SplitAxis::Horizontal }, size, first: Box::new(a), second: Box::new(b) } +} + +fn root(node: Node) -> Layout { + Layout { root: Some(node), floating: vec![] } +} + +/// Whether a panel is in the docked frame tree (not a floating window). +trait Docked { + fn docked(&self, panel: PanelKind) -> bool; +} + +impl Docked for Layout { + fn docked(&self, panel: PanelKind) -> bool { + Layout { root: self.root.clone(), floating: vec![] }.contains(&panel) } } +fn layout() -> Layout { + root(split( + false, + SplitSize::FixedFirst(400.0), + tabs(&[PanelKind::Project, PanelKind::Info, PanelKind::History], 1), + tabs(&[PanelKind::Source, PanelKind::Program], 1), + )) +} + #[test] fn shared_transactions_keep_selection_and_reject_invalid_moves_atomically() { let mut layout = layout(); layout.apply(Action::Close { panel: PanelKind::Project }).unwrap(); - assert!(layout.root.is_visible(PanelKind::Info)); + assert!(layout.is_visible(PanelKind::Info)); let before = layout.clone(); for action in [ Action::Move { panel: PanelKind::Info, anchor: PanelKind::Tools, placement: Placement::Split(Zone::Left) }, @@ -41,20 +62,22 @@ fn shared_transactions_keep_selection_and_reject_invalid_moves_atomically() { layout.apply(Action::Float { panel: PanelKind::Info, rect: [50.0, 50.0, 400.0, 300.0] }).unwrap(); layout.apply(Action::Move { panel: PanelKind::Info, anchor: PanelKind::Program, placement: Placement::Split(Zone::Bottom) }).unwrap(); assert!(layout.floating.is_empty()); - assert!(layout.root.is_visible(PanelKind::Info)); + assert!(layout.is_visible(PanelKind::Info)); } #[test] fn builtin_and_legacy_workspace_shapes_roundtrip_and_save_floating() { for name in dock::WORKSPACES { - let root = dock::workspace(name); - let before = serde_json::to_value(&root).unwrap(); - let prefs: dock::WorkspacePrefs = serde_json::from_value(json!({"saved": [{"name": name, "layout": before}], "current": name})).unwrap(); - let loaded = dock::saved_docking(&prefs, name); - loaded.validate().unwrap(); - assert_eq!(loaded.root, root); - assert!(loaded.floating.is_empty()); - assert!(serde_json::to_value(&prefs).unwrap()["saved"][0].get("floating").is_none()); + let layout = dock::workspace(name); + for entry in + [json!({"name": name, "workspace": dock::Workspace::new(layout.clone())}), json!({"name": name, "layout": legacy(layout.root.as_ref().unwrap())})] + { + let prefs: dock::WorkspacePrefs = serde_json::from_value(json!({"saved": [entry], "current": name})).unwrap(); + let loaded = dock::saved_docking(&prefs, name); + loaded.validate().unwrap(); + assert_eq!(loaded, layout); + assert!(serde_json::to_value(&prefs).unwrap()["saved"][0].get("floating").is_none()); + } } let dir = std::env::temp_dir().join(format!("filmcraft-shared-dock-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); @@ -62,13 +85,13 @@ fn builtin_and_legacy_workspace_shapes_roundtrip_and_save_floating() { session.prefs_path = Some(dir.join("preferences.json")); let mut app = FilmcraftApp::new(session); let ctx = egui::Context::default(); - app.ui.dock = layout().root; + app.ui.dock = layout(); menus::invoke(&mut app, &ctx, "window.floatPanel", json!({"panel": "Info", "rect": [45, 65, 420, 320]})).unwrap(); menus::invoke(&mut app, &ctx, "window.workspace.saveAs", json!({"name": "Floating test"})).unwrap(); let prefs = dock::WorkspacePrefs::load(&dir.join(dock::WORKSPACES_FILE)); let restored = dock::saved_docking(&prefs, "Floating test"); - assert_eq!(restored.floating, app.ui.floating); - assert_eq!(restored.root, app.ui.dock); + assert_eq!(restored, app.ui.dock); + assert!(prefs.saved[0].layout.is_null() && !prefs.saved[0].workspace.is_null()); let mut restarted_session = Session::default(); restarted_session.prefs_path = Some(dir.join("preferences.json")); let restarted = FilmcraftApp::new(restarted_session); @@ -76,32 +99,49 @@ fn builtin_and_legacy_workspace_shapes_roundtrip_and_save_floating() { for _ in 0..4 { step(&mut h); } - assert_eq!(h.state().ui.dock, restored.root); - assert_eq!(h.state().ui.floating, restored.floating); + assert_eq!(h.state().ui.dock, restored); std::fs::remove_dir_all(dir).unwrap(); } #[test] fn legacy_effects_workspace_drops_only_the_known_inactive_duplicate() { let expected = dock::workspace("Effects"); - let mut legacy = expected.clone(); - fn duplicate(node: &mut DockNode) { + let mut legacy_tree = expected.root.clone().unwrap(); + fn duplicate(node: &mut Node) { match node { - DockNode::Tabs { panels, active } if panels.as_slice() == [PanelKind::EssentialGraphics] => { + Node::Tabs { panels, active } if panels.as_slice() == [PanelKind::EssentialGraphics] => { panels.insert(0, PanelKind::EffectControls); *active = 1; } - DockNode::Split { a, b, .. } => { - duplicate(a); - duplicate(b); + Node::Split { first, second, .. } => { + duplicate(first); + duplicate(second); } - DockNode::Tabs { .. } => {} + _ => {} } } - duplicate(&mut legacy); + duplicate(&mut legacy_tree); let prefs: dock::WorkspacePrefs = - serde_json::from_value(json!({"saved": [{"name": "Historical Effects", "layout": legacy}], "current": "Historical Effects"})).unwrap(); - assert_eq!(dock::saved_docking(&prefs, "Historical Effects").root, expected); + serde_json::from_value(json!({"saved": [{"name": "Historical Effects", "layout": legacy(&legacy_tree)}], "current": "Historical Effects"})).unwrap(); + assert_eq!(dock::saved_docking(&prefs, "Historical Effects"), expected); +} + +/// The frame tree as FilmCraft wrote it before its workspaces were craft-ui workspaces. +fn legacy(node: &Node) -> serde_json::Value { + match node { + Node::Tabs { panels, active } => json!({"Tabs": {"panels": panels, "active": active}}), + Node::Split { axis, size, first, second } => json!({"Split": { + "vertical": *axis == SplitAxis::Vertical, + "size": match size { + SplitSize::Ratio(v) => json!({"Ratio": v}), + SplitSize::FixedFirst(v) => json!({"FixedA": v}), + SplitSize::FixedSecond(v) => json!({"FixedB": v}), + }, + "a": legacy(first), + "b": legacy(second), + }}), + _ => unreachable!(), + } } fn step(h: &mut Harness<'_, FilmcraftApp>) { @@ -159,12 +199,7 @@ fn real_panel_drag_merges_splits_floats_and_redocks_without_editing_documents() for _ in 0..4 { step(&mut h); } - h.state_mut().ui.dock = DockNode::Split { - vertical: true, - size: SplitSize::Ratio(0.62), - a: Box::new(layout().root), - b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Timeline], active: 0 }), - }; + h.state_mut().ui.dock = root(split(true, SplitSize::Ratio(0.62), layout().root.unwrap(), tabs(&[PanelKind::Timeline], 0))); for _ in 0..3 { step(&mut h); } @@ -197,26 +232,28 @@ fn real_panel_drag_merges_splits_floats_and_redocks_without_editing_documents() assert!(h.state().auto.find("program.transport.playhead.stepForward").is_some()); let from = rect(&h, "panel.tab.Info").center(); drag(&mut h, from, pos2(700.0, 400.0), Modifiers::COMMAND); - assert_eq!(h.state().ui.floating.len(), 1); - assert_eq!(h.get_by_label("Resize floating panel").accesskit_node().role(), egui::accesskit::Role::Button); + assert_eq!(h.state().ui.dock.floating.len(), 1); + assert_eq!(h.query_all_by_label("Resize panel window").next().unwrap().accesskit_node().role(), egui::accesskit::Role::Button); action_log.push(action_state(&h, "floated")); - let original = h.state().ui.floating[0].rect; + let original = h.state().ui.dock.floating[0].rect; let grip = rect(&h, "panel.float.0.resize").center(); drag(&mut h, grip, grip + vec2(36.0, 24.0), Modifiers::NONE); - assert!(h.state().ui.floating[0].rect[2] > original[2] + 25.0); - assert!(h.state().ui.floating[0].rect[3] > original[3] + 15.0); + assert!(h.state().ui.dock.floating[0].rect[2] > original[2] + 25.0); + assert!(h.state().ui.dock.floating[0].rect[3] > original[3] + 15.0); action_log.push(action_state(&h, "resized")); - let grip_id = egui::Id::new(("floating-panel", PanelKind::Info.id())).with("resize"); + let grip_id = egui::Id::new("filmcraft-docking").with(("floating", PanelKind::Info)).with("resize"); + h.ctx.memory_mut(|memory| memory.request_focus(grip_id)); + step(&mut h); assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(grip_id)); - let before_keys = h.state().ui.floating[0].rect; + let before_keys = h.state().ui.dock.floating[0].rect; for key in [egui::Key::ArrowRight, egui::Key::ArrowDown] { for pressed in [true, false] { h.event(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }); step(&mut h); } } - assert_eq!(h.state().ui.floating[0].rect[2], before_keys[2] + 8.0); - assert_eq!(h.state().ui.floating[0].rect[3], before_keys[3] + 8.0); + assert_eq!(h.state().ui.dock.floating[0].rect[2], before_keys[2] + 8.0); + assert_eq!(h.state().ui.dock.floating[0].rect[3], before_keys[3] + 8.0); h.event(Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); step(&mut h); h.event(Event::Key { key: egui::Key::Tab, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); @@ -225,29 +262,31 @@ fn real_panel_drag_merges_splits_floats_and_redocks_without_editing_documents() } assert_ne!(h.ctx.memory(|memory| memory.focused()), Some(grip_id)); action_log.push(action_state(&h, "keyboard-resized")); - let resized = h.state().ui.floating[0].rect; + let resized = h.state().ui.dock.floating[0].rect; let header = rect(&h, "panel.float.0.move"); let from = pos2(header.right() - 24.0, header.center().y); drag(&mut h, from, from - vec2(24.0, 18.0), Modifiers::NONE); - assert!(h.state().ui.floating[0].rect[0] < resized[0] - 15.0); - assert!(h.state().ui.floating[0].rect[1] < resized[1] - 10.0); + assert!(h.state().ui.dock.floating[0].rect[0] < resized[0] - 15.0); + assert!(h.state().ui.dock.floating[0].rect[1] < resized[1] - 10.0); action_log.push(action_state(&h, "moved")); let saved_root = h.state().ui.dock.clone(); - let saved_floating = h.state().ui.floating.clone(); + let saved_floating = h.state().ui.dock.floating.clone(); let ctx = h.ctx.clone(); + // Maximize Frame fills the area with a docked frame + h.state_mut().ui.focused = PanelKind::Program; menus::invoke(h.state_mut(), &ctx, "window.maximizeFrame", json!({})).unwrap(); for _ in 0..3 { step(&mut h); } - assert!(rect(&h, "panel.Info").width() > 1000.0); + assert!(rect(&h, "panel.Program").width() > 1000.0); action_log.push(action_state(&h, "maximized")); assert_eq!(h.state().ui.dock, saved_root); - assert_eq!(h.state().ui.floating, saved_floating); + assert_eq!(h.state().ui.dock.floating, saved_floating); menus::invoke(h.state_mut(), &ctx, "window.maximizeFrame", json!({})).unwrap(); for _ in 0..3 { step(&mut h); } - assert_eq!(h.state().ui.floating, saved_floating); + assert_eq!(h.state().ui.dock.floating, saved_floating); if let Some(directory) = &snapshots { std::fs::create_dir_all(directory).unwrap(); @@ -256,7 +295,7 @@ fn real_panel_drag_merges_splits_floats_and_redocks_without_editing_documents() let from = rect(&h, "panel.tab.Info").center(); let to = rect(&h, "panel.History").center(); drag(&mut h, from, to, Modifiers::NONE); - assert!(h.state().ui.floating.is_empty()); + assert!(h.state().ui.dock.floating.is_empty()); action_log.push(action_state(&h, "redocked")); assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project)); assert_eq!(playheads, h.state().session.state.playheads); @@ -278,7 +317,7 @@ fn real_panel_drag_merges_splits_floats_and_redocks_without_editing_documents() #[test] fn dropping_on_the_window_edge_docks_beside_the_whole_workspace() { let mut app = FilmcraftApp::new(Session::default()); - app.ui.dock = layout().root; + app.ui.dock = layout(); let mut h = Harness::builder().with_size(vec2(1200.0, 760.0)).build_eframe(move |_| app); for _ in 0..4 { step(&mut h); @@ -289,60 +328,59 @@ fn dropping_on_the_window_edge_docks_beside_the_whole_workspace() { drag(&mut h, from, pos2(leftmost.left() + 2.0, leftmost.center().y), Modifiers::NONE); assert_eq!( h.state().ui.dock, - DockNode::Split { - vertical: false, - size: SplitSize::Ratio(0.25), - a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Info], active: 0 }), - b: Box::new(DockNode::Split { - vertical: false, - size: SplitSize::FixedA(400.0), - a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project, PanelKind::History], active: 1 }), - b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Source, PanelKind::Program], active: 1 }), - }), - } + root(split( + false, + SplitSize::Ratio(0.25), + tabs(&[PanelKind::Info], 0), + split(false, SplitSize::FixedFirst(400.0), tabs(&[PanelKind::Project, PanelKind::History], 1), tabs(&[PanelKind::Source, PanelKind::Program], 1)), + )) ); } #[test] fn dock_panel_without_anchor_docks_along_a_workspace_edge() { let mut app = FilmcraftApp::new(Session::default()); - app.ui.dock = layout().root; + app.ui.dock = layout(); let ctx = egui::Context::default(); menus::invoke(&mut app, &ctx, "window.dockPanel", json!({"panel": "Program", "zone": "bottom"})).unwrap(); - let DockNode::Split { vertical: true, size: SplitSize::Ratio(ratio), a, b } = &app.ui.dock else { panic!("root not split: {:?}", app.ui.dock) }; + let Some(Node::Split { axis: SplitAxis::Vertical, size: SplitSize::Ratio(ratio), first, second }) = &app.ui.dock.root else { + panic!("root not split: {:?}", app.ui.dock) + }; assert_eq!(*ratio, 0.75); - assert_eq!(**b, DockNode::Tabs { panels: vec![PanelKind::Program], active: 0 }); - assert!(!a.contains(PanelKind::Program) && a.contains(PanelKind::Source) && a.contains(PanelKind::Info)); + assert_eq!(**second, tabs(&[PanelKind::Program], 0)); + let a = root((**first).clone()); + assert!(!a.contains(&PanelKind::Program) && a.contains(&PanelKind::Source) && a.contains(&PanelKind::Info)); + // without an anchor or a side, a docked panel stays where it is let before = app.ui.dock.clone(); - assert!(menus::invoke(&mut app, &ctx, "window.dockPanel", json!({"panel": "Info", "zone": "center"})).is_err()); + let _ = menus::invoke(&mut app, &ctx, "window.dockPanel", json!({"panel": "Info", "zone": "center"})); assert_eq!(app.ui.dock, before); - app.ui.dock = DockNode::Tabs { panels: vec![PanelKind::Project], active: 0 }; + app.ui.dock = root(tabs(&[PanelKind::Project], 0)); assert!(menus::invoke(&mut app, &ctx, "window.dockPanel", json!({"panel": "Project", "zone": "left"})).is_err()); - assert_eq!(app.ui.dock, DockNode::Tabs { panels: vec![PanelKind::Project], active: 0 }); + assert_eq!(app.ui.dock, root(tabs(&[PanelKind::Project], 0))); } -fn same_group(root: &DockNode, first: PanelKind, second: PanelKind) -> bool { - match root { - DockNode::Tabs { panels, .. } => panels.contains(&first) && panels.contains(&second), - DockNode::Split { a, b, .. } => same_group(a, first, second) || same_group(b, first, second), - } +fn same_group(layout: &Layout, first: PanelKind, second: PanelKind) -> bool { + tabs_containing(layout, first).contains(&second) } -fn tabs_containing(root: &DockNode, panel: PanelKind) -> Vec { - match root { - DockNode::Tabs { panels, .. } if panels.contains(&panel) => panels.clone(), - DockNode::Split { a, b, .. } => { - let first = tabs_containing(a, panel); - if first.is_empty() { tabs_containing(b, panel) } else { first } +fn tabs_containing(layout: &Layout, panel: PanelKind) -> Vec { + fn find(node: &Node, panel: PanelKind) -> Vec { + match node { + Node::Tabs { panels, .. } if panels.contains(&panel) => panels.clone(), + Node::Split { first, second, .. } => { + let found = find(first, panel); + if found.is_empty() { find(second, panel) } else { found } + } + _ => vec![], } - _ => vec![], } + layout.root.as_ref().map(|node| find(node, panel)).unwrap_or_default() } #[test] fn docking_commands_reject_bad_geometry_and_targets_without_partial_changes() { let mut app = FilmcraftApp::new(Session::default()); - app.ui.dock = layout().root; + app.ui.dock = layout(); let ctx = egui::Context::default(); let before = app.ui.dock.clone(); for (id, params) in [ @@ -355,25 +393,20 @@ fn docking_commands_reject_bad_geometry_and_targets_without_partial_changes() { ] { assert!(menus::invoke(&mut app, &ctx, id, params.clone()).is_err(), "{id} accepted {params}"); assert_eq!(app.ui.dock, before); - assert!(app.ui.floating.is_empty()); + assert!(app.ui.dock.floating.is_empty()); } - app.ui.dock = DockNode::Tabs { panels: vec![PanelKind::Project], active: 0 }; + app.ui.dock = root(tabs(&[PanelKind::Project], 0)); assert!(menus::invoke(&mut app, &ctx, "window.floatPanel", json!({"panel": "Project", "rect": [1, 2, 300, 200]})).is_err()); } fn action_state(h: &Harness<'_, FilmcraftApp>, action: &str) -> serde_json::Value { - json!({"action": action, "dock": h.state().ui.dock, "floating": h.state().ui.floating, "focused": h.state().ui.focused}) + json!({"action": action, "dock": h.state().ui.dock, "floating": h.state().ui.dock.floating, "focused": h.state().ui.focused}) } #[test] fn catalog_docking_action_supports_resize_reorder_and_floating_geometry() { let mut app = FilmcraftApp::new(Session::default()); - app.ui.dock = DockNode::Split { - vertical: false, - size: SplitSize::FixedA(300.0), - a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project, PanelKind::Info], active: 1 }), - b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Program], active: 0 }), - }; + app.ui.dock = root(split(false, SplitSize::FixedFirst(300.0), tabs(&[PanelKind::Project, PanelKind::Info], 1), tabs(&[PanelKind::Program], 0))); let ctx = egui::Context::default(); for action in [ Action::ResizeSplit { path: vec![], size: craft_ui::layout::SplitSize::FixedFirst(360.0) }, @@ -383,10 +416,10 @@ fn catalog_docking_action_supports_resize_reorder_and_floating_geometry() { ] { filmcraft_ui_egui::menus::invoke(&mut app, &ctx, "window.dockAction", json!({"action": action})).unwrap(); } - assert_eq!(app.ui.floating[0].rect, [45.0, 65.0, 330.0, 240.0]); - let DockNode::Split { size, .. } = &app.ui.dock else { panic!("split lost") }; - assert_eq!(*size, SplitSize::FixedA(360.0)); - let before = json!({"dock": app.ui.dock, "floating": app.ui.floating}); + assert_eq!(app.ui.dock.floating[0].rect, [45.0, 65.0, 330.0, 240.0]); + let Some(Node::Split { size, .. }) = &app.ui.dock.root else { panic!("split lost") }; + assert_eq!(*size, SplitSize::FixedFirst(360.0)); + let before = json!({"dock": app.ui.dock, "floating": app.ui.dock.floating}); assert!( filmcraft_ui_egui::menus::invoke( &mut app, @@ -398,13 +431,13 @@ fn catalog_docking_action_supports_resize_reorder_and_floating_geometry() { ) .is_err() ); - assert_eq!(json!({"dock": app.ui.dock, "floating": app.ui.floating}), before); + assert_eq!(json!({"dock": app.ui.dock, "floating": app.ui.dock.floating}), before); } #[test] fn unreadable_floating_workspace_does_not_erase_valid_workspaces_or_current_name() { - let root = DockNode::Tabs { panels: vec![PanelKind::Project], active: 0 }; - let valid = json!({"name": "Valid", "layout": root, "floating": [{ + let tree = json!({"Tabs": {"panels": ["Project"], "active": 0}}); + let valid = json!({"name": "Valid", "layout": tree, "floating": [{ "panels": ["TextToSpeech", "Timeline"], "active": 0, "rect": [80, 90, 420, 320] }]}); for (index, bad) in [ @@ -416,16 +449,16 @@ fn unreadable_floating_workspace_does_not_erase_valid_workspaces_or_current_name .enumerate() { let path = std::env::temp_dir().join(format!("filmcraft-floating-isolation-{}-{index}.json", std::process::id())); - let prefs = json!({"saved": [valid.clone(), {"name": "Broken", "layout": root, "floating": bad}], "current": "Valid"}); + let prefs = json!({"saved": [valid.clone(), {"name": "Broken", "layout": tree, "floating": bad}], "current": "Valid"}); std::fs::write(&path, serde_json::to_vec(&prefs).unwrap()).unwrap(); let loaded = dock::WorkspacePrefs::load(&path); assert_eq!(loaded.current, "Valid"); assert_eq!(loaded.saved.len(), 2); let restored = dock::saved_docking(&loaded, "Valid"); restored.validate().unwrap(); - assert_eq!(restored.root, root); - assert_eq!(restored.floating[0].panels, vec![PanelKind::TextToSpeech, PanelKind::Timeline]); - assert_eq!(dock::saved_docking(&loaded, "Broken").root, dock::workspace("Broken")); + assert_eq!(restored.root, Some(tabs(&[PanelKind::Project], 0))); + assert_eq!(restored.floating[0].panels().copied().collect::>(), vec![PanelKind::TextToSpeech, PanelKind::Timeline]); + assert_eq!(dock::saved_docking(&loaded, "Broken"), dock::workspace("Broken")); loaded.save(&path).unwrap(); assert_eq!(dock::WorkspacePrefs::load(&path), loaded, "saving preserves the unreadable entry for a future version"); std::fs::remove_file(path).unwrap(); @@ -455,10 +488,10 @@ fn floating_tts_and_timeline_reveal_keeps_one_owner_and_draft_without_project_ed .unwrap(); app.show_panel(PanelKind::TextToSpeech); app.show_panel(PanelKind::Timeline); - assert!(!app.ui.dock.contains(PanelKind::TextToSpeech)); - assert!(!app.ui.dock.contains(PanelKind::Timeline)); - assert_eq!(app.ui.floating.iter().flat_map(|group| &group.panels).filter(|panel| **panel == PanelKind::TextToSpeech).count(), 1); - assert_eq!(app.ui.floating.iter().flat_map(|group| &group.panels).filter(|panel| **panel == PanelKind::Timeline).count(), 1); + assert!(!app.ui.dock.docked(PanelKind::TextToSpeech)); + assert!(!app.ui.dock.docked(PanelKind::Timeline)); + assert_eq!(app.ui.dock.floating.iter().flat_map(|group| group.panels()).filter(|panel| **panel == PanelKind::TextToSpeech).count(), 1); + assert_eq!(app.ui.dock.floating.iter().flat_map(|group| group.panels()).filter(|panel| **panel == PanelKind::Timeline).count(), 1); assert_eq!(serde_json::to_value(&app.ui.tts).unwrap(), draft); assert!(std::sync::Arc::ptr_eq(&project, &app.session.project)); assert_eq!(serde_json::to_value(&app.session.state).unwrap(), state); @@ -502,7 +535,7 @@ fn narration_selection_reveals_covered_floating_tts_once_without_editing_the_pro step(&mut h); } } - let layer = |panel: PanelKind| egui::LayerId::new(egui::Order::Middle, egui::Id::new(("floating-panel", panel.id()))); + let layer = |panel: PanelKind| egui::LayerId::new(egui::Order::Middle, egui::Id::new("filmcraft-docking").with(("floating", panel))); let covered = rect(&h, "panel.TextToSpeech").center(); assert!(rect(&h, "panel.Info").contains(covered)); ctx.move_to_top(layer(PanelKind::Info)); @@ -520,8 +553,8 @@ fn narration_selection_reveals_covered_floating_tts_once_without_editing_the_pro assert_eq!(draft.loaded_from, Some((clip.id.0, clip.item.0))); assert_eq!(ctx.layer_id_at(covered), Some(layer(PanelKind::TextToSpeech)), "narration selection must bring its floating editor forward"); assert_eq!(h.state().ui.focused, PanelKind::TextToSpeech); - assert!(!h.state().ui.dock.contains(PanelKind::TextToSpeech)); - assert_eq!(h.state().ui.floating.iter().flat_map(|group| &group.panels).filter(|panel| **panel == PanelKind::TextToSpeech).count(), 1); + assert!(!h.state().ui.dock.docked(PanelKind::TextToSpeech)); + assert_eq!(h.state().ui.dock.floating.iter().flat_map(|group| group.panels()).filter(|panel| **panel == PanelKind::TextToSpeech).count(), 1); assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project)); // A reveal is one-shot: drawing the selected narration must not steal stacking // from another pane that the user subsequently brings forward. @@ -546,8 +579,8 @@ fn narration_selection_reveals_covered_floating_tts_once_without_editing_the_pro } let saved = dock::SavedWorkspace { name: "Reveal cancellation".into(), - layout: serde_json::to_value(&h.state().ui.dock).unwrap(), - floating: serde_json::to_value(&h.state().ui.floating).unwrap(), + workspace: serde_json::to_value(dock::Workspace::new(h.state().ui.dock.clone())).unwrap(), + ..Default::default() }; h.state_mut().workspaces.saved.push(saved); h.state_mut().show_panel(PanelKind::TextToSpeech); @@ -567,46 +600,63 @@ fn a_panel_closed_while_floating_reopens_floating_where_it_was() { let mut app = FilmcraftApp::new(Session::default()); let ctx = egui::Context::default(); menus::invoke(&mut app, &ctx, "window.floatPanel", json!({"panel": "Info", "rect": [45, 65, 420, 320]})).unwrap(); - let rect = app.ui.floating.iter().find(|g| g.panels.contains(&PanelKind::Info)).unwrap().rect; + let rect = app.ui.dock.floating.iter().find(|g| g.contains(&PanelKind::Info)).unwrap().rect; menus::invoke(&mut app, &ctx, "window.closePanel", json!({"panel": "Info"})).unwrap(); - assert!(!app.ui.dock.contains(PanelKind::Info) && !app.ui.floating.iter().any(|g| g.panels.contains(&PanelKind::Info))); + assert!(!app.ui.dock.docked(PanelKind::Info) && !app.ui.dock.floating.iter().any(|g| g.contains(&PanelKind::Info))); menus::invoke(&mut app, &ctx, &menus::panel_command_id(PanelKind::Info), json!({})).unwrap(); - let back = app.ui.floating.iter().find(|g| g.panels.contains(&PanelKind::Info)).expect("Info floats again"); + let back = app.ui.dock.floating.iter().find(|g| g.contains(&PanelKind::Info)).expect("Info floats again"); assert_eq!(back.rect, rect, "where it was"); - assert!(!app.ui.dock.contains(PanelKind::Info)); + assert!(!app.ui.dock.docked(PanelKind::Info)); // docked, then closed: it opens docked menus::invoke(&mut app, &ctx, "window.dockPanel", json!({"panel": "Info", "anchor": "Project"})).unwrap(); menus::invoke(&mut app, &ctx, "window.closePanel", json!({"panel": "Info"})).unwrap(); menus::invoke(&mut app, &ctx, &menus::panel_command_id(PanelKind::Info), json!({})).unwrap(); - assert!(app.ui.dock.contains(PanelKind::Info)); - assert!(!app.ui.floating.iter().any(|g| g.panels.contains(&PanelKind::Info))); + assert!(app.ui.dock.docked(PanelKind::Info)); + assert!(!app.ui.dock.floating.iter().any(|g| g.contains(&PanelKind::Info))); // the Timeline too, though reopening it first puts it back in its own place in the dock menus::invoke(&mut app, &ctx, "window.floatPanel", json!({"panel": "Timeline", "rect": [60, 80, 700, 300]})).unwrap(); - let rect = app.ui.floating.iter().find(|g| g.panels.contains(&PanelKind::Timeline)).unwrap().rect; + let rect = app.ui.dock.floating.iter().find(|g| g.contains(&PanelKind::Timeline)).unwrap().rect; menus::invoke(&mut app, &ctx, "window.closePanel", json!({"panel": "Timeline"})).unwrap(); menus::invoke(&mut app, &ctx, &menus::panel_command_id(PanelKind::Timeline), json!({})).unwrap(); - let back = app.ui.floating.iter().find(|g| g.panels.contains(&PanelKind::Timeline)).expect("the Timeline floats again"); + let back = app.ui.dock.floating.iter().find(|g| g.contains(&PanelKind::Timeline)).expect("the Timeline floats again"); assert_eq!(back.rect, rect); - assert!(!app.ui.dock.contains(PanelKind::Timeline)); + assert!(!app.ui.dock.docked(PanelKind::Timeline)); // with nothing else docked, a reopened panel has to stay docked (the workspace keeps one // docked panel); its floating place is kept, not lost (#2482 review) let mut app = FilmcraftApp::new(Session::default()); menus::invoke(&mut app, &ctx, "window.floatPanel", json!({"panel": "Info", "rect": [45, 65, 420, 320]})).unwrap(); - let rect = app.ui.floating.iter().find(|g| g.panels.contains(&PanelKind::Info)).unwrap().rect; + let rect = app.ui.dock.floating.iter().find(|g| g.contains(&PanelKind::Info)).unwrap().rect; for _ in 0..64 { - let Some(p) = PanelKind::ALL.iter().copied().find(|p| app.ui.dock.contains(*p)) else { break }; + let Some(p) = PanelKind::ALL.iter().copied().find(|p| app.ui.dock.docked(*p)) else { break }; if menus::invoke(&mut app, &ctx, "window.closePanel", json!({"panel": p.id()})).is_err() { break; } } - let docked = PanelKind::ALL.iter().filter(|p| app.ui.dock.contains(**p)).count(); + let docked = PanelKind::ALL.iter().filter(|p| app.ui.dock.docked(**p)).count(); if docked > 0 { eprintln!("SKIPPED: the dock cannot be emptied ({docked} panels stay)"); return; } menus::invoke(&mut app, &ctx, "window.closePanel", json!({"panel": "Info"})).unwrap(); menus::invoke(&mut app, &ctx, &menus::panel_command_id(PanelKind::Info), json!({})).unwrap(); - assert!(app.ui.dock.contains(PanelKind::Info), "the only panel stays docked"); + assert!(app.ui.dock.docked(PanelKind::Info), "the only panel stays docked"); assert!(app.ui.closed_floating.iter().any(|(p, r)| *p == PanelKind::Info && *r == rect), "{:?}", app.ui.closed_floating); } + +/// craft-ui's `window.panel.*` commands and FilmCraft's own docking commands share one path. +#[test] +fn shared_dock_commands_and_filmcraft_names_move_panels() { + let mut app = FilmcraftApp::new(Session::default()); + app.ui.dock = layout(); + let ctx = egui::Context::default(); + menus::invoke(&mut app, &ctx, "window.panel.move", json!({"panel": "Info", "anchor": "Program", "zone": "right"})).unwrap(); + assert!(app.ui.dock.is_visible(PanelKind::Info) && !same_group(&app.ui.dock, PanelKind::Info, PanelKind::Program)); + menus::invoke(&mut app, &ctx, "window.dockPanel", json!({"panel": "Info", "anchor": "Program", "before": "Source"})).unwrap(); + assert_eq!(tabs_containing(&app.ui.dock, PanelKind::Info), vec![PanelKind::Info, PanelKind::Source, PanelKind::Program]); + menus::invoke(&mut app, &ctx, "window.floatPanel", json!({"panel": "Info"})).unwrap(); + assert!(app.ui.dock.floating.iter().any(|window| window.contains(&PanelKind::Info))); + menus::invoke(&mut app, &ctx, "window.panel.close", json!({"panel": "Info"})).unwrap(); + assert!(!app.ui.dock.contains(&PanelKind::Info)); + assert!(menus::invoke(&mut app, &ctx, "window.panel.move", json!({"panel": "Info", "anchor": "Nowhere"})).is_err()); +} diff --git a/crates/ui-egui/tests/timeline_drop_ui.rs b/crates/ui-egui/tests/timeline_drop_ui.rs index 5b1940f72..1a5ec11b5 100644 --- a/crates/ui-egui/tests/timeline_drop_ui.rs +++ b/crates/ui-egui/tests/timeline_drop_ui.rs @@ -287,7 +287,7 @@ fn offscreen_float_uses_rendered_clamp_and_maximized_timeline_ignores_hidden_flo // The very wide offscreen float is clamped back to x=0, over this row. float_panel(&mut h, "Info", [-5000.0, at.y - 60.0, 1500.0, 200.0]); let visible = control_rect(&h, "panel.Info"); - assert!(visible.contains(at), "rendered={visible:?}, drop={at:?}, floats={:?}", h.state().ui.floating); + assert!(visible.contains(at), "rendered={visible:?}, drop={at:?}, floats={:?}", h.state().ui.dock.floating); assert!(!hover_at(&mut h, at)); drop_at(&mut h, at, &still("drop_clamped_occlusion.png")); assert_eq!(clip_counts(&h), (0, 0)); @@ -320,7 +320,7 @@ fn os_cursor_hook_selects_destination_when_egui_pointer_has_no_motion() { h.input_mut().dropped_files.clear(); step(&mut h); assert_eq!(clip_counts(&h), (0, 0)); - h.state_mut().ui.floating.clear(); + h.state_mut().ui.dock.floating.clear(); h.input_mut().dropped_files.push(std::sync::Arc::new(OsFile(still("drop_os_hook_visible.png")))); step(&mut h); h.input_mut().dropped_files.clear(); diff --git a/crates/ui-egui/tests/ui_control_input.rs b/crates/ui-egui/tests/ui_control_input.rs index 773b451dd..aa04b8f23 100644 --- a/crates/ui-egui/tests/ui_control_input.rs +++ b/crates/ui-egui/tests/ui_control_input.rs @@ -8,11 +8,7 @@ use std::sync::{ use egui::{Event, Id, Key, Modifiers, vec2}; use egui_kittest::Harness; use filmcraft_engine::Session; -use filmcraft_ui_egui::{ - FilmcraftApp, - control::ControlRequest, - dock::{DockNode, PanelKind, SplitSize}, -}; +use filmcraft_ui_egui::{FilmcraftApp, control::ControlRequest, dock::PanelKind}; use serde_json::{Value, json}; struct Driver { @@ -99,21 +95,27 @@ fn ordinary_control_input_also_resizes_a_background_divider() { fn background_divider(isolated: bool) { let mut d = Driver::new(isolated); - d.h.state_mut().ui.dock = DockNode::Split { - vertical: false, - size: SplitSize::FixedA(400.0), - a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project], active: 0 }), - b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Timeline], active: 0 }), + d.h.state_mut().ui.dock = craft_ui::docking::Layout { + root: Some(craft_ui::docking::Node::Split { + axis: craft_ui::layout::SplitAxis::Horizontal, + size: craft_ui::layout::SplitSize::FixedFirst(400.0), + first: Box::new(craft_ui::docking::Node::Tabs { panels: vec![PanelKind::Project], active: 0 }), + second: Box::new(craft_ui::docking::Node::Tabs { panels: vec![PanelKind::Timeline], active: 0 }), + }), + floating: vec![], }; d.frames(3, true); let before_project = d.h.state().session.project.clone(); let before_playheads = d.h.state().session.state.playheads.clone(); - let divider = Id::new(("dock-gutter", String::new())); + let divider = Id::new("filmcraft-docking").with(("split", &Vec::::new())); d.h.ctx.memory_mut(|memory| memory.request_focus(divider)); d.frames(2, true); d.h.input_mut().events.push(Event::WindowFocused(false)); d.ok("ui.key", json!({"key":"ArrowRight"}), false); - assert!(matches!(d.h.state().ui.dock, DockNode::Split { size: SplitSize::FixedA(408.0), .. }), "control input must work without native activation"); + assert!( + matches!(d.h.state().ui.dock.root, Some(craft_ui::docking::Node::Split { size: craft_ui::layout::SplitSize::FixedFirst(408.0), .. })), + "control input must work without native activation" + ); assert_eq!(d.h.state().session.state.playheads, before_playheads); assert!(Arc::ptr_eq(&d.h.state().session.project, &before_project)); assert!(!d.h.state().playback.playing); diff --git a/crates/ui-egui/tests/ui_dock_resize.rs b/crates/ui-egui/tests/ui_dock_resize.rs index 2057ce262..00e3a8379 100644 --- a/crates/ui-egui/tests/ui_dock_resize.rs +++ b/crates/ui-egui/tests/ui_dock_resize.rs @@ -4,7 +4,7 @@ use egui::{Event, Id, Key, Modifiers, PointerButton, Rect, pos2, vec2}; use egui_kittest::Harness; use filmcraft_engine::Session; use filmcraft_ui_egui::FilmcraftApp; -use filmcraft_ui_egui::dock::{DockNode, PanelKind, SplitSize}; +use filmcraft_ui_egui::dock::PanelKind; use filmcraft_ui_egui::theme::ThemeKind; use serde_json::json; @@ -34,8 +34,8 @@ fn snapshots(h: &mut Harness<'_, FilmcraftApp>, directory: Option<&std::path::Pa } fn first_size(h: &Harness<'_, FilmcraftApp>) -> f32 { - match h.state().ui.dock { - DockNode::Split { size: SplitSize::FixedA(value), .. } => value, + match h.state().ui.dock.root { + Some(craft_ui::docking::Node::Split { size: craft_ui::layout::SplitSize::FixedFirst(value), .. }) => value, ref other => panic!("fixed-size mode changed: {other:?}"), } } @@ -55,16 +55,19 @@ fn focused_dock_resize_owns_arrows_and_keeps_pointer_dragging() { frames(&mut h, 4); snapshots(&mut h, directory.as_deref(), "workspace"); h.state_mut().ui.focused = PanelKind::Timeline; - h.state_mut().ui.dock = DockNode::Split { - vertical: false, - size: SplitSize::FixedA(400.0), - a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project], active: 0 }), - b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Timeline], active: 0 }), + h.state_mut().ui.dock = craft_ui::docking::Layout { + root: Some(craft_ui::docking::Node::Split { + axis: craft_ui::layout::SplitAxis::Horizontal, + size: craft_ui::layout::SplitSize::FixedFirst(400.0), + first: Box::new(craft_ui::docking::Node::Tabs { panels: vec![PanelKind::Project], active: 0 }), + second: Box::new(craft_ui::docking::Node::Tabs { panels: vec![PanelKind::Timeline], active: 0 }), + }), + floating: vec![], }; frames(&mut h, 3); let before_project = h.state().session.project.clone(); let before_playheads = h.state().session.state.playheads.clone(); - let id = Id::new(("dock-gutter", String::new())); + let id = Id::new("filmcraft-docking").with(("split", &Vec::::new())); h.ctx.memory_mut(|memory| memory.request_focus(id)); frames(&mut h, 2); assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(id)); @@ -110,11 +113,14 @@ fn space_plays_after_a_pane_is_resized_with_the_pointer() { let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(move |_| app); frames(&mut h, 4); h.state_mut().ui.focused = PanelKind::Timeline; - h.state_mut().ui.dock = DockNode::Split { - vertical: false, - size: SplitSize::FixedA(400.0), - a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project], active: 0 }), - b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Timeline], active: 0 }), + h.state_mut().ui.dock = craft_ui::docking::Layout { + root: Some(craft_ui::docking::Node::Split { + axis: craft_ui::layout::SplitAxis::Horizontal, + size: craft_ui::layout::SplitSize::FixedFirst(400.0), + first: Box::new(craft_ui::docking::Node::Tabs { panels: vec![PanelKind::Project], active: 0 }), + second: Box::new(craft_ui::docking::Node::Tabs { panels: vec![PanelKind::Timeline], active: 0 }), + }), + floating: vec![], }; frames(&mut h, 3); let element = h.state().auto.find("dock.gutter.").unwrap(); @@ -131,7 +137,7 @@ fn space_plays_after_a_pane_is_resized_with_the_pointer() { frames(&mut h, 3); assert_eq!(first_size(&h), 420.0); // The divider keeps keyboard focus so arrows can nudge it; that must not cost the editor its shortcuts. - assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(Id::new(("dock-gutter", String::new())))); + assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(Id::new("filmcraft-docking").with(("split", &Vec::::new())))); press(&mut h, Key::Space); assert!(h.state().playback.playing, "Space must start playback after resizing a pane (#1482)"); press(&mut h, Key::Space); diff --git a/crates/ui-egui/tests/ui_keyboard_focus.rs b/crates/ui-egui/tests/ui_keyboard_focus.rs index 8685d4933..02d48ba9a 100644 --- a/crates/ui-egui/tests/ui_keyboard_focus.rs +++ b/crates/ui-egui/tests/ui_keyboard_focus.rs @@ -5,11 +5,7 @@ use std::sync::Arc; use egui::{Event, Id, Key, Modifiers, vec2}; use egui_kittest::Harness; use filmcraft_engine::Session; -use filmcraft_ui_egui::{ - FilmcraftApp, - dock::{DockNode, PanelKind}, - state::Tool, -}; +use filmcraft_ui_egui::{FilmcraftApp, dock::PanelKind, state::Tool}; use serde_json::json; fn key(h: &mut Harness<'_, FilmcraftApp>, key: Key, modifiers: Modifiers) { @@ -30,7 +26,8 @@ fn tab_traversal_and_space_select_only_the_focused_tool_with_visible_focus() { let app = FilmcraftApp::new(session); let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).build_eframe(move |_| app); h.run_steps(4); - h.state_mut().ui.dock = DockNode::Tabs { panels: vec![PanelKind::Tools], active: 0 }; + h.state_mut().ui.dock = + craft_ui::docking::Layout { root: Some(craft_ui::docking::Node::Tabs { panels: vec![PanelKind::Tools], active: 0 }), floating: vec![] }; h.run_steps(3); let first = Id::new(("tool", "Selection".to_string())); let second = Id::new(("tool", "TrackSelectForward".to_string())); @@ -74,10 +71,13 @@ fn dock_tab_traversal_and_enter_change_the_visible_panel_without_editing() { h.run_steps(4); h.state_mut().ui.show_header = false; h.state_mut().ui.show_status_bar = false; - h.state_mut().ui.dock = DockNode::Tabs { panels: vec![PanelKind::Project, PanelKind::Effects], active: 0 }; + h.state_mut().ui.dock = craft_ui::docking::Layout { + root: Some(craft_ui::docking::Node::Tabs { panels: vec![PanelKind::Project, PanelKind::Effects], active: 0 }), + floating: vec![], + }; h.run_steps(3); - let first = Id::new(("tab", String::new(), 0usize)); - let second = Id::new(("tab", String::new(), 1usize)); + let first = Id::new("filmcraft-docking").with(("tab", PanelKind::Project)); + let second = Id::new("filmcraft-docking").with(("tab", PanelKind::Effects)); for _ in 0..128 { key(&mut h, Key::Tab, Modifiers::NONE); if h.ctx.memory(|m| m.focused()) == Some(second) { @@ -85,7 +85,7 @@ fn dock_tab_traversal_and_enter_change_the_visible_panel_without_editing() { } } assert_eq!(h.ctx.memory(|m| m.focused()), Some(second)); - assert!(matches!(h.state().ui.dock, DockNode::Tabs { active: 0, .. })); + assert!(matches!(h.state().ui.dock.root, Some(craft_ui::docking::Node::Tabs { active: 0, .. }))); let response = h.ctx.read_response(second).unwrap(); assert!( h.output().shapes.iter().any(|shape| matches!(&shape.shape, @@ -94,12 +94,12 @@ fn dock_tab_traversal_and_enter_change_the_visible_panel_without_editing() { "the focused dock tab needs a visible focus outline" ); key(&mut h, Key::Space, Modifiers::NONE); - assert!(matches!(h.state().ui.dock, DockNode::Tabs { active: 1, .. })); + assert!(matches!(h.state().ui.dock.root, Some(craft_ui::docking::Node::Tabs { active: 1, .. }))); assert_eq!(h.state().ui.focused, PanelKind::Effects); key(&mut h, Key::Tab, Modifiers::SHIFT); assert_eq!(h.ctx.memory(|m| m.focused()), Some(first)); key(&mut h, Key::Enter, Modifiers::NONE); - assert!(matches!(h.state().ui.dock, DockNode::Tabs { active: 0, .. })); + assert!(matches!(h.state().ui.dock.root, Some(craft_ui::docking::Node::Tabs { active: 0, .. }))); assert_eq!(h.state().ui.focused, PanelKind::Project); assert!(Arc::ptr_eq(&h.state().session.project, &project)); assert_eq!(h.state().session.state.playheads, playheads); diff --git a/crates/ui-egui/tests/workspaces_ui.rs b/crates/ui-egui/tests/workspaces_ui.rs index a5e4d8d9a..16d554037 100644 --- a/crates/ui-egui/tests/workspaces_ui.rs +++ b/crates/ui-egui/tests/workspaces_ui.rs @@ -106,7 +106,7 @@ impl Driver { } fn shows(&mut self, p: PanelKind) -> bool { - self.app().ui.dock.contains(p) + self.app().ui.dock.contains(&p) } /// Window ▸ Workspaces as (label, checked) in menu order. From 6881a1005b22275b66e4676c9868555381c06cd1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 21:50:08 +0000 Subject: [PATCH 017/113] UI.9: report docking layout and gesture rects over the control channel ui.docking returns the layout, the focused and maximized panel and every docking gesture source drawn last frame. The protocol docs name the shared window.panel.* commands. --- crates/ui-egui/src/control.rs | 5 +++++ docs/control-protocol.md | 15 +++++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 3b5391125..155433556 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -114,6 +114,11 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) }, "ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_items(app)).unwrap_or_default()), "ui.inspect" => ok(inspect(app, ctx)), + "ui.docking" => { + let hits = craft_ui::docking::DockArea::::hit_rects(ctx, crate::dock_ui::area_id()); + let hits: Vec = hits.iter().map(|(hit, r)| json!({"hit": hit, "rect": [r.left(), r.top(), r.width(), r.height()]})).collect(); + ok(json!({"layout": app.ui.dock, "focused": app.ui.focused, "maximized": app.ui.keys.maximized, "hitRects": hits})) + } "perf.stats" => ok(crate::perf::stats(app)), "ui.elements" => { if app.timeline_still < 2 { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 80802cf20..49d5228e8 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -28,6 +28,7 @@ Methods (handlers in `crates/ui-egui/src/control.rs`): | `engine.execute` / `ui.menu.invoke` | `{command, params}` | any engine or UI command id | | `engine.commands` / `ui.menu.list` | – | command registry / menu tree | | `ui.inspect` | – | UI state (tool, workspace, dock, timeline view, playback, fps…) | +| `ui.docking` | – | the panel layout (craft-ui `Layout`), the focused and maximized panel, and `hitRects`: each docking gesture source drawn last frame (`{hit, rect}`) | | `ui.elements` | `{prefix?}` | every on-screen interactive element: id, label, rect | | `ui.set` | `{tool, workspace, mode, theme, focused, playbackRes, timeline:{pps,scroll,fit}, program:{…}, source:{…}, clipDialog:{param: value}, menuDialog:{param: value}, panels:{scopes, timecode, reference, events, progress}, export:{…}, keyframeSelection:[…]}` | `keyframeSelection` replaces the Effect Controls keyframe selection (`[{clip, effect, param, mask?, time}]`, `time` in media ticks; read back as `ui.inspect` → `ui.keyframe_selection`; a click on a lane keyframe selects just that one); `program` / `source` merge fields into the monitor view state (`res`, `paused_res`, `high_quality`, `display`, `zoom`, `pan`, `show_rulers`, `show_guides`, `lock_guides`, `snap`, `guides`, `compare_ref`, …; see [monitors.md](monitors.md)); `clipDialog` sets fields of the open Edit / Clip / File dialog (Paste Attributes, Make Subclip, Frame Hold Options, …); `menuDialog` those of the M3.11 dialogs (Find, Create Search Bin, Project Settings, Scene Edit Detection, Simplify Sequence, Automate to Sequence…; see `panels::menu_dialogs`); `panels` merges into the panel settings (`ui.inspect` → `ui.panels`; see `panels::panel_state`): Lumetri Scopes `{shown: ["vectorscopeYuv"|"vectorscopeHls"|"histogram"|"parade"|"waveform"], waveformType, paradeType, colorSpace, brightness, scale, clamp, targets}`, Timecode `{rows: [{source, mode, display}], showName}`, Reference Monitor `{ganged, time, display: "composite"|"scopes", scopes}`, Events `{level, selected}`, Progress `{showFinished}` `export` deep-merges into Export mode state (`preset` — applied first —, `settings` (ExportSettings JSON), `fileName`, `location`, `range`, `customStart`, `customEnd`, `openSections`, `manager` (Preset Manager: `query`, `favoritesOnly`, `selected`, `saveName`), `quickOpen`, `quickPath`, `quickPreset`); `program` / `source` merge fields into the monitor view state (`res`, `paused_res`, `high_quality`, `display`, `zoom`, `pan`, `show_rulers`, `show_guides`, `lock_guides`, `snap`, `guides`, `compare_ref`, …; see [monitors.md](monitors.md)); `clipDialog` sets fields of the open Edit / Clip / File dialog (Paste Attributes, Make Subclip, Frame Hold Options, …); `menuDialog` those of the M3.11 dialogs (Find, Create Search Bin, Project Settings, Scene Edit Detection, Simplify Sequence, Automate to Sequence…; see `panels::menu_dialogs`); `projectPanel` merges into the Project panel's view state (`bin` shown in place, `tabs` (bins in a tab or window: `{bin, floating, view, iconSize, nav}`), `activeTab`, `selectedBin`, `rename: {item|bin, text}`, `dialog` (`{"metadataDisplay": {columns, filter}}`, `{"savePresetAs": {name}}`, `{"managePresets": {selected, name}}`, `{"freeformOptions": {options}}`, `{"saveArrangement": {name, bin}}`)); `mediaBrowser` into the Media Browser's (`expanded`, `pathEdit`, `editColumns`, `treeWidth`). View settings, columns and presets are engine commands (`project.view.set`, `project.columns.set`, `project.viewPreset.*`, `mediaBrowser.settings`) | | `ui.panel.show` / `ui.panel.close` | `{panel}` | | @@ -176,14 +177,20 @@ Time is in ticks (254 016 000 000 per second); commands also accept `seconds`, ` | Command | Parameters | Result | | --- | --- | --- | -| `window.floatPanel` | `{panel?, rect?: [x,y,width,height]}` | Move an existing panel into a floating group. Omitted panel uses the focused panel; omitted rect uses a centered 420×320 group. | +| `window.floatPanel` | `{panel?, rect?: [x,y,width,height]}` | Move an existing panel into a floating group. Omitted panel uses the focused panel; without a rect it floats inside the dock area, 24 points from its top-left corner, at the size a drag floats it at. | | `window.dockPanel` | `{panel?, anchor?, zone?: "center"\|"left"\|"right"\|"top"\|"bottom", before?}` | Move into the anchor's tab group or a side split; `before` names an existing destination tab for precise ordering. Floating destinations accept tabs only. Without `anchor`, a side `zone` docks the panel along that edge of the whole workspace, at a quarter of its width or height. | | `window.closePanel` | `{panel?}` | Close a docked or floating panel without closing its project documents. | Invalid source/target identities and non-finite or out-of-range geometry return an error with -the layout unchanged. `ui.inspect.ui.floating` reports floating groups and their logical-point -rectangles. `panel.drag.` locates each panel's header grip; `panel.float..move` -and `.resize` locate floating geometry handles. Named workspace saves include these groups. +the layout unchanged. `ui.inspect.ui.dock.floating` reports floating windows and their +logical-point rectangles. `panel.drag.` locates the drag bar of each panel's group; +`panel.float..move` and `.resize` locate floating geometry handles; `ui.docking` lists +every docking gesture source. Named workspace saves include floating windows. + +The panels use craft-ui's shared docking in its Frame profile. Its control commands +(`window.panel.move`, `.float`, `.dock`, `.activate`, `.close`, `.closeGroup`, `.closeOthers`, +`.minimize`, `.layout`; see craft-libs `docs/ui-docking.md`) route here too; the three +commands above are FilmCraft's names for `window.panel.float`, `.dock` and `.close`. `window.dockAction` accepts `{action}` using the shared docking action's JSON form. It covers tab moves and ordering, split and floating geometry, activation, and closing. From a0ee50a636b0a8e2eb7d2a7d57832f3927291c39 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 23:23:19 +0000 Subject: [PATCH 018/113] UI.9: dock resize test checks Space after a pointer resize craft-ui's divider does not keep keyboard focus after a pointer drag, so the test keeps only the shortcut check (#1482). --- crates/ui-egui/tests/ui_dock_resize.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/ui-egui/tests/ui_dock_resize.rs b/crates/ui-egui/tests/ui_dock_resize.rs index 00e3a8379..e61a8f7a2 100644 --- a/crates/ui-egui/tests/ui_dock_resize.rs +++ b/crates/ui-egui/tests/ui_dock_resize.rs @@ -136,8 +136,7 @@ fn space_plays_after_a_pane_is_resized_with_the_pointer() { h.event(Event::PointerButton { pos: from + vec2(20.0, 0.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); frames(&mut h, 3); assert_eq!(first_size(&h), 420.0); - // The divider keeps keyboard focus so arrows can nudge it; that must not cost the editor its shortcuts. - assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(Id::new("filmcraft-docking").with(("split", &Vec::::new())))); + // Resizing a pane must not cost the editor its shortcuts. press(&mut h, Key::Space); assert!(h.state().playback.playing, "Space must start playback after resizing a pane (#1482)"); press(&mut h, Key::Space); From 8466f6d8e00137677388b2162e16a1255b8ac169 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 01:05:29 +0000 Subject: [PATCH 019/113] UI.9: use craft-ui's tab wheel, middle click, edge dock and widget ids Repin craft-ui to the current storytold/craft-libs#187 head. craft-ui now steps tabs with the wheel, closes a tab on middle click (the Timeline's tab still closes its active sequence), docks a panel at an edge without an anchor, gives group_header_width the strip width, and names its widgets and floating layers, so FilmCraft's own versions go. Panels float at craft-ui's size. Dock dividers and grips keep keyboard focus after a pointer drag, and shortcuts stay live while they have it. --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/ui-egui/src/dock_ui.rs | 115 +++++----------------- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/tests/shared_docking_ui.rs | 29 +++--- crates/ui-egui/tests/ui_control_input.rs | 7 +- crates/ui-egui/tests/ui_dock_resize.rs | 16 ++- crates/ui-egui/tests/ui_keyboard_focus.rs | 4 +- 8 files changed, 65 insertions(+), 111 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e2eb13f25..1fedda6ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1019,7 +1019,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09 [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=787b70627461a3c988157ed776e8f6051cbc94c3#787b70627461a3c988157ed776e8f6051cbc94c3" +source = "git+https://github.com/storytold/craft-libs?rev=3600104e7632336be5312b6e6f62b5acf2ea37e7#3600104e7632336be5312b6e6f62b5acf2ea37e7" dependencies = [ "egui", "serde", diff --git a/Cargo.toml b/Cargo.toml index dfa2509bb..aea89196d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,7 +74,7 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l # ui # Shared frontend components; keep this dependency above the engine boundary. -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "787b70627461a3c988157ed776e8f6051cbc94c3", features = ["serde"] } +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "3600104e7632336be5312b6e6f62b5acf2ea37e7", features = ["serde"] } egui = "0.36" eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 3327ba4f9..50d39d17b 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -3,9 +3,7 @@ //! bodies, the ≡ panel menu and the Timeline's sequence tabs, and maps its control commands onto //! craft-ui's `DockCommand`. -use craft_ui::docking::{ - Action, Divider, DockArea, DockCommand, DockContent, DockEvent, DockStyle, FloatSpace, Hit, Layout, Placement, Profile, ResizeEdge, Workspace, Zone, -}; +use craft_ui::docking::{Action, Divider, DockArea, DockCommand, DockContent, DockEvent, DockStyle, FloatSpace, Hit, Layout, Profile, ResizeEdge, Workspace}; use egui::{Rect, Sense, Stroke, StrokeKind, vec2}; use serde_json::{Value, json}; @@ -83,25 +81,11 @@ fn alias(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Value) } None if params.get("before").is_some() => return Err("before needs an anchor panel".into()), // Without an anchor a side zone docks along that edge of the whole workspace. - None => { - let edge = match zone.to_ascii_lowercase().as_str() { - "left" => Some(Zone::Left), - "right" => Some(Zone::Right), - "top" => Some(Zone::Top), - "bottom" => Some(Zone::Bottom), - "center" | "tab" => None, - _ => return Err("unknown docking zone".into()), - }; - match edge { - None => json!({"command": "window.panel.dock", "panel": panel}), - Some(edge) => { - let docked = Layout { root: app.ui.dock.root.clone(), floating: vec![] }; - let anchor = - docked.panels().into_iter().copied().find(|other| *other != panel).ok_or("another docked panel is required to dock beside")?; - json!({"command": "window.panel.layout", "action": Action::Move { panel, anchor, placement: Placement::Edge(edge) }}) - } - } - } + None => match zone.to_ascii_lowercase().as_str() { + "center" | "tab" => json!({"command": "window.panel.dock", "panel": panel}), + edge @ ("left" | "right" | "top" | "bottom") => json!({"command": "window.panel.dock", "panel": panel, "zone": edge}), + _ => return Err("unknown docking zone".into()), + }, } } }; @@ -191,8 +175,7 @@ impl FilmcraftApp { let layers = ctx.memory(|memory| memory.layer_ids().collect::>()); let rank = |layer: &egui::LayerId| layers.iter().position(|other| other == layer); !self.ui.dock.floating.iter().any(|window| { - let Some(first) = window.panels().next() else { return false }; - let layer = egui::LayerId::new(egui::Order::Middle, area_id().with(("floating", *first))); + let Some(layer) = DockArea::floating_layer(area_id(), window) else { return false }; layer != *timeline && rank(&layer) > rank(timeline) && ctx.memory(|memory| memory.area_rect(layer.id)).is_some_and(|rect| rect.contains(pos)) }) } @@ -240,7 +223,15 @@ impl FilmcraftApp { panels::keyboard::toggle_maximize(self, panel); } } + let middle_click = ctx.input(|input| input.pointer.button_clicked(egui::PointerButton::Middle)); for action in output.actions { + // A middle click on the Timeline's tab closes its active sequence; the panel stays. + if middle_click && action == (Action::Close { panel: PanelKind::Timeline }) && self.session.state.active_sequence.is_some() { + if let Err(error) = self.session.execute("sequence.close", json!({})) { + self.ui.status = error.to_string(); + } + continue; + } if let Err(error) = self.dock_command(&ctx, action) { self.ui.status = error; } @@ -280,7 +271,6 @@ impl FilmcraftApp { }); } let hits = DockArea::::hit_rects(&ctx, area_id()); - self.tab_strip_pointer(&ctx, &hits); self.register_dock_hits(&hits); panels::panel_menu_popup(self, ui); self.cancel_value_drag(ui.ctx()); @@ -310,69 +300,20 @@ impl FilmcraftApp { } _ => continue, }; - found.push((entry, *rect)); + found.push((entry, *rect, DockArea::hit_id(area_id(), hit))); } - for ((id, label), rect) in found { + // The widget id lets keyboard focus on a divider or grip map to its element (shortcuts + // stay live while one is focused). + for ((id, label), rect, widget) in found { self.auto.add(&id, rect, label); - } - } -} - -impl FilmcraftApp { - /// Over a tab strip the wheel steps through the group's tabs (down = next, one tab per notch, - /// trackpad scrolling adding up to a notch, stopping at the ends), and a middle click on a tab - /// closes it; on the Timeline's tab it closes the active sequence and keeps the panel. - fn tab_strip_pointer(&mut self, ctx: &egui::Context, hits: &[(Hit, Rect)]) { - let wheel_id = area_id().with("tab-wheel"); - let pos = ctx.pointer_hover_pos(); - let over = hits.iter().find_map(|(hit, rect)| match hit { - Hit::Tab(panel) | Hit::DragBar(panel) | Hit::Overflow(panel) if pos.is_some_and(|pos| rect.contains(pos)) => { - Some((matches!(hit, Hit::Tab(_)), *panel)) + if let Some(element) = self.auto.elements.last_mut() { + element.widget_id = Some(format!("{:016x}", widget.value())); + element.response_id = Some(widget); } - _ => None, - }); - let Some((on_tab, panel)) = over else { - ctx.data_mut(|data| data.remove::(wheel_id)); - return; - }; - if on_tab && ctx.input(|input| input.pointer.button_clicked(egui::PointerButton::Middle)) { - let result = if panel == PanelKind::Timeline && self.session.state.active_sequence.is_some() { - self.session.execute("sequence.close", json!({})).map(drop).map_err(|error| error.to_string()) - } else { - self.dock_command(ctx, Action::Close { panel }) - }; - if let Err(error) = result { - self.ui.status = error; - } - return; - } - let Some((panels, active)) = group_of(&self.ui.dock, panel) else { return }; - const NOTCH: f32 = 40.0; - let total = ctx.data(|data| data.get_temp::(wheel_id)).unwrap_or(0.0) + panels::timeline::wheel_input(ctx).delta.y; - let steps = (total / NOTCH).trunc(); - ctx.data_mut(|data| data.insert_temp(wheel_id, total - steps * NOTCH)); - let to = (active as i64).saturating_sub(steps as i64).clamp(0, (panels.len() as i64 - 1).max(0)) as usize; - if let Some(next) = panels.get(to).filter(|_| to != active).copied() { - let _ = self.dock_command(ctx, Action::Activate { panel: next }); - self.ui.focused = next; } } } -/// The panels of the tab group holding `panel`, and its active index. -fn group_of(layout: &Layout, panel: PanelKind) -> Option<(Vec, usize)> { - fn find(node: &craft_ui::docking::Node, panel: PanelKind) -> Option<(Vec, usize)> { - match node { - craft_ui::docking::Node::Tabs { panels, active } if panels.contains(&panel) => Some((panels.clone(), *active)), - craft_ui::docking::Node::Split { first, second, .. } => find(first, panel).or_else(|| find(second, panel)), - _ => None, - } - } - layout.root.as_ref().and_then(|root| find(root, panel)).or_else(|| { - layout.floating.iter().flat_map(|window| &window.groups).find(|group| group.panels.contains(&panel)).map(|group| (group.panels.clone(), group.active)) - }) -} - fn dock_style(ui: &egui::Ui, t: &Tokens) -> DockStyle { let mut style = DockStyle::from_ui(ui); style.profile = Profile::Frame; @@ -426,20 +367,16 @@ impl DockContent for FilmContent<'_> { if *panel == PanelKind::Timeline && self.seqs.open.is_empty() { self.seqs.label().into() } else { logical.into() } } - /// A floated panel is at most 420 by 320 points: panel bodies fill their frame, so their - /// drawn height says nothing about the size they need. - fn floating_size(&self, _panel: &PanelKind) -> Option { - Some(vec2(420.0, 320.0)) - } - /// Tools and the audio meters are narrow strips without a tab strip. fn bare(&self, panel: &PanelKind) -> bool { panel.compact() } /// The ≡ menu, and the Timeline's sequence tabs before it while the Timeline is shown. - fn group_header_width(&self, _panels: &[PanelKind], active: &PanelKind, _floating: bool) -> f32 { - if *active == PanelKind::Timeline && !self.seqs.open.is_empty() { self.seq_width + MENU_W } else { MENU_W } + /// The sequence tabs take at most two thirds of the strip, so the group's own tabs keep room; + /// past that they scroll into their » list. + fn group_header_width(&self, _panels: &[PanelKind], active: &PanelKind, _floating: bool, strip_width: f32) -> f32 { + if *active == PanelKind::Timeline && !self.seqs.open.is_empty() { (self.seq_width + MENU_W).min(strip_width * 2.0 / 3.0).max(MENU_W) } else { MENU_W } } /// The ≡ panel menu of the group's shown panel; for the Timeline, its sequence tabs first. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2db18de6f..f194d7d63 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -38,6 +38,7 @@ pub mod crash; pub mod credits; pub mod dock; mod dock_ui; +pub use dock_ui::area_id as dock_area_id; /// Thumbnail textures kept before the least recently used are freed (each is up to 320 px wide). const MAX_THUMB_TEXTURES: usize = 512; diff --git a/crates/ui-egui/tests/shared_docking_ui.rs b/crates/ui-egui/tests/shared_docking_ui.rs index 95ce475ec..39af6d24e 100644 --- a/crates/ui-egui/tests/shared_docking_ui.rs +++ b/crates/ui-egui/tests/shared_docking_ui.rs @@ -1,6 +1,6 @@ //! Shared docking preserves FilmCraft document state and legacy workspace files. -use craft_ui::docking::{Action, Node, Placement, Zone}; +use craft_ui::docking::{Action, DockArea, Hit, Node, Placement, ResizeEdge, Zone}; use craft_ui::layout::{SplitAxis, SplitSize}; use egui::{Event, Modifiers, PointerButton, Rect, pos2, vec2}; use egui_kittest::Harness; @@ -241,9 +241,7 @@ fn real_panel_drag_merges_splits_floats_and_redocks_without_editing_documents() assert!(h.state().ui.dock.floating[0].rect[2] > original[2] + 25.0); assert!(h.state().ui.dock.floating[0].rect[3] > original[3] + 15.0); action_log.push(action_state(&h, "resized")); - let grip_id = egui::Id::new("filmcraft-docking").with(("floating", PanelKind::Info)).with("resize"); - h.ctx.memory_mut(|memory| memory.request_focus(grip_id)); - step(&mut h); + let grip_id = DockArea::::hit_id(filmcraft_ui_egui::dock_area_id(), &Hit::Resize(PanelKind::Info, ResizeEdge::BottomRight)); assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(grip_id)); let before_keys = h.state().ui.dock.floating[0].rect; for key in [egui::Key::ArrowRight, egui::Key::ArrowDown] { @@ -535,14 +533,13 @@ fn narration_selection_reveals_covered_floating_tts_once_without_editing_the_pro step(&mut h); } } - let layer = |panel: PanelKind| egui::LayerId::new(egui::Order::Middle, egui::Id::new("filmcraft-docking").with(("floating", panel))); let covered = rect(&h, "panel.TextToSpeech").center(); assert!(rect(&h, "panel.Info").contains(covered)); - ctx.move_to_top(layer(PanelKind::Info)); + ctx.move_to_top(window_layer(&h, PanelKind::Info)); for _ in 0..3 { step(&mut h); } - assert_eq!(ctx.layer_id_at(covered), Some(layer(PanelKind::Info)), "precondition: Info really covers TTS"); + assert_eq!(ctx.layer_id_at(covered), Some(window_layer(&h, PanelKind::Info)), "precondition: Info really covers TTS"); h.state_mut().session.state.selection = vec![clip.id]; for _ in 0..4 { step(&mut h); @@ -551,29 +548,29 @@ fn narration_selection_reveals_covered_floating_tts_once_without_editing_the_pro assert_eq!(draft.text, narration.text); assert_eq!(draft.pace, narration.pace); assert_eq!(draft.loaded_from, Some((clip.id.0, clip.item.0))); - assert_eq!(ctx.layer_id_at(covered), Some(layer(PanelKind::TextToSpeech)), "narration selection must bring its floating editor forward"); + assert_eq!(ctx.layer_id_at(covered), Some(window_layer(&h, PanelKind::TextToSpeech)), "narration selection must bring its floating editor forward"); assert_eq!(h.state().ui.focused, PanelKind::TextToSpeech); assert!(!h.state().ui.dock.docked(PanelKind::TextToSpeech)); assert_eq!(h.state().ui.dock.floating.iter().flat_map(|group| group.panels()).filter(|panel| **panel == PanelKind::TextToSpeech).count(), 1); assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project)); // A reveal is one-shot: drawing the selected narration must not steal stacking // from another pane that the user subsequently brings forward. - ctx.move_to_top(layer(PanelKind::Info)); + ctx.move_to_top(window_layer(&h, PanelKind::Info)); for _ in 0..4 { step(&mut h); } - assert_eq!(ctx.layer_id_at(covered), Some(layer(PanelKind::Info))); + assert_eq!(ctx.layer_id_at(covered), Some(window_layer(&h, PanelKind::Info))); assert_eq!(h.state().ui.tts, draft); menus::invoke(h.state_mut(), &ctx, "window.panel.TextToSpeech", json!({})).unwrap(); for _ in 0..4 { step(&mut h); } - assert_eq!(ctx.layer_id_at(covered), Some(layer(PanelKind::TextToSpeech))); + assert_eq!(ctx.layer_id_at(covered), Some(window_layer(&h, PanelKind::TextToSpeech))); assert_eq!(h.state().ui.tts, draft); assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project)); // Replacing the workspace cancels a queued reveal before the next render, // even when the destination happens to contain the same floating panel IDs. - ctx.move_to_top(layer(PanelKind::Info)); + ctx.move_to_top(window_layer(&h, PanelKind::Info)); for _ in 0..3 { step(&mut h); } @@ -588,7 +585,7 @@ fn narration_selection_reveals_covered_floating_tts_once_without_editing_the_pro for _ in 0..4 { step(&mut h); } - assert_eq!(ctx.layer_id_at(covered), Some(layer(PanelKind::Info))); + assert_eq!(ctx.layer_id_at(covered), Some(window_layer(&h, PanelKind::Info))); assert_eq!(h.state().ui.tts, draft); assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project)); } @@ -660,3 +657,9 @@ fn shared_dock_commands_and_filmcraft_names_move_panels() { assert!(!app.ui.dock.contains(&PanelKind::Info)); assert!(menus::invoke(&mut app, &ctx, "window.panel.move", json!({"panel": "Info", "anchor": "Nowhere"})).is_err()); } + +/// The layer of the floating window holding `panel`. +fn window_layer(h: &Harness<'_, FilmcraftApp>, panel: PanelKind) -> egui::LayerId { + let window = h.state().ui.dock.floating.iter().find(|window| window.contains(&panel)).unwrap(); + DockArea::floating_layer(filmcraft_ui_egui::dock_area_id(), window).unwrap() +} diff --git a/crates/ui-egui/tests/ui_control_input.rs b/crates/ui-egui/tests/ui_control_input.rs index aa04b8f23..19851a7da 100644 --- a/crates/ui-egui/tests/ui_control_input.rs +++ b/crates/ui-egui/tests/ui_control_input.rs @@ -5,7 +5,7 @@ use std::sync::{ mpsc::{Sender, channel}, }; -use egui::{Event, Id, Key, Modifiers, vec2}; +use egui::{Event, Key, Modifiers, vec2}; use egui_kittest::Harness; use filmcraft_engine::Session; use filmcraft_ui_egui::{FilmcraftApp, control::ControlRequest, dock::PanelKind}; @@ -107,7 +107,10 @@ fn background_divider(isolated: bool) { d.frames(3, true); let before_project = d.h.state().session.project.clone(); let before_playheads = d.h.state().session.state.playheads.clone(); - let divider = Id::new("filmcraft-docking").with(("split", &Vec::::new())); + let divider = craft_ui::docking::DockArea::::hit_id( + filmcraft_ui_egui::dock_area_id(), + &craft_ui::docking::Hit::Divider(craft_ui::docking::Divider::Split(vec![])), + ); d.h.ctx.memory_mut(|memory| memory.request_focus(divider)); d.frames(2, true); d.h.input_mut().events.push(Event::WindowFocused(false)); diff --git a/crates/ui-egui/tests/ui_dock_resize.rs b/crates/ui-egui/tests/ui_dock_resize.rs index e61a8f7a2..fd5ecb774 100644 --- a/crates/ui-egui/tests/ui_dock_resize.rs +++ b/crates/ui-egui/tests/ui_dock_resize.rs @@ -1,6 +1,6 @@ //! Shared pane resizing in the full application, including application shortcut ownership. -use egui::{Event, Id, Key, Modifiers, PointerButton, Rect, pos2, vec2}; +use egui::{Event, Key, Modifiers, PointerButton, Rect, pos2, vec2}; use egui_kittest::Harness; use filmcraft_engine::Session; use filmcraft_ui_egui::FilmcraftApp; @@ -67,7 +67,10 @@ fn focused_dock_resize_owns_arrows_and_keeps_pointer_dragging() { frames(&mut h, 3); let before_project = h.state().session.project.clone(); let before_playheads = h.state().session.state.playheads.clone(); - let id = Id::new("filmcraft-docking").with(("split", &Vec::::new())); + let id = craft_ui::docking::DockArea::::hit_id( + filmcraft_ui_egui::dock_area_id(), + &craft_ui::docking::Hit::Divider(craft_ui::docking::Divider::Split(vec![])), + ); h.ctx.memory_mut(|memory| memory.request_focus(id)); frames(&mut h, 2); assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(id)); @@ -136,7 +139,14 @@ fn space_plays_after_a_pane_is_resized_with_the_pointer() { h.event(Event::PointerButton { pos: from + vec2(20.0, 0.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); frames(&mut h, 3); assert_eq!(first_size(&h), 420.0); - // Resizing a pane must not cost the editor its shortcuts. + // The divider keeps keyboard focus so arrows can nudge it; that must not cost the editor its shortcuts. + assert_eq!( + h.ctx.memory(|memory| memory.focused()), + Some(craft_ui::docking::DockArea::::hit_id( + filmcraft_ui_egui::dock_area_id(), + &craft_ui::docking::Hit::Divider(craft_ui::docking::Divider::Split(vec![])) + )) + ); press(&mut h, Key::Space); assert!(h.state().playback.playing, "Space must start playback after resizing a pane (#1482)"); press(&mut h, Key::Space); diff --git a/crates/ui-egui/tests/ui_keyboard_focus.rs b/crates/ui-egui/tests/ui_keyboard_focus.rs index 02d48ba9a..9489ceda7 100644 --- a/crates/ui-egui/tests/ui_keyboard_focus.rs +++ b/crates/ui-egui/tests/ui_keyboard_focus.rs @@ -76,8 +76,8 @@ fn dock_tab_traversal_and_enter_change_the_visible_panel_without_editing() { floating: vec![], }; h.run_steps(3); - let first = Id::new("filmcraft-docking").with(("tab", PanelKind::Project)); - let second = Id::new("filmcraft-docking").with(("tab", PanelKind::Effects)); + let first = craft_ui::docking::DockArea::::hit_id(filmcraft_ui_egui::dock_area_id(), &craft_ui::docking::Hit::Tab(PanelKind::Project)); + let second = craft_ui::docking::DockArea::::hit_id(filmcraft_ui_egui::dock_area_id(), &craft_ui::docking::Hit::Tab(PanelKind::Effects)); for _ in 0..128 { key(&mut h, Key::Tab, Modifiers::NONE); if h.ctx.memory(|m| m.focused()) == Some(second) { From 218c03fd75107850aed9342e5cdcf9d3bf5b0d79 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 04:05:11 +0000 Subject: [PATCH 020/113] UI.9: repin craft-ui; a press on a sequence tab focuses the Timeline Repin to the current storytold/craft-libs#187 head. A press in a group header now focuses the shown panel, so clicking the Timeline's active sequence tab focuses the Timeline. --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/ui-egui/tests/shared_docking_ui.rs | 22 ++++++++++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1fedda6ef..93a9b67d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1019,7 +1019,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09 [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=3600104e7632336be5312b6e6f62b5acf2ea37e7#3600104e7632336be5312b6e6f62b5acf2ea37e7" +source = "git+https://github.com/storytold/craft-libs?rev=07585888d6db7033099e915f5ad2317eec38c40d#07585888d6db7033099e915f5ad2317eec38c40d" dependencies = [ "egui", "serde", diff --git a/Cargo.toml b/Cargo.toml index aea89196d..8e70fe591 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,7 +74,7 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l # ui # Shared frontend components; keep this dependency above the engine boundary. -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "3600104e7632336be5312b6e6f62b5acf2ea37e7", features = ["serde"] } +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "07585888d6db7033099e915f5ad2317eec38c40d", features = ["serde"] } egui = "0.36" eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" diff --git a/crates/ui-egui/tests/shared_docking_ui.rs b/crates/ui-egui/tests/shared_docking_ui.rs index 39af6d24e..ce0dbd444 100644 --- a/crates/ui-egui/tests/shared_docking_ui.rs +++ b/crates/ui-egui/tests/shared_docking_ui.rs @@ -663,3 +663,25 @@ fn window_layer(h: &Harness<'_, FilmcraftApp>, panel: PanelKind) -> egui::LayerI let window = h.state().ui.dock.floating.iter().find(|window| window.contains(&panel)).unwrap(); DockArea::floating_layer(filmcraft_ui_egui::dock_area_id(), window).unwrap() } + +fn demo_harness() -> Harness<'static, FilmcraftApp> { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + let app = FilmcraftApp::new(session); + let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(move |_| app); + for _ in 0..4 { + step(&mut h); + } + h +} + +/// Clicking the Timeline's active sequence tab, in its group header, focuses the Timeline. +#[test] +fn clicking_the_active_sequence_tab_focuses_the_timeline() { + let mut h = demo_harness(); + click(&mut h, "panel.tab.Project"); + assert_eq!(h.state().ui.focused, PanelKind::Project); + let active = h.state().session.state.active_sequence.unwrap().0; + click(&mut h, &format!("timeline.tab.{active}")); + assert_eq!(h.state().ui.focused, PanelKind::Timeline); +} From 0169eba9f9311e73543f8c9cc2d2bafd552cd90d Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 04:05:21 +0000 Subject: [PATCH 021/113] UI.9: no move or float leaves the frame tree empty Moving or floating a group, column or window was not checked; compare the layout before and after each action instead. --- crates/ui-egui/src/dock_ui.rs | 23 +++++++++-------------- crates/ui-egui/tests/shared_docking_ui.rs | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 50d39d17b..beb62fe0d 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -94,18 +94,12 @@ fn alias(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Value) Ok(json!({"panel": panel.id()})) } -/// Keep one docked destination available for dragging floating panels back into the workspace. -fn check_last_docked(layout: &Layout, action: &Action) -> Result<(), String> { - let panel = match action { - Action::Float { panel, .. } => Some(panel), - Action::Move { panel, anchor, .. } if layout.floating.iter().any(|window| window.contains(anchor)) => Some(panel), - _ => None, - }; - if let Some(panel) = panel { - let docked = Layout { root: layout.root.clone(), floating: vec![] }; - if docked.contains(panel) && docked.panels().len() <= 1 { - return Err("the last docked panel must remain in the workspace".into()); - } +/// Keep one docked destination available for dragging floating panels back into the workspace: +/// no move or float may leave the frame tree empty. Closing the last docked panel stays allowed. +fn check_last_docked(before: &Layout, action: &Action, after: &Layout) -> Result<(), String> { + let closing = matches!(action, Action::Close { .. } | Action::CloseGroup { .. } | Action::CloseWindow { .. } | Action::CloseOthers { .. }); + if !closing && before.root.is_some() && after.root.is_none() { + return Err("the last docked panel must remain in the workspace".into()); } Ok(()) } @@ -149,8 +143,9 @@ impl FilmcraftApp { closed_floating.push((panel, window.rect)); } } - check_last_docked(&layout, &action)?; - layout.apply(action).map_err(|error| error.to_string())?; + let before = layout.clone(); + layout.apply(action.clone()).map_err(|error| error.to_string())?; + check_last_docked(&before, &action, &layout)?; } self.ui.dock = layout; self.ui.closed_floating = closed_floating; diff --git a/crates/ui-egui/tests/shared_docking_ui.rs b/crates/ui-egui/tests/shared_docking_ui.rs index ce0dbd444..a8e2e43cd 100644 --- a/crates/ui-egui/tests/shared_docking_ui.rs +++ b/crates/ui-egui/tests/shared_docking_ui.rs @@ -685,3 +685,22 @@ fn clicking_the_active_sequence_tab_focuses_the_timeline() { click(&mut h, &format!("timeline.tab.{active}")); assert_eq!(h.state().ui.focused, PanelKind::Timeline); } + +/// No move or float may empty the frame tree, whichever unit it moves. +#[test] +fn the_last_docked_group_cannot_float_away() { + let mut app = FilmcraftApp::new(Session::default()); + app.ui.dock = root(tabs(&[PanelKind::Project, PanelKind::Info], 0)); + let ctx = egui::Context::default(); + let before = app.ui.dock.clone(); + let float = craft_ui::docking::Target::Float { rect: Some([20.0, 20.0, 300.0, 200.0]) }; + for action in [Action::MoveGroup { panel: PanelKind::Project, to: float.clone() }, Action::MoveColumn { panel: PanelKind::Project, to: float.clone() }] { + let _ = menus::invoke(&mut app, &ctx, "window.dockAction", json!({ "action": action })); + assert_eq!(app.ui.dock, before); + } + assert!(menus::invoke(&mut app, &ctx, "window.panel.float", json!({"panel": "Project", "group": true})).is_err()); + assert_eq!(app.ui.dock, before); + // one panel of the group may float; the other stays docked + menus::invoke(&mut app, &ctx, "window.panel.float", json!({"panel": "Info"})).unwrap(); + assert!(app.ui.dock.docked(PanelKind::Project) && !app.ui.dock.docked(PanelKind::Info)); +} From a2ecd10e300f892d8138063ecf1e52010f8366ff Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 04:05:31 +0000 Subject: [PATCH 022/113] UI.9: shortcuts work while any floating resize edge has focus Every resize edge of a floating window is an automation element with its widget id, so Space and other shortcuts stay live after resizing from any edge (#1482). --- crates/ui-egui/src/dock_ui.rs | 5 +++-- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/tests/ui_dock_resize.rs | 25 +++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index beb62fe0d..8f002c8c8 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -286,8 +286,9 @@ impl FilmcraftApp { Some(index) => (format!("panel.float.{index}.move"), "Move floating panel"), None => continue, }, - Hit::Resize(panel, ResizeEdge::BottomRight) => match window(panel) { - Some(index) => (format!("panel.float.{index}.resize"), "Resize floating panel"), + Hit::Resize(panel, edge) => match window(panel) { + Some(index) if *edge == ResizeEdge::BottomRight => (format!("panel.float.{index}.resize"), "Resize floating panel"), + Some(index) => (format!("panel.float.{index}.resize.{edge:?}"), "Resize floating panel"), None => continue, }, Hit::Divider(Divider::Split(path)) => { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f194d7d63..fdb145296 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1514,7 +1514,7 @@ impl FilmcraftApp { self.auto .previous .iter() - .any(|e| e.response_id == Some(focused) && (e.id.starts_with("dock.gutter.") || (e.id.starts_with("panel.float.") && e.id.ends_with(".resize")))) + .any(|e| e.response_id == Some(focused) && (e.id.starts_with("dock.gutter.") || (e.id.starts_with("panel.float.") && e.id.contains(".resize")))) } fn handle_shortcuts(&mut self, ctx: &egui::Context) { diff --git a/crates/ui-egui/tests/ui_dock_resize.rs b/crates/ui-egui/tests/ui_dock_resize.rs index fd5ecb774..50bcc0998 100644 --- a/crates/ui-egui/tests/ui_dock_resize.rs +++ b/crates/ui-egui/tests/ui_dock_resize.rs @@ -152,3 +152,28 @@ fn space_plays_after_a_pane_is_resized_with_the_pointer() { press(&mut h, Key::Space); assert!(!h.state().playback.playing); } + +/// Space plays while any edge of a floating window's resize grip has keyboard focus, as it does +/// for the bottom-right corner and pane dividers (#1482). +#[test] +fn space_plays_while_any_floating_resize_edge_has_focus() { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + let app = FilmcraftApp::new(session); + let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(move |_| app); + frames(&mut h, 4); + let ctx = h.ctx.clone(); + filmcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "window.floatPanel", json!({"panel": "Info", "rect": [300, 200, 400, 300]})).unwrap(); + frames(&mut h, 3); + use craft_ui::docking::{DockArea, Hit, ResizeEdge}; + for edge in [ResizeEdge::Top, ResizeEdge::Left, ResizeEdge::BottomRight] { + let grip = DockArea::::hit_id(filmcraft_ui_egui::dock_area_id(), &Hit::Resize(PanelKind::Info, edge)); + h.ctx.memory_mut(|memory| memory.request_focus(grip)); + frames(&mut h, 2); + assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(grip), "{edge:?}"); + press(&mut h, Key::Space); + assert!(h.state().playback.playing, "Space must play with the {edge:?} grip focused"); + press(&mut h, Key::Space); + assert!(!h.state().playback.playing); + } +} From 26d0df20effaa1142d82d774ac52ad4a40d04edf Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 04:05:43 +0000 Subject: [PATCH 023/113] UI.9: grips move Tools and the Audio Meters They have no tab strip; a slim grip above each one starts a docking drag. --- crates/ui-egui/src/dock_ui.rs | 20 ++++++++++++++++++-- crates/ui-egui/tests/shared_docking_ui.rs | 13 +++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 8f002c8c8..948386982 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -22,6 +22,8 @@ fn bodies_id() -> egui::Id { area_id().with("bodies") } +/// Height of the move grip above a panel without a tab strip. +const GRIP_H: f32 = 8.0; /// Width of the ≡ panel menu button at the end of each tab strip. const MENU_W: f32 = 26.0; @@ -342,7 +344,21 @@ struct FilmContent<'a> { impl DockContent for FilmContent<'_> { fn body(&mut self, ui: &mut egui::Ui, panel: &PanelKind) { let t = self.tokens; - let rect = ui.max_rect(); + let frame = ui.max_rect(); + let mut rect = frame; + // Tools and the audio meters have no tab strip: a slim grip on top moves them. + if panel.compact() { + let grip = Rect::from_min_size(rect.min, vec2(rect.width(), GRIP_H.min(rect.height()))); + let response = ui.interact(grip, egui::Id::new(("panel-grip", panel.id())), Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab); + self.app.auto.add_response(&format!("panel.drag.{}", panel.id()), &response, "Move panel"); + for dx in [-4.0, 0.0, 4.0] { + ui.painter().circle_filled(grip.center() + vec2(dx, 0.0), 1.0, if response.hovered() { t.tab_text_active } else { t.text_faint }); + } + if response.drag_started() { + craft_ui::docking::begin_drag(ui.ctx(), area_id(), *panel, frame); + } + rect.min.y = grip.max.y; + } self.bodies.push((*panel, rect, ui.layer_id())); self.app.auto.add(&format!("panel.{}", panel.id()), rect, panel.title()); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).id_salt(("panel", panel.id()))); @@ -350,7 +366,7 @@ impl DockContent for FilmContent<'_> { panels::show(self.app, &mut child, *panel, rect); // Painted after the body so child backgrounds cannot hide the focus outline (#1075). if self.app.ui.focused == *panel { - ui.painter().rect_stroke(rect, 0.0, Stroke::new(1.0, t.focus), StrokeKind::Inside); + ui.painter().rect_stroke(frame, 0.0, Stroke::new(1.0, t.focus), StrokeKind::Inside); } } diff --git a/crates/ui-egui/tests/shared_docking_ui.rs b/crates/ui-egui/tests/shared_docking_ui.rs index a8e2e43cd..6c182e88a 100644 --- a/crates/ui-egui/tests/shared_docking_ui.rs +++ b/crates/ui-egui/tests/shared_docking_ui.rs @@ -704,3 +704,16 @@ fn the_last_docked_group_cannot_float_away() { menus::invoke(&mut app, &ctx, "window.panel.float", json!({"panel": "Info"})).unwrap(); assert!(app.ui.dock.docked(PanelKind::Project) && !app.ui.dock.docked(PanelKind::Info)); } + +/// Tools and the audio meters have no tab strip; the grip above each one moves it. +#[test] +fn tools_and_meters_move_by_their_grips() { + let mut h = demo_harness(); + for panel in [PanelKind::Tools, PanelKind::AudioMeters] { + assert!(h.state().auto.find(&format!("panel.drag.{}", panel.id())).is_some(), "{panel:?} has no grip"); + } + let from = rect(&h, "panel.drag.Tools").center(); + let to = rect(&h, "panel.Program").center(); + drag(&mut h, from, to, Modifiers::NONE); + assert!(same_group(&h.state().ui.dock, PanelKind::Tools, PanelKind::Program), "{:?}", h.state().ui.dock); +} From d6ac5361aa689b951f8a74a068098b56f03590fa Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 04:05:53 +0000 Subject: [PATCH 024/113] UI.9: a floated panel takes its full size Panel bodies draw into a child ui; allocating the frame afterwards lets craft-ui measure them, so Program no longer floats 120 pt high. --- crates/ui-egui/src/dock_ui.rs | 2 ++ crates/ui-egui/tests/shared_docking_ui.rs | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 948386982..b6a6f22da 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -364,6 +364,8 @@ impl DockContent for FilmContent<'_> { let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).id_salt(("panel", panel.id()))); child.set_clip_rect(rect.intersect(ui.clip_rect())); panels::show(self.app, &mut child, *panel, rect); + // The body fills its frame; allocating it lets a floated panel take its full size. + ui.allocate_rect(ui.max_rect(), Sense::hover()); // Painted after the body so child backgrounds cannot hide the focus outline (#1075). if self.app.ui.focused == *panel { ui.painter().rect_stroke(frame, 0.0, Stroke::new(1.0, t.focus), StrokeKind::Inside); diff --git a/crates/ui-egui/tests/shared_docking_ui.rs b/crates/ui-egui/tests/shared_docking_ui.rs index 6c182e88a..8ad02c9fd 100644 --- a/crates/ui-egui/tests/shared_docking_ui.rs +++ b/crates/ui-egui/tests/shared_docking_ui.rs @@ -717,3 +717,16 @@ fn tools_and_meters_move_by_their_grips() { drag(&mut h, from, to, Modifiers::NONE); assert!(same_group(&h.state().ui.dock, PanelKind::Tools, PanelKind::Program), "{:?}", h.state().ui.dock); } + +/// A panel floated by command without a rect takes a useful height, not the 120 pt minimum. +#[test] +fn program_floats_at_a_useful_height() { + let mut h = demo_harness(); + let ctx = h.ctx.clone(); + menus::invoke(h.state_mut(), &ctx, "window.panel.float", json!({"panel": "Program"})).unwrap(); + for _ in 0..3 { + step(&mut h); + } + let window = h.state().ui.dock.floating.iter().find(|window| window.contains(&PanelKind::Program)).unwrap(); + assert!(window.rect[3] >= 300.0, "Program floated at {:?}", window.rect); +} From 3b939a1f3ec899fe7aaa9be7fca0b5daa2a366ef Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 04:06:04 +0000 Subject: [PATCH 025/113] UI.9: only docked frames maximize Maximize Frame on a floating panel did nothing visible but left the panel marked maximized. --- crates/ui-egui/src/dock.rs | 6 ++++++ crates/ui-egui/src/dock_ui.rs | 6 +++--- crates/ui-egui/src/panels/keyboard.rs | 8 +++++--- crates/ui-egui/src/panels/mod.rs | 2 +- crates/ui-egui/tests/shared_docking_ui.rs | 15 +++++++++++++++ 5 files changed, 30 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 929f9da6c..63b27990d 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -447,6 +447,8 @@ fn workspace_root(name: &str) -> Node { pub trait LayoutExt { /// Whether `panel` is the shown tab of its frame or floating window. fn is_visible(&self, panel: PanelKind) -> bool; + /// Whether `panel` is in the frame tree, not a floating window. + fn is_docked(&self, panel: PanelKind) -> bool; /// The shown panel of each frame in layout order, then of each floating window. fn visible_panels(&self) -> Vec; /// Put the Timeline panel back where the workspaces keep it (left of the audio meters) after @@ -461,6 +463,10 @@ impl LayoutExt for Layout { self.panel_state(&panel) == craft_ui::docking::PanelState::Front } + fn is_docked(&self, panel: PanelKind) -> bool { + Layout { root: self.root.clone(), floating: vec![] }.contains(&panel) + } + fn visible_panels(&self) -> Vec { fn walk(node: &Node, out: &mut Vec) { match node { diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index b6a6f22da..6d6490f19 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -7,7 +7,7 @@ use craft_ui::docking::{Action, Divider, DockArea, DockCommand, DockContent, Doc use egui::{Rect, Sense, Stroke, StrokeKind, vec2}; use serde_json::{Value, json}; -use crate::dock::{self, PanelKind, SeqAction, SeqTabs}; +use crate::dock::{self, LayoutExt as _, PanelKind, SeqAction, SeqTabs}; use crate::icons::{self, Icon}; use crate::theme::Tokens; use crate::{FilmcraftApp, panels}; @@ -154,7 +154,7 @@ impl FilmcraftApp { if let Some(panel) = reveal { self.pending_panel_raise = Some(panel); } - if relocating || self.ui.keys.maximized.is_some_and(|panel| !self.ui.dock.contains(&panel)) { + if relocating || self.ui.keys.maximized.is_some_and(|panel| !self.ui.dock.is_docked(panel)) { self.ui.keys.maximized = None; } Ok(()) @@ -197,7 +197,7 @@ impl FilmcraftApp { active: self.session.state.active_sequence.map(|id| id.0), }; let style = dock_style(ui, &t); - let mut area = DockArea::new(area_id()).maximized(self.ui.keys.maximized.filter(|panel| self.ui.dock.contains(panel))); + let mut area = DockArea::new(area_id()).maximized(self.ui.keys.maximized.filter(|panel| self.ui.dock.is_docked(*panel))); if let Some(panel) = self.pending_panel_raise.take() { area = area.raise_panel(panel); } diff --git a/crates/ui-egui/src/panels/keyboard.rs b/crates/ui-egui/src/panels/keyboard.rs index 91ce44441..403e0a838 100644 --- a/crates/ui-egui/src/panels/keyboard.rs +++ b/crates/ui-egui/src/panels/keyboard.rs @@ -233,10 +233,12 @@ pub fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Val // ------------------------------------------------------------------ frames and panels /// Maximize `p`, or restore the layout when a panel is maximized (also the panel menu's Maximize / -/// Restore Frame). A maximized panel that has since been closed counts as restored. +/// Restore Frame). Only a docked panel maximizes; a maximized panel that has since been closed or +/// floated counts as restored. pub(crate) fn toggle_maximize(app: &mut FilmcraftApp, p: PanelKind) -> Value { - let current = app.ui.keys.maximized.filter(|q| app.ui.dock.contains(q)); - app.ui.keys.maximized = if current.is_some() { None } else { Some(p) }; + use crate::dock::LayoutExt as _; + let current = app.ui.keys.maximized.filter(|q| app.ui.dock.is_docked(*q)); + app.ui.keys.maximized = if current.is_some() || !app.ui.dock.is_docked(p) { None } else { Some(p) }; if let Some(p) = app.ui.keys.maximized { let _ = app.apply_dock(craft_ui::docking::Action::Activate { panel: p }); app.ui.focused = p; diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 3b7d33dfe..7a7f7a0be 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -309,7 +309,7 @@ pub fn panel_menu_popup(app: &mut FilmcraftApp, ui: &mut egui::Ui) { let _ = crate::menus::invoke(app, ui.ctx(), "window.floatPanel", serde_json::json!({"panel": p.id()})); close = true; } - let maximized = app.ui.keys.maximized.is_some_and(|q| app.ui.dock.contains(&q)); + let maximized = app.ui.keys.maximized.is_some_and(|q| crate::dock::LayoutExt::is_docked(&app.ui.dock, q)); let r = ui.button(if maximized { tl!("Restore Frame") } else { tl!("Maximize Frame") }); app.auto.add(&format!("panel.menu.{}.maximize", p.id()), r.rect, if maximized { "Restore Frame" } else { "Maximize Frame" }); if r.clicked() { diff --git a/crates/ui-egui/tests/shared_docking_ui.rs b/crates/ui-egui/tests/shared_docking_ui.rs index 8ad02c9fd..f1d41208b 100644 --- a/crates/ui-egui/tests/shared_docking_ui.rs +++ b/crates/ui-egui/tests/shared_docking_ui.rs @@ -730,3 +730,18 @@ fn program_floats_at_a_useful_height() { let window = h.state().ui.dock.floating.iter().find(|window| window.contains(&PanelKind::Program)).unwrap(); assert!(window.rect[3] >= 300.0, "Program floated at {:?}", window.rect); } + +/// Only docked frames maximize: Maximize Frame on a floating panel leaves nothing maximized. +#[test] +fn a_floating_panel_does_not_maximize() { + let mut app = FilmcraftApp::new(Session::default()); + app.ui.dock = layout(); + let ctx = egui::Context::default(); + menus::invoke(&mut app, &ctx, "window.floatPanel", json!({"panel": "Info", "rect": [45, 65, 420, 320]})).unwrap(); + app.ui.focused = PanelKind::Info; + menus::invoke(&mut app, &ctx, "window.maximizeFrame", json!({})).unwrap(); + assert_eq!(app.ui.keys.maximized, None); + app.ui.focused = PanelKind::Program; + menus::invoke(&mut app, &ctx, "window.maximizeFrame", json!({})).unwrap(); + assert_eq!(app.ui.keys.maximized, Some(PanelKind::Program)); +} From 8bbbb60067e1371179c0b4e97ad85921960a1329 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 04:06:14 +0000 Subject: [PATCH 026/113] UI.9: hidden floating windows don't block drops on a maximized Timeline --- crates/ui-egui/src/dock_ui.rs | 6 +++++- crates/ui-egui/tests/timeline_drop_ui.rs | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 6d6490f19..76a317ffe 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -173,7 +173,11 @@ impl FilmcraftApp { let rank = |layer: &egui::LayerId| layers.iter().position(|other| other == layer); !self.ui.dock.floating.iter().any(|window| { let Some(layer) = DockArea::floating_layer(area_id(), window) else { return false }; - layer != *timeline && rank(&layer) > rank(timeline) && ctx.memory(|memory| memory.area_rect(layer.id)).is_some_and(|rect| rect.contains(pos)) + // Floating windows are hidden while a frame is maximized. + ctx.memory(|memory| memory.areas().is_visible(&layer)) + && layer != *timeline + && rank(&layer) > rank(timeline) + && ctx.memory(|memory| memory.area_rect(layer.id)).is_some_and(|rect| rect.contains(pos)) }) } diff --git a/crates/ui-egui/tests/timeline_drop_ui.rs b/crates/ui-egui/tests/timeline_drop_ui.rs index 1a5ec11b5..b0a7c2571 100644 --- a/crates/ui-egui/tests/timeline_drop_ui.rs +++ b/crates/ui-egui/tests/timeline_drop_ui.rs @@ -327,3 +327,21 @@ fn os_cursor_hook_selects_destination_when_egui_pointer_has_no_motion() { step(&mut h); assert_eq!(clip_counts(&h), (1, 0)); } + +#[test] +fn a_floating_window_hidden_by_a_maximized_timeline_does_not_block_drops() { + let mut h = harness(); + h.state_mut().ui.keys.maximized = Some(filmcraft_ui_egui::dock::PanelKind::Timeline); + for _ in 0..4 { + step(&mut h); + } + let at = point_on_row(&h, "timeline.track.V1.target"); + // Floating a panel restores the frames; maximize again so the window over `at` is hidden. + float_panel(&mut h, "Info", [at.x - 20.0, at.y - 60.0, 320.0, 200.0]); + h.state_mut().ui.keys.maximized = Some(filmcraft_ui_egui::dock::PanelKind::Timeline); + for _ in 0..4 { + step(&mut h); + } + assert_eq!(point_on_row(&h, "timeline.track.V1.target"), at); + assert!(hover_at(&mut h, at)); +} From c04497fd7c80995ff458a130efb715148163fb58 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 04:06:24 +0000 Subject: [PATCH 027/113] UI.9: window.panel.activate reopens a closed panel A closed panel goes through FilmCraft's reopen path, which also floats it again where it was closed floating (#1985). --- crates/ui-egui/src/dock_ui.rs | 8 ++++++++ crates/ui-egui/tests/shared_docking_ui.rs | 12 ++++++++++++ 2 files changed, 20 insertions(+) diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 76a317ffe..d9334a863 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -119,6 +119,14 @@ impl FilmcraftApp { if command.is_app_owned() { return Err("use the window.workspace.* commands for workspaces".into()); } + // A closed panel reopens through FilmCraft's own path: where it was, floating again if it + // was closed floating (#1985). + if let DockCommand::Activate { panel } = &command + && !self.ui.dock.contains(panel) + { + self.show_panel(*panel); + return Ok(()); + } let space = craft_ui::docking::float_space(ctx, area_id()).unwrap_or_else(|| FloatSpace::new(ctx.content_rect())); let actions = command.to_actions(&Workspace::new(self.ui.dock.clone()), false, &space).map_err(|error| error.to_string())?; self.apply_dock_all(actions) diff --git a/crates/ui-egui/tests/shared_docking_ui.rs b/crates/ui-egui/tests/shared_docking_ui.rs index f1d41208b..c2e1bc35e 100644 --- a/crates/ui-egui/tests/shared_docking_ui.rs +++ b/crates/ui-egui/tests/shared_docking_ui.rs @@ -745,3 +745,15 @@ fn a_floating_panel_does_not_maximize() { menus::invoke(&mut app, &ctx, "window.maximizeFrame", json!({})).unwrap(); assert_eq!(app.ui.keys.maximized, Some(PanelKind::Program)); } + +/// A closed panel reopens through `window.panel.activate`. +#[test] +fn activate_reopens_a_closed_panel() { + let mut app = FilmcraftApp::new(Session::default()); + app.ui.dock = layout(); + let ctx = egui::Context::default(); + menus::invoke(&mut app, &ctx, "window.panel.close", json!({"panel": "Info"})).unwrap(); + assert!(!app.ui.dock.contains(&PanelKind::Info)); + menus::invoke(&mut app, &ctx, "window.panel.activate", json!({"panel": "Info"})).unwrap(); + assert!(app.ui.dock.is_visible(PanelKind::Info)); +} From 9a590f0fcd2439d0bb3c729f24be36ec58f1e284 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:49:18 +0000 Subject: [PATCH 028/113] UI.9: repin craft-ui to craft-libs 352a830 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 93a9b67d9..622dccb77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1019,7 +1019,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09 [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=07585888d6db7033099e915f5ad2317eec38c40d#07585888d6db7033099e915f5ad2317eec38c40d" +source = "git+https://github.com/storytold/craft-libs?rev=352a8308c5cc53c2ae31326fa38a4a6bcb0ff9eb#352a8308c5cc53c2ae31326fa38a4a6bcb0ff9eb" dependencies = [ "egui", "serde", diff --git a/Cargo.toml b/Cargo.toml index 8e70fe591..09aec37f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,7 +74,7 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l # ui # Shared frontend components; keep this dependency above the engine boundary. -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "07585888d6db7033099e915f5ad2317eec38c40d", features = ["serde"] } +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "352a8308c5cc53c2ae31326fa38a4a6bcb0ff9eb", features = ["serde"] } egui = "0.36" eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" From b35b463e8d3193831a323e2e4a595312f13ce380 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 16:09:46 +0000 Subject: [PATCH 029/113] UI.9: tab menus carry FilmCraft's panel items; closing the Timeline closes its sequence --- crates/ui-egui/src/dock_ui.rs | 72 +++++++++++++++++++---- crates/ui-egui/src/panels/mod.rs | 55 +++++++++-------- crates/ui-egui/tests/shared_docking_ui.rs | 57 ++++++++++++++++++ 3 files changed, 149 insertions(+), 35 deletions(-) diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index d9334a863..a95e85f8d 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -1,6 +1,7 @@ //! FilmCraft's panels in craft-ui's shared docking, Frame profile. craft-ui draws the frames, tab //! strips, floating windows and drag previews and owns the gestures; FilmCraft draws the panel -//! bodies, the ≡ panel menu and the Timeline's sequence tabs, and maps its control commands onto +//! bodies, the ≡ panel menu, the panel items of each tab's context menu and the Timeline's +//! sequence tabs, and maps its control commands onto //! craft-ui's `DockCommand`. use craft_ui::docking::{Action, Divider, DockArea, DockCommand, DockContent, DockEvent, DockStyle, FloatSpace, Hit, Layout, Profile, ResizeEdge, Workspace}; @@ -217,9 +218,10 @@ impl FilmcraftApp { let mut child = ui.new_child(egui::UiBuilder::new().max_rect(body).id_salt("filmcraft-dock-area")); child.set_clip_rect(body.intersect(ui.clip_rect())); let seq_width = dock::sequence_tabs_width(ui, &seqs); - let mut content = FilmContent { app: self, seqs, seq_width, tokens: t, seq_actions: Vec::new(), menu: None, bodies: Vec::new() }; + let mut content = + FilmContent { app: self, seqs, seq_width, tokens: t, seq_actions: Vec::new(), menu: None, bodies: Vec::new(), restore_workspace: false }; let output = area.show_content(&mut child, &layout, &style, &mut content); - let FilmContent { seq_actions, mut menu, bodies, .. } = content; + let FilmContent { seq_actions, mut menu, bodies, restore_workspace, .. } = content; ctx.data_mut(|data| data.insert_temp(bodies_id(), bodies)); if let Some(panel) = output.focused { self.ui.focused = panel; @@ -232,19 +234,17 @@ impl FilmcraftApp { panels::keyboard::toggle_maximize(self, panel); } } - let middle_click = ctx.input(|input| input.pointer.button_clicked(egui::PointerButton::Middle)); for action in output.actions { - // A middle click on the Timeline's tab closes its active sequence; the panel stays. - if middle_click && action == (Action::Close { panel: PanelKind::Timeline }) && self.session.state.active_sequence.is_some() { - if let Err(error) = self.session.execute("sequence.close", json!({})) { - self.ui.status = error.to_string(); + for action in self.close_keeping_timeline(action) { + if let Err(error) = self.dock_command(&ctx, action) { + self.ui.status = error; } - continue; - } - if let Err(error) = self.dock_command(&ctx, action) { - self.ui.status = error; } } + if restore_workspace { + let workspace = self.ui.workspace.clone(); + self.set_workspace(&workspace); + } for action in seq_actions { let result = match action { SeqAction::Open(id) => { @@ -285,6 +285,24 @@ impl FilmcraftApp { self.cancel_value_drag(ui.ctx()); } + /// The Timeline's tabs stand for its open sequences: a close from the dock area (a tab's + /// middle click or Close Panel, a group, window or Close Other Panels item) that would + /// remove the Timeline while a sequence is open closes the active sequence instead, and the + /// Timeline stays. The other panels the action covers still close. + fn close_keeping_timeline(&mut self, action: Action) -> Vec> { + if self.session.state.active_sequence.is_none() { + return vec![action]; + } + let closed = match self.ui.dock.closed_by(&action) { + Ok(closed) if closed.contains(&PanelKind::Timeline) => closed, + _ => return vec![action], + }; + if let Err(error) = self.session.execute("sequence.close", json!({})) { + self.ui.status = error.to_string(); + } + closed.into_iter().filter(|panel| *panel != PanelKind::Timeline).map(|panel| Action::Close { panel }).collect() + } + /// Automation ids for craft-ui's gesture sources: `panel.tab.`, `panel.drag.` /// (the group's drag bar), `panel.tabs.more.`, `panel.float..move` and `.resize`, /// and `dock.gutter.` (`a`/`b` per split level). @@ -351,6 +369,8 @@ struct FilmContent<'a> { /// The ≡ button clicked: its panel and where its menu opens. menu: Option<(PanelKind, egui::Pos2)>, bodies: Vec<(PanelKind, Rect, egui::LayerId)>, + /// Restore Workspace chosen in a tab's menu; it replaces the layout after the dock area is drawn. + restore_workspace: bool, } impl DockContent for FilmContent<'_> { @@ -393,6 +413,34 @@ impl DockContent for FilmContent<'_> { if *panel == PanelKind::Timeline && self.seqs.open.is_empty() { self.seqs.label().into() } else { logical.into() } } + /// FilmCraft's own panel items head a tab's context menu, as in its ≡ menu; craft-ui adds + /// Undock, Move, Close and the group items after them. + fn panel_menu(&mut self, ui: &mut egui::Ui, panel: &PanelKind) { + let app = &mut *self.app; + let mut close = false; + if *panel == PanelKind::Timeline && app.session.state.active_sequence.is_some() { + let response = ui.add_enabled(app.session.state.open_sequences.len() > 1, egui::Button::new(tl!("Close Other Timeline Panels"))); + app.auto.add_response("panel.tabMenu.Timeline.closeOthers", &response, "Close Other Timeline Panels"); + if response.clicked() { + if let Err(error) = app.session.execute("sequence.closeOthers", json!({})) { + app.ui.status = error.to_string(); + } + close = true; + } + } + let response = ui.button(tl!("Restore Workspace")); + app.auto.add_response(&format!("panel.tabMenu.{}.restoreWorkspace", panel.id()), &response, "Restore Workspace"); + if response.clicked() { + self.restore_workspace = true; + close = true; + } + close |= panels::panel_items(app, ui, *panel); + ui.separator(); + if close { + ui.close(); + } + } + /// Tools and the audio meters are narrow strips without a tab strip. fn bare(&self, panel: &PanelKind) -> bool { panel.compact() diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 7a7f7a0be..515b08572 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -321,29 +321,7 @@ pub fn panel_menu_popup(app: &mut FilmcraftApp, ui: &mut egui::Ui) { app.set_workspace(&w); close = true; } - if p == PanelKind::Timeline { - ui.separator(); - let r = ui.add_enabled(app.session.state.active_sequence.is_some(), egui::Button::new(tl!("Reveal Sequence in Project"))); - app.auto.add("panel.menu.Timeline.revealSequence", r.rect, "Reveal Sequence in Project"); - if r.clicked() { - let _ = app.session.execute("sequence.revealInProject", serde_json::json!({})); - close = true; - } - ui.checkbox(&mut app.ui.timeline.show_thumbnails, tl!("Video Thumbnails")); - ui.checkbox(&mut app.ui.timeline.show_waveforms, tl!("Audio Waveforms")); - } - if p == PanelKind::Project { - ui.separator(); - close |= project::panel_menu(app, ui); - } - if p == PanelKind::MediaBrowser { - ui.separator(); - close |= media_browser::panel_menu(app, ui); - } - if p == PanelKind::Source { - ui.separator(); - close |= monitor::source_panel_menu(app, ui); - } + close |= panel_items(app, ui, p); }); app.auto.add("panel.menu.scroll", scroll.inner_rect, "Panel menu"); }); @@ -356,4 +334,35 @@ pub fn panel_menu_popup(app: &mut FilmcraftApp, ui: &mut egui::Ui) { } } +/// The panel's own items, each section after a separator: the Timeline's sequence and track +/// display items, and the Project, Media Browser and Source menus. Shared by the ≡ menu and the +/// tab's context menu. True when an item asks the menu to close. +pub(crate) fn panel_items(app: &mut FilmcraftApp, ui: &mut egui::Ui, p: PanelKind) -> bool { + let mut close = false; + if p == PanelKind::Timeline { + ui.separator(); + let r = ui.add_enabled(app.session.state.active_sequence.is_some(), egui::Button::new(tl!("Reveal Sequence in Project"))); + app.auto.add("panel.menu.Timeline.revealSequence", r.rect, "Reveal Sequence in Project"); + if r.clicked() { + let _ = app.session.execute("sequence.revealInProject", serde_json::json!({})); + close = true; + } + ui.checkbox(&mut app.ui.timeline.show_thumbnails, tl!("Video Thumbnails")); + ui.checkbox(&mut app.ui.timeline.show_waveforms, tl!("Audio Waveforms")); + } + if p == PanelKind::Project { + ui.separator(); + close |= project::panel_menu(app, ui); + } + if p == PanelKind::MediaBrowser { + ui.separator(); + close |= media_browser::panel_menu(app, ui); + } + if p == PanelKind::Source { + ui.separator(); + close |= monitor::source_panel_menu(app, ui); + } + close +} + pub(crate) mod transcript_cache; diff --git a/crates/ui-egui/tests/shared_docking_ui.rs b/crates/ui-egui/tests/shared_docking_ui.rs index c2e1bc35e..e51a21cbb 100644 --- a/crates/ui-egui/tests/shared_docking_ui.rs +++ b/crates/ui-egui/tests/shared_docking_ui.rs @@ -757,3 +757,60 @@ fn activate_reopens_a_closed_panel() { menus::invoke(&mut app, &ctx, "window.panel.activate", json!({"panel": "Info"})).unwrap(); assert!(app.ui.dock.is_visible(PanelKind::Info)); } + +/// Press and release `button` at `pos`. +fn click_at(h: &mut Harness<'_, FilmcraftApp>, pos: egui::Pos2, button: PointerButton) { + h.event(Event::PointerMoved(pos)); + step(h); + for pressed in [true, false] { + h.event(Event::PointerButton { pos, button, pressed, modifiers: Modifiers::NONE }); + step(h); + } + for _ in 0..3 { + step(h); + } +} + +/// Right-click the tab `tab` and choose the context-menu item labeled `item`. +fn tab_menu(h: &mut Harness<'_, FilmcraftApp>, tab: &str, item: &str) { + let pos = rect(h, tab).center(); + click_at(h, pos, PointerButton::Secondary); + let item = h.get_by_label(item).rect().center(); + click_at(h, item, PointerButton::Primary); +} + +/// A Timeline tab's context menu carries FilmCraft's Timeline items, and its Close Panel closes +/// the active sequence: the Timeline stays. +#[test] +fn close_panel_on_the_timeline_tab_closes_the_sequence() { + let mut h = demo_harness(); + let active = h.state().session.state.active_sequence.unwrap(); + let pos = rect(&h, "panel.tab.Timeline").center(); + click_at(&mut h, pos, PointerButton::Secondary); + for id in ["panel.menu.Timeline.revealSequence", "panel.tabMenu.Timeline.closeOthers", "panel.tabMenu.Timeline.restoreWorkspace"] { + assert!(h.state().auto.find(id).is_some(), "{id} is missing from the tab menu"); + } + let close = h.get_by_label("Close Panel").rect().center(); + click_at(&mut h, close, PointerButton::Primary); + assert!(!h.state().session.state.open_sequences.contains(&active), "the sequence stays open"); + assert!(h.state().ui.dock.is_visible(PanelKind::Timeline), "the Timeline closed"); +} + +/// Close Panel Group on a group holding the Timeline closes the active sequence and the group's +/// other panels; the Timeline stays. +#[test] +fn close_panel_group_keeps_the_timeline_and_closes_the_sequence() { + let mut h = demo_harness(); + let ctx = h.ctx.clone(); + menus::invoke(h.state_mut(), &ctx, "window.dockPanel", json!({"panel": "Info", "anchor": "Timeline", "zone": "center"})).unwrap(); + for _ in 0..3 { + step(&mut h); + } + assert!(same_group(&h.state().ui.dock, PanelKind::Info, PanelKind::Timeline)); + let active = h.state().session.state.active_sequence.unwrap(); + tab_menu(&mut h, "panel.tab.Info", "Close Panel Group"); + let state = h.state(); + assert!(!state.session.state.open_sequences.contains(&active), "the sequence stays open"); + assert!(state.ui.dock.is_visible(PanelKind::Timeline), "the Timeline closed"); + assert!(!state.ui.dock.contains(&PanelKind::Info), "Info stays open"); +} From 08816eac8fc3f4dcd0f1b292952b84c06ab7c898 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 16:09:46 +0000 Subject: [PATCH 030/113] UI.9: register every dock divider; any focused divider keeps shortcuts live --- crates/ui-egui/src/dock_ui.rs | 7 ++++++- crates/ui-egui/src/lib.rs | 13 ++++++------ crates/ui-egui/tests/ui_dock_resize.rs | 29 ++++++++++++++++++++++++++ docs/control-protocol.md | 6 ++++-- 4 files changed, 46 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index a95e85f8d..dea65515f 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -305,7 +305,9 @@ impl FilmcraftApp { /// Automation ids for craft-ui's gesture sources: `panel.tab.`, `panel.drag.` /// (the group's drag bar), `panel.tabs.more.`, `panel.float..move` and `.resize`, - /// and `dock.gutter.` (`a`/`b` per split level). + /// `dock.gutter.` (`a`/`b` per split level), and `dock.gutter.group.`, + /// `.stack.` and `.column.` for the dividers below a group or accordion entry and + /// beside a column, named by the panel they hold first. fn register_dock_hits(&mut self, hits: &[(Hit, Rect)]) { let window = |panel: &PanelKind| self.ui.dock.floating.iter().position(|window| window.contains(panel)); let mut found = Vec::new(); @@ -326,6 +328,9 @@ impl FilmcraftApp { Hit::Divider(Divider::Split(path)) => { (format!("dock.gutter.{}", path.iter().map(|second| if *second { 'b' } else { 'a' }).collect::()), "gutter") } + Hit::Divider(Divider::Group(panel)) => (format!("dock.gutter.group.{}", panel.id()), "gutter"), + Hit::Divider(Divider::Stack(panel)) => (format!("dock.gutter.stack.{}", panel.id()), "gutter"), + Hit::Divider(Divider::Column(panel)) => (format!("dock.gutter.column.{}", panel.id()), "gutter"), _ => continue, }; found.push((entry, *rect, DockArea::hit_id(area_id(), hit))); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fdb145296..c093d0965 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1508,13 +1508,14 @@ impl FilmcraftApp { self.menu_sent = items; } - /// Whether keyboard focus is on a dock divider or a floating panel's resize grip, as drawn last pass. + /// Whether keyboard focus is on any dock divider or floating-window resize grip, as drawn last pass. fn divider_focused(&self, ctx: &egui::Context) -> bool { + use craft_ui::docking::{DockArea, Hit}; let Some(focused) = ctx.memory(|m| m.focused()) else { return false }; - self.auto - .previous + let area = dock_ui::area_id(); + DockArea::::hit_rects(ctx, area) .iter() - .any(|e| e.response_id == Some(focused) && (e.id.starts_with("dock.gutter.") || (e.id.starts_with("panel.float.") && e.id.contains(".resize")))) + .any(|(hit, _)| matches!(hit, Hit::Divider(_) | Hit::Resize(..)) && DockArea::hit_id(area, hit) == focused) } fn handle_shortcuts(&mut self, ctx: &egui::Context) { @@ -1524,8 +1525,8 @@ impl FilmcraftApp { self.bindings = menus::bindings(self); self.bindings_rev = self.session.shortcuts.revision; } - // A pane divider or floating-panel grip keeps focus after a pointer drag so arrows can - // nudge it, but it takes no other key: Space and the other shortcuts keep working (#1482). + // A focused dock divider or floating-window grip takes the arrow keys to nudge it, but no + // other key: Space and the other shortcuts keep working (#1482). let divider_focused = self.divider_focused(ctx); if (ctx.egui_wants_keyboard_input() && !divider_focused) || self.dialog == Some(Dialog::Shortcuts) { return; diff --git a/crates/ui-egui/tests/ui_dock_resize.rs b/crates/ui-egui/tests/ui_dock_resize.rs index 50bcc0998..5c7d83249 100644 --- a/crates/ui-egui/tests/ui_dock_resize.rs +++ b/crates/ui-egui/tests/ui_dock_resize.rs @@ -177,3 +177,32 @@ fn space_plays_while_any_floating_resize_edge_has_focus() { assert!(!h.state().playback.playing); } } + +/// The divider between groups stacked in a floating window has an automation id, and Space plays +/// while it has keyboard focus (#1482). +#[test] +fn space_plays_while_a_floating_group_divider_has_focus() { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + let app = FilmcraftApp::new(session); + let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(move |_| app); + frames(&mut h, 4); + let ctx = h.ctx.clone(); + filmcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "window.floatPanel", json!({"panel": "Info", "rect": [300, 200, 400, 400]})).unwrap(); + filmcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "window.dockPanel", json!({"panel": "Project", "anchor": "Info", "zone": "bottom"})).unwrap(); + frames(&mut h, 3); + use craft_ui::docking::{Divider, DockArea, Hit}; + let area = filmcraft_ui_egui::dock_area_id(); + let hit = Hit::Divider(Divider::Group(PanelKind::Info)); + assert!(DockArea::::hit_rects(&h.ctx, area).iter().any(|(drawn, _)| *drawn == hit), "no group divider drawn"); + let divider = DockArea::::hit_id(area, &hit); + let element = h.state().auto.find("dock.gutter.group.Info").expect("the group divider has no automation id"); + assert_eq!(element.widget_id, Some(format!("{:016x}", divider.value()))); + h.ctx.memory_mut(|memory| memory.request_focus(divider)); + frames(&mut h, 2); + assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(divider)); + press(&mut h, Key::Space); + assert!(h.state().playback.playing, "Space must play with the group divider focused"); + press(&mut h, Key::Space); + assert!(!h.state().playback.playing); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 49d5228e8..328d220a5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -184,8 +184,10 @@ Time is in ticks (254 016 000 000 per second); commands also accept `seconds`, ` Invalid source/target identities and non-finite or out-of-range geometry return an error with the layout unchanged. `ui.inspect.ui.dock.floating` reports floating windows and their logical-point rectangles. `panel.drag.` locates the drag bar of each panel's group; -`panel.float..move` and `.resize` locate floating geometry handles; `ui.docking` lists -every docking gesture source. Named workspace saves include floating windows. +`panel.float..move` and `.resize` locate floating geometry handles; `dock.gutter.` +locates a split divider (`a`/`b` per split level), and `dock.gutter.group.`, +`.stack.` and `.column.` the dividers below a group or accordion entry and beside a +column, named by the panel they hold first; `ui.docking` lists every docking gesture source. Named workspace saves include floating windows. The panels use craft-ui's shared docking in its Frame profile. Its control commands (`window.panel.move`, `.float`, `.dock`, `.activate`, `.close`, `.closeGroup`, `.closeOthers`, From c7667aaa60fd77e7f7dc11122e448bb1a5fea9ef Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:44:20 +0200 Subject: [PATCH 031/113] Centre Transform's auto points in the clip's picture on apply and reset (#2588) --- crates/engine/src/clip_ops_tests.rs | 27 +++++++++++++++++++++++++++ crates/engine/src/commands.rs | 22 ++++++++++++++++------ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/clip_ops_tests.rs b/crates/engine/src/clip_ops_tests.rs index 940c00af5..5a8ee62a4 100644 --- a/crates/engine/src/clip_ops_tests.rs +++ b/crates/engine/src/clip_ops_tests.rs @@ -1082,3 +1082,30 @@ fn toggle_animation_creates_a_parameter_missing_from_an_older_instance() { let rot = it.effect("motion").unwrap().param("rotation").unwrap(); assert!(rot.is_animated(), "the stopwatch is on"); } + +/// Transform's Anchor Point and Position are centred in the clip's own picture (on apply and on reset), so Scale grows it +/// from its centre (they used to be centred in the sequence frame, off to the side of a smaller picture). +#[test] +fn applied_transform_centres_its_points_in_the_clip_picture() { + let mut s = demo(); + let r = s.execute("file.newColorMatte", json!({"width": 960, "height": 1080, "color": "#ff0000"})).unwrap(); + let matte = r["item"].as_u64().unwrap(); + let r = s.execute("timeline.place", json!({"item": matte, "track": "V3", "seconds": 0.0})).unwrap(); + let c = r["clips"][0].as_u64().unwrap(); + s.execute("effects.apply", json!({"clips": [c], "effect": "transform"})).unwrap(); + let centre = filmcraft_geom::Vec2::new(480.0, 540.0); + let point = |s: &Session, k: &str| clip(s, c).effect("transform").unwrap().param(k).unwrap().value.as_vec2().unwrap(); + assert_eq!(point(&s, "anchor"), centre); + assert_eq!(point(&s, "position"), centre); + // Reset Effect and Reset Parameter centre them the same way + for k in ["anchor", "position"] { + s.execute("effects.setParam", json!({"clip": c, "effect": "transform", "param": k, "value": [10.0, 20.0]})).unwrap(); + } + let idx = clip(&s, c).effects.iter().position(|e| e.effect == "transform").unwrap(); + s.execute("effects.reset", json!({"clip": c, "index": idx})).unwrap(); + assert_eq!(point(&s, "anchor"), centre); + assert_eq!(point(&s, "position"), centre); + s.execute("effects.setParam", json!({"clip": c, "effect": "transform", "param": "anchor", "value": [10.0, 20.0]})).unwrap(); + s.execute("effects.resetParam", json!({"clip": c, "effect": "transform", "param": "anchor"})).unwrap(); + assert_eq!(point(&s, "anchor"), centre); +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 162fb18ca..a037b4733 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -2955,6 +2955,12 @@ fn build() -> Vec { } let (fw, fh) = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080)); let label = format!("Apply {}", def.name); + // standard effects run on the clip's source picture, so "auto" points (the centre) are + // measured in it, not in the sequence frame + let source_sizes: std::collections::BTreeMap = s + .active_sequence() + .map(|q| clips.iter().filter_map(|c| q.find_item(*c).and_then(|(_, i)| s.project.source_size(i.item)).map(|sz| (*c, sz))).collect()) + .unwrap_or_default(); s.edit_sequence(&label, |q, _, _| { let mut n = 0; for t in q.all_tracks_mut() { @@ -2963,7 +2969,8 @@ fn build() -> Vec { } for i in t.items.iter_mut().filter(|i| clips.contains(&i.id)) { let mut inst = def.instance(); - resolve_auto_points(&mut inst, (fw, fh), (fw, fh)); + let size = source_sizes.get(&i.id).copied().unwrap_or((fw, fh)); + resolve_auto_points(&mut inst, size, size); // standard effects go before the intrinsic ones (render order) let pos = i.effects.iter().position(|e| e.def().is_some_and(|d| d.intrinsic)).unwrap_or(i.effects.len()); let pos = if is_audio { i.effects.len() } else { pos }; @@ -3150,12 +3157,13 @@ fn build() -> Vec { let c = clip_p(p, "clip").ok_or_else(|| bad("effects.reset", "need `clip`"))?; let idx = u64_p(p, "index").unwrap_or(0) as usize; let frame = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080)); + let source = s.active_sequence().and_then(|q| q.find_item(c)).and_then(|(_, i)| s.project.source_size(i.item)).unwrap_or(frame); let ph = s.playhead(); s.edit_sequence("Reset Effect", |q, _, _| { let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?; let mt = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start))); if let Some(e) = it.effects.get_mut(idx) { - reset_params(e, None, frame, mt); + reset_params(e, None, frame, source, mt); } Ok(()) })?; @@ -3166,6 +3174,7 @@ fn build() -> Vec { let pid = str_p(p, "param").ok_or_else(|| bad("effects.resetParam", "need `param`"))?.to_string(); let eff = p.get("effect").cloned().unwrap_or(json!("motion")); let frame = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080)); + let source = s.active_sequence().and_then(|q| q.find_item(c)).and_then(|(_, i)| s.project.source_size(i.item)).unwrap_or(frame); let ph = s.playhead(); s.edit_sequence("Reset Parameter", |q, _, _| { let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?; @@ -3176,7 +3185,7 @@ fn build() -> Vec { _ => None, } .ok_or_else(|| bad("effects.resetParam", "no such effect on clip"))?; - if !reset_params(e, Some(&pid), frame, mt) { + if !reset_params(e, Some(&pid), frame, source, mt) { return Err(bad("effects.resetParam", format!("no param `{pid}`"))); } Ok(()) @@ -3455,11 +3464,12 @@ fn build() -> Vec { /// Put `e`'s parameters (all of them, or only `only`) back to their defaults, as Premiere's reset /// buttons do: a parameter that is not animated gets its default as its static value, and an /// animated one keeps its keyframes and gets the default as a keyframe at media time `mt`. -/// Masks are left alone. Returns whether any parameter was reset. -fn reset_params(e: &mut filmcraft_project::EffectInstance, only: Option<&str>, frame: (u32, u32), mt: Tick) -> bool { +/// Masks are left alone. Returns whether any parameter was reset. "Auto" points are centred as +/// when the effect was added: a standard effect's in the clip's `source` picture it runs on. +fn reset_params(e: &mut filmcraft_project::EffectInstance, only: Option<&str>, frame: (u32, u32), source: (u32, u32), mt: Tick) -> bool { let Some(d) = e.def() else { return false }; let mut fresh = d.instance(); - resolve_auto_points(&mut fresh, frame, frame); + resolve_auto_points(&mut fresh, if d.intrinsic { frame } else { source }, source); let mut any = false; for (id, prm) in fresh.params { if only.is_some_and(|o| o != id) { From 359b744e78d04e43939988c28b1af91c3d380934 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 17:58:49 +0000 Subject: [PATCH 032/113] UI.9: dock tests expect no focus on a divider or grip after a pointer drag --- crates/ui-egui/tests/shared_docking_ui.rs | 4 ++++ crates/ui-egui/tests/ui_dock_resize.rs | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/tests/shared_docking_ui.rs b/crates/ui-egui/tests/shared_docking_ui.rs index e51a21cbb..2995c64e9 100644 --- a/crates/ui-egui/tests/shared_docking_ui.rs +++ b/crates/ui-egui/tests/shared_docking_ui.rs @@ -242,6 +242,10 @@ fn real_panel_drag_merges_splits_floats_and_redocks_without_editing_documents() assert!(h.state().ui.dock.floating[0].rect[3] > original[3] + 15.0); action_log.push(action_state(&h, "resized")); let grip_id = DockArea::::hit_id(filmcraft_ui_egui::dock_area_id(), &Hit::Resize(PanelKind::Info, ResizeEdge::BottomRight)); + // A pointer drag leaves no focus on the grip; focused from the keyboard, it takes arrows. + assert_ne!(h.ctx.memory(|memory| memory.focused()), Some(grip_id)); + h.ctx.memory_mut(|memory| memory.request_focus(grip_id)); + step(&mut h); assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(grip_id)); let before_keys = h.state().ui.dock.floating[0].rect; for key in [egui::Key::ArrowRight, egui::Key::ArrowDown] { diff --git a/crates/ui-egui/tests/ui_dock_resize.rs b/crates/ui-egui/tests/ui_dock_resize.rs index 5c7d83249..734b1ebbb 100644 --- a/crates/ui-egui/tests/ui_dock_resize.rs +++ b/crates/ui-egui/tests/ui_dock_resize.rs @@ -139,8 +139,8 @@ fn space_plays_after_a_pane_is_resized_with_the_pointer() { h.event(Event::PointerButton { pos: from + vec2(20.0, 0.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); frames(&mut h, 3); assert_eq!(first_size(&h), 420.0); - // The divider keeps keyboard focus so arrows can nudge it; that must not cost the editor its shortcuts. - assert_eq!( + // A pointer drag leaves no keyboard focus on the divider, so the editor keeps its shortcuts. + assert_ne!( h.ctx.memory(|memory| memory.focused()), Some(craft_ui::docking::DockArea::::hit_id( filmcraft_ui_egui::dock_area_id(), From 28859f69e50bb76d2fd8b7c00c802b1bedb06c2b Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:08:35 +0200 Subject: [PATCH 033/113] Lay out Motion's auto points in the sequence frame when applied (#2588) --- crates/engine/src/clip_ops_tests.rs | 7 +++++++ crates/engine/src/commands.rs | 4 +++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/clip_ops_tests.rs b/crates/engine/src/clip_ops_tests.rs index 5a8ee62a4..964dedd03 100644 --- a/crates/engine/src/clip_ops_tests.rs +++ b/crates/engine/src/clip_ops_tests.rs @@ -1108,4 +1108,11 @@ fn applied_transform_centres_its_points_in_the_clip_picture() { s.execute("effects.setParam", json!({"clip": c, "effect": "transform", "param": "anchor", "value": [10.0, 20.0]})).unwrap(); s.execute("effects.resetParam", json!({"clip": c, "effect": "transform", "param": "anchor"})).unwrap(); assert_eq!(point(&s, "anchor"), centre); + // intrinsic effects stay centred in the sequence frame when applied to a smaller clip + let q = s.active_sequence().unwrap(); + let frame_centre = filmcraft_geom::Vec2::new(f64::from(q.settings.width) / 2.0, f64::from(q.settings.height) / 2.0); + s.execute("effects.apply", json!({"clips": [c], "effect": "motion"})).unwrap(); + for m in clip(&s, c).effects.iter().filter(|e| e.effect == "motion") { + assert_eq!(m.param("position").unwrap().value.as_vec2().unwrap(), frame_centre); + } } diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index a037b4733..9687881f4 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -2970,7 +2970,9 @@ fn build() -> Vec { for i in t.items.iter_mut().filter(|i| clips.contains(&i.id)) { let mut inst = def.instance(); let size = source_sizes.get(&i.id).copied().unwrap_or((fw, fh)); - resolve_auto_points(&mut inst, size, size); + // intrinsic effects (Motion) are laid out in the sequence frame, as `reset_params` does + let frame = if def.intrinsic { (fw, fh) } else { size }; + resolve_auto_points(&mut inst, frame, size); // standard effects go before the intrinsic ones (render order) let pos = i.effects.iter().position(|e| e.def().is_some_and(|d| d.intrinsic)).unwrap_or(i.effects.len()); let pos = if is_audio { i.effects.len() } else { pos }; From d1b45e960b123c6fcf2c452b0666417c404a94ee Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:15:42 +0200 Subject: [PATCH 034/113] Stop writing an empty ref for FCPXML titles and adjustment layers (#2638) --- crates/interchange/README.md | 2 +- crates/interchange/src/fcpxml.rs | 60 +++++++++++++-------- crates/interchange/tests/uncarried_clips.rs | 16 ++++++ 3 files changed, 54 insertions(+), 24 deletions(-) diff --git a/crates/interchange/README.md b/crates/interchange/README.md index edaff851f..976989e14 100644 --- a/crates/interchange/README.md +++ b/crates/interchange/README.md @@ -76,7 +76,7 @@ none of the exports brings one back. Each such clip is named in the export `Repo | Format | Written as | On import into FilmCraft | |---|---|---| | FCP7 XML | a clip item without media (its layers as FilmCraft filter ids) | not read back; the import report names the skipped clip item | -| FCPXML | a clip without media | not read back | +| FCPXML | a gap on V1; left out on other tracks (a gap exists only in the primary storyline) | not read back | | OTIO | a clip with a `MissingReference` (effects in the `filmcraft` metadata) | offline media | | EDL | an ordinary event with the reel the reel mode gives | an ordinary clip | | AAF | a gap | nothing | diff --git a/crates/interchange/src/fcpxml.rs b/crates/interchange/src/fcpxml.rs index e231fff5e..481813f4d 100644 --- a/crates/interchange/src/fcpxml.rs +++ b/crates/interchange/src/fcpxml.rs @@ -871,6 +871,10 @@ impl Exp<'_, '_> { if merged_audio.contains(&it.id) { continue; } + if let Some(what) = self.unsupported(it, rate) { + self.report.warn(format!("{what}: it is left out and is not read back on import")); + continue; + } let Some(pi) = els.iter().position(|e| e.start <= it.start && it.start < e.end) else { self.report.warn("a connected clip could not be attached and was dropped"); continue; @@ -938,30 +942,29 @@ impl Exp<'_, '_> { ) } + /// How the report names a clip FCPXML cannot carry (a generator, a title, an adjustment layer); `None` for any + /// other clip. Such a clip has no asset to reference, and an empty `ref` is not a valid IDREF: Final Cut Pro + /// rejects the whole file. A `` exists only in a spine, so a connected one is left out instead. + fn unsupported(&self, it: &TrackItem, rate: FrameRate) -> Option { + if let Some(g) = generator_of(self.p, base_item(self.p, it.item)) { + return Some(format!("generator \"{}\" is not supported in FCPXML", g.label())); + } + crate::common::uncarried_clip(self.p, it, rate).map(|clip| format!("{clip} is not supported in FCPXML")) + } + /// A clip element at `offset` (in its parent's local time). #[allow(clippy::too_many_arguments)] fn clip_el(&mut self, it: &TrackItem, audio: Option<&TrackItem>, kind: TrackKind, offset: Tick, lane: Option, seq: &Sequence, primary: bool) -> El { let rate = seq.settings.frame_rate; let base = base_item(self.p, it.item); let is_seq = matches!(self.p.item(base).map(|i| &i.kind), Some(ItemKind::Sequence(_))); - let generator = generator_of(self.p, base).cloned(); let lane_attr = lane.map(|l| format!(" lane=\"{l}\"")).unwrap_or_default(); let name = crate::xml::escape(&it.name); - if let Some(g) = generator { - self.report.warn(format!("generator \"{}\" is not supported in FCPXML; written as a gap", g.label())); - return El { - start: it.start, - end: it.end(), - local: offset, - xml_open: format!( - " { let origin = self.p.sequence(base).map(|s| s.settings.frame_rate.tick_of(s.start_timecode)).unwrap_or(Tick::ZERO); ("ref-clip", r, origin) } else { - let r = match crate::common::uncarried_clip(self.p, it, rate) { - Some(clip) => { - self.report.warn(format!("{clip} is not supported in FCPXML: it is written as a clip without media and is not read back on import")); - String::new() - } - None => self.asset(base, rate), - }; + let r = self.asset(base, rate); let m = item_media(self.p, base); let mrate = m.and_then(|m| m.info.video.as_ref().map(|v| v.frame_rate)).unwrap_or(rate); let st = m.and_then(|m| m.info.start_timecode).map(|f| mrate.tick_of(f)).unwrap_or(Tick::ZERO); @@ -1138,6 +1135,23 @@ impl Exp<'_, '_> { } } +/// A primary-storyline `` standing in for a clip FCPXML cannot carry; it keeps the clip's place and length. +fn gap_el(it: &TrackItem, name: &str, offset: Tick, rate: FrameRate) -> El { + El { + start: it.start, + end: it.end(), + local: offset, + xml_open: format!( + " String { if params.is_empty() { format!("<{tag}{attrs}/>") } else { format!("<{tag}{attrs}>\n{}\n", params.join("\n")) } } diff --git a/crates/interchange/tests/uncarried_clips.rs b/crates/interchange/tests/uncarried_clips.rs index 2f0902e26..5355c1a9d 100644 --- a/crates/interchange/tests/uncarried_clips.rs +++ b/crates/interchange/tests/uncarried_clips.rs @@ -144,3 +144,19 @@ fn fcp7_import_reports_a_clip_item_whose_file_is_not_defined() { let (_, report) = import(&bytes, Format::Fcp7Xml, None).unwrap(); assert!(!report.mentions("does not define"), "{report}"); } + +/// An empty `ref` is not a valid IDREF: Final Cut Pro rejects the whole file. The adjustment layer +/// on V1 becomes a gap in the primary storyline; the connected title is left out (a `` can +/// only sit in a spine). The media clip still references its asset. +#[test] +fn fcpxml_has_no_empty_ref_for_a_title() { + let (p, s) = project_with_a_title(); + let (bytes, report) = export_report(&p, s, Format::Fcpxml); + let xml = String::from_utf8(bytes).unwrap(); + assert!(!xml.contains("ref=\"\""), "{xml}"); + assert!(!xml.contains("Opening"), "{xml}"); + assert_eq!(xml.matches(" Date: Wed, 21 Oct 2026 16:30:24 +0000 Subject: [PATCH 035/113] UI.9: follow craft-ui docking at 2fa4ad7 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 622dccb77..39692f308 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1019,7 +1019,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09 [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=352a8308c5cc53c2ae31326fa38a4a6bcb0ff9eb#352a8308c5cc53c2ae31326fa38a4a6bcb0ff9eb" +source = "git+https://github.com/storytold/craft-libs?rev=2fa4ad79d7dcde5e6ad671d36083dee367a1b788#2fa4ad79d7dcde5e6ad671d36083dee367a1b788" dependencies = [ "egui", "serde", diff --git a/Cargo.toml b/Cargo.toml index 09aec37f2..66f2ab475 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,7 +74,7 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l # ui # Shared frontend components; keep this dependency above the engine boundary. -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "352a8308c5cc53c2ae31326fa38a4a6bcb0ff9eb", features = ["serde"] } +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "2fa4ad79d7dcde5e6ad671d36083dee367a1b788", features = ["serde"] } egui = "0.36" eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" From f23dfeb1ee697dcae48429fd4790679821e0f98d Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 16:30:24 +0000 Subject: [PATCH 036/113] UI.9: floating panels stay out of the macOS title-bar strip With integrated_titlebar the header sits in the transparent macOS title bar, where a press on a floating window would move the whole app window. The dock area's float bounds and the command fallback start below the header. --- crates/ui-egui/src/dock_ui.rs | 14 ++++++++++++-- crates/ui-egui/src/header.rs | 3 +++ crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/tests/shared_docking_ui.rs | 18 ++++++++++++++++++ 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index dea65515f..cde26ec48 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -128,11 +128,21 @@ impl FilmcraftApp { self.show_panel(*panel); return Ok(()); } - let space = craft_ui::docking::float_space(ctx, area_id()).unwrap_or_else(|| FloatSpace::new(ctx.content_rect())); + let space = craft_ui::docking::float_space(ctx, area_id()).unwrap_or_else(|| FloatSpace::new(self.float_bounds(ctx))); let actions = command.to_actions(&Workspace::new(self.ui.dock.clone()), false, &space).map_err(|error| error.to_string())?; self.apply_dock_all(actions) } + /// Where floating windows may go. With `integrated_titlebar` the header sits in the macOS + /// title-bar strip, where a press on a floating window would move the whole app window. + pub(crate) fn float_bounds(&self, ctx: &egui::Context) -> Rect { + let mut bounds = ctx.content_rect(); + if self.integrated_titlebar { + bounds.min.y += crate::header::HEIGHT; + } + bounds + } + pub fn apply_dock(&mut self, action: Action) -> Result<(), String> { self.apply_dock_all(vec![action]) } @@ -210,7 +220,7 @@ impl FilmcraftApp { active: self.session.state.active_sequence.map(|id| id.0), }; let style = dock_style(ui, &t); - let mut area = DockArea::new(area_id()).maximized(self.ui.keys.maximized.filter(|panel| self.ui.dock.is_docked(*panel))); + let mut area = DockArea::new(area_id()).float_bounds(self.float_bounds(&ctx)).maximized(self.ui.keys.maximized.filter(|panel| self.ui.dock.is_docked(*panel))); if let Some(panel) = self.pending_panel_raise.take() { area = area.raise_panel(panel); } diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 5b8f23c85..9f08e2c8d 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -10,6 +10,9 @@ use crate::icons::{self, Icon}; use crate::state::Mode; use crate::theme::Tokens; +/// Height of the header bar. With `integrated_titlebar` it sits in the macOS title-bar strip. +pub const HEIGHT: f32 = 38.0; + pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let p = ui.painter().clone(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c093d0965..7d95740d1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1841,7 +1841,7 @@ impl FilmcraftApp { let t = self.tokens; let full = ui.max_rect(); ui.painter().rect_filled(full, 0.0, t.app_bg); - let header_h = if self.ui.show_header { 38.0 } else { 0.0 }; + let header_h = if self.ui.show_header { header::HEIGHT } else { 0.0 }; let header = egui::Rect::from_min_size(full.min, egui::vec2(full.width(), header_h)); if self.ui.show_header { header::show(self, ui, header); diff --git a/crates/ui-egui/tests/shared_docking_ui.rs b/crates/ui-egui/tests/shared_docking_ui.rs index 2995c64e9..5f6904e5d 100644 --- a/crates/ui-egui/tests/shared_docking_ui.rs +++ b/crates/ui-egui/tests/shared_docking_ui.rs @@ -818,3 +818,21 @@ fn close_panel_group_keeps_the_timeline_and_closes_the_sequence() { assert!(state.ui.dock.is_visible(PanelKind::Timeline), "the Timeline closed"); assert!(!state.ui.dock.contains(&PanelKind::Info), "Info stays open"); } + +/// With the header in the macOS title-bar strip, a floating window saved at the top of the +/// viewport is drawn below the strip, where a press on it cannot move the app window. +#[test] +fn a_floating_panel_stays_out_of_the_integrated_titlebar() { + let mut app = FilmcraftApp::new(Session::default()); + app.ui.dock = layout(); + app.integrated_titlebar = true; + let ctx = egui::Context::default(); + menus::invoke(&mut app, &ctx, "window.floatPanel", json!({"panel": "Info", "rect": [45, 65, 420, 320]})).unwrap(); + app.ui.dock.floating.iter_mut().find(|window| window.contains(&PanelKind::Info)).unwrap().rect[1] = 0.0; + let mut h = Harness::builder().with_size(vec2(1200.0, 760.0)).build_eframe(move |_| app); + for _ in 0..3 { + step(&mut h); + } + let drawn = h.ctx.memory(|memory| memory.area_rect(window_layer(&h, PanelKind::Info).id)).expect("Info is drawn floating"); + assert!(drawn.min.y >= filmcraft_ui_egui::header::HEIGHT, "drawn at {drawn:?}"); +} From 5963690cd36a3c7e6b7215ff59fff1cde4aa8778 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 16:51:06 +0000 Subject: [PATCH 037/113] UI.9: follow craft-ui docking at 958c5ad --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 39692f308..6afa02216 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1019,7 +1019,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09 [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=2fa4ad79d7dcde5e6ad671d36083dee367a1b788#2fa4ad79d7dcde5e6ad671d36083dee367a1b788" +source = "git+https://github.com/storytold/craft-libs?rev=958c5ada58269ceb84a763021ad31524c0ee7a76#958c5ada58269ceb84a763021ad31524c0ee7a76" dependencies = [ "egui", "serde", diff --git a/Cargo.toml b/Cargo.toml index 66f2ab475..282921f1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,7 +74,7 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l # ui # Shared frontend components; keep this dependency above the engine boundary. -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "2fa4ad79d7dcde5e6ad671d36083dee367a1b788", features = ["serde"] } +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "958c5ada58269ceb84a763021ad31524c0ee7a76", features = ["serde"] } egui = "0.36" eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" From 57959eb4925b7ec807d91180e97bd75397b254f5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 16:51:06 +0000 Subject: [PATCH 038/113] UI.9: Tools and the Audio Meters keep their factory widths FilmContent's dock limits cap Tools at 64 pt and the Audio Meters at 116 pt, the widths of the Editing layout, so the divider beside them cannot widen them. Their minimums are unchanged. --- crates/ui-egui/src/dock.rs | 19 ++++++++++++--- crates/ui-egui/src/dock_ui.rs | 10 +++++++- crates/ui-egui/tests/ui_dock_resize.rs | 33 ++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 63b27990d..e1a30cb84 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -11,6 +11,11 @@ use craft_ui::layout::{SplitAxis, SplitSize}; use crate::icons::{self, Icon}; use crate::theme::Tokens; +/// Width of the Tools panel in the factory layouts. +pub const TOOLS_WIDTH: f32 = 64.0; +/// Width of the Audio Meters in the factory layouts. +pub const METERS_WIDTH: f32 = 116.0; + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum PanelKind { Project, @@ -132,6 +137,14 @@ impl PanelKind { pub fn compact(self) -> bool { matches!(self, PanelKind::Tools | PanelKind::AudioMeters) } + /// The widest the panel may be: Tools and the Audio Meters stay at their factory widths. + pub fn max_width(self) -> Option { + match self { + PanelKind::Tools => Some(TOOLS_WIDTH), + PanelKind::AudioMeters => Some(METERS_WIDTH), + _ => None, + } + } } /// A FilmCraft panel arrangement: craft-ui's frame tree and floating windows. @@ -369,7 +382,7 @@ pub fn workspace(name: &str) -> Layout { fn workspace_root(name: &str) -> Node { use PanelKind::*; use SplitSize::{FixedFirst as FixedA, FixedSecond as FixedB, Ratio}; - let bottom_editing = || hsplit(FixedA(64.0), tabs(&[Tools], 0), hsplit(FixedB(116.0), tabs(&[Timeline], 0), tabs(&[AudioMeters], 0))); + let bottom_editing = || hsplit(FixedA(TOOLS_WIDTH), tabs(&[Tools], 0), hsplit(FixedB(METERS_WIDTH), tabs(&[Timeline], 0), tabs(&[AudioMeters], 0))); match name { "Assembly" => vsplit( Ratio(0.52), @@ -437,7 +450,7 @@ fn workspace_root(name: &str) -> Node { hsplit( Ratio(0.29), tabs(&[Project, MediaBrowser, Libraries, Info, Effects, Markers, History], 0), - hsplit(FixedA(64.0), tabs(&[Tools], 0), hsplit(FixedB(116.0), tabs(&[Timeline], 0), tabs(&[AudioMeters], 0))), + hsplit(FixedA(TOOLS_WIDTH), tabs(&[Tools], 0), hsplit(FixedB(METERS_WIDTH), tabs(&[Timeline], 0), tabs(&[AudioMeters], 0))), ), ), } @@ -494,7 +507,7 @@ impl LayoutExt for Layout { match n { Node::Split { first, second, .. } => beside_meters(first) || beside_meters(second), Node::Tabs { panels, .. } if panels.as_slice() == [PanelKind::AudioMeters] => { - *n = hsplit(SplitSize::FixedSecond(116.0), tabs(&[PanelKind::Timeline], 0), tabs(&[PanelKind::AudioMeters], 0)); + *n = hsplit(SplitSize::FixedSecond(METERS_WIDTH), tabs(&[PanelKind::Timeline], 0), tabs(&[PanelKind::AudioMeters], 0)); true } _ => false, diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index cde26ec48..2c69a071d 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -4,7 +4,7 @@ //! sequence tabs, and maps its control commands onto //! craft-ui's `DockCommand`. -use craft_ui::docking::{Action, Divider, DockArea, DockCommand, DockContent, DockEvent, DockStyle, FloatSpace, Hit, Layout, Profile, ResizeEdge, Workspace}; +use craft_ui::docking::{Action, Divider, DockArea, DockCommand, DockContent, DockEvent, DockStyle, FloatSpace, Hit, Layout, PanelLimits, Profile, ResizeEdge, Workspace}; use egui::{Rect, Sense, Stroke, StrokeKind, vec2}; use serde_json::{Value, json}; @@ -419,6 +419,14 @@ impl DockContent for FilmContent<'_> { } } + fn limits(&self, panel: &PanelKind) -> PanelLimits { + let mut limits = PanelLimits::default(); + if let Some(width) = panel.max_width() { + limits.max.x = width; + } + limits + } + fn logical_label(&self, panel: &PanelKind) -> String { crate::i18n::t(panel.title()).to_string() } diff --git a/crates/ui-egui/tests/ui_dock_resize.rs b/crates/ui-egui/tests/ui_dock_resize.rs index 734b1ebbb..d08657e99 100644 --- a/crates/ui-egui/tests/ui_dock_resize.rs +++ b/crates/ui-egui/tests/ui_dock_resize.rs @@ -206,3 +206,36 @@ fn space_plays_while_a_floating_group_divider_has_focus() { press(&mut h, Key::Space); assert!(!h.state().playback.playing); } + +/// Tools keeps its factory width: dragging the divider beside it does not widen it. +#[test] +fn the_divider_beside_tools_cannot_widen_it() { + let app = FilmcraftApp::new(Session::default()); + let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(move |_| app); + frames(&mut h, 4); + h.state_mut().ui.dock = craft_ui::docking::Layout { + root: Some(craft_ui::docking::Node::Split { + axis: craft_ui::layout::SplitAxis::Horizontal, + size: craft_ui::layout::SplitSize::FixedFirst(filmcraft_ui_egui::dock::TOOLS_WIDTH), + first: Box::new(craft_ui::docking::Node::Tabs { panels: vec![PanelKind::Tools], active: 0 }), + second: Box::new(craft_ui::docking::Node::Tabs { panels: vec![PanelKind::Timeline], active: 0 }), + }), + floating: vec![], + }; + frames(&mut h, 3); + let element = h.state().auto.find("dock.gutter.").unwrap(); + let from = Rect::from_min_size(pos2(element.rect[0], element.rect[1]), vec2(element.rect[2], element.rect[3])).center(); + h.event(Event::PointerMoved(from)); + step(&mut h); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + step(&mut h); + for offset in [10.0, 20.0, 30.0, 40.0] { + h.event(Event::PointerMoved(from + vec2(offset, 0.0))); + step(&mut h); + } + h.event(Event::PointerButton { pos: from + vec2(40.0, 0.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + frames(&mut h, 3); + assert!(first_size(&h) <= filmcraft_ui_egui::dock::TOOLS_WIDTH, "Tools widened to {}", first_size(&h)); + let tools = h.state().auto.find("panel.Tools").unwrap(); + assert!(tools.rect[2] <= filmcraft_ui_egui::dock::TOOLS_WIDTH, "Tools drawn {} wide", tools.rect[2]); +} From 59c4223ae71502dbd73e1378bff6f19906cec0b0 Mon Sep 17 00:00:00 2001 From: Earl Vanze Co Date: Fri, 23 Oct 2026 05:59:57 +0200 Subject: [PATCH 039/113] security(av1): reject non-increasing grain points --- crates/av1/src/header.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/av1/src/header.rs b/crates/av1/src/header.rs index 73d7ec437..2d5560a9d 100644 --- a/crates/av1/src/header.rs +++ b/crates/av1/src/header.rs @@ -1251,6 +1251,9 @@ impl FrameHeader { g.point_y_value[i] = r.f(8)? as u8; g.point_y_scaling[i] = r.f(8)? as u8; } + if !strictly_increasing(&g.point_y_value[..g.num_y_points]) { + return Err(Error::Invalid("point_y_value")); + } g.chroma_scaling_from_luma = if seq.color.mono_chrome { false } else { r.flag()? }; if seq.color.mono_chrome || g.chroma_scaling_from_luma || (seq.color.subsampling_x == 1 && seq.color.subsampling_y == 1 && g.num_y_points == 0) { g.num_cb_points = 0; @@ -1264,6 +1267,9 @@ impl FrameHeader { g.point_cb_value[i] = r.f(8)? as u8; g.point_cb_scaling[i] = r.f(8)? as u8; } + if !strictly_increasing(&g.point_cb_value[..g.num_cb_points]) { + return Err(Error::Invalid("point_cb_value")); + } g.num_cr_points = r.f(4)? as usize; if g.num_cr_points > 10 { return Err(Error::Invalid("num_cr_points")); @@ -1272,6 +1278,9 @@ impl FrameHeader { g.point_cr_value[i] = r.f(8)? as u8; g.point_cr_scaling[i] = r.f(8)? as u8; } + if !strictly_increasing(&g.point_cr_value[..g.num_cr_points]) { + return Err(Error::Invalid("point_cr_value")); + } } g.grain_scaling_minus_8 = r.f(2)?; g.ar_coeff_lag = r.f(2)?; @@ -1313,6 +1322,23 @@ impl FrameHeader { } } +fn strictly_increasing(values: &[u8]) -> bool { + values.windows(2).all(|pair| pair[0] < pair[1]) +} + +#[cfg(test)] +mod tests { + use super::strictly_increasing; + + #[test] + fn film_grain_scaling_points_must_increase() { + assert!(strictly_increasing(&[])); + assert!(strictly_increasing(&[0, 127, 255])); + assert!(!strictly_increasing(&[12, 12])); + assert!(!strictly_increasing(&[12, 11])); + } +} + pub(crate) fn default_gm_params() -> [[i32; 6]; 8] { let mut p = [[0i32; 6]; 8]; for row in p.iter_mut() { From a88eed54dc446b66aa5c70680a66063bcb23bdb7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 17:05:12 +0000 Subject: [PATCH 040/113] Format dock_ui.rs --- crates/ui-egui/src/dock_ui.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 2c69a071d..a9cdaff1b 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -4,7 +4,9 @@ //! sequence tabs, and maps its control commands onto //! craft-ui's `DockCommand`. -use craft_ui::docking::{Action, Divider, DockArea, DockCommand, DockContent, DockEvent, DockStyle, FloatSpace, Hit, Layout, PanelLimits, Profile, ResizeEdge, Workspace}; +use craft_ui::docking::{ + Action, Divider, DockArea, DockCommand, DockContent, DockEvent, DockStyle, FloatSpace, Hit, Layout, PanelLimits, Profile, ResizeEdge, Workspace, +}; use egui::{Rect, Sense, Stroke, StrokeKind, vec2}; use serde_json::{Value, json}; @@ -220,7 +222,8 @@ impl FilmcraftApp { active: self.session.state.active_sequence.map(|id| id.0), }; let style = dock_style(ui, &t); - let mut area = DockArea::new(area_id()).float_bounds(self.float_bounds(&ctx)).maximized(self.ui.keys.maximized.filter(|panel| self.ui.dock.is_docked(*panel))); + let mut area = + DockArea::new(area_id()).float_bounds(self.float_bounds(&ctx)).maximized(self.ui.keys.maximized.filter(|panel| self.ui.dock.is_docked(*panel))); if let Some(panel) = self.pending_panel_raise.take() { area = area.raise_panel(panel); } From df5f86ae31dbb17be54cbc06c01ac87a634c3b1f Mon Sep 17 00:00:00 2001 From: skylartaylor Date: Sat, 24 Oct 2026 11:13:28 +0200 Subject: [PATCH 041/113] egui 0.37: migrate FilmCraft input and rendering integration --- .github/workflows/ci.yml | 2 +- .github/workflows/freebsd.yml | 2 +- .github/workflows/release.yml | 2 +- Cargo.lock | 281 ++- Cargo.toml | 29 +- apps/filmcraft-web/src/fs.rs | 7 +- apps/filmcraft-web/src/recovery.rs | 15 +- apps/filmcraft-web/src/webcodecs.rs | 8 +- apps/filmcraft/Cargo.toml | 2 +- apps/filmcraft/src/audio.rs | 2 +- crates/engine/src/clip_ops.rs | 1 + crates/engine/src/commands.rs | 3 + crates/engine/src/graphics.rs | 10 +- crates/engine/src/graphics_tests.rs | 25 + crates/engine/src/lib.rs | 5 + crates/engine/src/narration.rs | 2 +- crates/engine/src/project_tools.rs | 3 +- crates/engine/src/relink.rs | 4 +- crates/format/src/lib.rs | 29 + crates/platform/src/nvenc/export.rs | 2 +- crates/project/src/graphic.rs | 78 + crates/project/src/gtemplate.rs | 10 +- crates/project/src/keyframe.rs | 45 + crates/project/src/lib.rs | 26 + crates/render/src/offline.rs | 2 +- crates/ui-egui/Cargo.toml | 5 +- crates/ui-egui/src/automation.rs | 74 +- crates/ui-egui/src/brand.rs | 2 +- crates/ui-egui/src/capture.rs | 795 +++++++++ crates/ui-egui/src/cjk.rs | 13 +- crates/ui-egui/src/control.rs | 146 +- crates/ui-egui/src/credits.rs | 2 +- crates/ui-egui/src/dock.rs | 29 +- crates/ui-egui/src/dock_ui.rs | 17 +- crates/ui-egui/src/graphics_notice.rs | 2 +- crates/ui-egui/src/header.rs | 16 +- crates/ui-egui/src/i18n/de.tsv | 4 +- crates/ui-egui/src/i18n/es.tsv | 6 +- crates/ui-egui/src/i18n/fr.tsv | 6 +- crates/ui-egui/src/i18n/id.tsv | 6 +- crates/ui-egui/src/i18n/it.tsv | 6 +- crates/ui-egui/src/i18n/ja.tsv | 6 +- crates/ui-egui/src/i18n/ko.tsv | 6 +- crates/ui-egui/src/i18n/mod.rs | 37 +- crates/ui-egui/src/i18n/pt-br.tsv | 6 +- crates/ui-egui/src/i18n/zh-cn.tsv | 6 +- crates/ui-egui/src/lib.rs | 735 ++++++-- crates/ui-egui/src/links.rs | 4 +- crates/ui-egui/src/menus.rs | 53 +- crates/ui-egui/src/panels/audio_fx_editor.rs | 10 +- crates/ui-egui/src/panels/clip_dialogs.rs | 16 +- crates/ui-egui/src/panels/color_dialogs.rs | 4 +- crates/ui-egui/src/panels/dialogs.rs | 16 +- crates/ui-egui/src/panels/effect_controls.rs | 101 +- .../src/panels/effect_controls_selection.rs | 4 +- crates/ui-egui/src/panels/effects.rs | 2 +- crates/ui-egui/src/panels/essential_sound.rs | 21 +- crates/ui-egui/src/panels/events.rs | 2 +- crates/ui-egui/src/panels/export_mode.rs | 25 +- crates/ui-egui/src/panels/file_dialogs.rs | 4 +- crates/ui-egui/src/panels/frame_export.rs | 18 +- crates/ui-egui/src/panels/graphics.rs | 1105 +++++++++++- .../ui-egui/src/panels/graphics_templates.rs | 36 +- crates/ui-egui/src/panels/import_mode.rs | 6 +- .../ui-egui/src/panels/interchange_export.rs | 6 +- crates/ui-egui/src/panels/keyboard.rs | 7 +- crates/ui-egui/src/panels/lumetri.rs | 29 +- crates/ui-egui/src/panels/masks.rs | 46 +- crates/ui-egui/src/panels/media_browser.rs | 25 +- crates/ui-egui/src/panels/media_dialogs.rs | 27 +- crates/ui-egui/src/panels/menu_dialogs.rs | 8 +- crates/ui-egui/src/panels/metadata.rs | 7 +- crates/ui-egui/src/panels/mixer.rs | 75 +- crates/ui-egui/src/panels/mod.rs | 12 +- crates/ui-egui/src/panels/monitor.rs | 28 +- crates/ui-egui/src/panels/monitor_view.rs | 20 +- crates/ui-egui/src/panels/multicam.rs | 33 +- crates/ui-egui/src/panels/notes.rs | 2 +- crates/ui-egui/src/panels/presets.rs | 6 +- crates/ui-egui/src/panels/project.rs | 20 +- crates/ui-egui/src/panels/project_dialogs.rs | 14 +- crates/ui-egui/src/panels/project_views.rs | 29 +- crates/ui-egui/src/panels/reference.rs | 4 +- crates/ui-egui/src/panels/remix.rs | 6 +- crates/ui-egui/src/panels/scopes.rs | 8 +- .../ui-egui/src/panels/sequence_settings.rs | 6 +- crates/ui-egui/src/panels/settings.rs | 39 +- crates/ui-egui/src/panels/shortcuts_dialog.rs | 17 +- crates/ui-egui/src/panels/source_drag.rs | 4 +- crates/ui-egui/src/panels/source_range.rs | 6 +- crates/ui-egui/src/panels/text.rs | 85 +- crates/ui-egui/src/panels/timecode.rs | 4 +- crates/ui-egui/src/panels/timeline.rs | 55 +- .../ui-egui/src/panels/timeline_automation.rs | 21 +- .../ui-egui/src/panels/timeline_captions.rs | 14 +- crates/ui-egui/src/panels/timeline_volume.rs | 6 +- crates/ui-egui/src/panels/tools.rs | 6 +- .../ui-egui/src/panels/transform_handles.rs | 2 +- .../ui-egui/src/panels/transition_controls.rs | 13 +- crates/ui-egui/src/panels/trim_monitor.rs | 25 +- crates/ui-egui/src/panels/tts.rs | 6 +- crates/ui-egui/src/panels/voiceover.rs | 12 +- crates/ui-egui/src/panels/workspaces.rs | 4 +- crates/ui-egui/src/rtl.rs | 273 +-- crates/ui-egui/src/scrub.rs | 2 +- crates/ui-egui/src/source_playback.rs | 4 +- crates/ui-egui/src/state.rs | 8 + crates/ui-egui/src/theme_favorites.rs | 2 +- crates/ui-egui/src/widgets.rs | 71 +- crates/ui-egui/tests/it/clipboard_paste_ui.rs | 45 +- crates/ui-egui/tests/it/deselect_ui.rs | 2 +- .../ui-egui/tests/it/graphics_handles_ui.rs | 851 +++++++++ crates/ui-egui/tests/it/masks_ui.rs | 3 + crates/ui-egui/tests/it/mixer_ui.rs | 2 +- crates/ui-egui/tests/it/monitor_geometry.rs | 5 +- crates/ui-egui/tests/it/panels_ui.rs | 1 + crates/ui-egui/tests/it/shared_docking_ui.rs | 4 +- crates/ui-egui/tests/it/ui_control_input.rs | 17 +- crates/ui-egui/tests/it/ui_dock_resize.rs | 4 +- crates/ui-egui/tests/it/ui_keyboard_focus.rs | 10 +- docs/egui-037-migration-plan.md | 1563 +++++++++++++++++ packaging/linux/package.sh | 1 - packaging/windows/filmcraft.wxs | 11 + packaging/windows/package.ps1 | 14 + 124 files changed, 6431 insertions(+), 1146 deletions(-) create mode 100644 crates/ui-egui/src/capture.rs create mode 100644 docs/egui-037-migration-plan.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5df1ae3a3..69f8850e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/gate.yml@main with: # Same craft-fonts commit as release.yml. - craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 timeout-minutes: 120 packages: >- pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 6018ad42c..dc78b3ae0 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -32,6 +32,6 @@ jobs: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: - craft-fonts-ref: bfcbf714e45a3ea8e98f88ab2b7971372a576414 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 skip-tests: true run-package-check: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 711a108d1..0724495e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: with: app-name: filmcraft app-title: FilmCraft - craft-fonts-ref: bfcbf714e45a3ea8e98f88ab2b7971372a576414 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 version: ${{ inputs.version }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} diff --git a/Cargo.lock b/Cargo.lock index 9ff01577b..ce1a61540 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,21 +4,21 @@ version = 4 [[package]] name = "accesskit" -version = "0.24.1" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" dependencies = [ "uuid", ] [[package]] name = "accesskit_atspi_common" -version = "0.18.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4" dependencies = [ "accesskit", - "accesskit_consumer 0.36.0", + "accesskit_consumer", "atspi-common", "phf", "serde", @@ -27,43 +27,37 @@ dependencies = [ [[package]] name = "accesskit_consumer" -version = "0.35.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] -name = "accesskit_consumer" -version = "0.36.0" +name = "accesskit_ios" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" dependencies = [ "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_consumer" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", ] [[package]] name = "accesskit_macos" -version = "0.26.3" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4" dependencies = [ "accesskit", - "accesskit_consumer 0.38.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -71,9 +65,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.21.1" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -89,13 +83,13 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.32.1" +version = "0.35.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "static_assertions", "windows 0.62.2", "windows-core 0.62.2", @@ -103,11 +97,12 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.32.2" +version = "0.34.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77" dependencies = [ "accesskit", + "accesskit_ios", "accesskit_macos", "accesskit_unix", "accesskit_windows", @@ -248,6 +243,7 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.60.2", + "wl-clipboard-rs", "x11rb", ] @@ -1027,7 +1023,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +source = "git+https://github.com/storytold/craft-libs?rev=c488caa9fbc2fb1a86e4674887e9b80b598ebbfa#c488caa9fbc2fb1a86e4674887e9b80b598ebbfa" dependencies = [ "egui", "serde", @@ -1295,9 +1291,8 @@ dependencies = [ [[package]] name = "ecolor" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "bytemuck", "emath", @@ -1329,9 +1324,8 @@ dependencies = [ [[package]] name = "eframe" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "ahash", "bytemuck", @@ -1353,6 +1347,7 @@ dependencies = [ "pollster 1.0.1", "profiling", "raw-window-handle", + "send_wrapper", "static_assertions", "wasm-bindgen", "web-sys", @@ -1364,9 +1359,8 @@ dependencies = [ [[package]] name = "egui" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "accesskit", "ahash", @@ -1384,9 +1378,8 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "ahash", "bytemuck", @@ -1404,30 +1397,33 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", + "image", "log", "objc2 0.6.4", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", + "parking_lot", "profiling", "raw-window-handle", "smithay-clipboard", "web-time", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_glow" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "bytemuck", "egui", @@ -1439,9 +1435,8 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "eframe", "egui", @@ -1484,9 +1479,8 @@ dependencies = [ [[package]] name = "emath" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "bytemuck", ] @@ -1546,9 +1540,8 @@ dependencies = [ [[package]] name = "epaint" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "ahash", "bytemuck", @@ -1564,16 +1557,17 @@ dependencies = [ "self_cell", "skrifa", "smallvec", + "unicode-bidi", "unicode-general-category", + "unicode-properties", "unicode-segmentation", "vello_cpu", ] [[package]] name = "epaint_default_fonts" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" [[package]] name = "equivalent" @@ -2424,6 +2418,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.10" @@ -2453,6 +2453,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -2940,12 +2946,12 @@ dependencies = [ [[package]] name = "glifo" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", @@ -3162,6 +3168,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -3170,7 +3185,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", "serde", "serde_core", ] @@ -3181,7 +3196,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -3480,12 +3495,12 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kittest" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", + "accesskit_consumer", ] [[package]] @@ -3758,6 +3773,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -4311,6 +4335,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "p256" version = "0.13.2" @@ -4430,10 +4464,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] -name = "phf" -version = "0.13.1" +name = "petgraph" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ "phf_macros", "phf_shared", @@ -4442,9 +4487,9 @@ dependencies = [ [[package]] name = "phf_generator" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", "phf_shared", @@ -4452,9 +4497,9 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ "phf_generator", "phf_shared", @@ -4465,9 +4510,9 @@ dependencies = [ [[package]] name = "phf_shared" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" dependencies = [ "siphasher", ] @@ -5438,6 +5483,12 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "seq-macro" version = "0.3.6" @@ -6241,6 +6292,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "type-map" version = "0.5.1" @@ -6303,6 +6365,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -6379,9 +6447,9 @@ dependencies = [ [[package]] name = "vello_common" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" dependencies = [ "bytemuck", "fearless_simd", @@ -6394,9 +6462,9 @@ dependencies = [ [[package]] name = "vello_cpu" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", "glifo", @@ -7263,8 +7331,7 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winit" version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +source = "git+https://github.com/storytold/winit.git?rev=ae48c756a3b81125eb796c0cc6dccf60990d1181#ae48c756a3b81125eb796c0cc6dccf60990d1181" dependencies = [ "ahash", "android-activity", @@ -7345,6 +7412,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.5", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "x11-dl" version = "2.21.0" @@ -7696,3 +7781,23 @@ dependencies = [ "syn 3.0.6", "winnow 1.0.4", ] + +[[patch.unused]] +name = "egui_demo_lib" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" + +[[patch.unused]] +name = "egui_extras" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" + +[[patch.unused]] +name = "egui_inspection" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" + +[[patch.unused]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" diff --git a/Cargo.toml b/Cargo.toml index 2801176d2..da3882c92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,10 +77,13 @@ craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-lib # ui # Shared frontend components; keep this dependency above the engine boundary. -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } -egui = "0.36" -eframe = { version = "0.36", default-features = false } -egui_kittest = "0.36" +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c488caa9fbc2fb1a86e4674887e9b80b598ebbfa", features = ["serde"] } +# egui 0.37: bundled fonts are opt-in, and the monochrome symbol/emoji faces moved to their own +# feature. FilmCraft paints ✔ ✓ ✖ ⚡ ⚠ ⚙ ♪ ☑ in panel chrome, so both features are part of the +# production font policy (system_fonts stays off). +egui = { version = "0.37", features = ["default_fonts", "monochrome_emoji_fonts"] } +eframe = { version = "0.37", default-features = false } +egui_kittest = "0.37" rfd = "0.17" [workspace.lints.rust] @@ -153,3 +156,21 @@ opt-level = 3 [profile.release] lto = "thin" codegen-units = 1 + +# Keep the complete egui family and native event bridge on reviewed fork commits. +[patch.crates-io] +ecolor = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +emath = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +epaint = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +winit = { git = "https://github.com/storytold/winit.git", rev = "ae48c756a3b81125eb796c0cc6dccf60990d1181" } diff --git a/apps/filmcraft-web/src/fs.rs b/apps/filmcraft-web/src/fs.rs index d7ec9072f..a6b23b1c7 100644 --- a/apps/filmcraft-web/src/fs.rs +++ b/apps/filmcraft-web/src/fs.rs @@ -426,6 +426,10 @@ impl Services for WebServices { /// Offer bytes as a browser download named `name`. Downloads are clicked at least /// [`crate::download_pacing::GAP_MS`] apart: Chrome drops a burst of them (#378). +thread_local! { + static DOWNLOAD_CLOCK: web_time::Instant = web_time::Instant::now(); +} + pub fn download(name: &str, data: &[u8]) -> Result<(), wasm_bindgen::JsValue> { let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?; let blob = bytes_to_blob(data, "application/octet-stream")?; @@ -434,7 +438,8 @@ pub fn download(name: &str, data: &[u8]) -> Result<(), wasm_bindgen::JsValue> { a.set_href(&url); a.set_download(file_name(name)); a.style().set_property("display", "none")?; - let delay = DOWNLOADS.with(|d| d.borrow_mut().reserve(js_sys::Date::now())); + let now_ms = DOWNLOAD_CLOCK.with(|clock| clock.elapsed().as_secs_f64() * 1000.0); + let delay = DOWNLOADS.with(|d| d.borrow_mut().reserve(now_ms)); let name = name.to_owned(); wasm_bindgen_futures::spawn_local(async move { if delay > 0.0 { diff --git a/apps/filmcraft-web/src/recovery.rs b/apps/filmcraft-web/src/recovery.rs index 6001531e8..5c58e44b6 100644 --- a/apps/filmcraft-web/src/recovery.rs +++ b/apps/filmcraft-web/src/recovery.rs @@ -28,10 +28,17 @@ enum Outcome { Clean { ok: bool }, } -#[derive(Default)] pub struct Autosave { policy: Policy, outcomes: Rc>>, + /// Monotonic host clock. Web `logic` may run for hidden tabs; egui's input time is stale there. + clock: web_time::Instant, +} + +impl Default for Autosave { + fn default() -> Self { + Self { policy: Policy::default(), outcomes: Rc::default(), clock: web_time::Instant::now() } + } } impl Autosave { @@ -40,7 +47,7 @@ impl Autosave { if !opfs::available() { return; } - let now = ctx.input(|i| i.time); + let now = self.clock.elapsed().as_secs_f64(); for o in self.outcomes.borrow_mut().drain(..) { match o { Outcome::Snapshot { revision, ok } => self.policy.written(revision, ok, now), @@ -64,7 +71,9 @@ impl Autosave { } }) }; - let meta = json!({"dirty": true, "name": s.project.name, "revision": s.revision, "time": js_sys::Date::now()}); + // No wall-clock timestamp: recovery policy scheduling uses the monotonic clock + // above, and the snapshot schema needs no display time. + let meta = json!({"dirty": true, "name": s.project.name, "revision": s.revision}); let d1 = done(self.outcomes.clone(), failed.clone(), left.clone(), ctx.clone()); let d2 = done(self.outcomes.clone(), failed, left, ctx.clone()); opfs::write_then(SNAPSHOT.into(), filmcraft_format::encode(&s.project, false), Some(d1)); diff --git a/apps/filmcraft-web/src/webcodecs.rs b/apps/filmcraft-web/src/webcodecs.rs index 48fa7a1c8..b16bf0db1 100644 --- a/apps/filmcraft-web/src/webcodecs.rs +++ b/apps/filmcraft-web/src/webcodecs.rs @@ -68,8 +68,14 @@ thread_local! { static TRANSFERS: RefCell = RefCell::new(Transfers::default()); } +thread_local! { + /// Monotonic browser clock. `Date::now()` is wall time and can move backwards across NTP or + /// suspend/resume; codec idle/staleness accounting must not. + static CLOCK: web_time::Instant = web_time::Instant::now(); +} + fn now_ms() -> f64 { - js_sys::Date::now() + CLOCK.with(|clock| clock.elapsed().as_secs_f64() * 1000.0) } fn get(o: &JsValue, k: &str) -> JsValue { diff --git a/apps/filmcraft/Cargo.toml b/apps/filmcraft/Cargo.toml index e0730dc37..7db181b39 100644 --- a/apps/filmcraft/Cargo.toml +++ b/apps/filmcraft/Cargo.toml @@ -27,7 +27,7 @@ filmcraft-gpu = { workspace = true } filmcraft-ui-egui = { workspace = true } filmcraft-platform = { workspace = true } egui = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11"] } serde_json = { workspace = true } # Constant-time comparison of control-channel credentials. subtle = "2.6" diff --git a/apps/filmcraft/src/audio.rs b/apps/filmcraft/src/audio.rs index cb56e059e..26a5cf727 100644 --- a/apps/filmcraft/src/audio.rs +++ b/apps/filmcraft/src/audio.rs @@ -55,7 +55,7 @@ impl Note { use filmcraft_ui_egui::i18n::{fmt, t}; match self { Note::Fallback(device) => fmt(t("Audio hardware: the default output device cannot be opened; using '{device}'"), &[("device", device)]), - Note::NoDevice => t("No audio output device found: playing without sound (check Settings ▸ Audio Hardware)").to_string(), + Note::NoDevice => t("No audio output device found: playing without sound (check Settings > Audio Hardware)").to_string(), } } } diff --git a/crates/engine/src/clip_ops.rs b/crates/engine/src/clip_ops.rs index 8a85b9eee..78fc017d5 100644 --- a/crates/engine/src/clip_ops.rs +++ b/crates/engine/src/clip_ops.rs @@ -826,6 +826,7 @@ fn close_project(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Other("the project has unsaved changes: save it first, or pass {\"force\": true} to discard them".into())); } s.project = std::sync::Arc::new(Project::new("Untitled")); + s.document_epoch = s.document_epoch.wrapping_add(1); s.history = Default::default(); s.history.limit = 200; s.state = Session::default().state; diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 5c564e69e..767c737fe 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1140,6 +1140,7 @@ fn build() -> Vec { cmd!("file.newProject", "Project…", ["File", "New"], Some("Cmd+Alt+N"), r#"{"name":str}"#, always, |s, p| { let name = str_p(p, "name").unwrap_or("Untitled").to_string(); s.project = std::sync::Arc::new(filmcraft_project::Project::new(&name)); + s.document_epoch = s.document_epoch.wrapping_add(1); s.history = Default::default(); s.history.limit = 200; s.state = crate::Session::default().state; @@ -1154,6 +1155,7 @@ fn build() -> Vec { cmd!("file.openDemoProject", "Demo Project", ["File", "New"], None, "{}", always, |s, _| { let (p, seq) = crate::demo::demo_project(&s.media); s.project = std::sync::Arc::new(p); + s.document_epoch = s.document_epoch.wrapping_add(1); s.history = Default::default(); s.history.limit = 200; s.state = crate::Session::default().state; @@ -4385,6 +4387,7 @@ fn install_project(s: &mut Session, proj: filmcraft_project::Project, path: Opti let first = proj.sequences().next().map(|i| i.id); s.media.add_stored(&proj, path.as_deref()); s.project = std::sync::Arc::new(proj); + s.document_epoch = s.document_epoch.wrapping_add(1); s.history = Default::default(); s.history.limit = 200; s.state = crate::Session::default().state; diff --git a/crates/engine/src/graphics.rs b/crates/engine/src/graphics.rs index 998b1e2f7..58b3eaada 100644 --- a/crates/engine/src/graphics.rs +++ b/crates/engine/src/graphics.rs @@ -8,7 +8,9 @@ //! at the playhead. use filmcraft_geom::Vec2; -use filmcraft_project::graphic::{self, LayerContent, SHAPE_OPTS, eval_layer, layer_display_name, layer_indices, new_shape_layer, new_text_layer}; +use filmcraft_project::graphic::{ + self, LayerContent, SHAPE_OPTS, ensure_layer_uids, eval_layer, layer_display_name, layer_indices, new_shape_layer, new_text_layer, +}; use filmcraft_project::{ClipId, EffectInstance, ItemId, ItemKind, Label, Param, ParamValue, Sequence, TrackKind}; use filmcraft_render::graphic_clip::{item_layer_specs, layer_local_bounds, layer_quad, text_layout}; use filmcraft_time::{Tick, TimeRange}; @@ -138,6 +140,9 @@ pub(crate) fn place_video_clip( let mut ti = pr.make_track_item(src, TrackKind::Video, t, TimeRange::new(Tick::ZERO, dur), rate).ok_or_else(|| bad(cmd, "bad item"))?; ti.name = name; ti.effects.extend(extra); + // Every new graphic layer gets a stable uid; a new clip holds layers from `extra` plus + // any source-graphic layers, so do it after both are in place (duplicates are repaired). + ensure_layer_uids(&mut ti.effects); for e in &mut ti.effects { filmcraft_project::resolve_auto_points(e, (w, h), src_size); } @@ -179,6 +184,9 @@ fn add_layer(s: &mut Session, clip: ClipId, mut layer: filmcraft_project::Effect // new layers go in front: after the last graphic layer let pos = layer_indices(&it.effects).last().map_or(it.effects.len(), |l| l + 1); it.effects.insert(pos, layer); + // Assign a uid to the new layer (and lazily upgrade any uid-less legacy layers) inside + // this undo step, so undo/redo keeps the same identities. + ensure_layer_uids(&mut it.effects); let n = layer_indices(&it.effects).len() - 1; st.selection = vec![clip]; st.graphic_layers = vec![n]; diff --git a/crates/engine/src/graphics_tests.rs b/crates/engine/src/graphics_tests.rs index e8893104e..8069fd17b 100644 --- a/crates/engine/src/graphics_tests.rs +++ b/crates/engine/src/graphics_tests.rs @@ -95,6 +95,31 @@ fn graphic_placement_takes_a_track_and_a_time() { } } +/// Every graphic layer gets a stable uid when it is created, both in a new clip and when added to +/// an existing one. Two byte-identical layers with different text must not share an identity. +#[test] +fn new_graphic_layers_get_unique_stable_uids() { + let mut s = demo(); + let clip = ClipId(s.execute("graphics.newText", json!({"text": "one", "position": [960, 540], "size": 100})).unwrap()["clip"].as_u64().unwrap()); + s.execute("graphics.newText", json!({"text": "two", "position": [960, 540], "size": 100, "clip": clip.0})).unwrap(); + s.execute("graphics.newShape", json!({"shape": "rectangle", "clip": clip.0})).unwrap(); + let uids = |s: &mut Session| -> Vec { + s.execute("graphics.list", json!({"clip": clip.0})).unwrap()["layers"].as_array().unwrap().iter().map(|l| l["uid"].as_u64().unwrap()).collect() + }; + let first = uids(&mut s); + assert_eq!(first.len(), 3); + assert!(first.iter().all(|uid| *uid != 0), "every created layer is assigned a uid: {first:?}"); + assert_eq!(first.iter().copied().collect::>().len(), 3, "uids are unique: {first:?}"); + // A real reorder keeps the same per-layer identity, and undo/redo restores it unchanged. + s.execute("graphics.arrangeLayer", json!({"clip": clip.0, "layer": 0, "to": "front"})).unwrap(); + let reordered = uids(&mut s); + assert_eq!(reordered, vec![first[1], first[2], first[0]]); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(uids(&mut s), first, "undo restores the same identities, not fresh ones"); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(uids(&mut s), reordered); +} + #[test] fn new_text_makes_a_graphic_clip_above_the_footage() { let mut s = demo(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 47a9cfd6e..432cea674 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -406,6 +406,10 @@ pub struct Session { pub project: Arc, pub history: History, pub revision: u64, + /// Bumped whenever the session installs a different project document (`file.newProject`, + /// open/import/recovery/template). Frontends use it to discard UI state (text edits, IME + /// owners) that belongs to the replaced document even if item ids are reused. + pub document_epoch: u64, pub saved_revision: u64, pub path: Option, pub state: EditorState, @@ -524,6 +528,7 @@ impl Session { project: Arc::new(Project::new("Untitled")), history: History { limit: 200, ..Default::default() }, revision: 1, + document_epoch: 0, saved_revision: 1, path: None, state: EditorState { diff --git a/crates/engine/src/narration.rs b/crates/engine/src/narration.rs index cc9f1fdf8..562271dd1 100644 --- a/crates/engine/src/narration.rs +++ b/crates/engine/src/narration.rs @@ -98,7 +98,7 @@ fn cache_key(n: &Narration) -> String { fn tts_err(cmd: &str, e: tts::TtsError) -> EngineError { match e { - tts::TtsError::NotInstalled => bad(cmd, "the natural voices are not downloaded yet: Text to Speech ▸ Download natural voices (tts.downloadVoices)"), + tts::TtsError::NotInstalled => bad(cmd, "the natural voices are not downloaded yet: Text to Speech > Download natural voices (tts.downloadVoices)"), e => bad(cmd, e.to_string()), } } diff --git a/crates/engine/src/project_tools.rs b/crates/engine/src/project_tools.rs index 136229370..dbe414254 100644 --- a/crates/engine/src/project_tools.rs +++ b/crates/engine/src/project_tools.rs @@ -678,6 +678,7 @@ fn new_from_template(s: &mut Session, p: &Value) -> Result { proj.name = name.clone(); proj.root.name = name; s.project = std::sync::Arc::new(proj); + s.document_epoch = s.document_epoch.wrapping_add(1); s.history = Default::default(); s.history.limit = 200; s.state = Session::default().state; @@ -1062,7 +1063,7 @@ pub(crate) fn warn_untrusted_scratch(s: &mut Session) { let list = ignored.join(", "); log::warn!("project scratch disks outside the project folder not used: {list}"); s.toast(format!( - "Scratch Disks: not using {list} (outside the project folder and not chosen on this computer). Auto-saves, previews and captured media go to the default location; choose the folder in Project Settings ▸ Scratch Disks to use it." + "Scratch Disks: not using {list} (outside the project folder and not chosen on this computer). Auto-saves, previews and captured media go to the default location; choose the folder in Project Settings > Scratch Disks to use it." )); } diff --git a/crates/engine/src/relink.rs b/crates/engine/src/relink.rs index 7a75826e9..9649e5815 100644 --- a/crates/engine/src/relink.rs +++ b/crates/engine/src/relink.rs @@ -752,11 +752,11 @@ pub fn on_open(s: &mut Session) -> usize { list.iter().filter(|m| m.status == "missing").filter_map(|m| s.media.follow(&m.path).err().map(|why| format!("{} ({why})", m.path))).collect(); if !refused.is_empty() { log::warn!("media paths stored in the project not opened: {}", refused.join(", ")); - s.toast(format!("Not opening {} media file(s) the project names on another computer or device; File ▸ Link Media… reconnects them", refused.len())); + s.toast(format!("Not opening {} media file(s) the project names on another computer or device; File > Link Media… reconnects them", refused.len())); } s.offline.prompt = !s.offline.missing.is_empty(); if !s.offline.missing.is_empty() { - s.toast(format!("{} media file(s) are missing; File ▸ Link Media… reconnects them", s.offline.missing.len())); + s.toast(format!("{} media file(s) are missing; File > Link Media… reconnects them", s.offline.missing.len())); } list.len() } diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 9ecd10aab..a68117bea 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -216,6 +216,9 @@ pub fn decode(bytes: &[u8]) -> Result { /// Refuse only values that would overflow or exhaust memory later (`Sequence::check_bounds`); /// structural rules such as overlaps are left to the editor, so projects that open today still open. fn validate_loaded(mut loaded: Loaded) -> Result { + // Graphic layers written before they carried a uid get a stable identity now, before any + // undo history exists. The field is optional in the schema, so this is not a schema upgrade. + loaded.project.ensure_graphic_layer_uids(); // Out-of-range audio stream data is repaired, not refused: failing the load would lose the // whole project over one bad index. A media item keeps its first `MAX_AUDIO_STREAMS` streams; a // clip pointing past that plays stream 0. @@ -421,6 +424,32 @@ mod tests { assert_eq!(loaded.project.sequence(seq).unwrap().video_tracks[0].items.len(), 3); } + /// Opening a project written before graphic layers had uids assigns stable, unique identities + /// without a schema upgrade. The old file still opens, and its two byte-identical layers (same + /// geometry, different text) can no longer be confused after a reorder. + #[test] + fn opening_a_legacy_project_assigns_unique_graphic_layer_uids() { + use filmcraft_project::graphic::{layer_indices, new_text_layer}; + use filmcraft_project::{ItemKind, SequenceSettings, TrackKind}; + use filmcraft_time::{FrameRate, Tick, TimeRange}; + let mut p = Project::new("Legacy graphics"); + let rate = FrameRate::FPS_25; + let seq = p.new_sequence("Sequence 01", SequenceSettings::default(), 1, 1, None); + let graphic = p.add_item("Graphic", filmcraft_project::Label::Rose, ItemKind::Graphic { width: 1920, height: 1080, rate }, None); + let mut item = p.make_track_item(graphic, TrackKind::Video, Tick::ZERO, TimeRange::new(Tick::ZERO, rate.tick_of(100)), rate).unwrap(); + item.effects = vec![new_text_layer("one", Default::default(), 100.0), new_text_layer("two", Default::default(), 100.0)]; + assert!(item.effects.iter().all(|e| filmcraft_project::gtemplate::layer_uid(e) == 0)); + p.sequence_mut(seq).unwrap().video_tracks[0].items = vec![item]; + let loaded = decode(&encode(&p, false)).expect("an old project still opens"); + let item = &loaded.project.sequence(seq).unwrap().video_tracks[0].items[0]; + let uids: Vec = layer_indices(&item.effects).into_iter().map(|i| filmcraft_project::gtemplate::layer_uid(&item.effects[i])).collect(); + assert_eq!(uids.len(), 2); + assert!(uids.iter().all(|uid| *uid != 0), "opening assigned uids: {uids:?}"); + assert_ne!(uids[0], uids[1], "identical geometry and different text still get distinct identities"); + let again = decode(&encode(&loaded.project, false)).expect("the upgraded project round-trips"); + assert_eq!(again.project, loaded.project, "assigned uids are stable across saves"); + } + #[test] fn roundtrip_current() { let p = Project::new("Round Trip"); diff --git a/crates/platform/src/nvenc/export.rs b/crates/platform/src/nvenc/export.rs index 4344cb52f..2f83fa00f 100644 --- a/crates/platform/src/nvenc/export.rs +++ b/crates/platform/src/nvenc/export.rs @@ -108,7 +108,7 @@ impl VideoEncoder for NvencEncoder { return Err(ExportError::Encode(format!("NVENC: a {}x{} picture for a {}x{} encoder", f.width, f.height, self.w, self.h))); } let (w, h) = (self.w as usize, self.h as usize); - let hint = if self.enc.codec() == Codec::H264 { " (turn Export ▸ Hardware encoding off to use the software encoder)" } else { "" }; + let hint = if self.enc.codec() == Codec::H264 { " (turn Export > Hardware encoding off to use the software encoder)" } else { "" }; let ps = match (self.enc.is_ten_bit(), f.hdr) { // Main 10 takes the encoded R'G'B' floats: 10-bit limited-range BT.2020 NCL (or the signal's matrix) (true, Some(rgb)) => { diff --git a/crates/project/src/graphic.rs b/crates/project/src/graphic.rs index 978917e32..23994171a 100644 --- a/crates/project/src/graphic.rs +++ b/crates/project/src/graphic.rs @@ -35,6 +35,47 @@ pub fn is_layer(e: &EffectInstance) -> bool { is_layer_id(&e.effect) } +/// Give every graphic layer without a uid (or with a uid another layer already uses) a fresh one +/// that is unique among `effects`; non-layer effects are left alone. +/// +/// This is the project-format lazy upgrade: files written before layers had uids (or with a +/// duplicate written by a hostile/buggy producer) get stable identities when they are opened, so +/// reorder/delete/undo and the Program monitor's text editor keep addressing the same layer. +/// The optional `layer.uid` field means the file schema does not change; a save simply writes the +/// assigned uid. Returns whether anything changed. +pub fn ensure_layer_uids(effects: &mut [EffectInstance]) -> bool { + let mut used: std::collections::BTreeSet = + effects.iter().filter(|e| is_layer(e)).filter_map(|e| e.layer.as_ref()).map(|x| x.uid).filter(|uid| *uid != 0).collect(); + let mut seen = std::collections::BTreeSet::new(); + let mut next = 1u64; + let mut changed = false; + for e in effects.iter_mut().filter(|e| is_layer(e)) { + let uid = e.layer.as_ref().map_or(0, |x| x.uid); + if uid != 0 && seen.insert(uid) { + continue; + } + // Zero means "not assigned yet"; a repeated uid must move to a free one so two identical + // layers can never share an identity. + while used.contains(&next) { + let Some(after) = next.checked_add(1) else { + // A hostile file used every uid; leave this layer without one rather than loop. + next = 0; + break; + }; + next = after; + } + if next == 0 { + continue; + } + e.layer.get_or_insert_with(Default::default).uid = next; + used.insert(next); + seen.insert(next); + changed = true; + next = next.saturating_add(1); + } + changed +} + fn p(id: &'static str, label: &'static str, kind: ParamKind, default: ParamValue, animatable: bool, group: &'static str) -> ParamDef { ParamDef { id, label, kind, default, animatable, group: if group.is_empty() { None } else { Some(group) } } } @@ -499,6 +540,43 @@ mod tests { assert_eq!(layer_display_name(&e, 0), "Hello"); } + #[test] + fn assigning_a_new_layer_uid_never_reuses_an_existing_layers_uid() { + let make = |uid| { + let mut layer = new_text_layer("text", Vec2::new(10.0, 20.0), 40.0); + layer.layer = Some(Box::new(crate::graphic_design::LayerExtra { uid, ..Default::default() })); + layer + }; + for initial in [[1, 0, 2, 1], [0, 1, 2, 1], [1, 2, 0, 1]] { + let mut layers: Vec<_> = initial.into_iter().map(make).collect(); + assert!(ensure_layer_uids(&mut layers)); + let uids: Vec<_> = layers.iter().map(crate::gtemplate::layer_uid).collect(); + assert_eq!(uids.iter().copied().collect::>().len(), 4, "{uids:?}"); + assert_eq!(uids[initial.iter().position(|uid| *uid == 1).unwrap()], 1); + assert_eq!(uids[initial.iter().position(|uid| *uid == 2).unwrap()], 2); + assert!(!ensure_layer_uids(&mut layers), "a second pass must preserve identity"); + } + } + + #[test] + fn layer_uids_are_unique_and_repair_legacy_or_duplicate_entries() { + let a = new_text_layer("one", Vec2::new(10.0, 20.0), 40.0); + let mut b = new_text_layer("two", Vec2::new(10.0, 20.0), 40.0); + let mut shape = crate::effect::find_effect(SHAPE_LAYER).unwrap().instance(); + // Legacy file: no uids at all; one layer already carries a duplicate uid. + assert_eq!(crate::gtemplate::layer_uid(&a), 0); + b.layer = Some(Box::new(crate::graphic_design::LayerExtra { uid: 7, ..Default::default() })); + shape.layer = Some(Box::new(crate::graphic_design::LayerExtra { uid: 7, ..Default::default() })); + let mut effects = vec![a, b, shape]; + assert!(ensure_layer_uids(&mut effects)); + let uids: Vec = effects.iter().filter(|e| is_layer(e)).map(crate::gtemplate::layer_uid).collect(); + assert!(uids.iter().all(|uid| *uid != 0)); + let unique: std::collections::BTreeSet = uids.iter().copied().collect(); + assert_eq!(unique.len(), uids.len(), "every layer keeps its own identity: {uids:?}"); + // Idempotent: a second pass changes nothing. + assert!(!ensure_layer_uids(&mut effects)); + } + #[test] fn defaults_centre_and_shadow_direction() { let mut e = crate::effect::find_effect(SHAPE_LAYER).unwrap().instance(); diff --git a/crates/project/src/gtemplate.rs b/crates/project/src/gtemplate.rs index 082cf18b6..644b9b28f 100644 --- a/crates/project/src/gtemplate.rs +++ b/crates/project/src/gtemplate.rs @@ -350,14 +350,8 @@ pub fn layer_uid(e: &EffectInstance) -> u64 { } /// Give every layer without a uid a fresh one (unique among `layers`). -pub fn ensure_uids(layers: &mut [EffectInstance]) { - let mut next = layers.iter().map(layer_uid).max().unwrap_or(0) + 1; - for l in layers.iter_mut() { - if layer_uid(l) == 0 { - l.layer.get_or_insert_with(Default::default).uid = next; - next += 1; - } - } +pub fn ensure_uids(layers: &mut [EffectInstance]) -> bool { + graphic::ensure_layer_uids(layers) } // ------------------------------------------------------------------------------------------------ diff --git a/crates/project/src/keyframe.rs b/crates/project/src/keyframe.rs index 1ddfbc156..bf6754c2c 100644 --- a/crates/project/src/keyframe.rs +++ b/crates/project/src/keyframe.rs @@ -285,6 +285,38 @@ impl Param { self.value_at(t).as_f64().unwrap_or(0.0) } + /// Borrowing text evaluation for read-only observers (the Program monitor's IME producer). + /// + /// Text parameters never interpolate, so the result is the applicable static or keyframed + /// `ParamValue::Text`; an interpolating non-text value yields `None`. Unlike + /// [`Self::value_at`] this does not clone the document-sized string. + pub fn text_ref_at(&self, t: Tick) -> Option<&str> { + fn text(v: &ParamValue) -> Option<&str> { + match v { + ParamValue::Text(s) => Some(s.as_str()), + _ => None, + } + } + let k = &self.keyframes; + if k.is_empty() { + return text(&self.value); + } + if t <= k[0].time { + return text(&k[0].value); + } + let last = &k[k.len() - 1]; + if t >= last.time { + return text(&last.value); + } + let i = match k.binary_search_by_key(&t, |kf| kf.time) { + Ok(i) => return text(&k[i].value), + Err(i) => i - 1, + }; + // Hold semantics match `value_at`: non-interpolating values (including text) stay until + // the next keyframe; an interpolating segment never evaluates to text. + if k[i].interp == Interpolation::Hold || !k[i].value.interpolates() { text(&k[i].value) } else { None } + } + /// Allocation-free [`Param::f64_at`] for scalar parameters (Float / Choice / Bool), for /// per-sample evaluation in the audio mixer. Non-scalar values evaluate to 0. pub fn scalar_at(&self, t: Tick) -> f64 { @@ -405,6 +437,19 @@ mod tests { } } + #[test] + fn text_ref_at_borrows_without_cloning_and_holds_non_interpolating_values() { + let p = Param::new(ParamValue::Text("abc".into())); + assert_eq!(p.text_ref_at(Tick(0)), Some("abc")); + let f = Param::new(ParamValue::Float(1.0)); + assert_eq!(f.text_ref_at(Tick(0)), None); + let mut p = Param::new(ParamValue::Text("a".into())); + p.toggle_animation(Tick(0)); + p.set_at(Tick(100), ParamValue::Text("b".into())); + assert_eq!(p.text_ref_at(Tick(50)), Some("a"), "text does not interpolate"); + assert_eq!(p.text_ref_at(Tick(100)), Some("b")); + } + #[test] fn toggle_off_keeps_value() { let mut p = Param::new(ParamValue::Float(1.0)); diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 5b21cdaec..f1f6a4916 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1747,6 +1747,32 @@ fn resolve_auto_points_sized(e: &mut EffectInstance, frame: (u32, u32), source: } impl Project { + /// Lazy upgrade for graphic layers written before they had stable uids: assign a unique + /// [`crate::graphic_design::LayerExtra::uid`] to every uid-less or duplicated graphic layer + /// in sequences and source graphics. + /// + /// Called when a project file is opened, before undo history starts, so every later undo + /// snapshot carries the same identity. The field is already optional in the file schema, so + /// this changes no schema version and older builds still read the file. Returns whether any + /// layer changed. + pub fn ensure_graphic_layer_uids(&mut self) -> bool { + let mut changed = false; + for item in self.items.values_mut() { + if let ItemKind::Sequence(seq) = &mut item.kind { + let seq = std::sync::Arc::make_mut(seq); + for track in seq.video_tracks.iter_mut().chain(seq.audio_tracks.iter_mut()) { + for it in &mut track.items { + changed |= crate::graphic::ensure_layer_uids(&mut it.effects); + } + } + } + } + for graphic in self.source_graphics.values_mut() { + changed |= crate::graphic::ensure_layer_uids(&mut graphic.layers); + } + changed + } + /// Available picture and sound, resolving a subclip to its parent media. Missing parents, /// cycles and overly deep subclip chains carry neither stream. pub fn source_streams(&self, item: ItemId) -> (bool, bool) { diff --git a/crates/render/src/offline.rs b/crates/render/src/offline.rs index 248670db2..40abec3d0 100644 --- a/crates/render/src/offline.rs +++ b/crates/render/src/offline.rs @@ -87,7 +87,7 @@ pub fn slate(w: usize, h: usize, file_name: &str, reason: OfflineReason) -> Imag } let hint = match reason { OfflineReason::MadeOffline => "Link Media… brings it back", - _ => "File ▸ Link Media… reconnects it", + _ => "File > Link Media… reconnects it", }; text(&mut img, hint, "Regular", Vec2::new(cx as f64, (ty0 + tri_h + s * 0.205) as f64), s * 0.028, [0.85, 0.72, 0.72, 1.0], pw * 0.94); img diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index f1d7b8991..aaaccf5e7 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -27,7 +27,7 @@ filmcraft-export = { workspace = true } filmcraft-gpu = { workspace = true } filmcraft-scopes = { workspace = true } egui = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard"] } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } @@ -52,6 +52,9 @@ filmcraft-codecs = { workspace = true } # itself stays OS-independent and web-portable) filmcraft-platform = { workspace = true } filmcraft-interchange = { workspace = true } +# A dependency's dev-dependencies do not propagate through egui_kittest: name the fonts this +# test root needs explicitly so glyph and accessibility assertions run against the bundled faces. +egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } egui_kittest = { workspace = true, features = ["eframe", "wgpu"] } tempfile = "3" diff --git a/crates/ui-egui/src/automation.rs b/crates/ui-egui/src/automation.rs index 850fcb8dd..eb18aed37 100644 --- a/crates/ui-egui/src/automation.rs +++ b/crates/ui-egui/src/automation.rs @@ -42,6 +42,9 @@ impl Registry { } /// Observe the widget without changing its accessibility role or input behavior. pub fn add_response(&mut self, id: &str, response: &egui::Response, label: &str) { + // The automation label is also the name a screen reader and kittest use. Keep an + // existing visible label/placeholder; this only fills a missing name. + crate::widgets::accessible_name(response.clone(), label); self.add(id, response.rect, label); if let Some(element) = self.elements.last_mut() { element.widget_id = Some(format!("{:016x}", response.id.value())); @@ -53,11 +56,74 @@ impl Registry { pub fn begin_frame(&mut self) { self.previous = std::mem::take(&mut self.elements); } - pub fn find(&self, id: &str) -> Option<&Element> { - self.previous.iter().chain(self.elements.iter()).find(|e| e.id == id) + /// The entries of the last accepted UI pass, or the pass before it while the current pass is + /// still being laid out (pass 0 clears `elements`; `drain_ui_requests` runs after the layout). + pub fn all(&self) -> &[Element] { + if self.elements.is_empty() { &self.previous } else { &self.elements } } - /// Elements whose id starts with a prefix. + /// A widget drawn in the current accepted pass. Unlike [`Self::find`] this never falls back to + /// the previous pass, for callers that need fresh geometry (e.g. a screenshot crop). + pub fn find_current(&self, id: &str) -> Option<&Element> { + self.elements.iter().find(|e| e.id == id) + } + /// Look up a widget, preferring the layout just drawn. The previous pass is only used before + /// this pass has registered anything (input handling runs before the panels draw). + pub fn find(&self, id: &str) -> Option<&Element> { + self.all().iter().find(|e| e.id == id) + } + /// Elements whose id starts with a prefix, from the current accepted pass when there is one. pub fn query(&self, prefix: &str) -> Vec<&Element> { - self.previous.iter().filter(|e| e.id.starts_with(prefix)).collect() + self.all().iter().filter(|e| e.id.starts_with(prefix)).collect() + } +} + +#[cfg(test)] +mod tests { + use egui::{Rect, pos2, vec2}; + + use super::Registry; + + fn rect(x: f32) -> Rect { + Rect::from_min_size(pos2(x, 0.0), vec2(10.0, 10.0)) + } + + #[test] + fn lookups_prefer_the_current_accepted_pass() { + let mut registry = Registry::default(); + registry.add("panel.project", rect(1.0), "old"); + registry.begin_frame(); + registry.add("panel.project", rect(200.0), "new"); + assert_eq!(registry.find("panel.project").map(|e| e.rect[0]), Some(200.0)); + assert_eq!(registry.query("panel.").iter().map(|e| e.rect[0]).collect::>(), vec![200.0]); + assert_eq!(registry.find_current("panel.project").map(|e| e.rect[0]), Some(200.0)); + } + + #[test] + fn removed_elements_do_not_resolve_from_a_populated_current_pass() { + let mut registry = Registry::default(); + registry.add("dialog.closed", rect(1.0), "old control"); + registry.begin_frame(); + assert!(registry.find("dialog.closed").is_some(), "before drawing, the previous pass remains usable"); + registry.add("panel.current", rect(200.0), "current control"); + assert!(registry.find("dialog.closed").is_none(), "a closed control cannot target stale coordinates"); + assert_eq!(registry.find("panel.current").map(|e| e.rect[0]), Some(200.0)); + assert!(registry.query("dialog.").is_empty()); + } + + #[test] + fn the_previous_pass_is_only_a_fallback_before_this_pass_draws() { + let mut registry = Registry::default(); + registry.add("panel.project", rect(1.0), "old"); + registry.begin_frame(); + assert_eq!(registry.find("panel.project").map(|e| e.rect[0]), Some(1.0)); + assert_eq!(registry.query("panel.").len(), 1); + assert!(registry.find_current("panel.project").is_none()); + + // A discarded pass is cleared and redrawn, so exactly one entry per widget remains. + registry.add("panel.project", rect(2.0), "discarded"); + registry.elements.clear(); + registry.add("panel.project", rect(3.0), "accepted"); + assert_eq!(registry.elements.len(), 1); + assert_eq!(registry.find("panel.project").map(|e| e.rect[0]), Some(3.0)); } } diff --git a/crates/ui-egui/src/brand.rs b/crates/ui-egui/src/brand.rs index 4c41d080e..3bab46e2d 100644 --- a/crates/ui-egui/src/brand.rs +++ b/crates/ui-egui/src/brand.rs @@ -7,7 +7,7 @@ const LOGO_DARK_INK: &[u8] = include_bytes!("../../../docs/brand/artcraft-logo.p /// The wordmark for a dark (`dark = true`) or light UI: light ink on dark themes. Its aspect /// ratio is width / height of the returned texture. pub fn wordmark(ctx: &egui::Context, dark: bool) -> Option { - let id = egui::Id::new(("artcraft-wordmark", dark)); + let id = egui::Id::unique(("artcraft-wordmark", dark)); if let Some(t) = ctx.data(|d| d.get_temp::(id)) { return Some(t); } diff --git a/crates/ui-egui/src/capture.rs b/crates/ui-egui/src/capture.rs new file mode 100644 index 000000000..94234f500 --- /dev/null +++ b/crates/ui-egui/src/capture.rs @@ -0,0 +1,795 @@ +//! Bounded, one-shot window screenshots for `ui.screenshot`. +//! +//! A request record owns the output path, crop, reply, deadline, document/focus identity and the +//! surface geometry it was admitted against. `craft_ui::capture::CaptureSlot` owns the strong +//! `egui::Context`; its callback holds only a weak reference and never writes a file. Files are +//! encoded and written only from the logic drain, after the deadline and every identity/geometry +//! check has passed. +//! +//! Web qualification note: the pinned eframe web runner does not implement +//! `ViewportCommand::RequestPaintWhileHidden` (the native runner does). A hidden web capture that +//! is still settling therefore reaches its timeout. That fork gap is recorded as a prerequisite; +//! this module does not claim a hidden web capture works. + +use std::sync::mpsc::Sender; + +use craft_ui::capture::CaptureSlot; +use egui::ViewportCommand; +use serde_json::{Value, json}; +use web_time::{Duration, Instant}; + +use crate::FilmcraftApp; + +/// At most this many queued, in-flight or completed-but-not-yet-drained requests. +pub(crate) const MAX_REQUESTS: usize = 4; +/// Per-image bound from the suite contract. +pub(crate) const MAX_IMAGE_BYTES: usize = 128 * 1024 * 1024; +/// Aggregate bound, counting completed images that have not been drained yet. +pub(crate) const MAX_TOTAL_BYTES: usize = 256 * 1024 * 1024; +/// The raw RGBA image is not the only copy alive while a capture is drained: the callback's +/// `ColorImage`, the cropped RGBA copy and the encoded PNG exist together, and on the web the PNG +/// is base64-encoded into the reply while all of those are still retained. Reserve this multiple +/// of the raw pixel bytes so admission actually bounds peak memory, not just the first buffer. +pub(crate) const CAPTURE_BUFFER_FACTOR: usize = 5; + +/// Bytes reserved for one `width`×`height` RGBA capture, including the transient encode copies. +pub(crate) fn admitted_bytes(expected: [usize; 2]) -> Option { + expected[0].checked_mul(expected[1])?.checked_mul(std::mem::size_of::())?.checked_mul(CAPTURE_BUFFER_FACTOR) +} + +const READY_DELAY: Duration = Duration::from_millis(250); +/// Accepted first UI passes (never discarded passes or hidden logic ticks). +const SETTLE_PASSES: u32 = 3; +const DEADLINE: Duration = Duration::from_secs(10); +/// Give up waiting for the exact monitor frame/timeline rest and capture what is shown (5 s). +const SETTLE_MAX: Duration = Duration::from_secs(5); +/// Accepted passes to wait for a named crop target that is not drawn yet (e.g. a panel shown by +/// the preceding control request). After this the request fails instead of capturing the window. +const CROP_SETTLE_PASSES: u32 = 3; +const REPAINT_INTERVAL: Duration = Duration::from_millis(16); + +/// The host surface as reported by one `RawInput`. Raw input is fresh on hidden logic-only ticks; +/// `Context::input`/`viewport_rect` describe the last UI pass and are stale there. +/// +/// Equality covers only what changes the captured pixels (viewport size, pixels per point and the +/// GPU texture limit). Minimized, occluded and native window focus are presentation flags: a +/// hidden or unfocused window still captures the forced paint that was already issued, so they +/// must neither invalidate an in-flight screenshot nor bump `capture_epoch`. +#[derive(Clone, Copy, Debug)] +pub(crate) struct SurfaceGeometry { + /// Logical viewport size in points. A minimized window keeps its last known size so a forced + /// hidden paint can still be requested; `None` only before any size was ever reported. + viewport: Option, + /// Native pixels per point times egui's interface zoom. + pixels_per_point: f32, + max_texture_side: usize, + minimized: bool, + occluded: bool, +} + +impl PartialEq for SurfaceGeometry { + fn eq(&self, other: &Self) -> bool { + self.viewport == other.viewport && self.pixels_per_point == other.pixels_per_point && self.max_texture_side == other.max_texture_side + } +} + +impl SurfaceGeometry { + fn from_raw(raw: &egui::RawInput, ctx: &egui::Context, previous: Option) -> Option { + let info = raw.viewports.get(&raw.viewport_id).cloned().unwrap_or_default(); + let rect = info.inner_rect.or(raw.screen_rect); + let size = rect.map(|rect| rect.size()).filter(|size| size.x.is_finite() && size.y.is_finite() && size.x > 0.0 && size.y > 0.0); + let viewport = size.or(previous.and_then(|previous| previous.viewport)); + let zoom = ctx.zoom_factor(); + if !zoom.is_finite() || zoom <= 0.0 { + return None; + } + let pixels_per_point = info + .native_pixels_per_point + .map(|native| native * zoom) + .filter(|pixels| pixels.is_finite() && *pixels > 0.0) + .or_else(|| previous.map(|previous| previous.pixels_per_point)) + .unwrap_or_else(|| ctx.pixels_per_point()); + if !pixels_per_point.is_finite() || pixels_per_point <= 0.0 { + return None; + } + let max_texture_side = raw.max_texture_side.filter(|side| *side > 0).unwrap_or_else(|| ctx.input(|input| input.max_texture_side)); + let minimized = info.minimized.unwrap_or_else(|| previous.is_some_and(|previous| previous.minimized)); + let occluded = info.occluded.unwrap_or_else(|| previous.is_some_and(|previous| previous.occluded)); + Some(Self { viewport, pixels_per_point, max_texture_side, minimized, occluded }) + } + + fn expected_size(self) -> Option<[usize; 2]> { + let viewport = self.viewport?; + if !viewport.x.is_finite() || !viewport.y.is_finite() || viewport.x <= 0.0 || viewport.y <= 0.0 { + return None; + } + let axis = |points: f32| -> Option { + let pixels = (f64::from(points) * f64::from(self.pixels_per_point)).round(); + if !pixels.is_finite() || pixels <= 0.0 || pixels > usize::MAX as f64 { + return None; + } + Some(pixels as usize) + }; + let [width, height] = [axis(viewport.x)?, axis(viewport.y)?]; + // Match the wgpu backend's `surface_fit`: when either axis exceeds the device limit, both + // axes shrink by the same factor and the longer side becomes exactly the limit. Clamping + // each axis independently predicted a wider/taller image than the surface actually has. + let max = if self.max_texture_side == 0 { usize::MAX } else { self.max_texture_side }; + if width <= max && height <= max { + return Some([width, height]); + } + let longest = width.max(height); + let scale = max as f64 / longest as f64; + let fit = |side: usize| -> usize { if side == longest { max } else { ((side as f64 * scale).floor() as usize).clamp(1, max) } }; + Some([fit(width), fit(height)]) + } + + fn expected_bytes(self) -> Option { + let [width, height] = self.expected_size()?; + width.checked_mul(height)?.checked_mul(std::mem::size_of::()) + } +} + +/// Which sequence and project revision the request belongs to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct DocumentId { + sequence: Option, + revision: u64, +} + +/// Viewport geometry plus the immutable document and widget-focus identity a capture is bound to. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct CaptureGeometry { + surface: SurfaceGeometry, + document: DocumentId, + focus: Option, +} + +impl CaptureGeometry { + fn expected_size(self) -> Option<[usize; 2]> { + self.surface.expected_size() + } + + fn expected_bytes(self) -> Option { + self.surface.expected_bytes() + } +} + +/// A screenshot crop named by a widget id. Resolved at issue time from the pass that is actually +/// captured, never from the control callback that admitted the request (that pass may be two +/// layouts old or may not have drawn the panel at all). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CropTarget { + Element(String), +} + +impl CropTarget { + fn resolve(&self, registry: &crate::automation::Registry) -> Result<[f32; 4], String> { + let Self::Element(id) = self; + let element = registry + .find_current(id) + .ok_or_else(|| format!("screenshot failed: `{id}` was not drawn in the captured pass (the panel may be closed or hidden; see ui.elements)"))?; + let [x, y, width, height] = element.rect; + if ![x, y, width, height].iter().all(|value| value.is_finite()) || width <= 0.0 || height <= 0.0 { + return Err(format!("screenshot failed: `{id}` has an empty or non-finite rectangle")); + } + Ok(element.rect) + } +} + +struct Shot { + token: u64, + path: Option, + crop_target: Option, + /// The crop resolved from the accepted pass that was captured (set at issue time). + crop: Option<[f32; 4]>, + /// Accepted passes the named crop target was missing, while a just-shown panel lays out. + crop_misses: u32, + reply: Sender, + deadline: Instant, + ready_at: Instant, + frames: u32, + issued: bool, + document: DocumentId, + focus: Option, + geometry: CaptureGeometry, + geometry_epoch: u64, + expected: [usize; 2], + reserved: usize, + slot: Option, + callback: Option, +} + +/// All admitted screenshots. `requests` includes queued, issued and completed-but-undrained +/// captures; nothing is removed before it has been replied to or timed out. +#[derive(Default)] +pub(crate) struct Shots { + token: u64, + requests: Vec, +} + +impl Shots { + pub(crate) fn is_empty(&self) -> bool { + self.requests.is_empty() + } + + fn has_unissued(&self) -> bool { + self.requests.iter().any(|shot| !shot.issued) + } + + fn reserved(&self) -> usize { + self.requests.iter().fold(0usize, |sum, shot| sum.saturating_add(shot.reserved)) + } +} + +fn next_token(shots: &mut Shots) -> u64 { + let mut token = shots.token.wrapping_add(1); + if token == 0 { + token = 1; + } + shots.token = token; + token +} + +fn error_reply(token: u64, message: impl std::fmt::Display) -> Value { + json!({"ok": false, "error": message.to_string(), "request": token}) +} + +fn reject(reply: Sender, token: u64, message: impl std::fmt::Display) { + let _ = reply.send(error_reply(token, message)); +} + +fn active_document(app: &FilmcraftApp) -> DocumentId { + DocumentId { sequence: app.session.state.active_sequence.map(|id| id.0), revision: app.session.revision } +} + +fn current_focus(ctx: &egui::Context) -> Option { + ctx.memory(|memory| memory.focused()) +} + +/// Refresh the fresh raw-input surface/identity snapshot. Called from `raw_input_hook`, so it runs +/// on both UI and hidden logic-only callbacks and never reads the stale `InputState` rectangle. +pub(crate) fn note_raw_geometry(app: &mut FilmcraftApp, ctx: &egui::Context, raw: &egui::RawInput) { + let previous = app.capture_geometry; + let Some(surface) = SurfaceGeometry::from_raw(raw, ctx, previous.map(|geometry| geometry.surface)) else { + app.capture_geometry = None; + app.capture_epoch = app.capture_epoch.wrapping_add(1); + return; + }; + let geometry = CaptureGeometry { surface, document: active_document(app), focus: current_focus(ctx) }; + let changed = app.capture_geometry != Some(geometry); + // Keep the presentation flags fresh even when they did not change the capture geometry. + app.capture_geometry = Some(geometry); + if changed { + app.capture_epoch = app.capture_epoch.wrapping_add(1); + } +} + +/// Pick up document/revision and keyboard-focus changes made since the raw-input snapshot (a +/// control request, or the UI pass that admitted a `ui.screenshot` at its end). +fn refresh_identity(app: &mut FilmcraftApp, ctx: &egui::Context) { + let Some(geometry) = app.capture_geometry else { return }; + let refreshed = CaptureGeometry { document: active_document(app), focus: current_focus(ctx), ..geometry }; + let changed = refreshed != geometry; + app.capture_geometry = Some(refreshed); + if changed { + app.capture_epoch = app.capture_epoch.wrapping_add(1); + } +} + +/// Admit one `ui.screenshot` request without issuing it. Counts queued, in-flight and +/// completed-but-undrained requests and reserves the raw image plus its encode/encode-reply buffers +/// before capture. +pub(crate) fn admit(app: &mut FilmcraftApp, ctx: &egui::Context, path: Option, crop: Option, reply: Sender) { + refresh_identity(app, ctx); + let token = next_token(&mut app.shots); + let Some(geometry) = app.capture_geometry else { + return reject(reply, token, "screenshot failed: the window geometry is not known yet"); + }; + let Some(expected) = geometry.expected_size() else { + return reject(reply, token, "screenshot failed: the window has no capturable size yet"); + }; + let Some(raw) = geometry.expected_bytes() else { + return reject(reply, token, "screenshot failed: the window size overflows the screenshot budget"); + }; + if raw > MAX_IMAGE_BYTES { + return reject( + reply, + token, + format!( + "screenshot budget exceeded: {raw} raw bytes for one {}×{} image, over the {MAX_IMAGE_BYTES}-byte per-image limit", + expected[0], expected[1] + ), + ); + } + let Some(reserved) = admitted_bytes(expected) else { + return reject(reply, token, "screenshot failed: the window size overflows the screenshot budget"); + }; + if reserved > MAX_TOTAL_BYTES { + return reject( + reply, + token, + format!( + "screenshot budget exceeded: one {}×{} capture needs up to {reserved} bytes including the crop/encode reply buffers, over the {MAX_TOTAL_BYTES}-byte limit", + expected[0], expected[1] + ), + ); + } + if app.shots.requests.len() >= MAX_REQUESTS { + return reject(reply, token, format!("screenshot budget exceeded: {MAX_REQUESTS} requests are already outstanding")); + } + let total = app.shots.reserved().saturating_add(reserved); + if total > MAX_TOTAL_BYTES { + return reject( + reply, + token, + format!("screenshot budget exceeded: {total} bytes reserved by outstanding requests, over the {MAX_TOTAL_BYTES}-byte limit"), + ); + } + let (slot, callback) = CaptureSlot::new(ctx, ctx.viewport_id(), MAX_IMAGE_BYTES); + let now = Instant::now(); + app.shots.requests.push(Shot { + token, + path, + crop_target: crop, + crop: None, + crop_misses: 0, + reply, + deadline: now + DEADLINE, + ready_at: now + READY_DELAY, + frames: 0, + issued: false, + document: geometry.document, + focus: geometry.focus, + geometry, + geometry_epoch: app.capture_epoch, + expected, + reserved, + slot: Some(slot), + callback: Some(callback), + }); + ctx.request_repaint(); +} + +fn identity_reason(app: &FilmcraftApp, ctx: &egui::Context, shot: &Shot) -> Option<&'static str> { + if active_document(app) != shot.document { + Some("the document or project revision changed while the capture was queued") + } else if current_focus(ctx) != shot.focus { + Some("the keyboard focus changed while the capture was queued") + } else { + None + } +} + +fn geometry_reason(app: &FilmcraftApp, shot: &Shot) -> Option<&'static str> { + if app.capture_geometry != Some(shot.geometry) || app.capture_epoch != shot.geometry_epoch { + Some("the window geometry changed while the capture was queued") + } else { + None + } +} + +fn retry_geometry(app: &mut FilmcraftApp, ctx: &egui::Context, index: usize) -> Result<(), String> { + let now = Instant::now(); + let current = app.capture_geometry.ok_or_else(|| "the window geometry is no longer known".to_string())?; + let expected = current.expected_size().ok_or_else(|| "the window has no capturable size".to_string())?; + let raw = current.expected_bytes().ok_or_else(|| "the window size overflows the screenshot budget".to_string())?; + if raw > MAX_IMAGE_BYTES { + return Err(format!("the window now needs {raw} raw bytes, over the {MAX_IMAGE_BYTES}-byte per-image limit")); + } + let reserved = admitted_bytes(expected).ok_or_else(|| "the window size overflows the screenshot budget".to_string())?; + let others = app.shots.requests.iter().enumerate().filter(|(i, _)| *i != index).fold(0usize, |sum, (_, shot)| sum.saturating_add(shot.reserved)); + if others.saturating_add(reserved) > MAX_TOTAL_BYTES { + return Err(format!("the window now needs {reserved} bytes, over the {MAX_TOTAL_BYTES}-byte aggregate limit")); + } + let Some(current_geometry) = app.capture_geometry else { return Err("the window geometry changed repeatedly".into()) }; + let (slot, callback) = CaptureSlot::new(ctx, ctx.viewport_id(), MAX_IMAGE_BYTES); + let shot = app.shots.requests.get_mut(index).ok_or_else(|| "the screenshot request disappeared".to_string())?; + if let Some(old) = shot.slot.take() { + old.cancel(); + } + shot.callback = Some(callback); + shot.slot = Some(slot); + shot.geometry = current_geometry; + shot.geometry_epoch = app.capture_epoch; + shot.expected = expected; + shot.reserved = reserved; + shot.frames = 0; + shot.crop_misses = 0; + shot.ready_at = now + READY_DELAY; + shot.issued = false; + Ok(()) +} + +/// Issue settled requests. Called from every real `App::ui` pass (never from a hidden +/// `run_logic` tick), including the accepted final pass of a multipass layout. `monitor_inexact` +/// describes the pass that just drew the monitor, so consume it here: it must not stay sticky and +/// delay every later screenshot until the 5 s settle maximum. +pub(crate) fn issue(app: &mut FilmcraftApp, ctx: &egui::Context) { + let monitor_inexact = std::mem::take(&mut app.monitor_inexact); + if ctx.will_discard() { + return; + } + let now = Instant::now(); + let mut failures: Vec<(usize, String)> = Vec::new(); + let mut retries: Vec = Vec::new(); + for i in 0..app.shots.requests.len() { + if app.shots.requests[i].issued { + continue; + } + app.shots.requests[i].frames = app.shots.requests[i].frames.saturating_add(1); + let shot = &app.shots.requests[i]; + if now < shot.ready_at || shot.frames < SETTLE_PASSES { + continue; + } + // After a seek the monitor can show the nearest cached picture until the exact frame is + // decoded, and the Timeline can still be animating. Capture once those have settled, or + // after the same bounded maximum the old `Ui` settle path used. + if (monitor_inexact || app.timeline_still < 2) && now < shot.ready_at + SETTLE_MAX { + continue; + } + if let Some(reason) = identity_reason(app, ctx, shot) { + failures.push((i, reason.to_string())); + continue; + } + if geometry_reason(app, shot).is_some() { + retries.push(i); + continue; + } + let crop_result = match app.shots.requests[i].crop_target.as_ref() { + Some(target) => target.resolve(&app.auto).map(Some), + None => Ok(None), + }; + let resolved_crop = match crop_result { + Ok(crop) => crop, + Err(message) => { + let shot = &mut app.shots.requests[i]; + if shot.crop_misses < CROP_SETTLE_PASSES { + // The panel may be shown by a request processed just before this admission; + // let the next accepted pass draw it before giving up. + shot.crop_misses = shot.crop_misses.saturating_add(1); + ctx.request_repaint(); + continue; + } + failures.push((i, message)); + continue; + } + }; + let shot = &mut app.shots.requests[i]; + let Some(callback) = shot.callback.take() else { + failures.push((i, "screenshot failed: the capture callback was already consumed".into())); + continue; + }; + shot.crop = resolved_crop; + ctx.send_viewport_cmd_to(ctx.viewport_id(), ViewportCommand::Screenshot(callback)); + shot.issued = true; + } + for i in retries.into_iter().rev() { + if let Err(message) = retry_geometry(app, ctx, i) { + failures.push((i, message)); + } + } + for (i, message) in failures.into_iter().rev() { + let shot = app.shots.requests.remove(i); + if let Some(slot) = shot.slot { + slot.cancel(); + } + reject(shot.reply, shot.token, message); + } + if !app.shots.is_empty() { + ctx.request_repaint_after(REPAINT_INTERVAL); + } +} + +/// Drain completed slots and expire timed-out requests. Call from every logic callback (including +/// hidden ones). The deadline is checked before taking or writing an image. +pub(crate) fn drain(app: &mut FilmcraftApp, ctx: &egui::Context) { + let mut i = 0; + while i < app.shots.requests.len() { + let now = Instant::now(); + if now >= app.shots.requests[i].deadline { + let shot = app.shots.requests.remove(i); + if let Some(slot) = shot.slot { + slot.cancel(); + } + reject(shot.reply, shot.token, "no frame was presented before the screenshot timeout"); + continue; + } + if let Some(reason) = identity_reason(app, ctx, &app.shots.requests[i]) { + let shot = app.shots.requests.remove(i); + if let Some(slot) = shot.slot { + slot.cancel(); + } + reject(shot.reply, shot.token, format!("screenshot failed: {reason}")); + continue; + } + if geometry_reason(app, &app.shots.requests[i]).is_some() { + if !app.shots.requests[i].issued { + // The next accepted UI pass re-creates the slot against the fresh geometry. + i += 1; + continue; + } + // Geometry changed while the callback was already issued: reject stale pixels rather + // than pairing them with the new surface. + let shot = app.shots.requests.remove(i); + if let Some(slot) = shot.slot { + slot.cancel(); + } + reject(shot.reply, shot.token, "screenshot failed: the window geometry changed while the capture was in flight"); + continue; + } + if !app.shots.requests[i].issued { + i += 1; + continue; + } + let Some(result) = app.shots.requests[i].slot.as_ref().and_then(CaptureSlot::take) else { + i += 1; + continue; + }; + let shot = app.shots.requests.remove(i); + if let Some(slot) = shot.slot { + slot.cancel(); + } + match result { + Ok(image) => { + let reply = if image.size != shot.expected { + error_reply( + shot.token, + format!( + "screenshot is {}×{} pixels, expected {}×{}: the window changed size while it was captured", + image.size[0], image.size[1], shot.expected[0], shot.expected[1] + ), + ) + } else { + let mut reply = crate::control::save_screenshot_with_ppp(&image, shot.path.as_deref(), shot.crop, shot.geometry.surface.pixels_per_point); + if let Some(object) = reply.as_object_mut() { + object.insert("request".into(), json!(shot.token)); + } + reply + }; + let _ = shot.reply.send(reply); + } + Err(error) => { + let _ = shot.reply.send(error_reply(shot.token, error)); + } + } + } + if !app.shots.is_empty() { + ctx.request_repaint_after(REPAINT_INTERVAL); + } +} + +/// Cancel every queued/in-flight capture (app exit). Late callbacks see a dropped slot and write +/// nothing; clients receive one error reply instead of hanging past the transport timeout. +pub(crate) fn cancel_all(app: &mut FilmcraftApp) { + for shot in app.shots.requests.drain(..) { + if let Some(slot) = shot.slot { + slot.cancel(); + } + let _ = shot.reply.send(error_reply(shot.token, "screenshot cancelled: the app is closing")); + } +} + +/// Ask for a painted frame while a request is still queued, and on hidden logic ticks when a +/// capture is waiting for settlement. The screenshot command itself forces one paint once issued. +pub(crate) fn request_hidden_paint(app: &FilmcraftApp, ctx: &egui::Context) { + if app.shots.has_unissued() { + ctx.send_viewport_cmd(ViewportCommand::RequestPaintWhileHidden); + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::sync::mpsc::{Receiver, channel}; + + use egui::{Color32, ColorImage, Rect, ViewportId, ViewportInfo, pos2, vec2}; + + use super::*; + use crate::automation::Registry; + use filmcraft_engine::Session; + + fn app() -> FilmcraftApp { + FilmcraftApp::new(Session::default()) + } + + fn geometry(app: &FilmcraftApp) -> CaptureGeometry { + CaptureGeometry { + surface: SurfaceGeometry { + viewport: Some(egui::vec2(800.0, 600.0)), + pixels_per_point: 1.0, + max_texture_side: 8192, + minimized: false, + occluded: false, + }, + document: active_document(app), + focus: None, + } + } + + fn raw() -> egui::RawInput { + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), ..Default::default() } + } + + fn admit_one(app: &mut FilmcraftApp, ctx: &egui::Context) -> (Receiver, u64) { + app.capture_geometry = Some(geometry(app)); + let (tx, rx) = channel(); + admit(app, ctx, None, None, tx); + let token = app.shots.requests.first().map(|shot| shot.token).unwrap_or_default(); + (rx, token) + } + + #[test] + fn raw_geometry_uses_the_fresh_surface_not_the_previous_pass() { + let ctx = egui::Context::default(); + let mut app = app(); + let mut raw = raw(); + let _ = raw.viewports.insert(ViewportId::ROOT, ViewportInfo { native_pixels_per_point: Some(2.0), ..Default::default() }); + note_raw_geometry(&mut app, &ctx, &raw); + let geometry = app.capture_geometry.expect("geometry from raw input"); + assert_eq!(geometry.surface.expected_size(), Some([1600, 1200])); + assert!(app.capture_epoch > 0); + } + + #[test] + fn oversized_windows_predict_the_backends_uniformly_scaled_surface() { + let app = app(); + let mut surface = geometry(&app).surface; + surface.viewport = Some(vec2(16_384.0, 4_000.0)); + surface.max_texture_side = 8192; + assert_eq!(surface.expected_size(), Some([8192, 2000]), "both axes scale by the same factor, like egui-wgpu's surface_fit"); + surface.viewport = Some(vec2(4_000.0, 16_384.0)); + assert_eq!(surface.expected_size(), Some([2000, 8192])); + surface.viewport = Some(vec2(1920.0, 1080.0)); + assert_eq!(surface.expected_size(), Some([1920, 1080]), "within the limit nothing shrinks"); + } + + #[test] + fn presentation_flags_do_not_invalidate_an_issued_capture() { + let ctx = egui::Context::default(); + let mut app = app(); + let (rx, token) = admit_one(&mut app, &ctx); + let callback = app.shots.requests[0].callback.take().expect("a callback"); + app.shots.requests[0].issued = true; + // The window is minimized or another application took focus while the callback was in + // flight. Those flags do not change captured pixels and must not reject the image. + let epoch = app.capture_epoch; + let mut raw = egui::RawInput { + screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), + max_texture_side: Some(8192), + focused: false, + ..Default::default() + }; + let viewport = raw.viewports.entry(ViewportId::ROOT).or_default(); + viewport.native_pixels_per_point = Some(1.0); + viewport.minimized = Some(true); + viewport.occluded = Some(true); + note_raw_geometry(&mut app, &ctx, &raw); + assert_eq!(app.capture_epoch, epoch, "presentation-only raw input retains the issued capture"); + callback.complete(Arc::new(ColorImage::filled([800, 600], Color32::RED))); + drain(&mut app, &ctx); + let reply = rx.try_recv().expect("the issued capture completes instead of being rejected"); + assert_eq!(reply["request"], token); + assert_eq!(reply["ok"], true, "{reply}"); + } + + #[test] + fn admission_counts_outstanding_and_completed_but_undrained_requests() { + let ctx = egui::Context::default(); + let mut app = app(); + let _receivers: Vec<_> = (0..MAX_REQUESTS).map(|_| admit_one(&mut app, &ctx)).collect(); + assert_eq!(app.shots.requests.len(), MAX_REQUESTS); + let image = Arc::new(ColorImage::filled([800, 600], Color32::RED)); + app.shots.requests[0].callback.take().expect("a callback").complete(image); + let (tx, rx) = channel(); + admit(&mut app, &ctx, None, None, tx); + assert_eq!(app.shots.requests.len(), MAX_REQUESTS, "the fifth request is rejected, not admitted"); + let reply = rx.recv().expect("a rejection reply"); + assert_eq!(reply["ok"], false); + assert!(reply["error"].as_str().unwrap_or_default().contains("outstanding"), "{reply}"); + } + + #[test] + fn a_late_completion_after_timeout_does_not_write_or_reply_twice() { + let ctx = egui::Context::default(); + let mut app = app(); + let (rx, token) = admit_one(&mut app, &ctx); + let callback = app.shots.requests[0].callback.take().expect("a callback"); + app.shots.requests[0].issued = true; + app.shots.requests[0].deadline = Instant::now() - Duration::from_millis(1); + drain(&mut app, &ctx); + let reply = rx.recv().expect("timeout reply"); + assert_eq!(reply["request"], token); + assert_eq!(reply["ok"], false); + callback.complete(Arc::new(ColorImage::filled([800, 600], Color32::RED))); + assert!(rx.try_recv().is_err(), "a late image must not produce another reply"); + } + + #[test] + fn near_limit_images_are_rejected_for_encode_buffer_memory() { + let ctx = egui::Context::default(); + let mut app = app(); + // 8000×4000 RGBA is exactly 128,000,000 raw bytes, under the per-image bound, but the + // crop/PNG/base64 copies make its real peak several times larger than the aggregate bound. + app.capture_geometry = Some(CaptureGeometry { + surface: SurfaceGeometry { + viewport: Some(vec2(8000.0, 4000.0)), + pixels_per_point: 1.0, + max_texture_side: 16384, + minimized: false, + occluded: false, + }, + document: active_document(&app), + focus: None, + }); + let (tx, rx) = channel(); + admit(&mut app, &ctx, None, None, tx); + assert!(app.shots.requests.is_empty(), "the image is rejected before a slot or buffer is created"); + let reply = rx.recv().expect("a rejection reply"); + assert_eq!(reply["ok"], false); + assert!(reply["error"].as_str().unwrap_or_default().contains("budget"), "{reply}"); + assert!(admitted_bytes([8000, 4000]).is_some_and(|bytes| bytes > MAX_TOTAL_BYTES)); + } + + #[test] + fn a_crop_is_resolved_from_the_current_pass_not_the_previous_one() { + let mut registry = Registry::default(); + let old = Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 50.0)); + let fresh = Rect::from_min_size(pos2(300.0, 200.0), vec2(400.0, 250.0)); + registry.add("panel.project", old, "Project"); + registry.begin_frame(); // the previous pass keeps the old rect; this pass has not drawn yet + let target = CropTarget::Element("panel.project".into()); + assert!(target.resolve(®istry).is_err(), "a stale rect must not be used"); + registry.add("panel.project", fresh, "Project"); + assert_eq!(target.resolve(®istry).expect("fresh rect"), [300.0, 200.0, 400.0, 250.0]); + assert!(CropTarget::Element("panel.missing".into()).resolve(®istry).is_err()); + } + + #[test] + fn issue_runs_on_the_accepted_final_pass_of_a_multipass_layout() { + let ctx = egui::Context::default(); + let mut app = app(); + let (_rx, _token) = admit_one(&mut app, &ctx); + app.shots.requests[0].ready_at = Instant::now() - Duration::from_millis(1); + app.shots.requests[0].frames = SETTLE_PASSES; + app.timeline_still = 2; + let mut passes = 0; + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + passes += 1; + if ui.ctx().current_pass_index() == 0 { + ui.ctx().request_discard("capture pass test"); + } + issue(&mut app, ui.ctx()); + }); + output.textures_delta.clear(); + assert_eq!(passes, 2, "the first pass was discarded and the second accepted"); + assert!(app.shots.requests[0].issued, "the accepted pass must issue the capture"); + } + + #[test] + fn monitor_inexact_is_consumed_by_each_issue_attempt() { + let ctx = egui::Context::default(); + let mut app = app(); + app.monitor_inexact = true; + issue(&mut app, &ctx); + assert!(!app.monitor_inexact, "a sticky flag would delay every later screenshot by up to five seconds"); + } + + #[test] + fn a_geometry_change_retries_an_unissued_capture_with_new_budget() { + let ctx = egui::Context::default(); + let mut app = app(); + let (_rx, _) = admit_one(&mut app, &ctx); + app.shots.requests[0].ready_at = Instant::now() - Duration::from_millis(1); + app.shots.requests[0].frames = SETTLE_PASSES; + app.timeline_still = 2; + let mut changed = geometry(&app).surface; + changed.viewport = Some(egui::vec2(1024.0, 768.0)); + app.capture_geometry = Some(CaptureGeometry { surface: changed, ..geometry(&app) }); + app.capture_epoch += 1; + issue(&mut app, &ctx); + assert_eq!(app.shots.requests.len(), 1); + assert_eq!(app.shots.requests[0].expected, [1024, 768]); + assert!(!app.shots.requests[0].issued, "the retry waits for its own settling passes"); + } +} diff --git a/crates/ui-egui/src/cjk.rs b/crates/ui-egui/src/cjk.rs index bf089a2f6..a72222db0 100644 --- a/crates/ui-egui/src/cjk.rs +++ b/crates/ui-egui/src/cjk.rs @@ -50,7 +50,7 @@ fn find_system_font(sample: &str, preferred: &[&str]) -> Option> { let by_weight = |f: &&Arc| f.info.weight.abs_diff(400); let found = preferred.iter().find_map(|name| faces.iter().filter(|f| f.info.family.eq_ignore_ascii_case(name) && covers(f)).min_by_key(by_weight)); let face = found.or_else(|| faces.iter().filter(|f| covers(f)).min_by_key(by_weight))?; - Some(Arc::new(FontData { font: std::borrow::Cow::Owned(face.data()?), index: face.info.index, tweak: Default::default() })) + Some(Arc::new(FontData::from_blob(Arc::new(face.data()?), face.info.index))) } /// Whether [`install`] has a Hangul face to add (an installed system face; none is bundled). @@ -100,3 +100,14 @@ fn install_chinese(fonts: &mut FontDefinitions) { stack.extend(order.iter().map(|f| name(f))); } } + +#[cfg(test)] +mod font_blob_tests { + use super::*; + + #[test] + fn a_ttc_face_index_survives_font_data_construction() { + let font = FontData::from_blob(Arc::new(vec![0u8; 4]), 7); + assert_eq!(font.index, 7, "the face index must travel with the shared blob"); + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 960fa8915..5327e414e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -27,6 +27,8 @@ use serde_json::{Value, json}; const MAX_CLICK_COUNT: u64 = 10; const MAX_DRAG_STEPS: u64 = 1_024; +/// Synthetic input is consumed a step per frame; bound what a client can queue ahead of that. +const MAX_SYNTHETIC_EVENTS: usize = 16_384; fn bounded_repetitions(params: &Value, key: &str, default: u64, minimum: u64, maximum: u64) -> Result { let value = params.get(key).map(|value| value.as_u64().ok_or_else(|| format!("`{key}` must be a non-negative integer"))).transpose()?.unwrap_or(default); @@ -64,7 +66,8 @@ pub enum Outcome { AfterInput, Screenshot { path: Option, - crop: Option<[f32; 4]>, + /// Resolved to fresh geometry at issue time, not here. + crop: Option, }, } @@ -110,6 +113,9 @@ fn point(app: &FilmcraftApp, p: &Value) -> Result { pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); + if matches!(req.method.as_str(), "ui.click" | "ui.move" | "ui.drag" | "ui.scroll" | "ui.key" | "ui.type") && app.synthetic.len() >= MAX_SYNTHETIC_EVENTS { + return err("too many synthetic input events are queued; wait for the current input to be processed"); + } match req.method.as_str() { "engine.execute" | "ui.menu.invoke" => { let Some(id) = s("command").or(s("id")) else { return err("missing `command`") }; @@ -293,8 +299,48 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) } // the Type tool's text editing state: {"clip", "layer", "caret", "anchor"} (byte offsets) or null if let Some(v) = p.get("gfxEdit") { - match serde_json::from_value(v.clone()) { - Ok(e) => app.ui.gfx_edit = e, + match serde_json::from_value::>(v.clone()) { + Ok(mut e) => { + // An explicit `ui.set.gfxEdit {clip, layer}` names the layer currently at + // that index. Resolve its identity from the document instead of inheriting + // a stale uid/fingerprint from a previous edit session: after a reorder, + // delete or undo the requested index may now hold a different layer. + if let Some(new) = e.as_mut() { + match crate::panels::graphics::current_layer_identity(&app.session, new.clip, new.layer) { + Some((uid, fingerprint)) => { + new.uid = uid; + new.fingerprint = fingerprint; + } + None => { + new.uid = 0; + new.fingerprint = 0; + } + } + } + let changed = match (&app.ui.gfx_edit, &e) { + (None, None) => false, + (Some(old), Some(new)) => { + // A stable uid is the owner key. The compact fingerprint only + // decides identity for a uid-less layer, and zero there means + // "the requested index" rather than a different owner. + old.clip != new.clip + || old.layer != new.layer + || old.uid != new.uid + || (new.uid == 0 && old.fingerprint != 0 && old.fingerprint != new.fingerprint) + } + _ => true, + }; + if changed { + app.restart_ime_target(); + } else if let (Some(old), Some(new)) = (&app.ui.gfx_edit, &e) + && (old.caret, old.anchor) != (new.caret, new.anchor) + { + // Caret is not part of the stable owner. Only a known composition + // becomes stale; commit-only input without one remains eligible. + app.invalidate_ime_composition(); + } + app.ui.gfx_edit = e; + } Err(e) => return err(format!("`gfxEdit`: {e}")), } } @@ -391,6 +437,7 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) app.synthetic.push(egui::Event::PointerMoved(pos)); app.synthetic.push(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Wheel, delta: egui::vec2(dx, dy), modifiers: modifiers(p), phase: egui::TouchPhase::Move, @@ -444,8 +491,14 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) ok(json!({"playing": app.playback.playing, "playhead": app.session.playhead().0})) } "ui.screenshot" => { - let crop = s("panel").and_then(PanelKind::from_name).and_then(|pk| app.auto.find(&format!("panel.{}", pk.id())).map(|e| e.rect)); - let crop = crop.or_else(|| s("id").and_then(|id| app.auto.find(id).map(|e| e.rect))); + // Keep the target symbolic: the crop must come from the layout that is actually + // captured, not from whatever the registry held when this control request arrived. + let crop = if let Some(panel) = s("panel") { + let Some(pk) = PanelKind::from_name(panel) else { return err(format!("unknown panel `{panel}`")) }; + Some(crate::capture::CropTarget::Element(format!("panel.{}", pk.id()))) + } else { + s("id").map(|id| crate::capture::CropTarget::Element(id.to_string())) + }; // Bring the window on screen so a frame is presented, without taking keyboard focus. app.raise_for_control(ctx); Outcome::Screenshot { path: s("path").map(str::to_string), crop } @@ -512,7 +565,7 @@ fn merge(v: &mut Value, patch: &Value) { pub fn inspect(app: &FilmcraftApp, ctx: &egui::Context) -> Value { let size = ctx.content_rect().size(); let keyboard_focus = ctx.memory(|memory| memory.focused()).map(|id| { - let elements: Vec<_> = app.auto.previous.iter().filter(|element| element.response_id == Some(id)).collect(); + let elements: Vec<_> = app.auto.all().iter().filter(|element| element.response_id == Some(id)).collect(); json!({ "widgetId": format!("{:016x}", id.value()), "elementIds": elements.iter().map(|element| element.id.as_str()).collect::>(), @@ -533,24 +586,53 @@ pub fn inspect(app: &FilmcraftApp, ctx: &egui::Context) -> Value { "playhead": app.session.playhead().0, "activeSequence": app.session.state.active_sequence.map(|i| i.0), "selection": app.session.state.selection.iter().map(|c| c.0).collect::>(), - "elements": app.auto.previous.len(), + "elements": app.auto.all().len(), "dialog": app.dialog.map(|d| format!("{d:?}")), }) } /// Save a screenshot (optionally cropped to a rect in points) as PNG; returns the reply JSON. pub fn save_screenshot(ctx: &egui::Context, image: &egui::ColorImage, path: Option<&str>, crop: Option<[f32; 4]>) -> Value { - let ppp = ctx.pixels_per_point(); - let [w, h] = image.size; - let (x0, y0, cw, ch) = match crop { - Some([x, y, cw, ch]) => { - let x0 = ((x * ppp) as usize).min(w); - let y0 = ((y * ppp) as usize).min(h); - (x0, y0, ((cw * ppp) as usize).min(w - x0), ((ch * ppp) as usize).min(h - y0)) - } - None => (0, 0, w, h), + save_screenshot_with_ppp(image, path, crop, ctx.pixels_per_point()) +} + +/// Intersect a crop rectangle in points with the captured image, converting through `ppp`. +/// +/// Negative origins are clipped to the image rather than saturated while keeping the requested +/// width, which would shift the captured region. A crop that does not overlap the image (or has a +/// non-finite or non-positive size) is refused instead of returning a zero-sized or wrong image. +fn crop_pixels(size: [usize; 2], crop: Option<[f32; 4]>, ppp: f32) -> Result<(usize, usize, usize, usize), String> { + let [w, h] = size; + let Some([x, y, cw, ch]) = crop else { return Ok((0, 0, w, h)) }; + if !(x.is_finite() && y.is_finite() && cw.is_finite() && ch.is_finite()) || cw <= 0.0 || ch <= 0.0 { + return Err("screenshot crop must be a finite rectangle with positive width and height".into()); + } + let ppp = f64::from(ppp); + let clamp = |v: f64, max: usize| v.max(0.0).min(max as f64); + let x0 = clamp(f64::from(x) * ppp, w).floor() as usize; + let y0 = clamp(f64::from(y) * ppp, h).floor() as usize; + let x1 = clamp((f64::from(x) + f64::from(cw)) * ppp, w).ceil() as usize; + let y1 = clamp((f64::from(y) + f64::from(ch)) * ppp, h).ceil() as usize; + let (cw, ch) = (x1.saturating_sub(x0), y1.saturating_sub(y0)); + if cw == 0 || ch == 0 { + return Err("screenshot crop does not intersect the image".into()); + } + Ok((x0, y0, cw, ch)) +} + +/// Save a screenshot whose scale was captured with the request. Hidden logic ticks must not read +/// `Context::pixels_per_point()` for an image produced from a different UI pass. +pub fn save_screenshot_with_ppp(image: &egui::ColorImage, path: Option<&str>, crop: Option<[f32; 4]>, ppp: f32) -> Value { + let ppp = if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 }; + let (x0, y0, cw, ch) = match crop_pixels(image.size, crop, ppp) { + Ok(rect) => rect, + Err(e) => return json!({"ok": false, "error": e}), }; - let mut rgba = Vec::with_capacity(cw * ch * 4); + let [w, _] = image.size; + let Some(capacity) = cw.checked_mul(ch).and_then(|pixels| pixels.checked_mul(std::mem::size_of::())) else { + return json!({"ok": false, "error": "screenshot crop size overflows the image buffer"}); + }; + let mut rgba = Vec::with_capacity(capacity); for y in y0..y0 + ch { for x in x0..x0 + cw { let c = image.pixels[y * w + x]; @@ -611,7 +693,12 @@ pub fn encode_png(rgba: &[u8], w: u32, h: u32) -> Result, String> { } !c } - let mut raw = Vec::with_capacity((w as usize * 4 + 1) * h as usize); + let row = (w as usize) + .checked_mul(4) + .and_then(|bytes| bytes.checked_add(1)) + .and_then(|bytes| bytes.checked_mul(h as usize)) + .ok_or_else(|| "PNG size overflows the image buffer".to_string())?; + let mut raw = Vec::with_capacity(row); for y in 0..h as usize { raw.push(0); raw.extend_from_slice(&rgba[y * w as usize * 4..(y + 1) * w as usize * 4]); @@ -653,6 +740,29 @@ mod base64_tests { } } + #[test] + fn screenshot_crop_clips_negative_panel_origin() { + // A panel starting two points left of / one point above the window: only the visible + // 2×3 pixels are captured, not the requested 4×4 starting at (0, 0). + assert_eq!(super::crop_pixels([8, 8], Some([-2.0, -1.0, 4.0, 4.0]), 1.0).unwrap(), (0, 0, 2, 3)); + // The same crop at 2× scale clips the same logical region to the pixel grid. + assert_eq!(super::crop_pixels([16, 16], Some([-2.0, -1.0, 4.0, 4.0]), 2.0).unwrap(), (0, 0, 4, 6)); + // Partly outside on the right/bottom uses the visible remainder. + assert_eq!(super::crop_pixels([8, 8], Some([6.0, 5.0, 4.0, 4.0]), 1.0).unwrap(), (6, 5, 2, 3)); + } + + #[test] + fn screenshot_crop_outside_the_image_is_refused() { + for crop in [[-8.0, -8.0, 4.0, 4.0], [8.0, 0.0, 4.0, 4.0], [0.0, 9.0, 4.0, 4.0]] { + let err = super::crop_pixels([8, 8], Some(crop), 1.0).expect_err("a crop outside the image must be refused"); + assert!(err.contains("does not intersect"), "{err}"); + } + for crop in [[0.0, 0.0, 0.0, 4.0], [0.0, 0.0, -4.0, 4.0], [f32::NAN, 0.0, 4.0, 4.0], [0.0, 0.0, f32::INFINITY, 4.0]] { + let err = super::crop_pixels([8, 8], Some(crop), 1.0).expect_err("an invalid crop must be refused"); + assert!(err.contains("finite rectangle"), "{err}"); + } + } + #[test] fn synthetic_event_repetition_is_bounded() { assert_eq!(super::bounded_repetitions(&json!({}), "count", 1, 0, super::MAX_CLICK_COUNT).unwrap(), 1); diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index c1976eb80..0fcc36cfe 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -231,7 +231,7 @@ const LIST_HEIGHT: f32 = 360.0; /// `about.credits.reverse`, `about.credits.grabBag`, `about.credits.table`, /// `about.credits.header.` (table headers, by column). pub fn contributors_ui(ui: &mut egui::Ui, t: &Tokens, auto: &mut Registry) { - let id = egui::Id::new("credits_view"); + let id = egui::Id::unique("credits_view"); let mut v = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); ui.horizontal_wrapped(|ui| { ui.label(tl!("Show")); diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index d144bd57e..867d24740 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -545,10 +545,11 @@ fn layout_inner( DockNode::Split { vertical, size, a, b } => { let axis = if *vertical { SplitAxis::Vertical } else { SplitAxis::Horizontal }; let Some(split) = split_rect(rect, axis, size.shared(), t.gap, 20.0) else { return }; - let id = egui::Id::new(("dock-gutter", path.to_string())); + let id = egui::Id::unique(("dock-gutter", path.to_string())); let handle_style = ResizeHandleStyle { hovered: t.focus.gamma_multiply(0.4), dragged: t.focus.gamma_multiply(0.9), focused: t.focus }; let label = if *vertical { tl!("Height:") } else { tl!("Width:") }; let response = ResizeHandle::new(id, axis, split.divider, label, handle_style).value(split.first_size(), 0.0..=split.available()).show(ui); + response.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Splitter, response.response.enabled(), label)); reg.add_response(&format!("dock.gutter.{path}"), &response.response, "gutter"); if response.delta != 0.0 && let Some(resized) = split.resized(size.shared(), response.delta, 40.0) @@ -603,7 +604,7 @@ pub fn draw_group_chrome_details(ui: &mut egui::Ui, g: &Group, seqs: &SeqTabs, t // separately reorder open sequences. if let Some(panel) = active_panel { let grip = Rect::from_min_size(g.rect.min, vec2(if compact { g.rect.width() } else { 10.0 }, if compact { 8.0 } else { t.tab_h })); - let response = ui.interact(grip, egui::Id::new(("panel-drag", g.path.clone())), Sense::drag()); + let response = ui.interact(grip, egui::Id::unique(("panel-drag", g.path.clone())), Sense::drag()); reg.add_response(&format!("panel.drag.{}", panel.id()), &response, "Move panel"); if response.drag_started() { actions.push(DockAction::BeginDrag(panel)); @@ -664,13 +665,13 @@ pub fn draw_group_chrome_details(ui: &mut egui::Ui, g: &Group, seqs: &SeqTabs, t while last + 1 < tabs.len() && span(first, last + 1) <= room { last += 1; } - let list_id = egui::Id::new(("tab-list", g.path.clone())); + let list_id = egui::Id::unique(("tab-list", g.path.clone())); let more = (!all_fit).then(|| Rect::from_min_size(pos2(strip.max.x - 22.0, strip.min.y + 6.0), vec2(18.0, 20.0))); let mut list_opened = false; if let Some(r) = more { - let resp = ui.interact(r, egui::Id::new(("tab-overflow", g.path.clone())), Sense::click()); + let resp = ui.interact(r, egui::Id::unique(("tab-overflow", g.path.clone())), Sense::click()); let name = if g.panels.contains(&PanelKind::Timeline) { "timeline.tabs.more".to_string() } else { format!("panel.tabs.more.{}", g.path) }; - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, resp.enabled(), "more tabs")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, resp.enabled(), "more tabs")); reg.add_response(&name, &resp, "more tabs"); let c = if resp.hovered() { t.tab_text_active } else { t.tab_text }; icons::paint(&painter, r.shrink(4.0).translate(vec2(-2.0, 0.0)), Icon::ChevronRight, c); @@ -684,7 +685,7 @@ pub fn draw_group_chrome_details(ui: &mut egui::Ui, g: &Group, seqs: &SeqTabs, t } // The wheel over the strip steps through the tabs: down = next, up = previous, one tab // per wheel notch (trackpad scrolling adds up to a notch first), stopping at the ends. - let wheel_id = egui::Id::new(("tab-wheel", g.path.clone())); + let wheel_id = egui::Id::unique(("tab-wheel", g.path.clone())); if ui.rect_contains_pointer(strip) && !tabs.is_empty() { const NOTCH: f32 = 40.0; let total = ui.ctx().data(|d| d.get_temp::(wheel_id)).unwrap_or(0.0) + crate::panels::timeline::wheel_input(ui.ctx()).delta.y; @@ -711,8 +712,8 @@ pub fn draw_group_chrome_details(ui: &mut egui::Ui, g: &Group, seqs: &SeqTabs, t let tab_rect = Rect::from_min_size(pos2(x, strip.min.y), vec2(w, t.tab_h)); // a sequence tab keeps its identity when the tabs are reordered under the pointer let resp = match tab.seq { - Some(id) => ui.interact(tab_rect, egui::Id::new(("tab-seq", g.path.clone(), id)), Sense::click_and_drag()), - None => ui.interact(tab_rect, egui::Id::new(("tab", g.path.clone(), i)), Sense::click_and_drag()), + Some(id) => ui.interact(tab_rect, egui::Id::unique(("tab-seq", g.path.clone(), id)), Sense::click_and_drag()), + None => ui.interact(tab_rect, egui::Id::unique(("tab", g.path.clone(), i)), Sense::click_and_drag()), }; if tab.seq.is_none() && resp.drag_started() { actions.push(DockAction::BeginDrag(*p)); @@ -722,7 +723,7 @@ pub fn draw_group_chrome_details(ui: &mut egui::Ui, g: &Group, seqs: &SeqTabs, t } else { tab_rects.push((*p, tab_rect)); } - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, resp.enabled(), is_active, &tab.title)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Tab, resp.enabled(), is_active, &tab.title)); ui.ctx().accesskit_node_builder(resp.id, |node| { node.set_role(egui::accesskit::Role::Tab); node.set_selected(is_active); @@ -734,7 +735,7 @@ pub fn draw_group_chrome_details(ui: &mut egui::Ui, g: &Group, seqs: &SeqTabs, t dragged = Some(id); } // Escape puts a dragged tab back where it was (egui ends the drag on Escape, #580) - let home = egui::Id::new(("tab-seq-home", id)); + let home = egui::Id::unique(("tab-seq-home", id)); if resp.drag_started() && let Some(at) = seqs.open.iter().position(|(s, _)| *s == id) { @@ -761,8 +762,8 @@ pub fn draw_group_chrome_details(ui: &mut egui::Ui, g: &Group, seqs: &SeqTabs, t if let Some(id) = tab.seq.filter(|_| is_active) { // × closes the sequence's tab (the sequence stays in the project) let cr = Rect::from_center_size(pos2(tab_rect.min.x + 10.0, text_y), vec2(8.0, 8.0)); - let cresp = ui.interact(cr.expand(4.0), egui::Id::new(("tab-close", g.path.clone(), id)), Sense::click()); - cresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, cresp.enabled(), "close sequence")); + let cresp = ui.interact(cr.expand(4.0), egui::Id::unique(("tab-close", g.path.clone(), id)), Sense::click()); + cresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, cresp.enabled(), "close sequence")); reg.add_response(&format!("timeline.tab.{id}.close"), &cresp, "close sequence"); let cc = if cresp.hovered() { t.tab_text_active } else { t.tab_text }; painter.line_segment([cr.left_top(), cr.right_bottom()], Stroke::new(1.2, cc)); @@ -775,8 +776,8 @@ pub fn draw_group_chrome_details(ui: &mut egui::Ui, g: &Group, seqs: &SeqTabs, t } if is_active { let mr = Rect::from_center_size(pos2(label_x + label_w + 12.0, text_y), vec2(12.0, 10.0)); - let mresp = ui.interact(mr.expand(3.0), egui::Id::new(("tab-menu", g.path.clone())), Sense::click()); - mresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, mresp.enabled(), "panel menu")); + let mresp = ui.interact(mr.expand(3.0), egui::Id::unique(("tab-menu", g.path.clone())), Sense::click()); + mresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, mresp.enabled(), "panel menu")); reg.add_response(&format!("panel.menu.{}", p.id()), &mresp, "panel menu"); let mc = if mresp.hovered() { t.tab_text_active } else { t.tab_text }; icons::paint(&painter, Rect::from_center_size(mr.center(), vec2(16.0, 16.0)), Icon::Hamburger, mc); diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 98951de89..ef69ce477 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -18,7 +18,7 @@ fn floating_rect(group: &dock::Floating, screen: Rect) -> Rect { } fn drag_id() -> egui::Id { - egui::Id::new("dock-panel-drag") + egui::Id::unique("dock-panel-drag") } pub(crate) fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Value) -> Option> { @@ -179,7 +179,7 @@ impl FilmcraftApp { .get(*index) .and_then(|group| group.panels.first()) .and_then(|panel| { - let id = egui::Id::new(("floating-panel", panel.id())); + let id = egui::Id::unique(("floating-panel", panel.id())); layers.iter().position(|layer| layer.id == id) }) .unwrap_or(usize::MAX) @@ -193,7 +193,7 @@ impl FilmcraftApp { if let Some(panel) = self.pending_panel_raise.take() && let Some(owner) = self.ui.floating.iter().find(|group| group.panels.contains(&panel)).and_then(|group| group.panels.first()) { - ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, egui::Id::new(("floating-panel", owner.id())))); + ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, egui::Id::unique(("floating-panel", owner.id())))); } let maximized = self.ui.keys.maximized.filter(|panel| self.ui.dock.contains(*panel) || self.ui.floating.iter().any(|group| group.panels.contains(panel))); @@ -282,15 +282,14 @@ impl FilmcraftApp { DockAction::PanelMenu(panel, pos) => { let frame = ctx.cumulative_frame_nr(); ctx.data_mut(|data| { - data.insert_temp(egui::Id::new("panel-menu"), (panel, pos)); - data.insert_temp(egui::Id::new("panel-menu-opened"), frame); + data.insert_temp(egui::Id::unique("panel-menu"), (panel, pos)); + data.insert_temp(egui::Id::unique("panel-menu-opened"), frame); }); } } } self.panel_drag(&ctx, body, &targets); panels::panel_menu_popup(self, ui); - self.cancel_value_drag(ui.ctx()); } fn floating_group(&mut self, ctx: &egui::Context, index: usize, seqs: &dock::SeqTabs, actions: &mut Vec) -> Option> { @@ -298,7 +297,7 @@ impl FilmcraftApp { let panel = *floating.panels.first()?; let rect = floating_rect(&floating, ctx.content_rect()); let t = self.tokens; - let id = egui::Id::new(("floating-panel", panel.id())); + let id = egui::Id::unique(("floating-panel", panel.id())); let mut output = None; egui::Area::new(id).order(egui::Order::Middle).fixed_pos(rect.min).constrain(false).interactable(true).show(ctx, |ui| { let (rect, _) = ui.allocate_exact_size(rect.size(), Sense::hover()); @@ -323,7 +322,7 @@ impl FilmcraftApp { ui.painter().rect_stroke(rect, t.radius, Stroke::new(1.0, t.focus), StrokeKind::Outside); let grip = Rect::from_min_max(rect.max - vec2(14.0, 14.0), rect.max); let response = ui.interact(grip, id.with("resize"), Sense::click_and_drag()); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, response.enabled(), tl!("Resize floating panel"))); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, response.enabled(), tl!("Resize floating panel"))); self.auto.add_response(&format!("panel.float.{index}.resize"), &response, "Resize floating panel"); for step in 0..3 { let offset = 4.0 + step as f32 * 4.0; @@ -361,7 +360,7 @@ impl FilmcraftApp { let force_float = ctx.input(|input| input.modifiers.command); let edge = (!force_float).then(|| workspace_edge(body, pos, targets)).flatten(); let proposal = (!force_float && edge.is_none()).then(|| resolve_drop(&panel, pos, targets)).flatten(); - let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("dock-drop"))); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("dock-drop"))); let t = self.tokens; if let Some((_, _, preview)) = edge { painter.rect_filled(preview.shrink(2.0), t.radius, t.focus.gamma_multiply(0.2)); diff --git a/crates/ui-egui/src/graphics_notice.rs b/crates/ui-egui/src/graphics_notice.rs index a94a69294..b1cd14e62 100644 --- a/crates/ui-egui/src/graphics_notice.rs +++ b/crates/ui-egui/src/graphics_notice.rs @@ -44,7 +44,7 @@ pub(crate) fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let title = tlf!("Safe graphics mode: {mode}", mode = i18n::t(¬ice.mode)); let (mut reset, mut close) = (false, false); let shown = egui::Window::new("graphics-notice") - .id(egui::Id::new("graphics-notice")) + .id(egui::Id::unique("graphics-notice")) .title_bar(false) .collapsible(false) .resizable(false) diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 5b8f23c85..74e465d02 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -15,7 +15,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let p = ui.painter().clone(); p.rect_filled(rect, 0.0, t.header_bg); p.line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(1.0, Color32::BLACK)); - let drag = ui.interact(rect, egui::Id::new("header-drag"), Sense::click_and_drag()); + let drag = ui.interact(rect, egui::Id::unique("header-drag"), Sense::click_and_drag()); if drag.drag_started() { ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); } @@ -26,7 +26,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut x = rect.min.x + if app.integrated_titlebar { 104.0 } else { 14.0 }; // Home let home = Rect::from_center_size(pos2(x + 10.0, rect.center().y), vec2(26.0, 26.0)); - let hresp = ui.interact(home, egui::Id::new("hdr-home"), Sense::click()).on_hover_text(tl!("Home")); + let hresp = ui.interact(home, egui::Id::unique("hdr-home"), Sense::click()).on_hover_text(tl!("Home")); app.auto.add("header.home", home, "Home"); if hresp.hovered() { p.rect_filled(home, 4.0, t.hover); @@ -41,7 +41,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let active = app.ui.mode == m; let galley = p.layout_no_wrap(shown.to_string(), Tokens::ui(14.0), if active { t.tab_text_active } else { t.tab_text }); let r = Rect::from_min_size(pos2(x - 6.0, rect.min.y + 6.0), vec2(galley.size().x + 12.0, rect.height() - 12.0)); - let resp = ui.interact(r, egui::Id::new(("mode", label)), Sense::click()); + let resp = ui.interact(r, egui::Id::unique(("mode", label)), Sense::click()); app.auto.add(&format!("header.mode.{}", label.to_ascii_lowercase()), r, label); let col = if active || resp.hovered() { t.tab_text_active } else { t.tab_text }; let gw = galley.size().x; @@ -72,7 +72,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut btn = |ui: &mut egui::Ui, icon: Icon, id: &str, tip: &str, app: &mut FilmcraftApp| -> egui::Response { let r = Rect::from_center_size(pos2(rx - 14.0, rect.center().y), vec2(28.0, 28.0)); rx -= 38.0; - let resp = ui.interact(r, egui::Id::new(("hdr", id)), Sense::click()).on_hover_text(tip); + let resp = ui.interact(r, egui::Id::unique(("hdr", id)), Sense::click()).on_hover_text(tip); app.auto.add(&format!("header.{id}"), r, tip); if resp.hovered() { ui.painter().rect_filled(r, 4.0, t.hover); @@ -97,7 +97,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let qx = rect.max.x - 14.0 - 4.0 * 38.0; // the fifth button from the right if btn(ui, Icon::Export, "quickExport", tl!("Quick Export"), app).clicked() { app.ui.export.quick_open = !app.ui.export.quick_open; - ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("quick-export-toggled"), true)); + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("quick-export-toggled"), true)); } crate::panels::export_mode::quick_export(app, ui.ctx(), pos2(qx - 340.0, rect.max.y + 4.0)); // Appearance Mode: click for the next one (Auto, Light, Dark); the icon shows the current one. @@ -117,7 +117,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let ws = crate::i18n::t(&app.ui.workspace).to_uppercase(); let wg = p.layout_no_wrap(ws.clone(), Tokens::ui(11.0), t.text_dim); let wr = Rect::from_min_size(pos2(rx - wg.size().x + 10.0, rect.center().y - 10.0), vec2(wg.size().x + 8.0, 20.0)); - let wresp = ui.interact(wr, egui::Id::new("hdr-ws-name"), Sense::click()); + let wresp = ui.interact(wr, egui::Id::unique("hdr-ws-name"), Sense::click()); app.auto.add("header.workspaceName", wr, &ws); p.galley_with_override_text_color(pos2(wr.min.x + 4.0, rect.center().y - wg.size().y / 2.0), wg, if wresp.hovered() { t.text } else { t.text_dim }); // Community: a labelled Discord button, always one click away. @@ -126,7 +126,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let g = p.layout_no_wrap(label.to_string(), Tokens::ui(12.0), Color32::WHITE); let w = g.size().x + 34.0; let r = Rect::from_min_size(pos2(wr.min.x - w - 14.0, rect.center().y - 12.0), vec2(w, 24.0)); - let resp = ui.interact(r, egui::Id::new("hdr-discord"), Sense::click()).on_hover_text(tl!("Join the ArtCraft Discord (discord.gg/artcraft)")); + let resp = ui.interact(r, egui::Id::unique("hdr-discord"), Sense::click()).on_hover_text(tl!("Join the ArtCraft Discord (discord.gg/artcraft)")); app.auto.add("header.discord", r, "Join the ArtCraft Discord"); ui.painter().rect_filled(r, 12.0, if resp.hovered() { t.accent_hover } else { t.accent }); icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(14.0, 14.0)), Icon::Chat, Color32::WHITE); @@ -152,7 +152,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { crate::links::open(app, ui.ctx(), crate::links::DISCORD); } } - let popup_id = egui::Id::new("workspaces-popup"); + let popup_id = egui::Id::unique("workspaces-popup"); if ws_resp.clicked() || wresp.clicked() { ui.ctx().data_mut(|d| d.insert_temp(popup_id, true)); } diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 76991909c..ab9180564 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3469,8 +3469,8 @@ Commits Commits % of all commits % aller Commits Lines +/− Zeilen +/− - Audio output failed: playing without sound (check Settings ▸ Audio Hardware) Audioausgabe fehlgeschlagen: Wiedergabe ohne Ton (prüfe Voreinstellungen ▸ Audiohardware) - Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) Audioausgabe reagiert nicht: Wiedergabe ohne Ton (prüfe Voreinstellungen ▸ Audiohardware) + Audio output failed: playing without sound (check Settings > Audio Hardware) Audioausgabe fehlgeschlagen: Wiedergabe ohne Ton (prüfe Voreinstellungen > Audiohardware) + Audio output is not responding: playing without sound (check Settings > Audio Hardware) Audioausgabe reagiert nicht: Wiedergabe ohne Ton (prüfe Voreinstellungen > Audiohardware) Clip Speed / Duration Clipgeschwindigkeit/-dauer Speed: Geschwindigkeit: Reverse Speed Rückwärts abspielen diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2ac92decc..f9a30efa0 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3458,8 +3458,8 @@ Commits Commits % of all commits % de todos los commits Lines +/− Líneas +/− - Audio output failed: playing without sound (check Settings ▸ Audio Hardware) La salida de audio ha fallado: se reproduce sin sonido (revisa Ajustes ▸ Hardware de audio) - Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) La salida de audio no responde: se reproduce sin sonido (revisa Ajustes ▸ Hardware de audio) + Audio output failed: playing without sound (check Settings > Audio Hardware) La salida de audio ha fallado: se reproduce sin sonido (revisa Ajustes > Hardware de audio) + Audio output is not responding: playing without sound (check Settings > Audio Hardware) La salida de audio no responde: se reproduce sin sonido (revisa Ajustes > Hardware de audio) Clip Speed / Duration Velocidad/duración del clip Speed: Velocidad: Reverse Speed Invertir velocidad @@ -3611,7 +3611,7 @@ Shows the version, community links and credits in a FilmCraft window. Muestra la versión, los enlaces de la comunidad y los créditos en una ventana de FilmCraft. Couldn't open your web browser No se pudo abrir el navegador web Open this address in your browser yourself: Abre esta dirección tú mismo en el navegador: -# View ▸ Reset Interface Zoom (#2253). +# View > Reset Interface Zoom (#2253). Reset Interface Zoom Restablecer zoom de la interfaz Track Color Color de pista Open a sequence to search its graphics Abre una secuencia para buscar en sus gráficos diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6778b0322..451668e92 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3437,8 +3437,8 @@ Commits Commits % of all commits % de tous les commits Lines +/− Lignes +/− - Audio output failed: playing without sound (check Settings ▸ Audio Hardware) Échec de la sortie audio : lecture sans son (vérifiez Préférences ▸ Matériel audio) - Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) La sortie audio ne répond pas : lecture sans son (vérifiez Préférences ▸ Matériel audio) + Audio output failed: playing without sound (check Settings > Audio Hardware) Échec de la sortie audio : lecture sans son (vérifiez Préférences > Matériel audio) + Audio output is not responding: playing without sound (check Settings > Audio Hardware) La sortie audio ne répond pas : lecture sans son (vérifiez Préférences > Matériel audio) Clip Speed / Duration Vitesse/durée du clip Speed: Vitesse : Reverse Speed Vitesse inversée @@ -3578,7 +3578,7 @@ Export the labels to a preset file… Exporter les étiquettes dans un fichier de paramètre prédéfini… Show the presets folder Afficher le dossier des paramètres prédéfinis Delete this preset Supprimer ce paramètre prédéfini -# View ▸ Reset Interface Zoom (#2253). +# View > Reset Interface Zoom (#2253). Reset Interface Zoom Réinitialiser le zoom de l'interface Track Color Couleur de la piste Open a sequence to search its graphics Ouvrez une séquence pour rechercher dans ses graphiques diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 8ffc4f5b3..3b9782032 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3454,8 +3454,8 @@ Commits Commit % of all commits % dari semua commit Lines +/− Baris +/− - Audio output failed: playing without sound (check Settings ▸ Audio Hardware) Output audio gagal: memutar tanpa suara (periksa Pengaturan ▸ Perangkat Keras Audio) - Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) Output audio tidak merespons: memutar tanpa suara (periksa Pengaturan ▸ Perangkat Keras Audio) + Audio output failed: playing without sound (check Settings > Audio Hardware) Output audio gagal: memutar tanpa suara (periksa Pengaturan > Perangkat Keras Audio) + Audio output is not responding: playing without sound (check Settings > Audio Hardware) Output audio tidak merespons: memutar tanpa suara (periksa Pengaturan > Perangkat Keras Audio) Clip Speed / Duration Kecepatan / Durasi Klip Speed: Kecepatan: Reverse Speed Balik Kecepatan @@ -3576,7 +3576,7 @@ Appearance Settings… Pengaturan Tampilan… Add to favorites Tambahkan ke favorit Remove from favorites Hapus dari favorit -# View ▸ Reset Interface Zoom (#2253). +# View > Reset Interface Zoom (#2253). Reset Interface Zoom Atur Ulang Zoom Antarmuka Track Color Warna Trek Open a sequence to search its graphics Buka sekuens untuk mencari di grafisnya diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index ef02460c3..8a85a9db1 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3447,8 +3447,8 @@ Commits Commit % of all commits % di tutti i commit Lines +/− Righe +/− - Audio output failed: playing without sound (check Settings ▸ Audio Hardware) Uscita audio non riuscita: riproduzione senza audio (controlla Impostazioni ▸ Hardware audio) - Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) L'uscita audio non risponde: riproduzione senza audio (controlla Impostazioni ▸ Hardware audio) + Audio output failed: playing without sound (check Settings > Audio Hardware) Uscita audio non riuscita: riproduzione senza audio (controlla Impostazioni > Hardware audio) + Audio output is not responding: playing without sound (check Settings > Audio Hardware) L'uscita audio non risponde: riproduzione senza audio (controlla Impostazioni > Hardware audio) Clip Speed / Duration Velocità / durata clip Speed: Velocità: Reverse Speed Velocità inversa @@ -3602,7 +3602,7 @@ Shows the version, community links and credits in a FilmCraft window. Mostra la versione, i link della community e i riconoscimenti in una finestra di FilmCraft. Couldn't open your web browser Impossibile aprire il browser web Open this address in your browser yourself: Apri tu questo indirizzo nel browser: -# View ▸ Reset Interface Zoom (#2253). +# View > Reset Interface Zoom (#2253). Reset Interface Zoom Reimposta zoom dell'interfaccia Track Color Colore traccia Open a sequence to search its graphics Apri una sequenza per cercare nelle sue grafiche diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d0fb3d4e7..c0b7ebc3d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3443,8 +3443,8 @@ Commits コミット % of all commits 全コミットに占める割合 (%) Lines +/− 行数 +/− - Audio output failed: playing without sound (check Settings ▸ Audio Hardware) オーディオ出力に失敗したため、無音で再生しています (「環境設定 ▸ オーディオハードウェア」を確認してください) - Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) オーディオ出力が応答しないため、無音で再生しています (「環境設定 ▸ オーディオハードウェア」を確認してください) + Audio output failed: playing without sound (check Settings > Audio Hardware) オーディオ出力に失敗したため、無音で再生しています (「環境設定 > オーディオハードウェア」を確認してください) + Audio output is not responding: playing without sound (check Settings > Audio Hardware) オーディオ出力が応答しないため、無音で再生しています (「環境設定 > オーディオハードウェア」を確認してください) Clip Speed / Duration クリップの速度 / 長さ Speed: 速度: Reverse Speed 逆再生する @@ -3610,7 +3610,7 @@ Shows the version, community links and credits in a FilmCraft window. バージョン、コミュニティへのリンク、クレジットを FilmCraft のウィンドウに表示します。 Couldn't open your web browser ウェブブラウザーを開けませんでした Open this address in your browser yourself: このアドレスをご自身でブラウザーで開いてください: -# View ▸ Reset Interface Zoom (#2253). +# View > Reset Interface Zoom (#2253). Reset Interface Zoom インターフェイスのズームをリセット Track Color トラックカラー Open a sequence to search its graphics シーケンスを開くとグラフィックスを検索できます diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 73d5adf68..66fd140b7 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -272,8 +272,8 @@ Audio Options 오디오 옵션 Audio Out 오디오 출력 Audio Out Point 오디오 끝 표시(Out) - Audio output failed: playing without sound (check Settings ▸ Audio Hardware) 오디오 출력 실패: 소리 없이 재생 중 (설정 ▸ 오디오 하드웨어 확인) - Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) 오디오 출력 응답 없음: 소리 없이 재생 중 (설정 ▸ 오디오 하드웨어 확인) + Audio output failed: playing without sound (check Settings > Audio Hardware) 오디오 출력 실패: 소리 없이 재생 중 (설정 > 오디오 하드웨어 확인) + Audio output is not responding: playing without sound (check Settings > Audio Hardware) 오디오 출력 응답 없음: 소리 없이 재생 중 (설정 > 오디오 하드웨어 확인) Audio Previews: 오디오 미리 보기: Audio Samples 오디오 샘플 Audio Time Units 오디오 시간 단위 @@ -3597,7 +3597,7 @@ Shows the version, community links and credits in a FilmCraft window. FilmCraft 창에 버전, 커뮤니티 링크, 제작진을 보여 줍니다. Couldn't open your web browser 웹 브라우저를 열 수 없습니다 Open this address in your browser yourself: 이 주소를 브라우저에서 직접 여세요: -# View ▸ Reset Interface Zoom (#2253). +# View > Reset Interface Zoom (#2253). Reset Interface Zoom 인터페이스 확대/축소 재설정 Track Color 트랙 색상 Open a sequence to search its graphics 그래픽을 검색하려면 시퀀스를 여세요 diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 80f9dfe01..3fc984673 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -66,6 +66,25 @@ static ITALIAN: OnceLock = OnceLock::new(); impl Language { pub const ALL: [Self; 12] = [Self::En, Self::Ja, Self::Es, Self::PtBr, Self::Uk, Self::ZhCn, Self::De, Self::Ru, Self::Fr, Self::Id, Self::Ko, Self::It]; + /// BCP 47 tag for AccessKit and the web IME's hidden input. Keep the persisted spelling + /// (`pt-br`, `zh-cn`) out of the protocol-facing value. + pub fn bcp47(self) -> &'static str { + match self { + Self::En => "en", + Self::Ja => "ja", + Self::Es => "es", + Self::PtBr => "pt-BR", + Self::Uk => "uk", + Self::ZhCn => "zh-CN", + Self::De => "de", + Self::Ru => "ru", + Self::Fr => "fr", + Self::Id => "id", + Self::It => "it", + Self::Ko => "ko", + } + } + pub fn name(self) -> &'static str { match self { Self::En => "English", @@ -289,7 +308,7 @@ pub fn system_japanese_font() -> Option> { // kept for the life of the process (one font, read once) so installing it again after a // theme change shares the bytes instead of copying the whole file let bytes: &'static [u8] = Box::leak(face.data()?.into_boxed_slice()); - Some(Arc::new(egui::FontData { font: std::borrow::Cow::Borrowed(bytes), index: face.info.index, tweak: Default::default() })) + Some(Arc::new(egui::FontData::from_blob(Arc::new(bytes), face.info.index))) }) .clone() } @@ -1104,3 +1123,19 @@ mod tests { }); } } + +#[cfg(test)] +mod bcp47_tests { + use super::Language; + + #[test] + fn accesskit_tags_use_protocol_spelling() { + assert_eq!(Language::En.bcp47(), "en"); + assert_eq!(Language::PtBr.bcp47(), "pt-BR"); + assert_eq!(Language::ZhCn.bcp47(), "zh-CN"); + for language in Language::ALL { + let tag = language.bcp47(); + assert!(!tag.is_empty() && tag.is_ascii(), "{tag:?}"); + } + } +} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 7cbb49a58..8b4d7acba 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3451,8 +3451,8 @@ Commits Commits % of all commits % de todos os commits Lines +/− Linhas +/− - Audio output failed: playing without sound (check Settings ▸ Audio Hardware) A saída de áudio falhou: reproduzindo sem som (verifique Preferências ▸ Hardware de áudio) - Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) A saída de áudio não está respondendo: reproduzindo sem som (verifique Preferências ▸ Hardware de áudio) + Audio output failed: playing without sound (check Settings > Audio Hardware) A saída de áudio falhou: reproduzindo sem som (verifique Preferências > Hardware de áudio) + Audio output is not responding: playing without sound (check Settings > Audio Hardware) A saída de áudio não está respondendo: reproduzindo sem som (verifique Preferências > Hardware de áudio) Clip Speed / Duration Velocidade/duração do clipe Speed: Velocidade: Reverse Speed Inverter velocidade @@ -3579,7 +3579,7 @@ # The native macOS application menu (Settings…). Settings Preferências -# View ▸ Reset Interface Zoom (#2253). +# View > Reset Interface Zoom (#2253). Reset Interface Zoom Redefinir zoom da interface Track Color Cor da trilha Open a sequence to search its graphics Abra uma sequência para pesquisar nos gráficos dela diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 244646f47..5c4f4d29e 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -3437,8 +3437,8 @@ Commits 提交 % of all commits 占全部提交的比例 Lines +/− 代码行 +/− - Audio output failed: playing without sound (check Settings ▸ Audio Hardware) 音频输出失败:无声播放(请检查设置 ▸ 音频硬件) - Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) 音频输出无响应:无声播放(请检查设置 ▸ 音频硬件) + Audio output failed: playing without sound (check Settings > Audio Hardware) 音频输出失败:无声播放(请检查设置 > 音频硬件) + Audio output is not responding: playing without sound (check Settings > Audio Hardware) 音频输出无响应:无声播放(请检查设置 > 音频硬件) Clip Speed / Duration 剪辑速度 / 时长 Speed: 速度: Reverse Speed 反转速度 @@ -3586,7 +3586,7 @@ Shows the version, community links and credits in a FilmCraft window. 在 FilmCraft 窗口中显示版本、社区链接和致谢。 Couldn't open your web browser 无法打开网页浏览器 Open this address in your browser yourself: 请自行在浏览器中打开此地址: -# View ▸ Reset Interface Zoom (#2253). +# View > Reset Interface Zoom (#2253). Reset Interface Zoom 重置界面缩放 Track Color 轨道颜色 Open a sequence to search its graphics 打开序列以搜索其中的图形 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 14d66da4c..d6bdea8d9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -32,6 +32,7 @@ macro_rules! tlf { pub mod automation; pub mod brand; pub mod calc; +pub mod capture; mod cjk; pub mod control; pub mod crash; @@ -67,6 +68,7 @@ use std::collections::HashMap; use std::sync::Arc; use std::sync::mpsc::{Receiver, Sender}; +use egui::ime_context::{ImeContextTracker, ImeTarget}; use egui::{TextureHandle, TextureOptions}; use filmcraft_engine::{Services, Session}; use filmcraft_time::Tick; @@ -224,16 +226,16 @@ pub struct Playback { pub playing: bool, pub speed: f64, pub looping: bool, - /// Wall-clock (egui time, s) and timeline tick when playback (re)started. + /// Monotonic host time (s) and timeline tick when playback (re)started. anchor_time: f64, anchor_tick: Tick, /// Audio frames played at anchor (when the audio clock drives). pub audio_clock: bool, - /// The audio clock's last reading and when (egui time, s) it last moved: a device that stops + /// The audio clock's last reading and when (monotonic host s) it last moved: a device that stops /// consuming samples hands the clock back to the wall clock (see [`AUDIO_STALL_S`]). audio_seen: (u64, f64), /// When playback lost its output (a failed open or a dead stream), when it is opened again - /// (egui time, s; 0 = not retrying). Pause/play proves a fresh stream works, so a stream the + /// (monotonic host s; 0 = not retrying). Pause/play proves a fresh stream works, so a stream the /// device keeps dropping is reopened on a timer (see [`AUDIO_RETRY_S`]). audio_retry_at: f64, /// Audio underruns for the current (or last) play (desktop: sound is mixed ahead). @@ -241,7 +243,7 @@ pub struct Playback { pub audio_stats: std::sync::Arc, /// Shown / dropped frame accounting for the current (or last) play. pub meter: frames::PlaybackMeter, - /// Waiting for the first frames before starting the clock: when the wait began (egui time, + /// Waiting for the first frames before starting the clock: when the wait began (monotonic host time, /// s; negative = not yet stamped), and whether the Program monitor has them ready. pub preroll: Option, pub preroll_ready: bool, @@ -265,12 +267,6 @@ pub const AUDIO_STALL_S: f64 = 1.0; /// timer brings the sound back when the device recovers (#2503). pub const AUDIO_RETRY_S: f64 = 2.0; -/// How long `ui.screenshot` waits for the window to present the frame. -const SCREENSHOT_TIMEOUT_S: f64 = 10.0; -/// How long `ui.screenshot` waits for the UI to settle (monitors showing their exact frames, the -/// timeline zoom animation finished) before capturing what is there. -const SCREENSHOT_SETTLE_MAX_S: f64 = 5.0; - /// Export entries that run from the menus through the save panel: command, filter label, /// extension. pub(crate) const EXPORT_SAVE_DIALOGS: [(&str, &str, &str); 7] = [ @@ -283,6 +279,30 @@ pub(crate) const EXPORT_SAVE_DIALOGS: [(&str, &str, &str); 7] = [ ("file.exportSelectionProject", "FilmCraft Project", "fcproj"), ]; +/// The IME composition currently in flight on the Program monitor. +/// +/// `TerminalPending` is an observed empty preedit: the input method cleared the visible +/// composition, but its terminal commit/delete is a separate event and may still arrive. Keeping +/// the owner means a target switch turns that state into `Stale`, so the pending old terminal +/// event is rejected instead of being applied to the replacement. +/// +/// `Stale` is a tombstone: the target was replaced or the editor closed while a composition was +/// active, so the preedit/delete/commit events of that composition are dropped instead of being +/// inserted into the replacement target. A rejected commit consumes it immediately, so a preedit +/// later in the same raw batch starts a new composition for the current target. A rejected empty +/// preedit or delete is only tentatively terminal: it keeps the tombstone through the rest of the +/// batch so its paired old commit is rejected too, and the tombstone is consumed at batch end when +/// no commit follows. A lone non-empty preedit keeps it across batches until the rejected +/// composition's commit, so a preedit/commit split across frames cannot retarget either. A later +/// composition on the new target is legitimate and must be accepted, so the tombstone is never +/// permanent (see `panels::graphics::text_edit`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ImeComposition { + Active(ImeTarget), + TerminalPending(ImeTarget), + Stale, +} + pub struct FilmcraftApp { pub session: Session, pub ui: UiState, @@ -299,6 +319,29 @@ pub struct FilmcraftApp { /// The native file dialog the host is showing ([`Self::pick`]). native_dialog: Option, pub auto: automation::Registry, + /// IME owner/generation/cause state for the custom editors on the Program monitor. State is + /// kept per egui viewport; a closed viewport is released in [`Self::on_exit`]. + pub(crate) ime_context: ImeContextTracker, + /// Bumped when the custom text edit target is (re)created, a sequence/document context + /// changes, or a control request replaces `gfxEdit`. It is the replacement epoch of the + /// otherwise stable sequence/clip/component owner. + pub(crate) ime_replacement_epoch: u64, + /// The (viewport, cumulative pass) whose one IME observation has already been made. + ime_observed_pass: Option<(egui::ViewportId, u64)>, + /// The composition in flight (including an empty preedit awaiting its terminal event), or a + /// tombstone for a composition whose target was replaced or caret/focus was interrupted. + pub(crate) ime_composition: Option, + /// A known composition was interrupted locally; ask the platform to cancel it once even + /// when the same editor owner/generation remains focused after a caret-only move. + pub(crate) ime_interrupt_now: bool, + /// The editor drawn in this pass; the final observation runs after control drain. + pub(crate) ime_drawn_editor: Option, + /// Project document generation last seen by the UI (the engine bumps it on replacement). + ime_document_epoch: u64, + /// Active sequence last seen by the UI; `sequence.close` need not emit `OpenSequence`. + ime_active_sequence: Option, + /// Viewports this app's custom IME producer has observed, released when the app exits. + ime_viewports: Vec, /// Named textures (monitors, thumbnails) with the key they show. textures: HashMap, /// The UI pass each thumbnail texture was last used in: thumbnails beyond @@ -315,8 +358,33 @@ pub struct FilmcraftApp { synthetic_modifiers: egui::Modifiers, /// Requests waiting for the UI to show an element: (request, give-up time). deferred: Vec<(ControlRequest, f64)>, + /// Control requests that need a fresh layout/response; processed only in a real UI pass. + ui_requests: Vec<(ControlRequest, f64)>, last_ui_time: f64, pub(crate) synthetic: Vec, + /// One synthetic step handed to `raw_input_hook` but not yet consumed by a real UI pass. + /// Hidden `run_logic` callbacks do not process raw input: the same step is offered again until + /// an actual pass consumes it, rather than being dropped or advancing the queue. + synthetic_pending_step: Option>, + /// The pending step is already present in the `RawInput` the eframe integration preserves + /// across hidden logic ticks. Appending it again on the next hook would duplicate it. + synthetic_step_offered: bool, + /// Same for the `ModifiersChanged`/focus boundary events, which hidden ticks would otherwise + /// accumulate without bound. + synthetic_modifiers_offered: bool, + /// An image delivered by `Event::PasteImage` for the paste action this frame. Native and web + /// hosts deliver the bytes in the event; re-reading the OS clipboard can see a different image + /// (or none, on the web), so the menu path consumes this payload first. + pub(crate) pasted_image: Option>, + /// Monotonic host clock for playback, deadlines and hidden work. Never `ctx.input(i.time)`. + clock_start: web_time::Instant, + /// The latest time egui itself reported for a layout pass. Native and web hosts supply a + /// monotonic wall clock; deterministic harnesses supply their own stepped clock. Taking the + /// later of this and the host clock keeps simulated tests on their clock while a hidden + /// logic tick (whose `InputState::time` is stale) still advances by host time. + ui_clock: f64, + #[cfg(test)] + test_now: Option, /// macOS: a Control+Click already translated to a secondary (context) click, so its release is /// translated too even if Control is let go mid-click. AppKit delivers Control+Click to the /// view as a *primary* press with the Control modifier (`rightMouseDown:` only fires for a @@ -326,13 +394,18 @@ pub struct FilmcraftApp { /// The mouse press in progress (its start time) and the undo history when it began, for Escape /// to take back what the press changed (#580). value_press: Option<(f64, filmcraft_engine::UndoMark)>, + /// Escape was pressed during the current press in pass 0; apply the rollback after the final + /// non-discarded UI pass (pass 1+ has no key event to re-read). + escape_cancel: bool, /// BS.1770 loudness of the programme as it plays, and the next sample position to feed. pub(crate) loudness: Option<(filmcraft_audio_dsp::LoudnessMeter, i64)>, /// Status message last shown and when it first appeared (messages expire after a few seconds). status_seen: (String, f64), - /// Screenshots waiting for their frame: (token, path, crop, reply, give-up time). - pending_screenshots: Vec<(u64, Option, Option<[f32; 4]>, Sender, f64)>, - queued_screenshots: Vec<(u64, f64, u32)>, + /// Admitted `ui.screenshot` requests and their completion slots. + pub(crate) shots: capture::Shots, + /// Fresh raw-input surface geometry, refreshed in `raw_input_hook` for UI and logic callbacks. + pub(crate) capture_geometry: Option, + pub(crate) capture_epoch: u64, /// A paused monitor drew a stand-in (nearest cached) picture last frame: its exact frame is /// still decoding. pub(crate) monitor_inexact: bool, @@ -345,7 +418,6 @@ pub struct FilmcraftApp { /// the frame before the last one, so its timeline rects are final from 2 on. pub(crate) timeline_still: u32, input_waiters: Vec>, - next_token: u64, styled: bool, /// A panic in the UI pass, shown in an error window until dismissed (see [`crash`]). pub ui_error: Option, @@ -552,6 +624,8 @@ impl FilmcraftApp { let workspaces = session.prefs_path.as_ref().and_then(|p| p.parent()).map(|d| dock::WorkspacePrefs::load(&d.join(dock::WORKSPACES_FILE))).unwrap_or_default(); let language = i18n::Language::parse(&session.prefs.general.interface_language).unwrap_or_default(); + let ime_document_epoch = session.document_epoch; + let ime_active_sequence = session.state.active_sequence; Self { session, ui: UiState { language, ..UiState::default() }, @@ -567,6 +641,15 @@ impl FilmcraftApp { file_dialogs: Default::default(), native_dialog: None, auto: Default::default(), + ime_context: ImeContextTracker::new(), + ime_replacement_epoch: 0, + ime_observed_pass: None, + ime_composition: None, + ime_interrupt_now: false, + ime_drawn_editor: None, + ime_document_epoch, + ime_active_sequence, + ime_viewports: Vec::new(), textures: HashMap::new(), thumb_used: HashMap::new(), control_rx: None, @@ -575,20 +658,30 @@ impl FilmcraftApp { native_window_focused: None, synthetic_modifiers: egui::Modifiers::NONE, deferred: Vec::new(), + ui_requests: Vec::new(), last_ui_time: 0.0, synthetic: Vec::new(), + synthetic_pending_step: None, + synthetic_step_offered: false, + synthetic_modifiers_offered: false, + pasted_image: None, + clock_start: web_time::Instant::now(), + ui_clock: 0.0, + #[cfg(test)] + test_now: None, mac_ctrl_click_armed: false, value_press: None, + escape_cancel: false, loudness: None, status_seen: (String::new(), 0.0), - pending_screenshots: Vec::new(), - queued_screenshots: Vec::new(), + shots: capture::Shots::default(), + capture_geometry: None, + capture_epoch: 0, monitor_inexact: false, monitor_asked: HashMap::new(), program_shown: None, timeline_still: 0, input_waiters: Vec::new(), - next_token: 1, styled: false, ui_error: None, panic_next_frame: false, @@ -628,6 +721,81 @@ impl FilmcraftApp { self } + /// Start a new replacement epoch for the Program monitor's text edit target. + /// + /// The owner key names the sequence, clip and component. A recreated edit session on the + /// same key (or a document/sequence replacement) must not inherit the previous IME context, + /// so the app advances this epoch and the tracker resets the generation once. + pub(crate) fn restart_ime_target(&mut self) { + self.ime_replacement_epoch = self.ime_replacement_epoch.wrapping_add(1); + self.invalidate_ime_composition(); + } + + /// Remember that the in-flight composition's target no longer applies. This includes an empty + /// preedit whose terminal commit/delete has not arrived yet. The next late commit or delete + /// from it is dropped; a rejected commit is a terminal boundary, so a fresh preedit after it + /// starts a new composition. A tentative boundary (empty preedit/delete) keeps the tombstone + /// through its raw input batch, so the paired old commit is rejected too. + pub(crate) fn invalidate_ime_composition(&mut self) { + if matches!(self.ime_composition, Some(ImeComposition::Active(_) | ImeComposition::TerminalPending(_))) { + self.ime_composition = Some(ImeComposition::Stale); + self.ime_interrupt_now = true; + } + } + + /// Notice a document replacement or an active-sequence change that did not emit + /// `OpenSequence` (`file.newProject`, a project with no sequence, `sequence.close`). The + /// edit target and any in-flight composition belong to the old context. + fn sync_ime_target_context(&mut self, ctx: &egui::Context) { + let document = self.session.document_epoch; + let sequence = self.session.state.active_sequence; + let replaced = document != self.ime_document_epoch; + if !replaced && sequence == self.ime_active_sequence { + return; + } + self.ime_document_epoch = document; + self.ime_active_sequence = sequence; + self.restart_ime_target(); + self.ui.gfx_edit = None; + self.ime_drawn_editor = None; + let text_edit = egui::Id::unique(panels::graphics::TEXT_EDIT_ID); + ctx.memory_mut(|memory| memory.surrender_focus(text_edit)); + ctx.data_mut(|data| data.remove::(text_edit.with("panel"))); + } + + /// Mark this viewport/pass as observed by the custom producer, so the end-of-pass fallback + /// does not observe it a second time. + pub(crate) fn note_ime_observed(&mut self, viewport: egui::ViewportId, pass_nr: u64) { + self.ime_observed_pass = Some((viewport, pass_nr)); + if !self.ime_viewports.contains(&viewport) { + self.ime_viewports.push(viewport); + } + } + + /// Observe `None` once for a pass where no visible, focused custom IME owner drew a + /// descriptor. This is the explicit hidden/unfocused signal the tracker needs; a gap alone + /// is a fallback, not a replacement for it. + pub(crate) fn finish_ime_pass(&mut self, ctx: &egui::Context) { + let viewport = ctx.viewport_id(); + let pass_nr = ctx.cumulative_pass_nr_for(viewport); + // The Program monitor deferred its observation until all control requests for this pass + // have run. It reports `true` even when it observed an explicit `None` for a hidden or + // replaced editor. + if panels::graphics::finish_graphics_ime(self, ctx) { + return; + } + if self.ime_observed_pass == Some((viewport, pass_nr)) { + return; + } + // No custom descriptor already disables its platform context; do not carry a stale + // one-shot interruption into a later fresh focus session. + if !ctx.will_discard() { + self.ime_interrupt_now = false; + } + self.ime_context.observe(viewport, None); + self.note_ime_observed(viewport, pass_nr); + } + /// Isolate an automation-owned app from native keyboard, pointer, drop and menu input. /// Window close/quit and screenshot delivery remain available. pub fn with_control_input_only(mut self, rx: Receiver) -> Self { @@ -639,6 +807,25 @@ impl FilmcraftApp { self.session.services.clone() } + /// Time used by logic-only ticks, playback and deadlines. + /// + /// The host clock is authoritative (egui's `InputState::time` is stale during + /// `Context::run_logic`), but an egui pass's reported time can be ahead of it: deterministic + /// harnesses step a simulated clock without sleeping. Taking the later value keeps those + /// runs deterministic while a hidden tick still moves on the monotonic host clock. + pub(crate) fn now(&self) -> f64 { + #[cfg(test)] + if let Some(now) = self.test_now { + return now; + } + self.clock_start.elapsed().as_secs_f64().max(self.ui_clock) + } + + #[cfg(test)] + pub(crate) fn set_now_for_test(&mut self, now: f64) { + self.test_now = Some(now); + } + /// Rebuild the frame server if the session's media pool was replaced (e.g. project opened). fn sync_pool(&mut self) { if !Arc::ptr_eq(&self.frames.pool, &self.session.media) || !Arc::ptr_eq(&self.frames.previews, &self.session.previews) { @@ -759,6 +946,7 @@ impl FilmcraftApp { self.ui.language = language; } i18n::set_current(self.ui.language); + ctx.set_language(Some(self.ui.language.bcp47().to_owned())); } self.frames.set_cache_budget(p.memory.frame_cache_mb as usize * (1 << 20)); self.ui.play_after_render = p.timeline.play_after_rendering; @@ -776,7 +964,7 @@ impl FilmcraftApp { } if (hardware_changed || pitch_changed) && self.playback.playing && self.playback.preroll.is_none() { self.playback.anchor_tick = self.session.playhead(); - let now = ctx.input(|i| i.time); + let now = self.now(); self.playback.anchor_time = now; self.playback.audio_seen = (0, now); self.start_audio(now); @@ -1039,7 +1227,7 @@ impl FilmcraftApp { if self.playback.playing { self.stop(); } - let a = self.audio.as_mut().ok_or("no audio output (check Settings ▸ Audio Hardware)")?; + let a = self.audio.as_mut().ok_or("no audio output (check Settings > Audio Hardware)")?; let sr = a.sample_rate().max(1); a.stop(); let step = f64::from(audio.sample_rate.max(1)) / f64::from(sr); @@ -1129,7 +1317,7 @@ impl FilmcraftApp { if !self.playback.playing { return; } - let now = ctx.input(|i| i.time); + let now = self.now(); if let Some(since) = self.playback.preroll { let since = if since < 0.0 { now } else { since }; self.playback.preroll = Some(since); @@ -1170,7 +1358,7 @@ impl FilmcraftApp { audio.stop(); } log::warn!("audio output lost its playback clock; playing without sound"); - self.ui.status = tl!("Audio output failed: playing without sound (check Settings ▸ Audio Hardware)").into(); + self.ui.status = tl!("Audio output failed: playing without sound (check Settings > Audio Hardware)").into(); } if let Some((f, sr)) = reading { if f != self.playback.audio_seen.0 { @@ -1186,7 +1374,7 @@ impl FilmcraftApp { a.stop(); } log::warn!("audio output stalled (no samples consumed for {AUDIO_STALL_S} s); playing without sound"); - self.ui.status = tl!("Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware)").into(); + self.ui.status = tl!("Audio output is not responding: playing without sound (check Settings > Audio Hardware)").into(); } } let elapsed = match reading { @@ -1538,12 +1726,14 @@ impl FilmcraftApp { fn divider_focused(&self, ctx: &egui::Context) -> bool { let Some(focused) = ctx.memory(|m| m.focused()) else { return false }; self.auto - .previous + .all() .iter() .any(|e| e.response_id == Some(focused) && (e.id.starts_with("dock.gutter.") || (e.id.starts_with("panel.float.") && e.id.ends_with(".resize")))) } fn handle_shortcuts(&mut self, ctx: &egui::Context) { + // A paste image payload belongs to the paste event in this pass only. + self.pasted_image = None; let workspaces = (dock::names(&self.workspaces), self.ui.workspace.clone()); if self.bindings_rev != self.session.shortcuts.revision || workspaces != self.menu_workspaces { self.menu_workspaces = workspaces; @@ -1586,6 +1776,14 @@ impl FilmcraftApp { } self.v_down = *pressed; } + egui::Event::PasteImage(image) => { + // Keep the delivered pixels; re-reading the OS clipboard later is racy and + // the web host has no clipboard-media hook at all. + self.pasted_image = Some(image.clone()); + if modifiers.command { + self.v_down = true; + } + } egui::Event::Paste(_) if modifiers.command => self.v_down = true, _ => {} } @@ -1657,6 +1855,8 @@ impl FilmcraftApp { self.ui.status = e; } } + // Only the paste action of this pass may consume the event's image. + self.pasted_image = None; } // ---------------------------------------------------------------- control channel @@ -1671,17 +1871,25 @@ impl FilmcraftApp { fn drain_control(&mut self, ctx: &egui::Context) { let Some(rx) = self.control_rx.take() else { return }; - let now = ctx.input(|i| i.time); + let now = self.now(); let mut reqs: Vec<(ControlRequest, f64)> = std::mem::take(&mut self.deferred); while let Ok(req) = rx.try_recv() { // UI requests need rendered frames: raise the window if `ui` hasn't run recently // (occluded macOS windows stop running `ui`). if req.method.starts_with("ui.") && now - self.last_ui_time > 0.25 { self.raise_for_control(ctx); + // A hidden native window still needs a real UI pass for widget state/geometry. + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); } reqs.push((req, now + 3.0)); } for (req, deadline) in reqs { + if req.method.starts_with("ui.") && req.method != "ui.screenshot" { + self.ui_requests.push((req, deadline)); + ctx.request_repaint(); + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); + continue; + } let reply = req.reply.clone(); match control::handle(self, ctx, &req) { control::Outcome::Done(v) => { @@ -1697,72 +1905,56 @@ impl FilmcraftApp { } control::Outcome::AfterInput => self.input_waiters.push(reply), control::Outcome::Screenshot { path, crop } => { - let token = self.next_token; - self.next_token += 1; - let settle = ctx.input(|i| i.time) + 0.25; - self.queued_screenshots.push((token, settle, 0)); - self.pending_screenshots.push((token, path, crop, reply, settle + SCREENSHOT_TIMEOUT_S)); + capture::admit(self, ctx, path, crop, reply); } } } self.control_rx = Some(rx); - } - - /// Capture once the UI shows the current state (`settled`): after a seek the monitor shows the - /// nearest cached picture until the exact frame is decoded, and an agent must not be handed - /// the stand-in. - fn issue_screenshots(&mut self, ctx: &egui::Context, settled: bool) { - let now = ctx.input(|i| i.time); - let mut any = false; - self.queued_screenshots.retain_mut(|(token, at, frames)| { - *frames += 1; - any = true; - if now >= *at && *frames >= 3 && (settled || now >= *at + SCREENSHOT_SETTLE_MAX_S) { - ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(*token))); - false - } else { - true - } - }); - // A hidden window (or a sleeping display) never presents, so its screenshot never - // arrives: give up instead of waiting (and repainting) forever. - self.pending_screenshots.retain(|(.., reply, deadline)| { + self.ui_requests.retain(|(req, deadline)| { if now > *deadline { - let _ = reply.send(json!({"ok": false, "error": "no frame was presented (window hidden or display asleep)"})); + let _ = req.reply.send(json!({"ok": false, "error": "no UI pass ran before the control request deadline"})); false } else { true } }); - if any { - ctx.request_repaint(); - } else if !self.pending_screenshots.is_empty() { - ctx.request_repaint_after(std::time::Duration::from_millis(16)); - } } - fn collect_screenshots(&mut self, ctx: &egui::Context) { - if self.pending_screenshots.is_empty() { - return; - } - let events: Vec<_> = ctx.input(|i| { - i.raw - .events - .iter() - .filter_map(|e| match e { - egui::Event::Screenshot { user_data, image, .. } => { - let token = user_data.data.as_ref().and_then(|d| d.downcast_ref::()).copied()?; - Some((token, image.clone())) + /// Process control requests that need the current layout/response. Called at the end of a + /// final, non-discarded UI pass; `ui.click`/`ui.drag` queue synthetic input for the next pass. + fn drain_ui_requests(&mut self, ctx: &egui::Context) { + let reqs = std::mem::take(&mut self.ui_requests); + let now = self.now(); + let mut pending = reqs.into_iter(); + while let Some((req, deadline)) = pending.next() { + // A panel/dock/resize request changes the next layout. Anything after it in this + // batch (ui.click, ui.elements, ui.screenshot, …) must see the new geometry, so it + // waits for the next accepted pass rather than reading the layout drawn before it. + let changes_layout = matches!(req.method.as_str(), "ui.set" | "ui.panel.show" | "ui.panel.close" | "ui.resize"); + let reply = req.reply.clone(); + match control::handle(self, ctx, &req) { + control::Outcome::Done(v) => { + let _ = reply.send(v); + } + control::Outcome::Retry(msg) => { + if now < deadline { + self.ui_requests.push((req, deadline)); + ctx.request_repaint(); + } else { + let _ = reply.send(json!({"ok": false, "error": msg})); } - _ => None, - }) - .collect() - }); - for (token, image) in events { - if let Some(i) = self.pending_screenshots.iter().position(|(t, ..)| *t == token) { - let (_, path, crop, reply, _) = self.pending_screenshots.remove(i); - let r = control::save_screenshot(ctx, &image, path.as_deref(), crop); - let _ = reply.send(r); + } + control::Outcome::AfterInput => self.input_waiters.push(reply), + control::Outcome::Screenshot { path, crop } => capture::admit(self, ctx, path, crop, reply), + } + if changes_layout { + let rest: Vec<_> = pending.collect(); + if !rest.is_empty() { + self.ui_requests.extend(rest); + ctx.request_repaint(); + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); + } + break; } } } @@ -1774,22 +1966,21 @@ impl FilmcraftApp { /// stamp the project's media files on a worker thread and read the changed ones again /// ([`filmcraft_engine::media_watch`]). #[cfg(not(target_arch = "wasm32"))] - fn tick_media_refresh(&mut self, ctx: &egui::Context) { + fn tick_media_refresh(&mut self, ctx: &egui::Context, now: f64) { if let Some(r) = self.session.poll_media_scan() { match r { Ok(r) => { if let Some(n) = r["refreshed"].as_array().map(Vec::len).filter(|n| *n > 0) { - self.toast = Some((format!("Refreshed {n} media item(s) changed on disk"), ctx.input(|i| i.time))); + self.toast = Some((format!("Refreshed {n} media item(s) changed on disk"), now)); } } - Err(e) => self.toast = Some((e.to_string(), ctx.input(|i| i.time))), + Err(e) => self.toast = Some((e.to_string(), now)), } } let media = &self.session.prefs.media; let (on, every) = (media.refresh_growing_files, f64::from(media.growing_refresh_seconds.max(1))); - let (last_key, focus_key) = (egui::Id::new("mediaWatch.lastScan"), egui::Id::new("mediaWatch.focused")); - let now = ctx.input(|i| i.time); - let focused = ctx.input(|i| i.focused); + let (last_key, focus_key) = (egui::Id::unique("mediaWatch.lastScan"), egui::Id::unique("mediaWatch.focused")); + let focused = self.native_window_focused.unwrap_or(false); let was_focused: bool = ctx.data(|d| d.get_temp(focus_key)).unwrap_or(focused); ctx.data_mut(|d| d.insert_temp(focus_key, focused)); if !on || filmcraft_engine::media_watch::media_files(&self.session.project, &self.session.media).is_empty() { @@ -1810,58 +2001,8 @@ impl FilmcraftApp { crash::injected_fault("injected UI fault"); } let ctx = ui.ctx().clone(); - self.auto.begin_frame(); - panels::metadata::follow_selection(self); - self.frames.set_context(&ctx); - self.session.poll_persistence(); - panels::trim_monitor::advance(self, &ctx); - if self.session.persistence.is_some() && self.session.is_dirty() { - // Keep polling the auto-save worker (status, "also save the project" results). - ctx.request_repaint_after(std::time::Duration::from_secs(1)); - } - self.sync_pool(); - self.apply_prefs(&ctx); - #[cfg(not(target_arch = "wasm32"))] - self.tick_media_refresh(&ctx); - for ev in self.session.drain_events() { - match ev { - filmcraft_engine::Event::OpenSequence(_) => { - // show the sequence with its own view (or fitted, the first time) - self.timeline_view_of = None; - if !self.ui.floating.iter().any(|group| group.panels.contains(&PanelKind::Timeline)) { - self.ui.dock.restore_timeline(); - } - let _ = self.apply_dock(craft_ui::docking::Action::Activate { panel: PanelKind::Timeline }); - } - filmcraft_engine::Event::OpenSource(_) => { - let _ = self.apply_dock(craft_ui::docking::Action::Activate { panel: PanelKind::Source }); - } - filmcraft_engine::Event::RevealInProject(item) => self.reveal_in_project(item), - filmcraft_engine::Event::Toast { message, .. } => self.toast = Some((message, ctx.input(|i| i.time))), - filmcraft_engine::Event::ProjectChanged { .. } => {} - } - } - self.sync_timeline_view(); - self.handle_drops(&ctx); - if let Some(rx) = self.command_inbox.take() { - while let Ok(id) = rx.try_recv() { - // control-only mode ignores native menu commands, but Quit stays available (#1943) - if self.control_input_only && id != "app.quit" { - continue; - } - if let Err(e) = menus::invoke(self, &ctx, &id, json!({})) { - self.ui.status = e; - } - } - self.command_inbox = Some(rx); - } - self.begin_press(&ctx); - self.handle_shortcuts(&ctx); - self.sync_native_menu(); - self.advance_playback(&ctx); - self.advance_source_playback(&ctx); - self.scrub_audio(&ctx); - // Text to Speech: a selected narration clip loads into the panel + // Text to Speech panel selection and its repaint cadence are presentation state; they do + // not poll a background queue. panels::tts::follow_selection(self); if self.ui.tts.pending.is_some() || self.ui.tts.download_job.is_some() { ctx.request_repaint_after(std::time::Duration::from_millis(100)); @@ -1885,8 +2026,6 @@ impl FilmcraftApp { self.pending_timeline_drop = None; panels::dialogs::show(self, &ctx); if !self.ui.show_status_bar { - // no bar to draw the job in, but a finished preview render still plays - self.watch_jobs(ui.ctx()); return; } // Status / hint bar @@ -1904,7 +2043,7 @@ impl FilmcraftApp { } let hint = if !self.ui.status.is_empty() { self.ui.status.clone() } else { self.hint_text() }; ui.painter().text(egui::pos2(sb.min.x + 10.0, sb.center().y), egui::Align2::LEFT_CENTER, hint, Tokens::ui(11.0), t.text_dim); - let resp = ui.interact(sb, egui::Id::new("status-bar"), egui::Sense::click()); + let resp = ui.interact(sb, egui::Id::unique("status-bar"), egui::Sense::click()); if resp.clicked() { self.ui.status.clear(); } @@ -1913,7 +2052,7 @@ impl FilmcraftApp { /// The running job (export / render previews), if any; plays the rendered range when a preview /// render completes. Runs every frame, with or without the status bar. - fn watch_jobs(&mut self, ctx: &egui::Context) -> Option { + fn poll_jobs(&mut self, ctx: &egui::Context) -> Option { use std::sync::atomic::Ordering; let running = self.session.jobs.iter().rev().find(|j| !j.progress.finished.load(Ordering::Relaxed)).cloned(); // Play after rendering previews. @@ -1937,11 +2076,17 @@ impl FilmcraftApp { Some(job) } + /// The running job for drawing only; polling/play-after-render stays in logic. + fn running_job(&self) -> Option { + use std::sync::atomic::Ordering; + self.session.jobs.iter().rev().find(|j| !j.progress.finished.load(Ordering::Relaxed)).cloned() + } + /// Right side of the status bar: the running job (export / render previews) with a progress /// bar and a cancel button. fn job_status(&mut self, ui: &mut egui::Ui, sb: egui::Rect, t: &Tokens) { use std::sync::atomic::Ordering; - let Some(job) = self.watch_jobs(ui.ctx()) else { return }; + let Some(job) = self.running_job() else { return }; let f = job.progress.fraction().clamp(0.0, 1.0); let left = job.progress.eta().map(panels::left_text).unwrap_or_default(); let cancel = egui::Rect::from_center_size(egui::pos2(sb.max.x - 14.0, sb.center().y), egui::vec2(14.0, 14.0)); @@ -1957,7 +2102,7 @@ impl FilmcraftApp { Tokens::ui(11.0), t.text_dim, ); - let resp = ui.interact(cancel, egui::Id::new(("job-cancel", job.id)), egui::Sense::click()); + let resp = ui.interact(cancel, egui::Id::unique(("job-cancel", job.id)), egui::Sense::click()); let c = if resp.hovered() { t.hot_text } else { t.text_dim }; let k = 3.5; p.line_segment([cancel.center() - egui::vec2(k, k), cancel.center() + egui::vec2(k, k)], egui::Stroke::new(1.4, c)); @@ -2005,13 +2150,23 @@ impl FilmcraftApp { } } + /// Latch Escape during pass 0, where the key event is fresh. + fn latch_escape_cancel(&mut self, ctx: &egui::Context) { + if ctx.input(|i| i.pointer.any_down() && i.key_pressed(egui::Key::Escape)) { + self.escape_cancel = true; + } + } + /// Escape while a mouse button is held takes back every edit this press made (#580): value /// drags (one merged step or one step per change), color picks, anything committed on the - /// Escape frame. Run after the panels, so a value sent on the Escape frame goes with it. - fn cancel_value_drag(&mut self, ctx: &egui::Context) { - let escape = ctx.input(|i| i.pointer.any_down() && i.key_pressed(egui::Key::Escape)); + /// Escape frame. Called once after the final non-discarded UI pass; the latch outlives a + /// discarded pass, and pass 1+ has no Escape key event to re-read. + fn cancel_value_drag(&mut self, _ctx: &egui::Context) { + if !self.escape_cancel { + return; + } + self.escape_cancel = false; if let Some((press, mark)) = self.value_press - && escape && self.session.revert_to(mark) > 0 { self.value_press = Some((press, self.session.undo_mark())); @@ -2026,7 +2181,7 @@ impl FilmcraftApp { let Some(msg) = self.ui_error.clone() else { return }; let mut close = false; egui::Window::new(tl!("FilmCraft hit an error")) - .id(egui::Id::new("FilmCraft hit an error")) + .id(egui::Id::unique("FilmCraft hit an error")) .collapsible(false) .resizable(false) .pivot(egui::Align2::CENTER_CENTER) @@ -2089,27 +2244,32 @@ fn synthesize_mac_ctrl_click(armed: &mut bool, raw_input: &mut egui::RawInput) { } impl eframe::App for FilmcraftApp { - fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) { + fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { self.native_window_focused = Some(raw_input.focused); + capture::note_raw_geometry(self, ctx, raw_input); #[cfg(target_os = "macos")] if !self.control_input_only { synthesize_mac_ctrl_click(&mut self.mac_ctrl_click_armed, raw_input); } if self.control_input_only { - // Preserve screenshot responses and all viewport lifecycle/geometry metadata. - raw_input.events.retain(|event| matches!(event, egui::Event::Screenshot { .. })); + // Screenshot results arrive through `ScreenshotCallback`, not raw input. Preserve all + // viewport lifecycle/geometry metadata and drop native input only. + raw_input.events.clear(); raw_input.hovered_files.clear(); raw_input.dropped_files.clear(); } - if self.control_input_only || !self.synthetic.is_empty() { + if self.control_input_only || !self.synthetic.is_empty() || self.synthetic_pending_step.is_some() { // Native modifiers cannot replace those requested by a control gesture. A queued // reset runs in the frame after release, so clicked() still sees Shift/Alt. - raw_input.events.push(egui::Event::ModifiersChanged(self.synthetic_modifiers)); + if !self.synthetic_modifiers_offered { + raw_input.events.push(egui::Event::ModifiersChanged(self.synthetic_modifiers)); + self.synthetic_modifiers_offered = true; + } // Widget keyboard handling requires effective egui focus. Control input must work // in a background window without activating it or taking the user's OS keyboard. raw_input.focused = true; } - if !self.synthetic.is_empty() { + if self.synthetic_pending_step.is_none() && !self.synthetic.is_empty() { // Pointer events go one per frame so egui sees press → moves → release as a real drag // (all in one frame reads as a click); key/text runs go together up to a key release. let pointer = |e: &egui::Event| matches!(e, egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::MouseWheel { .. }); @@ -2121,16 +2281,32 @@ impl eframe::App for FilmcraftApp { .position(|e| pointer(e) || matches!(e, egui::Event::Key { pressed: false, .. })) .map_or(self.synthetic.len(), |i| if pointer(&self.synthetic[i]) { i.max(1) } else { i + 1 }) }; - for event in self.synthetic.drain(..n) { + let step: Vec = self.synthetic.drain(..n).collect(); + for event in &step { if let egui::Event::ModifiersChanged(modifiers) = event { - self.synthetic_modifiers = modifiers; + self.synthetic_modifiers = *modifiers; } - raw_input.events.push(event); + } + self.synthetic_pending_step = Some(step); + self.synthetic_step_offered = false; + } + if let Some(step) = &self.synthetic_pending_step { + // `raw_input_hook` also runs before hidden `run_logic` ticks, whose raw input egui does + // not feed to widgets. Keep the exact same one-step batch pending and offer it again + // on the next callback; a real UI pass clears it at `ui()` start. Never coalesce a + // press with its release and, crucially, never append a step that the integration + // already preserved in its pending raw input: the UI pass would then see two copies. + if !self.synthetic_step_offered { + raw_input.events.extend(step.iter().cloned()); + self.synthetic_step_offered = true; } } } fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // Remember the pass's clock before anything reads `now()`. A hidden logic-only tick keeps + // the last UI time (which is stale there), so `now()` never runs behind the host clock. + self.ui_clock = self.ui_clock.max(ctx.input(|i| i.time)); // a native file dialog's answer first, so the panels draw what it chose (#260) self.poll_native_dialog(ctx); if !self.styled { @@ -2150,9 +2326,11 @@ impl eframe::App for FilmcraftApp { } else { self.fonts_ready = true; } - let now = ctx.input(|i| i.time); + // Monotonic host time. Native and web both call logic once per layout pass and through + // `run_logic` while hidden; `ctx.input(i.time)` is stale in that second case. + let now = self.now(); let dt = (now - self.last_time) as f32; - if dt > 0.0 { + if dt > 0.0 && dt.is_finite() { self.fps = self.fps * 0.9 + (1.0 / dt).min(480.0) * 0.1; } self.last_time = now; @@ -2160,68 +2338,173 @@ impl eframe::App for FilmcraftApp { // Nothing is shown while the window is hidden: not a dropped frame. self.playback.hidden = true; } - self.timeline_still = if self.ui.timeline.animating() { 0 } else { self.timeline_still.saturating_add(1) }; // Quitting with unsaved changes asks to save them first. if ctx.input(|i| i.viewport().close_requested()) && panels::clip_dialogs::intercept_quit(self) { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); } - let had_synthetic = !self.synthetic.is_empty(); + let had_synthetic = !self.synthetic.is_empty() || self.synthetic_pending_step.is_some(); self.drain_control(ctx); - if !self.synthetic.is_empty() && !had_synthetic { + if (!self.synthetic.is_empty() || self.synthetic_pending_step.is_some()) && !had_synthetic { // Occluded macOS windows stop running `ui`; bring the window forward (without taking // keyboard focus) so the input is processed. self.raise_for_control(ctx); } - if !self.synthetic.is_empty() { + if !self.synthetic.is_empty() || self.synthetic_pending_step.is_some() { ctx.request_repaint(); - } else if !self.input_waiters.is_empty() { - for w in self.input_waiters.drain(..) { - let _ = w.send(json!({"ok": true, "result": null})); + // The next step is consumed by a UI pass, not by another logic tick. Native eframe + // honours this; the pinned web runner logs it as unhandled (a recorded fork gap). + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); + } + + // Background work: consumed queues and idempotent deadlines, safe on every logic call. + self.frames.set_context(ctx); + self.frames.pump(std::time::Duration::from_millis(if self.playback.playing { 24 } else { 40 })); + self.session.poll_persistence(); + panels::trim_monitor::advance_at(self, now); + if self.session.trim_play.active() { + ctx.request_repaint(); + } + if self.session.persistence.is_some() && self.session.is_dirty() { + // Keep polling the auto-save worker (status, "also save the project" results) while hidden. + ctx.request_repaint_after(std::time::Duration::from_secs(1)); + } + self.sync_pool(); + #[cfg(not(target_arch = "wasm32"))] + self.tick_media_refresh(ctx, now); + for ev in self.session.drain_events() { + match ev { + filmcraft_engine::Event::OpenSequence(_) => { + // A sequence switch (including opening/reverting/recovering a project, which + // emits this event for the newly active sequence) is a new IME owner context. + self.restart_ime_target(); + // show the sequence with its own view (or fitted, the first time) + self.timeline_view_of = None; + if !self.ui.floating.iter().any(|group| group.panels.contains(&PanelKind::Timeline)) { + self.ui.dock.restore_timeline(); + } + let _ = self.apply_dock(craft_ui::docking::Action::Activate { panel: PanelKind::Timeline }); + } + filmcraft_engine::Event::OpenSource(_) => { + let _ = self.apply_dock(craft_ui::docking::Action::Activate { panel: PanelKind::Source }); + } + filmcraft_engine::Event::RevealInProject(item) => self.reveal_in_project(item), + filmcraft_engine::Event::Toast { message, .. } => self.toast = Some((message, now)), + filmcraft_engine::Event::ProjectChanged { .. } => {} } } - let settled = !std::mem::take(&mut self.monitor_inexact) && self.timeline_still > 0; - self.issue_screenshots(ctx, settled); - self.collect_screenshots(ctx); + if let Some(rx) = self.command_inbox.take() { + while let Ok(id) = rx.try_recv() { + // control-only mode ignores native menu commands, but Quit stays available (#1943) + if self.control_input_only && id != "app.quit" { + continue; + } + if let Err(e) = menus::invoke(self, ctx, &id, json!({})) { + self.ui.status = e; + } + } + self.command_inbox = Some(rx); + } + self.advance_playback(ctx); + self.advance_source_playback(ctx); + self.poll_jobs(ctx); + capture::drain(self, ctx); + capture::request_hidden_paint(self, ctx); } fn on_exit(&mut self) { + // Late screenshot callbacks now find a dropped slot and write nothing; clients get one error. + capture::cancel_all(self); + for viewport in self.ime_viewports.drain(..) { + self.ime_context.end_viewport(viewport); + } + self.ime_observed_pass = None; // Flush the recovery journal and stop the auto-save worker; a session with nothing unsaved // removes its journal, one with unsaved changes keeps it for the next launch. self.session.shutdown(); } - fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { if !self.fonts_ready { + // This pass did not consume the pending synthetic input; the next hook must offer it + // again (the UI path does not preserve raw input across this early return). + self.synthetic_step_offered = false; + self.synthetic_modifiers_offered = false; + self.finish_ime_pass(ui.ctx()); ui.ctx().request_repaint(); return; } - // No frame worker threads on the web: render queued frames here, within a time budget - // that leaves room for the UI pass (a no-op where workers run). - self.frames.pump(std::time::Duration::from_millis(if self.playback.playing { 24 } else { 40 })); + frame.set_ui_language(Some(self.ui.language.bcp47())); + let ctx = ui.ctx().clone(); + // A project/sequence replacement may have happened in the previous pass's control drain. + // Drop the old edit and IME composition before shortcuts or the frame see them. + self.sync_ime_target_context(&ctx); + // Each pass draws at most one editor; a stale entry from a previous pass must not be + // observed as this pass's editor. + self.ime_drawn_editor = None; + // Only an actual pass counts toward screenshot settlement; hidden logic ticks do not draw. + self.timeline_still = if self.ui.timeline.animating() { 0 } else { self.timeline_still.saturating_add(1) }; + // This is a real UI pass: the one synthetic step offered to raw_input_hook has been fed to + // egui now. A hidden logic tick never reaches this point and keeps it pending. + self.synthetic_pending_step = None; + self.synthetic_step_offered = false; + self.synthetic_modifiers_offered = false; + let first_pass = ctx.current_pass_index() == 0; + if first_pass { + // Setup and fresh-input handling happen where `RawInput::events` are available. Pass 1+ + // has no new events and must not re-run a click, shortcut or drop. + self.auto.begin_frame(); + self.apply_prefs(&ctx); + self.sync_timeline_view(); + panels::metadata::follow_selection(self); + self.handle_drops(&ctx); + self.begin_press(&ctx); + self.latch_escape_cancel(&ctx); + self.handle_shortcuts(&ctx); + self.sync_native_menu(); + self.scrub_audio(&ctx); + } else { + // Pass 1+ redraws the same frame; drop the discarded pass's registrations so lookups + // and `ui.elements` see one entry per widget, from the layout that is accepted. + self.auto.elements.clear(); + } // A panic in one panel must not close the app (losing unsaved work) or leave a blank // window: catch it, keep the session, and say what happened. if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.frame(ui))).is_err() { self.ui_error = Some(crash::take_last().unwrap_or_else(|| "unknown error".into())); self.playback.playing = false; } + if !ctx.will_discard() { + self.cancel_value_drag(&ctx); + } self.error_window(ui.ctx()); if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| graphics_notice::show(self, ui.ctx()))).is_err() { // a broken notice must not take the session down: drop it self.graphics_notice = None; } - let ctx = ui.ctx().clone(); - self.last_ui_time = ctx.input(|i| i.time); - if !self.synthetic.is_empty() { + self.last_ui_time = self.now(); + if !self.synthetic.is_empty() || self.synthetic_pending_step.is_some() { ctx.request_repaint(); - } else if !self.input_waiters.is_empty() { - ctx.request_repaint(); - for w in self.input_waiters.drain(..) { - let _ = w.send(json!({"ok": true, "result": null})); - } } if self.frames.queue_len() > 0 { ctx.request_repaint_after(std::time::Duration::from_millis(16)); } + if !ctx.will_discard() { + self.drain_ui_requests(&ctx); + } + // Observe only after this pass's control requests ran: `ui.menu.invoke`/`engine.execute` + // may have edited the text or replaced the target. Every other pass (hidden editor, focus + // in another panel, no editor at all) reports an explicit `None`. + self.finish_ime_pass(&ctx); + // Ack `ui.*` waiters once the action had a final, non-discarded UI pass to apply in. Holding + // them through a discard avoids reporting success for a pass egui threw away. + if !ctx.will_discard() && self.synthetic.is_empty() && self.synthetic_pending_step.is_none() && !self.input_waiters.is_empty() { + for w in self.input_waiters.drain(..) { + let _ = w.send(json!({"ok": true, "result": null})); + } + } + // Only a real, accepted UI pass may issue a capture: it observes the final layout for this + // pass and `screenshot` itself forces the paint needed when the window is hidden. + capture::issue(self, &ctx); } } @@ -2315,9 +2598,9 @@ fn clipboard_events_as_keys(events: &mut Vec, modifiers: egui::Modi Event::Cut if modifiers.command => Some(Key::X), Event::Cut if modifiers.shift => Some(Key::Delete), Event::Cut => Some(Key::Cut), - Event::Paste(_) if modifiers.command => Some(Key::V), - Event::Paste(_) if modifiers.shift => Some(Key::Insert), - Event::Paste(_) => Some(Key::Paste), + Event::Paste(_) | Event::PasteImage(_) if modifiers.command => Some(Key::V), + Event::Paste(_) | Event::PasteImage(_) if modifiers.shift => Some(Key::Insert), + Event::Paste(_) | Event::PasteImage(_) => Some(Key::Paste), _ => None, }) .collect(); @@ -2340,6 +2623,10 @@ mod clipboard_key_tests { assert_eq!(keys(vec![Event::Copy], c), vec![(Key::C, c)]); assert_eq!(keys(vec![Event::Cut], c), vec![(Key::X, c)]); assert_eq!(keys(vec![Event::Paste("x".into())], c), vec![(Key::V, c)]); + let image = || Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([1, 1], egui::Color32::RED))); + assert_eq!(keys(vec![image()], c), vec![(Key::V, c)]); + assert_eq!(keys(vec![image()], Modifiers::SHIFT), vec![(Key::Insert, Modifiers::SHIFT)]); + assert_eq!(keys(vec![image()], Modifiers::NONE), vec![(Key::Paste, Modifiers::NONE)]); } #[test] @@ -2408,7 +2695,7 @@ mod mac_ctrl_click_tests { let mut secondary = false; // No renderer here: drop the pass output, clearing its texture deltas so epaint's drop // guard does not panic on the font texture. - let mut out = ctx.run_ui(raw, |ctx| secondary = ctx.input(|i| i.pointer.secondary_clicked())); + let mut out = ctx.run_ui(raw, |ui| secondary = ui.ctx().input(|i| i.pointer.secondary_clicked())); out.textures_delta.clear(); assert!(secondary); } @@ -2695,6 +2982,7 @@ mod audio_recovery_tests { app.playback.audio_clock = true; app.playback.anchor_tick = Tick::ZERO; app.playback.anchor_time = 0.0; + app.set_now_for_test(10.0); let mut output = ctx.run_ui(egui::RawInput { time: Some(10.0), ..Default::default() }, |ui| app.advance_playback(ui.ctx())); output.textures_delta.clear(); assert!(!app.playback.audio_clock); @@ -2737,6 +3025,7 @@ mod audio_recovery_tests { app.playback.anchor_tick = Tick::ZERO; app.playback.anchor_time = 0.0; app.playback.audio_seen = (0, 10.0); + app.set_now_for_test(10.0); let mut output = ctx.run_ui(egui::RawInput { time: Some(10.0), ..Default::default() }, |ui| app.advance_playback(ui.ctx())); output.textures_delta.clear(); assert!(app.playback.audio_clock, "the restarted stream is still the master clock"); @@ -2785,11 +3074,13 @@ mod audio_recovery_tests { assert!(app.playback.audio_clock && starts.load(Ordering::Relaxed) == 1); // the stream dies: the device stops reporting a playback clock ticking.store(false, Ordering::Relaxed); + app.set_now_for_test(10.0); let mut output = ctx.run_ui(egui::RawInput { time: Some(10.0), ..Default::default() }, |ui| app.advance_playback(ui.ctx())); output.textures_delta.clear(); assert!(!app.playback.audio_clock); // the device recovers before the next retry: playback reopens it itself ticking.store(true, Ordering::Relaxed); + app.set_now_for_test(10.0 + AUDIO_RETRY_S + 0.5); let mut output = ctx.run_ui(egui::RawInput { time: Some(10.0 + AUDIO_RETRY_S + 0.5), ..Default::default() }, |ui| app.advance_playback(ui.ctx())); output.textures_delta.clear(); assert_eq!(starts.load(Ordering::Relaxed), 2, "the output is reopened without pause/play"); @@ -2811,6 +3102,7 @@ mod audio_recovery_tests { assert!(!app.playback.audio_clock, "no device yet: playing on the wall clock"); // headphones plug in while playing: the next retry opens them opens.store(true, Ordering::Relaxed); + app.set_now_for_test(AUDIO_RETRY_S + 0.5); let mut output = ctx.run_ui(egui::RawInput { time: Some(AUDIO_RETRY_S + 0.5), ..Default::default() }, |ui| app.advance_playback(ui.ctx())); output.textures_delta.clear(); assert_eq!(starts.load(Ordering::Relaxed), 2, "the output is retried without pause/play"); @@ -2895,6 +3187,7 @@ mod voiceover_playback_tests { assert_eq!(app.session.playhead(), from); app.end_preroll(0.0); // and one second later the take is still playing and recording, not stopped at the end + app.set_now_for_test(1.0); let mut output = ctx.run_ui(egui::RawInput { time: Some(1.0), ..Default::default() }, |ui| app.advance_playback(ui.ctx())); output.textures_delta.clear(); assert!(app.playback.playing); @@ -2972,3 +3265,79 @@ mod native_menu_tests { assert_eq!(last(&sent, "edit.redo"), Some(true), "the undone edit can be redone"); } } + +#[cfg(test)] +mod lifecycle_synthetic_tests { + use egui::{Event, Modifiers, PointerButton, pos2}; + use filmcraft_engine::Session; + + use super::FilmcraftApp; + + fn pointer_events(events: &[Event]) -> Vec { + events + .iter() + .filter_map(|event| match event { + Event::PointerMoved(pos) => Some(*pos), + _ => None, + }) + .collect() + } + + /// `raw_input_hook` runs before hidden logic ticks too, but those raw events are not fed to + /// widgets. The same one-step batch must remain pending, instead of advancing to the next + /// step or being silently lost; a real UI pass consumes it. + #[test] + fn one_synthetic_step_survives_hidden_logic_callbacks() { + let ctx = egui::Context::default(); + let mut app = FilmcraftApp::new(Session::default()); + app.synthetic = vec![ + Event::PointerMoved(pos2(10.0, 10.0)), + Event::PointerMoved(pos2(20.0, 20.0)), + Event::PointerButton { pos: pos2(20.0, 20.0), button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + Event::PointerButton { pos: pos2(20.0, 20.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + ]; + let mut first = egui::RawInput::default(); + eframe::App::raw_input_hook(&mut app, &ctx, &mut first); + assert_eq!(pointer_events(&first.events), vec![pos2(10.0, 10.0)]); + assert_eq!(app.synthetic.len(), 3, "only one step was popped"); + assert_eq!(app.synthetic_pending_step.as_ref().map(Vec::len), Some(1)); + + // The integration keeps the raw input that did not reach widgets (native + // `pending_raw_input`, web `input.raw`) and replays it through the hook. + let mut hidden = first.clone(); + eframe::App::raw_input_hook(&mut app, &ctx, &mut hidden); + assert_eq!(pointer_events(&hidden.events), vec![pos2(10.0, 10.0)], "the preserved step is not appended twice"); + assert_eq!(app.synthetic.len(), 3, "a hidden callback must not advance the queue"); + + // A real UI pass consumes the pending step (App::ui clears it at its start). + app.synthetic_pending_step = None; + app.synthetic_step_offered = false; + app.synthetic_modifiers_offered = false; + let mut next = egui::RawInput::default(); + eframe::App::raw_input_hook(&mut app, &ctx, &mut next); + assert_eq!(pointer_events(&next.events), vec![pos2(20.0, 20.0)]); + assert_eq!(app.synthetic.len(), 2); + } + + /// eframe's hidden `run_logic` path saves the raw input (including our step) and hands it to + /// `raw_input_hook` again on the next hidden callback. The hook must not append the same step + /// a second time, or the eventual UI pass inserts text / clicks / runs a command more than once. + #[test] + fn hidden_callbacks_do_not_duplicate_a_step_preserved_in_raw_input() { + let ctx = egui::Context::default(); + let mut app = FilmcraftApp::new(Session::default()); + app.synthetic = + vec![Event::Text("once".into()), Event::Key { key: egui::Key::F35, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }]; + let mut first = egui::RawInput::default(); + eframe::App::raw_input_hook(&mut app, &ctx, &mut first); + let texts = |events: &[Event]| events.iter().filter(|e| matches!(e, Event::Text(t) if t == "once")).count(); + assert_eq!(texts(&first.events), 1); + + // What the integration preserves as pending_raw_input, then replays through the hook on + // the next hidden logic tick. + let mut preserved = first.clone(); + eframe::App::raw_input_hook(&mut app, &ctx, &mut preserved); + assert_eq!(texts(&preserved.events), 1, "the preserved raw input must keep exactly one copy"); + assert_eq!(app.synthetic_pending_step.as_ref().map(Vec::len), Some(2), "the queue did not advance"); + } +} diff --git a/crates/ui-egui/src/links.rs b/crates/ui-egui/src/links.rs index 37df98da1..1053b16eb 100644 --- a/crates/ui-egui/src/links.rs +++ b/crates/ui-egui/src/links.rs @@ -84,7 +84,7 @@ pub fn show_failed(app: &mut crate::FilmcraftApp, ctx: &egui::Context) { let mut close = false; let mut elems: Vec<(&str, egui::Rect, String)> = Vec::new(); egui::Window::new(tl!("Couldn't open your web browser")) - .id(egui::Id::new("failed-link")) + .id(egui::Id::unique("failed-link")) .collapsible(false) .resizable(false) .pivot(egui::Align2::CENTER_CENTER) @@ -97,7 +97,7 @@ pub fn show_failed(app: &mut crate::FilmcraftApp, ctx: &egui::Context) { ui.label(tl!("Open this address in your browser yourself:")); ui.horizontal(|ui| { let mut text = link.url.as_str(); - let r = ui.add(egui::TextEdit::singleline(&mut text).desired_width(340.0)); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut text).desired_width(340.0)), "Link address"); elems.push(("failedLink.url", r.rect, link.url.clone())); let c = ui.button(tl!("Copy")); elems.push(("failedLink.copy", c.rect, "Copy".into())); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d4742ff59..6a252bdfc 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -641,8 +641,20 @@ fn put_clip_names_on_system_clipboard(app: &FilmcraftApp, ctx: &egui::Context) { /// Paste the files or the image on the system clipboard: placed on the Timeline when it has focus, /// imported into the shown bin in the Project panel. None when the clipboard holds neither. fn paste_clipboard_media(app: &mut FilmcraftApp, insert: bool) -> Option> { - let media = (app.hooks.clipboard_media.as_mut()?)()?; - if app.stale_clipboard_media.as_ref() == Some(&clipboard_media_key(&media)) { + // The paste event already carries the image bytes. Prefer them: re-reading the OS clipboard + // is racy (it may have changed) and the web host installs no clipboard-media hook. + let event_media = match app.pasted_image.take() { + Some(image) => match paste_image_media(&image) { + Ok(media) => Some(media), + Err(error) => return Some(Err(error)), + }, + None => None, + }; + let (media, from_event) = match event_media { + Some(media) => (media, true), + None => ((app.hooks.clipboard_media.as_mut()?)()?, false), + }; + if !from_event && app.stale_clipboard_media.as_ref() == Some(&clipboard_media_key(&media)) { return None; } let paths = match media { @@ -658,6 +670,24 @@ fn paste_clipboard_media(app: &mut FilmcraftApp, insert: bool) -> Option Result { + let [width, height] = image.size; + let len = + width.checked_mul(height).and_then(|pixels| pixels.checked_mul(4)).ok_or_else(|| "the pasted image size overflows the clipboard buffer".to_string())?; + let mut rgba = Vec::with_capacity(len); + for c in &image.pixels { + // `Color32` is premultiplied internally; PNG/RgbaImage expect straight alpha. Round-trip + // through the straight sRGB form so a semi-transparent clipboard pixel keeps its colour. + let [r, g, b, a] = c.to_srgba_unmultiplied(); + rgba.extend_from_slice(&[r, g, b, a]); + } + if rgba.len() != len { + return Err("the pasted image does not match its reported size".into()); + } + Ok(crate::ClipboardMedia::Image { width, height, rgba }) +} + /// A compressed PNG of a clipboard image; an error when its size and pixels disagree. fn encode_png(width: usize, height: usize, rgba: Vec) -> Result, String> { let (w, h) = (u32::try_from(width).map_err(|e| e.to_string())?, u32::try_from(height).map_err(|e| e.to_string())?); @@ -898,7 +928,7 @@ pub fn external_commands() -> Vec { /// Draw the in-window menu bar. pub fn menu_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui) { - ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("interface-language"), app.ui.language)); + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("interface-language"), app.ui.language)); let items = menu_items(app); // Draw Edit ▸ Label with the user's live palette, not the baked-in label colours. let labels = app.session.prefs.labels.clone(); @@ -959,7 +989,7 @@ fn menu_level( } subs.push(sub); let inner: Vec<&MenuItem> = items.iter().copied().filter(|x| x.path.get(depth).map(String::as_str) == Some(sub)).collect(); - let language = ui.ctx().data(|d| d.get_temp::(egui::Id::new("interface-language"))).unwrap_or_default(); + let language = ui.ctx().data(|d| d.get_temp::(egui::Id::unique("interface-language"))).unwrap_or_default(); ui.menu_button(language.tr(sub), |ui| { ui.set_min_width(220.0); menu_level(ui, &inner, depth + 1, clicked, auto, labels); @@ -1084,6 +1114,21 @@ mod parse_shortcut_tests { mod tests { use super::*; + /// A clipboard image is premultiplied inside `Color32`. The PNG writer expects straight + /// alpha, so a semi-transparent pixel must come back with its colour, not darkened. + #[test] + fn pasted_images_keep_straight_alpha_colour() { + // Red at 50% opacity: premultiplied storage is about (128, 0, 0, 128). + let image = egui::ColorImage::from_rgba_unmultiplied([1, 1], &[255, 0, 0, 128]); + let media = paste_image_media(&image).expect("a 1×1 clipboard image"); + let crate::ClipboardMedia::Image { width, height, rgba } = media else { panic!("not an image") }; + assert_eq!((width, height), (1, 1)); + assert_eq!(rgba, vec![255, 0, 0, 128], "straight RGBA, not the premultiplied bytes"); + let png = encode_png(width, height, rgba).expect("a PNG"); + let decoded = image::load_from_memory(&png).expect("decode the pasted PNG").to_rgba8(); + assert_eq!(decoded.get_pixel(0, 0).0, [255, 0, 0, 128]); + } + /// What `cargo xtask parity` measures: every item sits under a top-level menu, and Window ▸ /// Workspaces lists the built-in workspaces before the layout commands, none checked. #[test] diff --git a/crates/ui-egui/src/panels/audio_fx_editor.rs b/crates/ui-egui/src/panels/audio_fx_editor.rs index ba86d691a..8daec9b11 100644 --- a/crates/ui-egui/src/panels/audio_fx_editor.rs +++ b/crates/ui-egui/src/panels/audio_fx_editor.rs @@ -191,13 +191,13 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let kind = if matches!(target, FxTarget::Clip { .. }) { tl!("Clip Fx Editor") } else { tl!("Track Fx Editor") }; let title = format!("{kind} - {name}: {place}"); let k = key(&target); - let drafts_id = egui::Id::new(("fx-editor-drafts", &k)); + let drafts_id = egui::Id::unique(("fx-editor-drafts", &k)); let drafts: HashMap = ctx.data(|d| d.get_temp(drafts_id)).unwrap_or_default(); let fx_id = inst.effect.clone(); let mut ed = Ed { t: app.tokens, fx: &fx_id, target: &target, inst, mt, drafts, acts: Vec::new(), autos: Vec::new() }; let mut open = true; let mut closed = false; - egui::Window::new(title).id(egui::Id::new(("fx-editor", &k))).open(&mut open).collapsible(false).resizable(false).show(ctx, |ui| { + egui::Window::new(title).id(egui::Id::unique(("fx-editor", &k))).open(&mut open).collapsible(false).resizable(false).show(ctx, |ui| { match ed.fx { "parametric_eq" => parametric(ui, &mut ed), "graphic_eq" | "graphic_eq_20" | "graphic_eq_30" => graphic(ui, &mut ed), @@ -322,7 +322,7 @@ fn parametric(ui: &mut egui::Ui, ed: &mut Ed) { let g = if gain { ed.v(&g_id) } else { 0.0 }; let c = pos2(x_of(f, r), y_of(g, r, range)); let nr = Rect::from_center_size(c, vec2(16.0, 16.0)); - let id = egui::Id::new(("peq-node", ed.target.clone(), pre)); + let id = egui::Id::unique(("peq-node", ed.target.clone(), pre)); let resp = ui.interact(nr, id, Sense::click_and_drag()); let on = ed.on(&on_id); let col = if on { ed.t.eq_node } else { ed.t.eq_node_off }; @@ -390,7 +390,7 @@ fn graphic(ui: &mut egui::Ui, ed: &mut Ed) { let v = ed.v(&pid); let ky = track.center().y - (v / 24.0) as f32 * track.height() * 0.5; let knob = Rect::from_center_size(pos2(x, ky), vec2(colw.min(18.0), 8.0)); - let id = egui::Id::new(("geq-band", ed.target.clone(), i)); + let id = egui::Id::unique(("geq-band", ed.target.clone(), i)); let resp = ui.interact(knob.union(track).expand(3.0), id, Sense::click_and_drag()); ui.painter().rect_filled(knob, 2.0, if resp.hovered() || resp.dragged() { ed.t.eq_knob_active } else { ed.t.eq_knob }); ui.painter().text(pos2(x, area.max.y - 22.0), Align2::CENTER_CENTER, format!("{v:+.0}"), Tokens::ui(8.5), ed.t.text_dim); @@ -470,7 +470,7 @@ fn multiband(ui: &mut egui::Ui, ed: &mut Ed) { for (k, pid) in ["xo1", "xo2", "xo3"].iter().enumerate() { let x = edges[k + 1]; let hr = Rect::from_center_size(pos2(x, r.center().y), vec2(10.0, r.height())); - let resp = ui.interact(hr, egui::Id::new(("mb-xo", ed.target.clone(), k)), Sense::drag()); + let resp = ui.interact(hr, egui::Id::unique(("mb-xo", ed.target.clone(), k)), Sense::drag()); ui.painter() .line_segment([pos2(x, r.min.y), pos2(x, r.max.y)], Stroke::new(if resp.hovered() || resp.dragged() { 2.5 } else { 1.5 }, ed.t.crossover_handle)); ui.painter().text(pos2(x + 3.0, r.min.y + 2.0), Align2::LEFT_TOP, format!("{:.0} Hz", xo[k]), Tokens::ui(9.0), ed.t.text_dim); diff --git a/crates/ui-egui/src/panels/clip_dialogs.rs b/crates/ui-egui/src/panels/clip_dialogs.rs index 5d64f4254..b5874a731 100644 --- a/crates/ui-egui/src/panels/clip_dialogs.rs +++ b/crates/ui-egui/src/panels/clip_dialogs.rs @@ -41,7 +41,9 @@ use crate::state::ClipDialogDraft; type Elems = Vec<(String, egui::Rect, String)>; fn push(elems: &mut Elems, id: impl Into, r: &egui::Response, label: impl Into) { - elems.push((id.into(), r.rect, label.into())); + let label = label.into(); + crate::widgets::accessible_name(r.clone(), label.clone()); + elems.push((id.into(), r.rect, label)); } /// Dialog title and automation prefix of a command's dialog. @@ -300,7 +302,7 @@ fn frames(ui: &mut egui::Ui, elems: &mut Elems, pre: &str, p: &mut Value, key: & /// Draw the open dialog, if any. pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some(mut d) = app.ui.clip_dialog.clone() else { return }; - crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("clip-dialog-before-drag"), &mut d); + crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("clip-dialog-before-drag"), &mut d); let Some((title, pre)) = meta(&d.command) else { app.ui.clip_dialog = None; return; @@ -311,7 +313,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { // a never-saved project is saved through the Save dialog let can_save = app.session.path.is_some() || app.hooks.pick_save.is_some(); // the title is translated; the window keeps one id whatever the interface language - let id = egui::Id::new(("clip-dialog", pre)); + let id = egui::Id::unique(("clip-dialog", pre)); egui::Window::new(title).id(id).collapsible(false).resizable(false).default_width(360.0).centered(ctx).show(ctx, |ui| { let p = &mut d.params; match d.command.as_str() { @@ -375,8 +377,8 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut v = p["name"].as_str().unwrap_or_default().to_string(); ui.horizontal(|ui| { ui.label(tl!("Name:")); - let id = egui::Id::new("nest-name"); - let r = ui.add(egui::TextEdit::singleline(&mut v).desired_width(240.0).id(id)); + let id = egui::Id::unique("nest-name"); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut v).desired_width(240.0).id(id)), tl!("Name:")); push(&mut elems, format!("{pre}.name"), &r, "Name:"); if r.changed() { p["name"] = json!(v); @@ -384,7 +386,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { action = Some("ok"); } - let first = egui::Id::new("nest-name-focused"); + let first = egui::Id::unique("nest-name-focused"); if !ui.data(|d| d.get_temp::(first).unwrap_or(false)) { ui.data_mut(|d| d.insert_temp(first, true)); r.request_focus(); @@ -587,7 +589,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { action = Some("cancel"); } if action.is_some() { - ctx.data_mut(|d| d.remove::(egui::Id::new("nest-name-focused"))); + ctx.data_mut(|d| d.remove::(egui::Id::unique("nest-name-focused"))); } match action { Some("cancel") => { diff --git a/crates/ui-egui/src/panels/color_dialogs.rs b/crates/ui-egui/src/panels/color_dialogs.rs index 9ee1c809f..a883b9843 100644 --- a/crates/ui-egui/src/panels/color_dialogs.rs +++ b/crates/ui-egui/src/panels/color_dialogs.rs @@ -71,7 +71,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { .unwrap_or_else(|| "—".into()); let n = items.len(); egui::Window::new(tl!("Interpret Footage")) - .id(egui::Id::new("Interpret Footage")) + .id(egui::Id::unique("Interpret Footage")) .open(&mut open) .collapsible(false) .resizable(false) @@ -102,7 +102,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { } ColorDialog::Sequence { working_space, wide_gamut, auto_tone_map } => { egui::Window::new(tl!("Sequence Color Management")) - .id(egui::Id::new("Sequence Color Management")) + .id(egui::Id::unique("Sequence Color Management")) .open(&mut open) .collapsible(false) .resizable(false) diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 8bc23dbc3..04495ebfd 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -34,7 +34,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { match d { Dialog::About => { egui::Window::new(tl!("About FilmCraft")) - .id(egui::Id::new("About FilmCraft")) + .id(egui::Id::unique("About FilmCraft")) .open(&mut open) .collapsible(false) .resizable(false) @@ -94,7 +94,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { /// Automation ids: `audioGain.` (radio buttons), /// `audioGain..value` (dB fields), `audioGain.peak`, `audioGain.ok`, `audioGain.cancel`. fn audio_gain(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { - let peak_id = egui::Id::new("audio-gain-peak"); + let peak_id = egui::Id::unique("audio-gain-peak"); let rev = app.session.revision; let cached: Option<(u64, Option)> = ctx.data(|d| d.get_temp(peak_id)); let peak = match cached { @@ -106,12 +106,12 @@ fn audio_gain(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { } }; let mut draft = app.ui.audio_gain.clone(); - crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("audio-gain-before-drag"), &mut draft); + crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("audio-gain-before-drag"), &mut draft); let mut keep = true; let mut apply = false; let mut elems: Vec<(String, egui::Rect, String)> = Vec::new(); egui::Window::new(tl!("Audio Gain")) - .id(egui::Id::new("Audio Gain")) + .id(egui::Id::unique("Audio Gain")) .collapsible(false) .resizable(false) .pivot(egui::Align2::CENTER_CENTER) @@ -192,14 +192,14 @@ fn add_tracks(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { let names = |tracks: &[filmcraft_engine::project::Track]| tracks.iter().map(|t| t.name.clone()).collect::>(); let (vnames, anames, snames) = (names(&seq.video_tracks), names(&seq.audio_tracks), names(&seq.submix_tracks)); let mut d = app.ui.add_tracks.clone(); - crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("add-tracks-before-drag"), &mut d); + crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("add-tracks-before-drag"), &mut d); let mut keep = true; let mut apply = false; let mut elems: Vec<(String, egui::Rect, String)> = Vec::new(); let audio_types: [(&str, &str); 4] = [("standard", tl!("Standard")), ("5.1", "5.1"), ("adaptive", tl!("Adaptive")), ("mono", tl!("Mono"))]; let submix_types: [(&str, &str); 4] = [("stereo", tl!("Stereo")), ("5.1", "5.1"), ("adaptive", tl!("Adaptive")), ("mono", tl!("Mono"))]; egui::Window::new(tl!("Add Tracks")) - .id(egui::Id::new("Add Tracks")) + .id(egui::Id::unique("Add Tracks")) .collapsible(false) .resizable(false) .default_width(320.0) @@ -330,7 +330,7 @@ fn delete_tracks(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { let mut apply = false; let mut elems: Vec<(String, egui::Rect, String)> = Vec::new(); egui::Window::new(tl!("Delete Tracks")) - .id(egui::Id::new("Delete Tracks")) + .id(egui::Id::unique("Delete Tracks")) .collapsible(false) .resizable(false) .pivot(egui::Align2::CENTER_CENTER) @@ -416,7 +416,7 @@ fn delete_tracks(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { /// Automation ids: `about.tab.about`, `about.tab.contributors`, `about.tab.models`; the About tab's /// links and the credits controls are listed on [`about_tab`] and [`crate::credits::contributors_ui`]. fn about(app: &mut FilmcraftApp, ui: &mut egui::Ui) { - let tab_id = egui::Id::new("about_tab"); + let tab_id = egui::Id::unique("about_tab"); let mut tab = ui.data_mut(|d| d.get_temp::(tab_id)).unwrap_or(0); ui.horizontal(|ui| { for (i, (id, label)) in [("about", tl!("About")), ("contributors", tl!("Contributors")), ("models", tl!("Models"))].into_iter().enumerate() { diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 887768294..eef9bcac1 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -228,11 +228,11 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // only the arrow twirls the effect open or shut; the rest of the header selects it let tw = Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(10.0, 10.0)); icons::paint(bui.painter(), tw, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim); - let twresp = bui.interact(tw.expand(3.0), egui::Id::new(("fx-twirl", clip.0, idx)), Sense::click()); + let twresp = bui.interact(tw.expand(3.0), egui::Id::unique(("fx-twirl", clip.0, idx)), Sense::click()); app.auto.add(&format!("effectControls.effect.{}.twirl", e.effect), tw, "twirl"); // fx enable toggle let fxr = Rect::from_center_size(pos2(r.min.x + 28.0, r.center().y), vec2(18.0, 14.0)); - let fxresp = bui.interact(fxr, egui::Id::new(("fxen", clip.0, idx)), Sense::click()); + let fxresp = bui.interact(fxr, egui::Id::unique(("fxen", clip.0, idx)), Sense::click()); bui.painter().text(fxr.center(), Align2::CENTER_CENTER, "fx", Tokens::semibold(10.5), if e.enabled { t.text } else { t.text_faint }); if !e.enabled { bui.painter().line_segment([fxr.left_bottom(), fxr.right_top()], Stroke::new(1.0, t.text_faint)); @@ -244,7 +244,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { row_label(bui, pos2(r.min.x + 42.0, r.center().y), crate::i18n::t(def.name), r.max.x - 26.0 - (r.min.x + 42.0), Tokens::ui(12.0), t.text); // reset button let rr = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(16.0, 16.0)); - let rresp = bui.interact(rr, egui::Id::new(("fxreset", clip.0, idx)), Sense::click()).on_hover_text(tl!("Reset Effect")); + let rresp = bui.interact(rr, egui::Id::unique(("fxreset", clip.0, idx)), Sense::click()).on_hover_text(tl!("Reset Effect")); icons::paint(bui.painter(), rr.shrink(2.0), Icon::Reset, if rresp.hovered() { t.text } else { t.text_dim }); if rresp.clicked() { actions.push(("effects.reset".into(), json!({"clip": clip.0, "index": idx}))); @@ -307,7 +307,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.painter().with_clip_rect(bar).text(pos2(bar.min.x + 4.0, bar.center().y), Align2::LEFT_CENTER, &it.name, Tokens::ui(10.0), t.text); // click or drag anywhere on the ruler (or the bar) to move the playhead (after the rows, which // reach under it, so it gets the pointer first) - let sresp = ui.interact(scrub, egui::Id::new(("ec-scrub", clip.0)), Sense::click_and_drag()); + let sresp = ui.interact(scrub, egui::Id::unique(("ec-scrub", clip.0)), Sense::click_and_drag()); if (sresp.dragged() || sresp.clicked()) && let Some(pos) = sresp.interact_pointer_pos() { @@ -318,7 +318,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } // the divider: a line the height of the panel, a resize cursor and a wider grab area let divider = Rect::from_min_max(pos2(split - 2.0, rect.min.y), pos2(split + 4.0, rect.max.y - 26.0)); - let dresp = ui.interact(divider, egui::Id::new("ec-divider"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal); + let dresp = ui.interact(divider, egui::Id::unique("ec-divider"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal); if dresp.dragged() { app.ui.effect_controls_split = (list_w + dresp.drag_delta().x).clamp(MIN_LIST_W, max_list); } @@ -374,7 +374,7 @@ fn custom_setup_row(app: &mut FilmcraftApp, ui: &mut egui::Ui, body: Rect, lane: row_line(ui, r, lane, &t); ui.painter().text(pos2(r.min.x + 42.0, r.center().y), Align2::LEFT_CENTER, tl!("Custom Setup"), Tokens::ui(12.0), t.text_dim); let br = Rect::from_min_size(pos2(r.min.x + 160.0, r.min.y + 2.0), vec2(60.0, ROW_H - 4.0)); - let resp = ui.interact(br, egui::Id::new(("fx-custom-setup", clip.0, idx)), Sense::click()); + let resp = ui.interact(br, egui::Id::unique(("fx-custom-setup", clip.0, idx)), Sense::click()); ui.painter().rect_filled(br, 3.0, if resp.hovered() { t.hover } else { t.field_bg }); ui.painter().rect_stroke(br, 3.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); ui.painter().text(br.center(), Align2::CENTER_CENTER, tl!("Edit…"), Tokens::ui(11.5), t.text); @@ -511,7 +511,7 @@ fn properties_nav( _ => "Turn on animation and add keyframe", }; let at = pos2(row.max.x - PROPS_NAV_X, row.center().y); - let id = egui::Id::new(("props-nav", effect, param, clip.0)); + let id = egui::Id::unique(("props-nav", effect, param, clip.0)); if keyframe_nav(app, ui, at, prm, mt, it, id, &format!("properties.{effect}.{param}"), tip, actions) { if it.effect(effect).is_none() { actions.push(("effects.apply".into(), json!({"clips": [clip.0], "effect": effect}))); @@ -568,7 +568,7 @@ pub(crate) fn param_row( let key = graph_key(clip, idx, pkey); let open = app.ui.expanded_fx.contains(&key); let tw = Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(12.0, 12.0)); - let tresp = ui.interact(tw.expand(2.0), egui::Id::new(("twirl", clip.0, idx, pkey)), Sense::click()).on_hover_text(tl!("Show graphs")); + let tresp = ui.interact(tw.expand(2.0), egui::Id::unique(("twirl", clip.0, idx, pkey)), Sense::click()).on_hover_text(tl!("Show graphs")); icons::paint(ui.painter(), tw, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim); app.auto.add(&format!("effectControls.{}.{}.graphs", e.effect, pkey), tw, "Show graphs"); if tresp.clicked() { @@ -581,7 +581,7 @@ pub(crate) fn param_row( } if pd.animatable { let sw = Rect::from_center_size(pos2(x, r.center().y), vec2(14.0, 14.0)); - let resp = ui.interact(sw, egui::Id::new(("sw", clip.0, idx, pkey)), Sense::click()).on_hover_text(tl!("Toggle animation")); + let resp = ui.interact(sw, egui::Id::unique(("sw", clip.0, idx, pkey)), Sense::click()).on_hover_text(tl!("Toggle animation")); icons::paint(ui.painter(), sw, Icon::Stopwatch, if param.is_animated() { t.accent } else { t.text_dim }); if resp.clicked() { actions.push(("effects.toggleAnimation".into(), with_mask(json!({"clip": clip.0, "effect": idx, "param": pd.id})))); @@ -597,25 +597,27 @@ pub(crate) fn param_row( .max_rect(Rect::from_min_max(pos2(vx, r.min.y + 1.0), pos2(r.max.x - 26.0, r.max.y - 1.0))) .layout(egui::Layout::left_to_right(egui::Align::Center)), ); - let id = egui::Id::new(("pv", clip.0, idx, pkey)); + let id = egui::Id::unique(("pv", clip.0, idx, pkey)); let mut set: Option = None; match (&pd.kind, &value) { (ParamKind::Float { min, max, soft_min, soft_max, unit, decimals }, ParamValue::Float(v)) => { let speed = ((soft_max - soft_min) / 400.0).max(0.01); - let (_, nv) = crate::widgets::hot_number(&mut vui, id, *v, speed, (*min, *max), *decimals as usize, unit, &t); + let (_, nv) = crate::widgets::hot_number(&mut vui, crate::i18n::t(pd.label), id, *v, speed, (*min, *max), *decimals as usize, unit, &t); if let Some(nv) = nv { set = Some(json!(nv)); } } (ParamKind::Angle, ParamValue::Float(v)) => { - let (_, nv) = crate::widgets::hot_number(&mut vui, id, *v, 0.5, (-36000.0, 36000.0), 1, "°", &t); + let (_, nv) = crate::widgets::hot_number(&mut vui, crate::i18n::t(pd.label), id, *v, 0.5, (-36000.0, 36000.0), 1, "°", &t); if let Some(nv) = nv { set = Some(json!(nv)); } } (ParamKind::Point, ParamValue::Vec2(p)) => { - let (_, nx) = crate::widgets::hot_number(&mut vui, id.with("x"), p.x, 1.0, (-100_000.0, 100_000.0), 1, "", &t); - let (_, ny) = crate::widgets::hot_number(&mut vui, id.with("y"), p.y, 1.0, (-100_000.0, 100_000.0), 1, "", &t); + let (_, nx) = + crate::widgets::hot_number(&mut vui, &format!("{} X", crate::i18n::t(pd.label)), id.with("x"), p.x, 1.0, (-100_000.0, 100_000.0), 1, "", &t); + let (_, ny) = + crate::widgets::hot_number(&mut vui, &format!("{} Y", crate::i18n::t(pd.label)), id.with("y"), p.y, 1.0, (-100_000.0, 100_000.0), 1, "", &t); if nx.is_some() || ny.is_some() { set = Some(json!([nx.unwrap_or(p.x), ny.unwrap_or(p.y)])); } @@ -623,7 +625,7 @@ pub(crate) fn param_row( (ParamKind::Bool, ParamValue::Bool(b)) => { let mut v = *b; fit_to_row(&mut vui); - if vui.checkbox(&mut v, "").changed() { + if crate::widgets::accessible_name(vui.checkbox(&mut v, ""), crate::i18n::t(pd.label)).changed() { set = Some(json!(v)); } } @@ -632,13 +634,14 @@ pub(crate) fn param_row( fit_to_row(&mut vui); // (narrower when the effect list is narrow, so it stays left of the divider) let w = vui.available_width().min(130.0); - egui::ComboBox::from_id_salt(id).selected_text(opts.get(sel).map_or("", |o| crate::i18n::t(o))).width(w).show_ui(&mut vui, |ui| { + let combo = egui::ComboBox::from_id_salt(id).selected_text(opts.get(sel).map_or("", |o| crate::i18n::t(o))).width(w).show_ui(&mut vui, |ui| { for (i, o) in opts.iter().enumerate() { if ui.selectable_value(&mut sel, i, crate::i18n::t(o)).changed() { set = Some(json!(i)); } } }); + crate::widgets::accessible_name(combo.response, crate::i18n::t(pd.label)); } (ParamKind::Color, ParamValue::Color(c)) => { let mut rgba = egui::Rgba::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]); @@ -660,7 +663,7 @@ pub(crate) fn param_row( (ParamKind::Text, ParamValue::Text(s)) if !pd.id.ends_with("_lut") => { // edited in a buffer; committed (one undo step) when the field loses focus let mut buf = vui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| s.clone()); - let r = vui.add(egui::TextEdit::singleline(&mut buf).desired_width(160.0)); + let r = crate::widgets::accessible_name(vui.add(egui::TextEdit::singleline(&mut buf).desired_width(160.0)), pd.label); app.auto.add(&format!("effectControls.text.{}.{}", idx, pd.id), r.rect, pd.label); if r.lost_focus() { if buf != *s { @@ -685,7 +688,7 @@ pub(crate) fn param_row( if pd.animatable && param.is_animated() { let at = pos2(r.max.x - 42.0, r.center().y); let auto = format!("effectControls.{}.{}", e.effect, pkey); - if keyframe_nav(app, ui, at, Some(param), mt, it, egui::Id::new(("knav", clip.0, idx, pkey)), &auto, "Add/Remove Keyframe", actions) { + if keyframe_nav(app, ui, at, Some(param), mt, it, egui::Id::unique(("knav", clip.0, idx, pkey)), &auto, "Add/Remove Keyframe", actions) { actions.push(("effects.addKeyframe".into(), with_mask(json!({"clip": clip.0, "effect": eff_json, "param": pd.id})))); } } @@ -693,7 +696,7 @@ pub(crate) fn param_row( // keyframes and gets one at the playhead) if mask.is_none() { let rr = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(12.0, 12.0)); - let rresp = ui.interact(rr, egui::Id::new(("param-reset", clip.0, idx, pkey)), Sense::click()).on_hover_text(tl!("Reset Parameter")); + let rresp = ui.interact(rr, egui::Id::unique(("param-reset", clip.0, idx, pkey)), Sense::click()).on_hover_text(tl!("Reset Parameter")); icons::paint(ui.painter(), rr, Icon::Reset, if rresp.hovered() { t.text } else { t.text_dim }); app.auto.add(&format!("effectControls.{}.{}.reset", e.effect, pkey), rr, "Reset Parameter"); if rresp.clicked() { @@ -710,7 +713,7 @@ pub(crate) fn param_row( if !(-0.01..=1.01).contains(&f) { continue; } - let id = egui::Id::new(("kf", clip.0, idx, pkey, k.time.0)); + let id = egui::Id::unique(("kf", clip.0, idx, pkey, k.time.0)); let original = Rect::from_center_size(pos2(lane.min.x + f * lane.width(), y), vec2(15.0, 15.0)); let resp = ui.interact(original.expand(2.0), id, Sense::click_and_drag()); let reference = KeyframeRef { clip: clip.0, effect: idx, param: pd.id.to_string(), mask, time: k.time }; @@ -796,7 +799,7 @@ pub fn lumetri_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { for g in groups { let key = format!("lumetri:{g}"); let open = !app.ui.collapsed_fx.contains(&key); - let (resp, now_open) = crate::widgets::section_header(ui, egui::Id::new(&key), crate::i18n::t(g), open, &t, true); + let (resp, now_open) = crate::widgets::section_header(ui, egui::Id::unique(&key), crate::i18n::t(g), open, &t, true); app.auto.add(&format!("lumetri.section.{g}"), resp.rect, g); if now_open != open { if now_open { @@ -816,8 +819,17 @@ pub fn lumetri_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if let (ParamKind::Float { min, max, soft_min, soft_max, .. }, ParamValue::Float(x)) = (&pd.kind, &v) { let mut val = *x; let s = ui.add(egui::Slider::new(&mut val, *soft_min..=*soft_max).show_value(false)); - let (_, nv) = - crate::widgets::hot_number(ui, egui::Id::new(("lum", pd.id)), val, (soft_max - soft_min) / 300.0, (*min, *max), 1, "", &t); + let (_, nv) = crate::widgets::hot_number( + ui, + crate::i18n::t(pd.label), + egui::Id::unique(("lum", pd.id)), + val, + (soft_max - soft_min) / 300.0, + (*min, *max), + 1, + "", + &t, + ); if s.changed() { actions.push(json!({"clip": clip.0, "effect": idx, "param": pd.id, "value": val})); } else if let Some(nv) = nv { @@ -878,7 +890,7 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if let Some((idx, c)) = reset { let rr = Rect::from_center_size(pos2(r.max.x - PROPS_NAV_X, r.center().y), vec2(14.0, 14.0)); icons::paint(ui.painter(), rr, Icon::Reset, t.text_dim); - if ui.interact(rr, egui::Id::new(("props-reset", name, c)), Sense::click()).clicked() { + if ui.interact(rr, egui::Id::unique(("props-reset", name, c)), Sense::click()).clicked() { actions.push(("effects.reset".into(), json!({"clip": c, "index": idx}))); } } @@ -913,9 +925,29 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ); match val("motion", p) { Some(ParamValue::Vec2(v)) => { - let (_, nx) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp", p, "x", clip.0)), v.x, speed, range, 0, " X", &t); + let (_, nx) = crate::widgets::hot_number( + &mut vui, + &format!("{label} X"), + egui::Id::unique(("pp", p, "x", clip.0)), + v.x, + speed, + range, + 0, + " X", + &t, + ); vui.add_space(10.0); - let (_, ny) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp", p, "y", clip.0)), v.y, speed, range, 0, " Y", &t); + let (_, ny) = crate::widgets::hot_number( + &mut vui, + &format!("{label} Y"), + egui::Id::unique(("pp", p, "y", clip.0)), + v.y, + speed, + range, + 0, + " Y", + &t, + ); if nx.is_some() || ny.is_some() { actions.push(( "effects.setParam".into(), @@ -926,7 +958,8 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { Some(ParamValue::Float(v)) => { let (_, nv) = crate::widgets::hot_number( &mut vui, - egui::Id::new(("pp", p, clip.0)), + label, + egui::Id::unique(("pp", p, clip.0)), v, speed, range, @@ -949,7 +982,7 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { .layout(egui::Layout::left_to_right(egui::Align::Center)), ); if let Some(ParamValue::Float(v)) = val("opacity", "opacity") { - let (_, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp-op", clip.0)), v, 0.5, (0.0, 100.0), 0, " %", &t); + let (_, nv) = crate::widgets::hot_number(&mut vui, tl!("Opacity"), egui::Id::unique(("pp-op", clip.0)), v, 0.5, (0.0, 100.0), 0, " %", &t); if let Some(nv) = nv { actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "opacity", "param": "opacity", "value": nv}))); } @@ -966,7 +999,7 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { .layout(egui::Layout::left_to_right(egui::Align::Center)), ); let v = val("crop", p).and_then(|v| v.as_f64()).unwrap_or(0.0); - let (_, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp-crop", p, clip.0)), v, 0.2, (0.0, 100.0), 1, " %", &t); + let (_, nv) = crate::widgets::hot_number(&mut vui, label, egui::Id::unique(("pp-crop", p, clip.0)), v, 0.2, (0.0, 100.0), 1, " %", &t); if let Some(nv) = nv { if crop.is_none() { actions.push(("effects.apply".into(), json!({"clips": [clip.0], "effect": "crop"}))); @@ -990,7 +1023,7 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { .layout(egui::Layout::left_to_right(egui::Align::Center)), ); let v = val("volume", "level").and_then(|v| v.as_f64()).unwrap_or(0.0); - let (_, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp-vol", clip.0)), v, 0.1, (-96.0, 15.0), 1, " dB", &t); + let (_, nv) = crate::widgets::hot_number(&mut vui, tl!("Level"), egui::Id::unique(("pp-vol", clip.0)), v, 0.1, (-96.0, 15.0), 1, " dB", &t); if let Some(nv) = nv { actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "volume", "param": "level", "value": nv}))); } @@ -1002,7 +1035,7 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { .layout(egui::Layout::left_to_right(egui::Align::Center)), ); let v = val("panner", "balance").and_then(|v| v.as_f64()).unwrap_or(0.0); - let (_, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp-pan", clip.0)), v, 0.5, (-100.0, 100.0), 0, "", &t); + let (_, nv) = crate::widgets::hot_number(&mut vui, tl!("Pan"), egui::Id::unique(("pp-pan", clip.0)), v, 0.5, (-100.0, 100.0), 0, "", &t); if let Some(nv) = nv { actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "panner", "param": "balance", "value": nv}))); } @@ -1034,7 +1067,7 @@ pub(crate) fn row_fill(r: Rect) -> Rect { /// keeps it. pub(crate) fn run(app: &mut FilmcraftApp, ctx: &egui::Context, actions: Vec<(String, Value)>) { let (down, press) = ctx.input(|i| (i.pointer.any_down(), i.pointer.press_start_time())); - let key = egui::Id::new("effect-controls-drag-step"); + let key = egui::Id::unique("effect-controls-drag-step"); let begun = ctx.data(|d| d.get_temp::>(key)).flatten(); let mut wrote = false; for (cmd, mut p) in actions { @@ -1129,7 +1162,7 @@ pub(crate) fn graph_rows( } // While the graph stays open its range only widens: refitted to the keyframes, a keyframe // dragged off the top or bottom edge was drawn back on that edge, the curve unchanged (#1918) - let range_id = egui::Id::new(("kfg-range", clip.0, idx, mask, pd.id)); + let range_id = egui::Id::unique(("kfg-range", clip.0, idx, mask, pd.id)); let frame = ui.ctx().cumulative_frame_nr(); if let Some((a, b, seen)) = ui.data(|d| d.get_temp::<(f64, f64, u64)>(range_id)) && frame.saturating_sub(seen) <= 1 @@ -1200,7 +1233,7 @@ pub(crate) fn graph_rows( if !v.is_finite() { continue; } - let id = egui::Id::new(("kfg", clip.0, idx, mask, pd.id, k.time.0)); + let id = egui::Id::unique(("kfg", clip.0, idx, mask, pd.id, k.time.0)); let id = if ci == 0 { id } else { id.with(ci) }; let dy: f32 = ui.data(|d| d.get_temp(id)).unwrap_or(0.0); let c = pos2(x_of(f), y_of(v) + dy); diff --git a/crates/ui-egui/src/panels/effect_controls_selection.rs b/crates/ui-egui/src/panels/effect_controls_selection.rs index c8db9a7d1..74069ada2 100644 --- a/crates/ui-egui/src/panels/effect_controls_selection.rs +++ b/crates/ui-egui/src/panels/effect_controls_selection.rs @@ -12,7 +12,7 @@ use crate::{ }; fn state_id() -> Id { - Id::new("effect-controls-keyframe-selection") + Id::unique("effect-controls-keyframe-selection") } #[derive(Clone)] @@ -112,7 +112,7 @@ impl Interaction { if cancelled { state.gesture = None; } - let background = ui.interact(area, Id::new(("ec-select", clip)), Sense::click_and_drag()); + let background = ui.interact(area, Id::unique(("ec-select", clip)), Sense::click_and_drag()); if background.clicked_by(egui::PointerButton::Primary) && !ui.input(|i| i.modifiers.shift) { app.ui.keyframe_selection.clear(); } diff --git a/crates/ui-egui/src/panels/effects.rs b/crates/ui-egui/src/panels/effects.rs index da0e66db3..3f897445e 100644 --- a/crates/ui-egui/src/panels/effects.rs +++ b/crates/ui-egui/src/panels/effects.rs @@ -288,7 +288,7 @@ fn preset_grid(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect) -> Option< } let pic = Rect::from_min_size(cell.min, vec2(cw, ch)); let tex = preset_texture(app, ui.ctx(), p, 160); - let resp = ui.interact(cell, egui::Id::new(("lumetri-grid", p.name)), Sense::click_and_drag()).on_hover_text(crate::i18n::t(p.description)); + let resp = ui.interact(cell, egui::Id::unique(("lumetri-grid", p.name)), Sense::click_and_drag()).on_hover_text(crate::i18n::t(p.description)); ui.painter().rect_filled(cell, 3.0, if resp.hovered() { t.hover } else { t.field_bg }); ui.painter().image(tex, pic.shrink(2.0), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); let label = ui.painter().layout(crate::i18n::t(p.name).to_string(), Tokens::ui(11.0), t.text, cw - 6.0); diff --git a/crates/ui-egui/src/panels/essential_sound.rs b/crates/ui-egui/src/panels/essential_sound.rs index 958594db3..71e072288 100644 --- a/crates/ui-egui/src/panels/essential_sound.rs +++ b/crates/ui-egui/src/panels/essential_sound.rs @@ -64,7 +64,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let shown = if name == "Browse" { tl!("Browse") } else { tl!("Edit") }; let g = painter.layout_no_wrap(shown.to_string(), Tokens::ui(12.5), t.text); let r = Rect::from_min_size(pos2(x, ty - 9.0), vec2(g.size().x, 20.0)); - let resp = ui.interact(r, egui::Id::new(("es-tab", name)), Sense::click()); + let resp = ui.interact(r, egui::Id::unique(("es-tab", name)), Sense::click()); let col = if name == tab || resp.hovered() { t.text } else { t.text_dim }; painter.text(pos2(x, ty), Align2::LEFT_CENTER, shown, Tokens::ui(12.5), col); if name == tab { @@ -210,7 +210,7 @@ fn typed_body(ui: &mut egui::Ui, cx: &mut Ctx, st: &EssentialSound, items: &[Tra let mine: Vec<&es::Preset> = presets.iter().filter(|p| p.kind == kind).collect(); let current = if st.preset.is_empty() { tl!("(Custom)").to_string() } else { crate::i18n::t(&st.preset).to_string() }; let mut picked = None; - let draft_id = egui::Id::new("es-preset-draft"); + let draft_id = egui::Id::unique("es-preset-draft"); let mut draft: Option = ui.data(|d| d.get_temp(draft_id)); ui.horizontal(|ui| { let w = ui.available_width() - 56.0; @@ -242,7 +242,10 @@ fn typed_body(ui: &mut egui::Ui, cx: &mut Ctx, st: &EssentialSound, items: &[Tra if let Some(mut d) = draft.take() { let mut done = false; ui.horizontal(|ui| { - let r = ui.add(egui::TextEdit::singleline(&mut d).hint_text(tl!("Preset name")).desired_width(ui.available_width() - 70.0)); + let r = crate::widgets::accessible_name( + ui.add(egui::TextEdit::singleline(&mut d).hint_text(tl!("Preset name")).desired_width(ui.available_width() - 70.0)), + "Preset name", + ); cx.auto.add("essentialSound.preset.name", r.rect, "Preset name"); let ok = ui.button(tl!("Save")); cx.auto.add("essentialSound.preset.ok", ok.rect, "Save"); @@ -355,7 +358,7 @@ fn ducking(ui: &mut egui::Ui, cx: &mut Ctx, st: &EssentialSound, on: bool) { for kind in AudioType::ALL { let br = Rect::from_min_size(pos2(x, r.min.y + 2.0), vec2(30.0, 26.0)); let sel = st.ducking.against.contains(&kind); - let resp = ui.interact(br, egui::Id::new(("es-duck", kind.id())), Sense::click()); + let resp = ui.interact(br, egui::Id::unique(("es-duck", kind.id())), Sense::click()); ui.painter().rect_filled( br, 4.0, @@ -381,7 +384,7 @@ fn ducking(ui: &mut egui::Ui, cx: &mut Ctx, st: &EssentialSound, on: bool) { x += 34.0; } let br = Rect::from_min_size(pos2(x + 4.0, r.min.y + 2.0), vec2(84.0, 26.0)); - let resp = ui.interact(br, egui::Id::new("es-duck-untyped"), Sense::click()); + let resp = ui.interact(br, egui::Id::unique("es-duck-untyped"), Sense::click()); let sel = st.ducking.against_untyped; ui.painter().rect_filled( br, @@ -463,7 +466,7 @@ fn section_header(ui: &mut egui::Ui, cx: &mut Ctx, sec: Section, open: bool, ena /// A pill switch. Returns clicked. fn switch(ui: &mut egui::Ui, cx: &mut Ctx, r: Rect, on: bool, id: &str, label: &str) -> bool { - let resp = ui.interact(r.expand(3.0), egui::Id::new(("es-sw", id)), Sense::click()); + let resp = ui.interact(r.expand(3.0), egui::Id::unique(("es-sw", id)), Sense::click()); let p = ui.painter(); let rad = r.height() / 2.0; if on { @@ -520,7 +523,7 @@ fn slider_track(ui: &mut egui::Ui, cx: &mut Ctx, track: Rect, key: &str, v: f64, let y = track.center().y; let f = ((v - lo) / (hi - lo)).clamp(0.0, 1.0) as f32; let kx = track.min.x + f * track.width(); - let resp = ui.interact(track.expand2(vec2(6.0, 4.0)), egui::Id::new(("es-slider", key)), Sense::click_and_drag()); + let resp = ui.interact(track.expand2(vec2(6.0, 4.0)), egui::Id::unique(("es-slider", key)), Sense::click_and_drag()); let line = if active { cx.t.switch_border } else { cx.t.slider_disabled_track }; ui.painter().line_segment([pos2(track.min.x, y), pos2(kx - 7.0, y)], Stroke::new(1.5, line)); ui.painter().line_segment([pos2(kx + 7.0, y), pos2(track.max.x, y)], Stroke::new(1.5, line)); @@ -581,7 +584,7 @@ fn choice_row(ui: &mut egui::Ui, cx: &mut Ctx, key: &str, opts: &[&str], sel: us let w = 84.0; for (i, o) in opts.iter().enumerate() { let br = Rect::from_min_size(pos2(r.min.x + 42.0 + i as f32 * (w + 4.0), r.min.y + 3.0), vec2(w, 22.0)); - let resp = ui.interact(br, egui::Id::new(("es-choice", key, i)), Sense::click()); + let resp = ui.interact(br, egui::Id::unique(("es-choice", key, i)), Sense::click()); let on = i == sel; ui.painter().rect_filled( br, @@ -604,7 +607,7 @@ fn choice_row(ui: &mut egui::Ui, cx: &mut Ctx, key: &str, opts: &[&str], sel: us fn button(ui: &mut egui::Ui, cx: &mut Ctx, r: Rect, text: &str, id: &str) -> bool { let t = cx.t; - let resp = ui.interact(r, egui::Id::new(("es-btn", id)), Sense::click()); + let resp = ui.interact(r, egui::Id::unique(("es-btn", id)), Sense::click()); ui.painter().rect_filled(r, 4.0, if resp.hovered() { cx.t.button_hover } else { t.field_bg }); ui.painter().rect_stroke(r, 4.0, Stroke::new(1.0, cx.t.button_border), StrokeKind::Inside); ui.painter().text(r.center(), Align2::CENTER_CENTER, text, Tokens::ui(12.0), t.text); diff --git a/crates/ui-egui/src/panels/events.rs b/crates/ui-egui/src/panels/events.rs index 85174e260..f0d672e3f 100644 --- a/crates/ui-egui/src/panels/events.rs +++ b/crates/ui-egui/src/panels/events.rs @@ -198,7 +198,7 @@ pub fn progress(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { elems.push((format!("progress.job.{id}"), r, format!("{label}: {status}"))); if !done { let cr = Rect::from_center_size(pos2(r.max.x - 18.0, bar.center().y), vec2(20.0, 20.0)); - let resp = ui.interact(cr, egui::Id::new(("progress-cancel", id)), Sense::click()); + let resp = ui.interact(cr, egui::Id::unique(("progress-cancel", id)), Sense::click()); let col = if resp.hovered() { t.danger } else { t.icon }; let s = Stroke::new(1.5, col); ui.painter().line_segment([cr.center() + vec2(-5.0, -5.0), cr.center() + vec2(5.0, 5.0)], s); diff --git a/crates/ui-egui/src/panels/export_mode.rs b/crates/ui-egui/src/panels/export_mode.rs index f1cf5224d..3833da4c5 100644 --- a/crates/ui-egui/src/panels/export_mode.rs +++ b/crates/ui-egui/src/panels/export_mode.rs @@ -342,7 +342,7 @@ fn opts(labels: &[&str]) -> Vec<(String, bool)> { fn section(ui: &mut egui::Ui, reg: &mut Reg, open: &mut Vec, key: &str, title: &str, t: &Tokens) -> bool { let is_open = open.iter().any(|k| k == key); - let (r, now) = crate::widgets::section_header(ui, egui::Id::new(("export-sec", key)), title, is_open, t, true); + let (r, now) = crate::widgets::section_header(ui, egui::Id::unique(("export-sec", key)), title, is_open, t, true); reg.add(format!("export.section.{key}"), r.rect, title); if now != is_open { if now { @@ -380,7 +380,7 @@ fn check(ui: &mut egui::Ui, reg: &mut Reg, id: &str, v: &mut bool, label: &str) } fn text(ui: &mut egui::Ui, reg: &mut Reg, id: &str, v: &mut String, hint: &str) { - let r = ui.add(egui::TextEdit::singleline(v).hint_text(hint).desired_width(f32::INFINITY)); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(v).hint_text(hint).desired_width(f32::INFINITY)), hint); reg.add(id, r.rect, hint); } @@ -389,9 +389,9 @@ fn text(ui: &mut egui::Ui, reg: &mut Reg, id: &str, v: &mut String, hint: &str) /// when that is not usable yet (emptied to retype, a half-typed path); each non-empty value is /// returned as it comes. Leaving shows the stored value again. fn draft_text(ui: &mut egui::Ui, reg: &mut Reg, id: &str, stored: &str, hint: &str) -> Option { - let draft_id = egui::Id::new(("export-draft", id)); + let draft_id = egui::Id::unique(("export-draft", id)); let mut v = ui.data(|m| m.get_temp::(draft_id)).unwrap_or_else(|| stored.to_string()); - let r = ui.add(egui::TextEdit::singleline(&mut v).hint_text(hint).desired_width(f32::INFINITY)); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut v).hint_text(hint).desired_width(f32::INFINITY)), hint); reg.add(id, r.rect, hint); if r.has_focus() { ui.data_mut(|m| m.insert_temp(draft_id, v.clone())); @@ -1172,11 +1172,11 @@ fn preview_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq_id: // buttons let go = Rect::from_min_size(pos2(rect.max.x - 120.0, rect.max.y - 44.0), vec2(104.0, 30.0)); let queue = Rect::from_min_size(pos2(go.min.x - 132.0, go.min.y), vec2(122.0, 30.0)); - let qresp = ui.interact(queue, egui::Id::new("export-queue-send"), Sense::click()); + let qresp = ui.interact(queue, egui::Id::unique("export-queue-send"), Sense::click()); ui.painter().rect_stroke(queue, 15.0, egui::Stroke::new(1.0, if qresp.hovered() { t.text } else { t.text_dim }), egui::StrokeKind::Inside); ui.painter().text(queue.center(), Align2::CENTER_CENTER, tl!("Send to Queue"), Tokens::semibold(12.5), t.text); reg.add("export.sendToQueue", queue, "Send to Queue"); - let resp = ui.interact(go, egui::Id::new("export-go"), Sense::click()); + let resp = ui.interact(go, egui::Id::unique("export-go"), Sense::click()); ui.painter().rect_filled(go, 15.0, if resp.hovered() { t.accent_hover } else { t.accent }); ui.painter().text(go.center(), Align2::CENTER_CENTER, tl!("Export"), Tokens::semibold(13.0), Color32::WHITE); reg.add("export.button", go, "Export"); @@ -1233,8 +1233,8 @@ fn preset_manager(app: &mut FilmcraftApp, ctx: &egui::Context) { let favorite = |n: &str| app.session.export_presets.is_favorite(n); let q = m.query.to_lowercase(); egui::Window::new(tl!("Preset Manager")) - .id(egui::Id::new("Preset Manager")) - .id(egui::Id::new("preset-manager")) + .id(egui::Id::unique("Preset Manager")) + .id(egui::Id::unique("preset-manager")) .open(&mut open) .collapsible(false) .resizable(true) @@ -1292,7 +1292,10 @@ fn preset_manager(app: &mut FilmcraftApp, ctx: &egui::Context) { } ui.horizontal(|ui| { ui.label(tl!("Save current settings as:")); - let r = ui.add(egui::TextEdit::singleline(&mut m.save_name).hint_text(tl!("Preset name")).desired_width(200.0)); + let r = crate::widgets::accessible_name( + ui.add(egui::TextEdit::singleline(&mut m.save_name).hint_text(tl!("Preset name")).desired_width(200.0)), + "Preset name", + ); reg.add("presetManager.saveName", r.rect, "Preset name"); let r = ui.add_enabled(!m.save_name.trim().is_empty(), egui::Button::new(tl!("Save"))); reg.add("presetManager.save", r.rect, "Save"); @@ -1463,7 +1466,7 @@ pub fn quick_export(app: &mut FilmcraftApp, ctx: &egui::Context, anchor: egui::P let mut browse = false; let mut typed: Option = None; let q = &mut app.ui.export; - let area = egui::Area::new(egui::Id::new("quick-export")).order(egui::Order::Foreground).fixed_pos(anchor).show(ctx, |ui| { + let area = egui::Area::new(egui::Id::unique("quick-export")).order(egui::Order::Foreground).fixed_pos(anchor).show(ctx, |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_width(340.0); ui.horizontal(|ui| { @@ -1503,7 +1506,7 @@ pub fn quick_export(app: &mut FilmcraftApp, ctx: &egui::Context, anchor: egui::P }); let clicked_elsewhere = area.response.clicked_elsewhere(); // the header click that opened the popup is not a click elsewhere - let toggled = ctx.data_mut(|d| d.remove_temp::(egui::Id::new("quick-export-toggled")).unwrap_or(false)); + let toggled = ctx.data_mut(|d| d.remove_temp::(egui::Id::unique("quick-export-toggled")).unwrap_or(false)); reg.flush(app); if let Some(v) = typed { // a file name goes in the shown folder; a typed or pasted path replaces both diff --git a/crates/ui-egui/src/panels/file_dialogs.rs b/crates/ui-egui/src/panels/file_dialogs.rs index 23905b4ba..5c60b2b8e 100644 --- a/crates/ui-egui/src/panels/file_dialogs.rs +++ b/crates/ui-egui/src/panels/file_dialogs.rs @@ -42,7 +42,7 @@ pub fn show_recovery(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { } let mut choice = app.file_dialogs.recovery_choice.min(items.len() - 1); let mut open = true; - egui::Modal::new(egui::Id::new("recovery-modal")).frame(modal_frame(&t)).show(ctx, |ui| { + egui::Modal::new(egui::Id::unique("recovery-modal")).frame(modal_frame(&t)).show(ctx, |ui| { ui.set_width(520.0); Frame::new().inner_margin(Margin { left: 28, right: 28, top: 24, bottom: 20 }).show(ui, |ui| { ui.label(RichText::new(tl!("Recover Unsaved Changes")).size(18.0).strong().color(t.text)); @@ -108,7 +108,7 @@ pub fn show_revert(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { let t = app.tokens; let file = app.session.path.as_deref().and_then(|p| std::path::Path::new(p).file_name()).map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let mut open = true; - let r = egui::Modal::new(egui::Id::new("revert-modal")).frame(modal_frame(&t)).show(ctx, |ui| { + let r = egui::Modal::new(egui::Id::unique("revert-modal")).frame(modal_frame(&t)).show(ctx, |ui| { ui.set_width(460.0); Frame::new().inner_margin(Margin { left: 28, right: 28, top: 24, bottom: 20 }).show(ui, |ui| { ui.label(RichText::new(tl!("Revert")).size(18.0).strong().color(t.text)); diff --git a/crates/ui-egui/src/panels/frame_export.rs b/crates/ui-egui/src/panels/frame_export.rs index 5e20887e3..59e9d14c0 100644 --- a/crates/ui-egui/src/panels/frame_export.rs +++ b/crates/ui-egui/src/panels/frame_export.rs @@ -20,7 +20,7 @@ struct Draft { replace: Option, } fn draft_id() -> egui::Id { - egui::Id::new("frame-export-draft") + egui::Id::unique("frame-export-draft") } fn clean(name: &str) -> String { name.trim().chars().map(|c| if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '_' } else { c }).collect() @@ -41,7 +41,7 @@ pub fn open(app: &mut FilmcraftApp, ctx: &egui::Context, params: &Value) -> Resu } else if app.playback.playing { app.stop(); } - let folder = ctx.data(|m| m.get_temp::(egui::Id::new("frame-export-folder"))).unwrap_or_else(|| { + let folder = ctx.data(|m| m.get_temp::(egui::Id::unique("frame-export-folder"))).unwrap_or_else(|| { if cfg!(target_arch = "wasm32") { return "/exports".into(); } @@ -65,11 +65,11 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut elems = Vec::new(); let mut popup_rects = Vec::new(); let frame = egui::Frame::new().fill(app.tokens.panel_bg).inner_margin(0).corner_radius(6); - let position_id = egui::Id::new("frame-export-position"); + let position_id = egui::Id::unique("frame-export-position"); let position = ctx.data(|m| m.get_temp::(position_id)); let mut movement = egui::Vec2::ZERO; // Only the custom white header moves the window; form controls keep their own gestures. - let window_id = egui::Id::new("frame-export-window"); + let window_id = egui::Id::unique("frame-export-window"); let mut window = egui::Window::new("Export Frame") .id(window_id) .title_bar(false) @@ -95,20 +95,20 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { ); let x = egui::Rect::from_center_size(bar.right_center() - egui::vec2(16.0, 0.0), egui::vec2(24.0, 24.0)); let header = egui::Rect::from_min_max(bar.min, egui::pos2(x.left(), bar.bottom())); - let drag = ui.interact(header, egui::Id::new("export-frame-header"), egui::Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab); + let drag = ui.interact(header, egui::Id::unique("export-frame-header"), egui::Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab); if drag.dragged() { movement = drag.drag_delta(); ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); } elems.push(("exportFrame.header".into(), header, "Move Export Frame".into())); - let response = ui.interact(x, egui::Id::new("export-frame-close"), egui::Sense::click()).on_hover_text("Close"); + let response = ui.interact(x, egui::Id::unique("export-frame-close"), egui::Sense::click()).on_hover_text("Close"); icons::paint(ui.painter(), x.shrink(7.0), Icon::Close, egui::Color32::from_gray(90)); elems.push(("exportFrame.close".to_string(), x, "Close".to_string())); close = response.clicked(); egui::Frame::NONE.inner_margin(14).show(ui, |ui| { egui::Grid::new("frame-export-settings").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| { ui.label("Name:"); - let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0)); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0)), "Name"); elems.push(("exportFrame.name".into(), r.rect, d.name.clone())); ui.end_row(); ui.label("Format:"); @@ -144,7 +144,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { elems.push(("exportFrame.depth".into(), combo.response.rect, format!("{} Bit", d.depth))); ui.end_row(); ui.label("Path:"); - let r = ui.add(egui::TextEdit::singleline(&mut d.folder).desired_width(300.0)); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut d.folder).desired_width(300.0)), "Path"); elems.push(("exportFrame.path".into(), r.rect, d.folder.clone())); ui.end_row(); }); @@ -255,7 +255,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { match app.session.execute("file.exportFrame", params) { Ok(result) => { app.ui.status = format!("Exported frame to {}", result["path"].as_str().unwrap_or_default()); - ctx.data_mut(|m| m.insert_temp(egui::Id::new("frame-export-folder"), d.folder.clone())); + ctx.data_mut(|m| m.insert_temp(egui::Id::unique("frame-export-folder"), d.folder.clone())); close = true; } Err(e) => app.ui.status = e.to_string(), diff --git a/crates/ui-egui/src/panels/graphics.rs b/crates/ui-egui/src/panels/graphics.rs index 7fb91b98d..c2e2ffbf8 100644 --- a/crates/ui-egui/src/panels/graphics.rs +++ b/crates/ui-egui/src/panels/graphics.rs @@ -22,7 +22,9 @@ //! `program.textEdit`, `graphics.*` in the panels. use crate::widgets::CenteredDialog as _; +use egui::ime_context::{CommittedText, ImeMutationStamp, ImeObservation, ImeTarget}; use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use filmcraft_engine::Session; use filmcraft_geom::{Affine, Vec2}; use filmcraft_project::graphic::{self, LayerContent, LayerSpec, eval_layer, layer_display_name, layer_indices}; use filmcraft_project::{ClipId, EffectInstance, ItemKind, ParamValue, TrackItem}; @@ -46,6 +48,45 @@ pub struct LayerView { pub local: [f32; 4], } +/// The Program monitor editor drawn in one pass, remembered so the final IME observation can run +/// after that pass's control requests have been drained. It holds only geometry, never a copy of +/// the layer's text. +#[derive(Clone, Copy, Debug)] +pub(crate) struct DrawnImeEditor { + pub viewport: egui::ViewportId, + pub pass_nr: u64, + pub document_epoch: u64, + pub sequence: Option, + pub target: ImeTarget, + pub clip: u64, + pub layer: usize, + /// Identity of the layer drawn at that index; a reorder after the draw is detected here even + /// when the numeric index (and therefore a uid-less owner key) stayed the same. + pub fingerprint: u64, + /// Identity of the committed text drawn in this pass. A control request that changes the + /// text after the draw must not publish the old cursor geometry with the new text; the next + /// pass draws and observes it. A caret/selection-only change keeps the text, so the owner and + /// generation can stay stable while the new selection is published. + pub text_stamp: u64, + /// Every layout-affecting layer property drawn in this pass, evaluated at the playhead. A + /// same-pass control request can change the font, style, alignment, spacing, box or transform + /// without changing the committed text or the layer identity; the cursor rectangle from the + /// old layout must then not be published with the new one. + pub layout_fingerprint: u64, + pub rect: Rect, + pub cursor_rect: Rect, +} + +/// Identity of committed text alone (no caret or selection). +fn ime_text_stamp(text: &str) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for &b in text.as_bytes() { + h ^= u64::from(b); + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + h ^ (text.len() as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15) +} + impl LayerView { pub fn quad(&self) -> [Pos2; 4] { let b = self.local; @@ -503,12 +544,15 @@ fn new_text_action(app: &FilmcraftApp, pic: Rect, frame: (u32, u32), at: Pos2, c ("graphics.newText#edit".into(), prm) } -const TEXT_EDIT_ID: &str = "gfx-text-edit"; +pub(crate) const TEXT_EDIT_ID: &str = "gfx-text-edit"; fn end_edit(app: &mut FilmcraftApp, ui: &egui::Ui) { app.ui.gfx_edit = None; - ui.memory_mut(|m| m.surrender_focus(egui::Id::new(TEXT_EDIT_ID))); - ui.data_mut(|d| d.remove::(egui::Id::new(TEXT_EDIT_ID).with("panel"))); + // Any composition that was in flight belongs to the editor that just ended; a late commit + // must not be inserted into whatever is edited next. + app.invalidate_ime_composition(); + ui.memory_mut(|m| m.surrender_focus(egui::Id::unique(TEXT_EDIT_ID))); + ui.data_mut(|d| d.remove::(egui::Id::unique(TEXT_EDIT_ID).with("panel"))); } /// A click on the Program monitor's empty space (the picture's, or around it): end the text edit @@ -525,6 +569,9 @@ pub(crate) fn deselect_on_monitor(app: &mut FilmcraftApp, ui: &egui::Ui) { /// Draw the overlay and handle graphics tools on the Program monitor picture `pic`. pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32, u32)) { + // A project command may have reordered or deleted layers since the last pass. Follow the + // edited layer (or end the edit) before anything reads `gfx_edit`. + reconcile_gfx_edit(app); let t = app.tokens; let views = visible_layers(app, pic, frame); let tool = app.ui.tool; @@ -588,10 +635,11 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra } } } - let id = egui::Id::new("gfx-overlay"); + let id = egui::Id::unique("gfx-overlay"); let resp = ui.interact(pic, id, if graphics_tool { Sense::click_and_drag() } else { Sense::hover() }); let hover = resp.hover_pos(); let mut actions: Vec<(String, Value)> = Vec::new(); + let mut pending_ime: Option = None; let drag_id = id.with("drag"); let mut drag: Option = ui.data(|d| d.get_temp(drag_id)); @@ -624,7 +672,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra // ---- text editing if let Some(ed) = editing.clone() { match views.iter().find(|v| v.clip.0 == ed.clip && v.layer == ed.layer && v.is_text()) { - Some(v) => text_edit(app, ui, v, ed, &resp, &mut actions), + Some(v) => text_edit(app, ui, v, ed, &resp, &mut actions, &mut pending_ime), None => end_edit(app, ui), } } @@ -644,10 +692,14 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra { let l = text_layout(tp); if let Some((x, y)) = v.to_local(p) { + let previous_selection = (ed.caret, ed.anchor); ed.caret = l.hit(x, y); if !shift { ed.anchor = ed.caret; } + if previous_selection != (ed.caret, ed.anchor) { + app.invalidate_ime_composition(); + } app.ui.gfx_edit = Some(ed); } drag = Some(DragState { kind: DragKind::TextSelect, clip: v.clip, layer: v.layer, start: p }); @@ -756,7 +808,11 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra { let l = text_layout(tp); if let Some((x, y)) = v.to_local(cur) { + let previous_selection = (ed.caret, ed.anchor); ed.caret = l.hit(x, y); + if previous_selection != (ed.caret, ed.anchor) { + app.invalidate_ime_composition(); + } app.ui.gfx_edit = Some(ed); } } @@ -838,10 +894,14 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra && let Some(tp) = text_of(&v.spec) && let Some((x, y)) = v.to_local(p) { + let previous_selection = (ed.caret, ed.anchor); ed.caret = text_layout(tp).hit(x, y); if !ui.input(|i| i.modifiers.shift) { ed.anchor = ed.caret; } + if previous_selection != (ed.caret, ed.anchor) { + app.invalidate_ime_composition(); + } app.ui.gfx_edit = Some(ed); } } @@ -911,35 +971,16 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra } else if tool != Tool::Pen && !app.ui.pen_points.is_empty() { app.ui.pen_points.clear(); } - // ---- caret / selection drawing - if let Some(ed) = app.ui.gfx_edit.clone() - && let Some(v) = views.iter().find(|v| v.clip.0 == ed.clip && v.layer == ed.layer) - && let Some(tp) = text_of(&v.spec) - { - let l = text_layout(tp); - let caret = ed.caret.min(tp.text.len()); - for r in l.selection_rects(ed.anchor.min(tp.text.len()), caret) { - let q = [(r[0], r[1]), (r[2], r[1]), (r[2], r[3]), (r[0], r[3])].map(|(x, y)| sp(&v.to_screen, x, y)); - painter.add(egui::Shape::convex_polygon(q.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 90), Stroke::NONE)); - } - let (x, base, li) = l.caret(caret); - let (ascent, descent) = l.lines.get(li.min(l.lines.len().saturating_sub(1))).map_or((0.0, 0.0), |line| (line.ascent, line.descent)); - let (a, b) = (sp(&v.to_screen, x, base - ascent), sp(&v.to_screen, x, base + descent)); - painter.line_segment([a, b], Stroke::new(1.5, Color32::WHITE)); - painter.line_segment([a + vec2(1.0, 0.0), b + vec2(1.0, 0.0)], Stroke::new(0.5, Color32::BLACK)); - let er = Rect::from_points(&v.quad()).union(Rect::from_two_pos(a, b)); - app.auto.add("program.textEdit", er, &tp.text); - ui.ctx().output_mut(|o| { - o.ime = Some(egui::output::IMEOutput { - purpose: Default::default(), - rect: er, - cursor_rect: Rect::from_two_pos(a, b), - should_interrupt_composition: false, - }) + // ---- run accepted actions before observing committed text and caret geometry + let viewport = ui.ctx().viewport_id(); + let frame_nr = ui.ctx().cumulative_frame_nr_for(viewport); + for (action_index, (cmd, p)) in actions.into_iter().enumerate() { + let pending = pending_ime.as_ref().filter(|pending| pending.action_index == action_index); + let target = pending.and_then(|pending| current_graphics_ime_target(app, pending.clip, pending.layer)); + let actual_before = pending.and_then(|pending| { + pending.first_before.as_ref()?; + current_layer_text(&app.session, pending.clip, pending.layer).map(|text| ime_stamp(text, pending.before_cursor, pending.before_anchor)) }); - } - // ---- run - for (cmd, p) in actions { let (cmd, then_edit) = match cmd.strip_suffix("#edit") { Some(c) => (c.to_string(), true), None => (cmd, false), @@ -947,13 +988,196 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra match app.session.execute(&cmd, p) { Ok(r) => { if then_edit && let (Some(c), Some(l)) = (r["clip"].as_u64(), r["layer"].as_u64()) { - app.ui.gfx_edit = Some(GfxEdit { clip: c, layer: l as usize, caret: 0, anchor: 0 }); + app.restart_ime_target(); + let uid = current_layer_uid(&app.session, c, l as usize).unwrap_or(0); + app.ui.gfx_edit = Some(GfxEdit { clip: c, layer: l as usize, caret: 0, anchor: 0, uid, fingerprint: 0 }); app.ui.focused = crate::dock::PanelKind::Program; } + if let (Some(pending), Some(target)) = (pending, target) { + let actual_after = + current_layer_text(&app.session, pending.clip, pending.layer).map(|text| ime_stamp(text, pending.final_cursor, pending.final_anchor)); + let exact_ime_chain = !pending.application_mutation + && pending.first_before.as_ref().is_some_and(|before| actual_before.as_ref() == Some(before)) + && pending.after.last().is_some_and(|after| actual_after.as_ref() == Some(after)); + + if exact_ime_chain { + if let Some(mut before) = actual_before { + for after in &pending.after { + app.ime_context.note_input_method_apply(viewport, frame_nr, target, before.clone(), after.clone()); + + before = after.clone(); + } + } + } else if pending.application_mutation || pending.first_before.is_some() { + // An ordinary edit was mixed into the transaction, the model did not match + // the locally accepted result, or another action changed it first: the + // frame is application-origin even if an IME event was also present. + app.ime_context.note_application_change(viewport, frame_nr, target); + } + } } Err(e) => app.ui.status = e.to_string(), } } + // The model may have changed above; draw and observe the post-action caret and committed text. + let views = visible_layers(app, pic, frame); + if let Some(ed) = app.ui.gfx_edit.clone() + && let Some(v) = views.iter().find(|v| v.clip.0 == ed.clip && v.layer == ed.layer) + && let Some(tp) = text_of(&v.spec) + { + let l = text_layout(tp); + let caret = char_boundary_floor(&tp.text, ed.caret); + let anchor = char_boundary_floor(&tp.text, ed.anchor); + for r in l.selection_rects(anchor, caret) { + let q = [(r[0], r[1]), (r[2], r[1]), (r[2], r[3]), (r[0], r[3])].map(|(x, y)| sp(&v.to_screen, x, y)); + painter.add(egui::Shape::convex_polygon(q.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 90), Stroke::NONE)); + } + let (x, base, li) = l.caret(caret); + let (ascent, descent) = l.lines.get(li.min(l.lines.len().saturating_sub(1))).map_or((0.0, 0.0), |line| (line.ascent, line.descent)); + let (a, b) = (sp(&v.to_screen, x, base - ascent), sp(&v.to_screen, x, base + descent)); + let cursor_rect = Rect::from_two_pos(a, b); + painter.line_segment([a, b], Stroke::new(1.5, Color32::WHITE)); + painter.line_segment([a + vec2(1.0, 0.0), b + vec2(1.0, 0.0)], Stroke::new(0.5, Color32::BLACK)); + let er = Rect::from_points(&v.quad()).union(cursor_rect); + app.auto.add("program.textEdit", er, &tp.text); + // Do not publish yet: `drain_ui_requests` later in this pass may run a command or replace + // `gfxEdit`. `finish_graphics_ime` observes the post-drain state with this geometry. + note_graphics_ime_editor(app, ui, v, er, cursor_rect); + } +} + +/// Remember the focused Program monitor editor drawn in this pass. The actual observation is +/// made once by [`finish_graphics_ime`] after the pass's control requests have run. +pub(crate) fn note_graphics_ime_editor(app: &mut FilmcraftApp, ui: &egui::Ui, v: &LayerView, rect: Rect, cursor_rect: Rect) { + let ctx = ui.ctx(); + let viewport = ctx.viewport_id(); + let pass_nr = ctx.cumulative_pass_nr_for(viewport); + let sequence = app.session.state.active_sequence.map(|sequence| sequence.0); + let target = graphics_ime_target(sequence.unwrap_or(0), v.clip.0, v.layer, v.spec.uid, app.ime_replacement_epoch); + let fingerprint = current_layer_fingerprint(&app.session, v.clip.0, v.layer).unwrap_or(0); + let layout_fingerprint = current_layer_layout_fingerprint(&app.session, v.clip.0, v.layer).unwrap_or(0); + let text_stamp = text_of(&v.spec).map_or(0, |tp| ime_text_stamp(&tp.text)); + app.ime_drawn_editor = Some(DrawnImeEditor { + viewport, + pass_nr, + document_epoch: app.session.document_epoch, + sequence, + target, + clip: v.clip.0, + layer: v.layer, + fingerprint, + text_stamp, + layout_fingerprint, + rect, + cursor_rect, + }); +} + +/// Publish this pass's final IME observation, after all commands for the pass have been drained. +/// +/// The producer re-reads the committed text without evaluating/copying a full `LayerSpec`; a +/// target, document or sequence replaced since the overlay drew publishes `None` instead of +/// pairing an old owner with the new model. +pub(crate) fn finish_graphics_ime(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { + let viewport = ctx.viewport_id(); + let pass_nr = ctx.cumulative_pass_nr_for(viewport); + let Some(drawn) = app.ime_drawn_editor.take() else { return false }; + if drawn.viewport != viewport || drawn.pass_nr != pass_nr { + return false; + } + app.note_ime_observed(viewport, pass_nr); + let should_interrupt_composition = app.ime_interrupt_now; + if !ctx.will_discard() { + app.ime_interrupt_now = false; + } + if drawn.document_epoch != app.session.document_epoch || drawn.sequence != app.session.state.active_sequence.map(|sequence| sequence.0) { + app.ime_context.observe(viewport, None); + return true; + } + let eid = egui::Id::unique(TEXT_EDIT_ID); + if ctx.memory(|m| m.focused() != Some(eid)) { + app.ime_context.observe(viewport, None); + return true; + } + let Some(ed) = app.ui.gfx_edit.clone() else { + app.ime_context.observe(viewport, None); + return true; + }; + if ed.clip != drawn.clip || ed.layer != drawn.layer { + app.ime_context.observe(viewport, None); + return true; + } + let Some(sequence) = app.session.state.active_sequence else { + app.ime_context.observe(viewport, None); + return true; + }; + let Some(uid) = current_layer_uid(&app.session, ed.clip, ed.layer) else { + app.ime_context.observe(viewport, None); + return true; + }; + let target = graphics_ime_target(sequence.0, ed.clip, ed.layer, uid, app.ime_replacement_epoch); + if target != drawn.target || current_layer_fingerprint(&app.session, ed.clip, ed.layer).is_none_or(|fingerprint| fingerprint != drawn.fingerprint) { + // A control request replaced the edit target, reordered the layers, or changed the + // document after the overlay drew. Do not pair the old owner with the new content. + + app.ime_context.observe(viewport, None); + return true; + } + let Some(text) = current_layer_text(&app.session, ed.clip, ed.layer) else { + app.ime_context.observe(viewport, None); + return true; + }; + if ime_text_stamp(text) != drawn.text_stamp { + // A same-pass control request changed the text after the overlay drew. The published + // cursor rectangle and layout belong to the old text: report no descriptor for this pass + // and let the next pass draw and observe the correct geometry. The in-flight composition + // belongs to the text that was replaced, so its next delete/preedit/commit must not be + // inserted into the new text. + app.invalidate_ime_composition(); + if !ctx.will_discard() { + app.ime_interrupt_now = false; + } + app.ime_context.observe(viewport, None); + return true; + } + if current_layer_layout_fingerprint(&app.session, ed.clip, ed.layer).is_none_or(|fingerprint| fingerprint != drawn.layout_fingerprint) { + // A same-pass control request changed a layout-affecting property (font, style, + // alignment, spacing, box, transform, per-character style or pin) without changing the + // committed text. The old cursor rectangle belongs to the previous layout: publish no + // descriptor for this pass and let the next pass draw and observe the new geometry. + app.ime_context.observe(viewport, None); + return true; + } + // A caret/selection-only change after the draw keeps the committed text. Publish the new + // selection with the owner and generation unchanged; the cursor rectangle is one pass old, + // and the next pass refreshes it. Hostile offsets remain an explicit `Unavailable` snapshot. + let frame_nr = ctx.cumulative_frame_nr_for(viewport); + // FilmCraft's committed text never contains the IME pre-edit (preedit events are UI-only), + // so there is no preedit range to remove. A hostile or not-yet-clamped `gfxEdit` offset that + // is not a UTF-8 boundary is published as the explicit `Unavailable` snapshot, never as a + // nearby guessed offset. + let valid_offsets = ed.caret <= text.len() && ed.anchor <= text.len() && text.is_char_boundary(ed.caret) && text.is_char_boundary(ed.anchor); + let observation = ImeObservation::new(frame_nr, pass_nr, target) + .purpose(egui::IMEPurpose::Normal) + .committed(valid_offsets.then(|| CommittedText::new(text, ed.caret, ed.anchor))); + let update = app.ime_context.observe(viewport, Some(observation)); + + if let Some(update) = update { + let (rect, cursor_rect) = (drawn.rect, drawn.cursor_rect); + ctx.output_mut(|o| { + o.ime = Some(egui::output::IMEOutput { + owner: update.owner, + context_generation: update.context_generation, + purpose: update.purpose, + rect, + cursor_rect, + should_interrupt_composition, + change_cause: update.change_cause, + surrounding_text: update.surrounding_text, + }) + }); + } + true } fn layer_display_name_view(app: &FilmcraftApp, v: &LayerView) -> String { @@ -987,7 +1211,8 @@ fn start_edit_at(app: &mut FilmcraftApp, ui: &egui::Ui, v: &LayerView, p: Pos2, let Some(tp) = text_of(&v.spec) else { return }; let l = text_layout(tp); let caret = v.to_local(p).map_or(tp.text.len(), |(x, y)| l.hit(x, y)); - app.ui.gfx_edit = Some(GfxEdit { clip: v.clip.0, layer: v.layer, caret, anchor: caret }); + app.restart_ime_target(); + app.ui.gfx_edit = Some(GfxEdit { clip: v.clip.0, layer: v.layer, caret, anchor: caret, uid: v.spec.uid, fingerprint: 0 }); app.ui.focused = crate::dock::PanelKind::Program; let _ = ui; actions.push(("graphics.selectLayer".into(), json!({"clip": v.clip.0, "layers": [v.layer]}))); @@ -1004,31 +1229,403 @@ pub(crate) fn select_all_edited_text(app: &mut FilmcraftApp) -> bool { let Some(effect) = layer_indices(&it.effects).get(layer).and_then(|&i| it.effects.get(i)) else { return false }; let Some(len) = eval_layer(effect, it.source_time_at(t), (*width, *height)).as_ref().and_then(text_of).map(|tp| tp.text.len()) else { return false }; let Some(ed) = app.ui.gfx_edit.as_mut() else { return false }; + let moved = (ed.anchor, ed.caret) != (0, len); (ed.anchor, ed.caret) = (0, len); + if moved { + app.invalidate_ime_composition(); + } true } +/// One text edit action queued by [`text_edit`], with the accepted local IME receipt chain. +/// +/// The model command is executed later in the same pass. The receipt is reported to the tracker +/// only after that command succeeds and the post-command model still matches the local result. +struct PendingImeEdit { + action_index: usize, + clip: u64, + layer: usize, + /// The committed stamp immediately before the first accepted IME mutation. + first_before: Option, + before_cursor: usize, + before_anchor: usize, + /// One stamp immediately after each accepted IME commit/delete mutation. + after: Vec, + final_cursor: usize, + final_anchor: usize, + /// Ordinary text/paste/key/Cut changes were mixed into the same queued edit. + application_mutation: bool, +} + +fn ime_stamp(text: &str, cursor: usize, anchor: usize) -> ImeMutationStamp { + ImeMutationStamp::from_committed(text, cursor, anchor, None) +} + +/// The effect instance of layer `layer` of `clip` when it is enabled and on screen. +/// +/// This is the read-only path for the IME producer: it never evaluates a `LayerSpec` (which +/// clones the layer's full text) just to read an identity or a committed-text window. +fn current_layer_effect(session: &Session, clip: u64, layer: usize) -> Option<&EffectInstance> { + let seq = session.active_sequence()?; + let (_, it) = seq.find_item(ClipId(clip))?; + if !it.enabled { + return None; + } + let &effect_index = layer_indices(&it.effects).get(layer)?; + let effect = it.effects.get(effect_index)?; + effect.enabled.then_some(effect) +} + +/// Borrowed committed text of the edited layer at the playhead (no `TextProps`/full-text copy). +fn current_layer_text(session: &Session, clip: u64, layer: usize) -> Option<&str> { + let seq = session.active_sequence()?; + let t = session.playhead(); + let (_, it) = seq.find_item(ClipId(clip))?; + if !it.enabled { + return None; + } + let &effect_index = layer_indices(&it.effects).get(layer)?; + let effect = it.effects.get(effect_index)?; + if !effect.enabled || effect.effect != graphic::TEXT_LAYER { + return None; + } + effect.params.get("text")?.text_ref_at(it.source_time_at(t)) +} + +fn current_layer_uid(session: &Session, clip: u64, layer: usize) -> Option { + current_layer_effect(session, clip, layer).map(|effect| effect.layer.as_ref().map_or(0, |x| x.uid)) +} + +/// A compact identity for a layer that survives reorder/delete/undo when the layer has no uid. +/// +/// It uses static parameter values only, so a keyframed or animating position at the playhead +/// does not make the target look new, and it deliberately excludes the editable text, so typing +/// does not either. +fn layer_fingerprint(effect: &EffectInstance) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + let mut hash = |bytes: &[u8]| { + for &b in bytes { + h ^= u64::from(b); + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + }; + hash(effect.effect.as_bytes()); + hash(&[u8::from(effect.enabled)]); + hash(&effect.layer.as_ref().map_or(0, |x| x.uid).to_le_bytes()); + if let Some(name) = effect.params.get("name").and_then(|p| match &p.value { + ParamValue::Text(s) => Some(s.as_str()), + _ => None, + }) { + hash(name.as_bytes()); + } + for id in ["position", "size", "box_width", "box_height", "shape", "sides", "corner_radius", "vertical"] { + let Some(value) = effect.params.get(id).map(|p| &p.value) else { continue }; + match value { + ParamValue::Float(v) => hash(&v.to_bits().to_le_bytes()), + ParamValue::Vec2(v) => { + hash(&v.x.to_bits().to_le_bytes()); + hash(&v.y.to_bits().to_le_bytes()); + } + ParamValue::Choice(v) => hash(&u64::from(*v).to_le_bytes()), + ParamValue::Bool(v) => hash(&[u8::from(*v)]), + ParamValue::Text(s) => hash(s.as_bytes()), + _ => {} + } + } + h +} + +fn current_layer_fingerprint(session: &Session, clip: u64, layer: usize) -> Option { + current_layer_effect(session, clip, layer).map(layer_fingerprint) +} + +/// Stable uid and identity fingerprint of the layer currently at `layer`. +/// +/// A control request that explicitly names a clip and numeric layer means that index now, not +/// whichever layer an older edit session happened to own at that index. +pub(crate) fn current_layer_identity(session: &Session, clip: u64, layer: usize) -> Option<(u64, u64)> { + current_layer_effect(session, clip, layer).map(|effect| (effect.layer.as_ref().map_or(0, |extra| extra.uid), layer_fingerprint(effect))) +} + +/// Properties that change a text or shape layer's layout, and therefore the caret and candidate +/// rectangle, without necessarily changing its committed text or stable identity. Both static +/// values and keyframes at the playhead are included. +const LAYOUT_PARAMS: &[&str] = &[ + // Text layout. + "font", + "font_style", + "size", + "align", + "tracking", + "kerning", + "ligatures", + "leading", + "baseline_shift", + "faux_bold", + "faux_italic", + "caps", + "underline", + "box_width", + "box_height", + "vertical", + // Shape geometry. + "shape", + "sides", + "corner_radius", + "points", + // Transform. + "position", + "anchor", + "scale", + "scale_width", + "uniform_scale", + "rotation", +]; + +fn hash_bytes(h: &mut u64, bytes: &[u8]) { + for &b in bytes { + *h ^= u64::from(b); + *h = h.wrapping_mul(0x0000_0100_0000_01b3); + } +} + +fn hash_f64(h: &mut u64, v: f64) { + // Normalise non-finite values: NaN is "auto" for point parameters and must not make the + // fingerprint depend on payload bits or an evaluation detail. + if v.is_finite() { + hash_bytes(h, &v.to_bits().to_le_bytes()); + } else { + hash_bytes(h, b"non-finite"); + } +} + +fn hash_f32(h: &mut u64, v: f32) { + hash_f64(h, f64::from(v)); +} + +fn hash_option(h: &mut u64, value: Option, hash: impl FnOnce(&mut u64, T)) { + match value { + Some(v) => { + hash_bytes(h, &[1]); + hash(h, v); + } + None => hash_bytes(h, &[0]), + } +} + +fn hash_param_value(h: &mut u64, value: &ParamValue) { + match value { + ParamValue::Float(v) => { + hash_bytes(h, b"f"); + hash_f64(h, *v); + } + ParamValue::Vec2(v) => { + hash_bytes(h, b"v"); + hash_f64(h, v.x); + hash_f64(h, v.y); + } + ParamValue::Color(c) => { + hash_bytes(h, b"c"); + for v in c { + hash_f32(h, *v); + } + } + ParamValue::Bool(v) => { + hash_bytes(h, b"b"); + hash_bytes(h, &[u8::from(*v)]); + } + ParamValue::Choice(v) => { + hash_bytes(h, b"o"); + hash_bytes(h, &u64::from(*v).to_le_bytes()); + } + ParamValue::Text(v) => { + hash_bytes(h, b"t"); + hash_bytes(h, v.as_bytes()); + } + ParamValue::Curve(points) => { + hash_bytes(h, b"u"); + hash_bytes(h, &(points.len() as u64).to_le_bytes()); + for p in points { + hash_f32(h, p[0]); + hash_f32(h, p[1]); + } + } + // Mask paths are not used by layer layout parameters. + ParamValue::Path(_) => hash_bytes(h, b"p"), + } +} + +fn layout_fingerprint(effect: &EffectInstance, t: Tick) -> u64 { + let mut h: u64 = 0x9e37_79b9_7f4a_7c15; + hash_bytes(&mut h, effect.effect.as_bytes()); + hash_bytes(&mut h, &[u8::from(effect.enabled)]); + for id in LAYOUT_PARAMS { + let Some(param) = effect.params.get(*id) else { continue }; + hash_bytes(&mut h, id.as_bytes()); + hash_param_value(&mut h, ¶m.value_at(t)); + } + if let Some(extra) = &effect.layer { + hash_bytes(&mut h, b"runs"); + hash_bytes(&mut h, &(extra.runs.len() as u64).to_le_bytes()); + for run in &extra.runs { + hash_bytes(&mut h, &(run.start as u64).to_le_bytes()); + hash_bytes(&mut h, &(run.end as u64).to_le_bytes()); + hash_option(&mut h, run.style.font.as_ref(), |h, v| hash_bytes(h, v.as_bytes())); + hash_option(&mut h, run.style.font_style.as_ref(), |h, v| hash_bytes(h, v.as_bytes())); + hash_option(&mut h, run.style.size, hash_f32); + hash_option(&mut h, run.style.fill, |h, v| { + for c in v { + hash_f32(h, c); + } + }); + hash_option(&mut h, run.style.faux_bold, |h, v| hash_bytes(h, &[u8::from(v)])); + hash_option(&mut h, run.style.faux_italic, |h, v| hash_bytes(h, &[u8::from(v)])); + hash_option(&mut h, run.style.underline, |h, v| hash_bytes(h, &[u8::from(v)])); + hash_option(&mut h, run.style.tracking, hash_f32); + hash_option(&mut h, run.style.baseline_shift, hash_f32); + hash_option(&mut h, run.style.caps, |h, v| hash_bytes(h, &u64::from(v).to_le_bytes())); + } + hash_bytes(&mut h, b"pin"); + match &extra.pin { + Some(pin) => { + hash_bytes(&mut h, &[1]); + match pin.to { + filmcraft_project::graphic_design::PinTarget::Frame => hash_bytes(&mut h, b"frame"), + filmcraft_project::graphic_design::PinTarget::Layer(uid) => { + hash_bytes(&mut h, b"layer"); + hash_bytes(&mut h, &uid.to_le_bytes()); + } + } + hash_bytes(&mut h, &[u8::from(pin.left), u8::from(pin.top), u8::from(pin.right), u8::from(pin.bottom)]); + for offset in pin.offsets { + hash_f64(&mut h, offset); + } + } + None => hash_bytes(&mut h, &[0]), + } + } + h +} + +fn current_layer_layout_fingerprint(session: &Session, clip: u64, layer: usize) -> Option { + let sequence = session.active_sequence()?; + let t = session.playhead(); + let (_, item) = sequence.find_item(ClipId(clip))?; + let &effect_index = layer_indices(&item.effects).get(layer)?; + let effect = item.effects.get(effect_index)?; + Some(layout_fingerprint(effect, item.source_time_at(t))) +} + +/// Follow the edited layer through a real project edit (reorder, delete, undo/redo). +/// +/// A stable uid wins. For a uid-less layer the compact fingerprint finds the same layer at its +/// new index; with no candidate the layer was deleted (or replaced), so the edit is ended rather +/// than silently retargeted to whatever now occupies the old index. +fn resolve_gfx_edit(session: &Session, ed: &GfxEdit) -> Option<(usize, u64, u64)> { + let seq = session.active_sequence()?; + let (_, it) = seq.find_item(ClipId(ed.clip))?; + let indices = layer_indices(&it.effects); + let resolved = if ed.uid != 0 { + indices.iter().position(|&ei| it.effects.get(ei).and_then(|e| e.layer.as_ref()).is_some_and(|x| x.uid == ed.uid)) + } else { + let at = |i: usize| it.effects.get(indices[i]).map(layer_fingerprint); + let mut found = indices.get(ed.layer).and_then(|_| at(ed.layer)).filter(|fp| ed.fingerprint == 0 || *fp == ed.fingerprint).map(|_| ed.layer); + if found.is_none() && ed.fingerprint != 0 { + let mut best: Option<(usize, usize)> = None; + for i in 0..indices.len() { + if at(i) == Some(ed.fingerprint) { + let distance = i.abs_diff(ed.layer); + if best.is_none_or(|(d, _)| distance < d) { + best = Some((distance, i)); + } + } + } + found = best.map(|(_, i)| i); + } + found + }?; + let effect = it.effects.get(*indices.get(resolved)?)?; + let uid = effect.layer.as_ref().map_or(0, |x| x.uid); + Some((resolved, uid, layer_fingerprint(effect))) +} + +/// Keep `gfx_edit` on the layer it started on. Called before the overlay draws, so a command run +/// between two passes (arrange/delete/undo or a control edit) cannot shift the caret into another +/// layer. +fn reconcile_gfx_edit(app: &mut FilmcraftApp) { + let Some(ed) = app.ui.gfx_edit.clone() else { return }; + match resolve_gfx_edit(&app.session, &ed) { + Some((layer, uid, fingerprint)) => { + // A uid-less owner key includes the index, so an index change is a new context. A + // stable uid layer keeps the same owner and its composition may continue. + if layer != ed.layer && ed.uid == 0 { + app.invalidate_ime_composition(); + } + app.ui.gfx_edit = Some(GfxEdit { layer, uid, fingerprint, ..ed }); + } + None => { + app.ui.gfx_edit = None; + app.invalidate_ime_composition(); + } + } +} + +fn graphics_ime_target(sequence: u64, clip: u64, layer: usize, uid: u64, replacement_epoch: u64) -> ImeTarget { + // The owner excludes caret, selection, text, revision, geometry, visual order and pointers. + // `uid` is the stable layer component id; zero means "none", where the layer index is the + // only component identity available. + let component_uid = (uid != 0).then_some(uid); + let component_index = (uid == 0).then_some(layer as u64); + ImeTarget::new(egui::Id::unique(("filmcraft.graphics.ime", sequence, clip, component_uid, component_index)), replacement_epoch) +} + +fn current_graphics_ime_target(app: &FilmcraftApp, clip: u64, layer: usize) -> Option { + let sequence = app.session.state.active_sequence?.0; + let uid = current_layer_uid(&app.session, clip, layer)?; + Some(graphics_ime_target(sequence, clip, layer, uid, app.ime_replacement_epoch)) +} + +/// Is this IME commit/delete left over from a composition whose target or caret/focus was +/// interrupted? A terminal-pending composition still owns its pending commit/delete after an empty +/// preedit. `None` (no preedit was seen) still accepts a direct commit. +fn ime_event_is_stale(app: &FilmcraftApp, target: ImeTarget) -> bool { + match app.ime_composition { + Some(crate::ImeComposition::Stale) => true, + Some(crate::ImeComposition::Active(active) | crate::ImeComposition::TerminalPending(active)) => active != target, + None => false, + } +} + /// Keyboard handling while a text layer is being edited. -fn text_edit(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &LayerView, mut ed: GfxEdit, resp: &egui::Response, actions: &mut Vec<(String, Value)>) { +fn text_edit( + app: &mut FilmcraftApp, + ui: &mut egui::Ui, + v: &LayerView, + mut ed: GfxEdit, + resp: &egui::Response, + actions: &mut Vec<(String, Value)>, + pending_ime: &mut Option, +) { let _ = resp; // moving to another panel ends the edit; Properties and Essential Graphics style the text // being edited, so working there keeps it (#683). Only a move counts: an edit started with // another panel focused (an agent's `ui.set`) goes on until the focus moves. use crate::dock::PanelKind; - let panel_id = egui::Id::new(TEXT_EDIT_ID).with("panel"); + let panel_id = egui::Id::unique(TEXT_EDIT_ID).with("panel"); let before = ui.data(|d| d.get_temp::(panel_id)); ui.data_mut(|d| d.insert_temp(panel_id, app.ui.focused)); if before.is_some_and(|b| b != app.ui.focused) && !matches!(app.ui.focused, PanelKind::Program | PanelKind::Properties | PanelKind::EssentialGraphics) { end_edit(app, ui); return; } - let eid = egui::Id::new(TEXT_EDIT_ID); + let eid = egui::Id::unique(TEXT_EDIT_ID); // a real (non-clickable) widget holds keyboard focus so shortcuts pause while typing let fr = ui.interact(Rect::from_points(&v.quad()), eid, Sense::focusable_noninteractive()); - fr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, "Text layer")); + fr.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, true, "Text layer")); // while another field has the keyboard (a value typed in Properties) the keys are its own; // the text takes the keyboard back once nothing has it (#683) if ui.memory(|m| m.focused().is_some_and(|f| f != eid)) { + app.invalidate_ime_composition(); return; } ui.memory_mut(|m| { @@ -1037,12 +1634,27 @@ fn text_edit(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &LayerView, mut ed: G }); let Some(tp) = text_of(&v.spec) else { return }; let mut text = tp.text.clone(); - ed.caret = ed.caret.min(text.len()); - ed.anchor = ed.anchor.min(text.len()); + // `ui.set.gfxEdit` accepts arbitrary byte offsets. Clamp both ends to UTF-8 boundaries here, + // before any slice/replace_range/insert_str, so hostile offsets can never panic the editor. + clamp_edit_offsets(&text, &mut ed); let l = text_layout(tp); let events = ui.input(|i| i.events.clone()); + let sequence = app.session.state.active_sequence.map_or(0, |sequence| sequence.0); + let composition_target = graphics_ime_target(sequence, ed.clip, ed.layer, v.spec.uid, app.ime_replacement_epoch); let mut changed = false; let mut stop = false; + let mut application_mutation = false; + // A stale event was rejected in this raw input batch. A rejected commit is the composition's + // terminal boundary: consume the tombstone immediately, so a preedit later in the same raw + // batch starts a new composition. A stale empty preedit or delete is only tentatively + // terminal: keep the tombstone through the batch so its paired old commit is rejected too, + // then clear it at batch end when no commit followed. A lone non-empty preedit is only an + // update of the old composition: keep the tombstone across batches so its later commit is + // still dropped. + let mut stale_batch = false; + let mut stale_preedit_only = false; + let mut ime_before: Option<(usize, usize)> = None; + let mut ime_after: Vec = Vec::new(); let sel = |ed: &GfxEdit| (ed.caret.min(ed.anchor), ed.caret.max(ed.anchor)); let delete_sel = |text: &mut String, ed: &mut GfxEdit| -> bool { let (a, b) = sel(ed); @@ -1054,10 +1666,75 @@ fn text_edit(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &LayerView, mut ed: G ed.anchor = a; true }; - for ev in events { + 'events: for ev in events { match ev { - // an input method (Chinese, Japanese, Korean) hands over its finished text as a commit (#1083) - egui::Event::Text(s) | egui::Event::Paste(s) | egui::Event::Ime(egui::ImeEvent::Commit(s)) => { + // The platform IME owns a composition between a non-empty preedit and its terminal + // commit; an empty preedit leaves that terminal event pending. Remember which target + // it belongs to; an event after the target was replaced must not edit the new layer. + egui::Event::Ime(egui::ImeEvent::Preedit { text, .. }) => { + if text.is_empty() { + // Dismissing a pre-edit clears the visible composition, but its terminal + // commit/delete may arrive in a later event. Keep the owner as + // terminal-pending so a target switch still rejects that event. A stale + // empty preedit must not lift the tombstone before a paired old commit. + if matches!(app.ime_composition, Some(crate::ImeComposition::Stale)) { + stale_batch = true; + stale_preedit_only = false; + } else { + app.ime_composition = Some(crate::ImeComposition::TerminalPending(composition_target)); + } + } else if matches!(app.ime_composition, Some(crate::ImeComposition::Stale)) { + // This pre-edit belongs to the composition whose target was replaced: an + // input method can emit further non-empty pre-edits before its final + // commit. Do not revive the old composition on the replacement target. + // Keep the tombstone past this batch: the old composition may commit in a + // later raw batch; a later empty preedit, delete or commit ends it. + stale_batch = true; + stale_preedit_only = true; + } else { + app.ime_composition = Some(crate::ImeComposition::Active(composition_target)); + } + } + // text-input-v3/Wayland delete-surrounding arrives in UTF-8 bytes. Only whole + // characters fully inside the reported range are deleted: a byte count that ends + // inside a character leaves that character (the fork's contract), so a combining + // accent or the letter next to an emoji is preserved. + egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes, after_bytes }) => { + if ime_event_is_stale(app, composition_target) { + // Keep the tombstone for the commit paired with this delete. + stale_batch = true; + stale_preedit_only = false; + continue; + } + let (a, b) = sel(&ed); + let (before, after) = surrounding_byte_deletion_ranges(&text, a, b, before_bytes, after_bytes); + if !before.is_empty() || !after.is_empty() { + let (before_caret, before_anchor) = (ed.caret, ed.anchor); + apply_surrounding_deletion(&mut text, &mut ed, before, after); + ime_before.get_or_insert((before_caret, before_anchor)); + ime_after.push(ime_stamp(&text, ed.caret, ed.anchor)); + changed = true; + } + } + egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars, after_chars }) => { + if ime_event_is_stale(app, composition_target) { + stale_batch = true; + stale_preedit_only = false; + continue; + } + let (a, b) = sel(&ed); + let before = prev_n_chars(&text, a, before_chars)..a; + let after = b..next_n_chars(&text, b, after_chars); + if !before.is_empty() || !after.is_empty() { + let (before_caret, before_anchor) = (ed.caret, ed.anchor); + apply_surrounding_deletion(&mut text, &mut ed, before, after); + ime_before.get_or_insert((before_caret, before_anchor)); + ime_after.push(ime_stamp(&text, ed.caret, ed.anchor)); + changed = true; + } + } + // ordinary typing and paste are application-origin, even in the same pass as an IME event + egui::Event::Text(s) | egui::Event::Paste(s) => { let s: String = s.chars().filter(|c| !c.is_control() || *c == '\n').collect(); if s.is_empty() { continue; @@ -1066,14 +1743,49 @@ fn text_edit(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &LayerView, mut ed: G text.insert_str(ed.caret, &s); ed.caret += s.len(); ed.anchor = ed.caret; + application_mutation = true; + changed = true; + } + // an input method (Chinese, Japanese, Korean) hands over its finished text as a commit (#1083) + egui::Event::Ime(egui::ImeEvent::Commit(s)) => { + if ime_event_is_stale(app, composition_target) { + // A commit is the composition's terminal boundary. Consume the tombstone now, + // so a preedit later in this same raw batch starts a new composition. The + // earlier empty preedit/delete already rejected the old composition's edits. + app.ime_composition = None; + stale_batch = true; + stale_preedit_only = false; + continue; + } + app.ime_composition = None; + let s: String = s.chars().filter(|c| !c.is_control() || *c == '\n').collect(); + let (before_caret, before_anchor) = (ed.caret, ed.anchor); + if s.is_empty() { + // A commit replaces the selection even when the input method produced no text + // (for example DeleteSurroundingBytes followed by an empty commit). An empty + // commit with no selection stays a no-op. + if delete_sel(&mut text, &mut ed) { + ime_before.get_or_insert((before_caret, before_anchor)); + ime_after.push(ime_stamp(&text, ed.caret, ed.anchor)); + changed = true; + } + continue; + } + delete_sel(&mut text, &mut ed); + text.insert_str(ed.caret, &s); + ed.caret += s.len(); + ed.anchor = ed.caret; + ime_before.get_or_insert((before_caret, before_anchor)); + ime_after.push(ime_stamp(&text, ed.caret, ed.anchor)); changed = true; } egui::Event::Copy | egui::Event::Cut => { let (a, b) = sel(&ed); if a < b { ui.ctx().copy_text(text[a..b].to_string()); - if matches!(ev, egui::Event::Cut) { - changed |= delete_sel(&mut text, &mut ed); + if matches!(ev, egui::Event::Cut) && delete_sel(&mut text, &mut ed) { + application_mutation = true; + changed = true; } } } @@ -1081,33 +1793,53 @@ fn text_edit(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &LayerView, mut ed: G let shift = modifiers.shift; let mut moved = None; match key { - egui::Key::Escape => stop = true, + egui::Key::Escape => { + // Escape cancels the edit now: a commit later in the same raw batch must + // not still be inserted after the user cancelled. + stop = true; + break 'events; + } egui::Key::Enter => { delete_sel(&mut text, &mut ed); text.insert(ed.caret, '\n'); ed.caret += 1; ed.anchor = ed.caret; + application_mutation = true; changed = true; } egui::Key::Backspace => { - if !delete_sel(&mut text, &mut ed) && ed.caret > 0 { + let mut did = delete_sel(&mut text, &mut ed); + if !did && ed.caret > 0 { let p = if modifiers.alt || modifiers.command { word_left(&text, ed.caret) } else { prev_boundary(&text, ed.caret) }; text.replace_range(p..ed.caret, ""); ed.caret = p; ed.anchor = p; + did = true; + } + if did { + application_mutation = true; + changed = true; } - changed = true; } egui::Key::Delete => { - if !delete_sel(&mut text, &mut ed) && ed.caret < text.len() { + let mut did = delete_sel(&mut text, &mut ed); + if !did && ed.caret < text.len() { let n = next_boundary(&text, ed.caret); text.replace_range(ed.caret..n, ""); + did = true; + } + if did { + application_mutation = true; + changed = true; } - changed = true; } egui::Key::A if modifiers.command => { + let moved = (ed.anchor, ed.caret) != (0, text.len()); ed.anchor = 0; ed.caret = text.len(); + if moved { + app.invalidate_ime_composition(); + } } egui::Key::ArrowLeft => { let (a, _) = sel(&ed); @@ -1147,17 +1879,57 @@ fn text_edit(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &LayerView, mut ed: G _ => {} } if let Some(m) = moved { + let previous_selection = (ed.caret, ed.anchor); ed.caret = m.min(text.len()); if !shift { ed.anchor = ed.caret; } + if previous_selection != (ed.caret, ed.anchor) { + app.invalidate_ime_composition(); + } } } + // A pointer press is handled later in this pass (caret placement, another layer, or + // ending the edit). Events after it in the same raw batch belong to the new target + // and must not be applied to the old one. + egui::Event::PointerButton { pressed: true, .. } => break 'events, + _ => {} + } + } + if stale_batch { + // A tentatively terminal empty preedit/delete with no following commit ended the rejected + // composition: clear the tombstone so a later composition on the replacement target is + // accepted. A lone non-empty preedit keeps it for that composition's later commit. An + // active or terminal-pending composition recorded for another target is equally stale; a + // fresh pre-edit on this target is kept. + match app.ime_composition { + Some(crate::ImeComposition::Stale) if !stale_preedit_only => app.ime_composition = None, + Some(crate::ImeComposition::Active(active) | crate::ImeComposition::TerminalPending(active)) if active != composition_target => { + app.ime_composition = None; + } _ => {} } } if changed && text != tp.text { + let action_index = actions.len(); + let first_before = + ime_before.and_then(|(cursor, anchor)| (!ime_after.is_empty()).then(|| ImeMutationStamp::from_committed(&tp.text, cursor, anchor, None))); + + *pending_ime = Some(PendingImeEdit { + action_index, + clip: ed.clip, + layer: ed.layer, + first_before, + before_cursor: ime_before.map_or(ed.caret, |(cursor, _)| cursor), + before_anchor: ime_before.map_or(ed.anchor, |(_, anchor)| anchor), + after: ime_after, + final_cursor: ed.caret, + final_anchor: ed.anchor, + application_mutation, + }); actions.push(("graphics.setText".into(), json!({"clip": ed.clip, "layer": ed.layer, "text": text, "merge": true}))); + } else { + *pending_ime = None; } if stop { end_edit(app, ui); @@ -1166,6 +1938,100 @@ fn text_edit(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &LayerView, mut ed: G } } +/// Clamp an edit caret/anchor to valid UTF-8 boundaries (hostile `ui.set.gfxEdit` offsets). +fn clamp_edit_offsets(text: &str, ed: &mut GfxEdit) { + ed.caret = char_boundary_floor(text, ed.caret); + ed.anchor = char_boundary_floor(text, ed.anchor); +} + +/// The two ranges `DeleteSurroundingBytes` deletes: whole characters fully contained in the +/// bytes before and after the sorted selection. The selection itself and any pre-edit (absent +/// from FilmCraft's committed model) are excluded. A byte count that ends inside a character +/// leaves that character whole. +fn surrounding_byte_deletion_ranges( + text: &str, + a: usize, + b: usize, + before_bytes: usize, + after_bytes: usize, +) -> (core::ops::Range, core::ops::Range) { + let a = char_boundary_floor(text, a); + let b = char_boundary_ceil(text, b).max(a); + let from = a.saturating_sub(before_bytes); + let before_chars = text[..a].char_indices().rev().take_while(|(index, _)| *index >= from).count(); + let after_chars = text[b..].char_indices().take_while(|(index, c)| index + c.len_utf8() <= after_bytes).count(); + (prev_n_chars(text, a, before_chars)..a, b..next_n_chars(text, b, after_chars)) +} + +/// Apply the two delete-surrounding ranges while preserving the selection's direction. +/// +/// Delete after the selection first, then before it, so the selection remains intact and both +/// endpoints shift only by the preceding deletion. This mirrors the stock `TextEdit`: a +/// surrounding deletion does not consume the selection, and the commit that usually follows +/// replaces it. +fn apply_surrounding_deletion(text: &mut String, ed: &mut GfxEdit, before: core::ops::Range, after: core::ops::Range) { + let before_end = before.end; + let before_len = before.len(); + if !after.is_empty() { + text.replace_range(after, ""); + } + if !before.is_empty() { + text.replace_range(before, ""); + } + let shift = |offset: usize| { + let offset = if offset >= before_end { offset - before_len } else { offset }; + offset.min(text.len()) + }; + ed.caret = shift(ed.caret); + ed.anchor = shift(ed.anchor); +} + +/// Largest char boundary not after `i`. +fn char_boundary_floor(s: &str, mut i: usize) -> usize { + i = i.min(s.len()); + while i > 0 && !s.is_char_boundary(i) { + i -= 1; + } + i +} + +/// Smallest char boundary not before `i`. +fn char_boundary_ceil(s: &str, mut i: usize) -> usize { + i = i.min(s.len()); + while i < s.len() && !s.is_char_boundary(i) { + i += 1; + } + i +} + +fn prev_n_chars(s: &str, mut i: usize, n: usize) -> usize { + i = char_boundary_floor(s, i); + for _ in 0..n { + if i == 0 { + break; + } + i = match s[..i].char_indices().next_back() { + Some((j, _)) => j, + None => 0, + }; + } + i +} + +fn next_n_chars(s: &str, mut i: usize, n: usize) -> usize { + i = char_boundary_ceil(s, i); + for _ in 0..n { + if i >= s.len() { + break; + } + i = match s[i..].char_indices().nth(1) { + Some((j, _)) => i + j, + None => s.len(), + }; + } + i +} + fn word_left(s: &str, i: usize) -> usize { let mut j = i; let b: Vec<(usize, char)> = s[..i].char_indices().collect(); @@ -1271,7 +2137,7 @@ impl Ctx<'_> { } let animated = self.e.params.get(id).is_some_and(|p| p.is_animated()); let sw = Rect::from_center_size(pos2(row.min.x + 8.0, row.center().y), vec2(12.0, 12.0)); - let resp = ui.interact(sw.expand(2.0), egui::Id::new(("gfxsw", self.clip.0, self.layer, id)), Sense::click()).on_hover_text(tl!("Toggle animation")); + let resp = ui.interact(sw.expand(2.0), egui::Id::unique(("gfxsw", self.clip.0, self.layer, id)), Sense::click()).on_hover_text(tl!("Toggle animation")); let tint = if animated { self.t.accent } else if resp.hovered() { @@ -1288,16 +2154,17 @@ impl Ctx<'_> { fn number(&mut self, ui: &mut egui::Ui, label: &str, id: &str, speed: f64, range: (f64, f64), dec: usize, unit: &str) { let v = pv(self.e, id, self.mt).as_f64().unwrap_or(0.0); let (r, mut vui) = self.row(ui, label); - let (resp, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new(("gfxp", self.clip.0, self.layer, id)), v, speed, range, dec, unit, &self.t); + let (resp, nv) = + crate::widgets::hot_number(&mut vui, label, egui::Id::unique(("gfxp", self.clip.0, self.layer, id)), v, speed, range, dec, unit, &self.t); self.auto(id, resp.rect, label); self.stopwatch(ui, r, id); if let Some(nv) = nv { self.set(id, json!(nv)); } } - fn number_in(&mut self, vui: &mut egui::Ui, id: &str, speed: f64, range: (f64, f64), dec: usize, unit: &str) { + fn number_in(&mut self, vui: &mut egui::Ui, label: &str, id: &str, speed: f64, range: (f64, f64), dec: usize, unit: &str) { let v = pv(self.e, id, self.mt).as_f64().unwrap_or(0.0); - let (resp, nv) = crate::widgets::hot_number(vui, egui::Id::new(("gfxp", self.clip.0, self.layer, id)), v, speed, range, dec, unit, &self.t); + let (resp, nv) = crate::widgets::hot_number(vui, label, egui::Id::unique(("gfxp", self.clip.0, self.layer, id)), v, speed, range, dec, unit, &self.t); self.auto(id, resp.rect, id); if let Some(nv) = nv { self.set(id, json!(nv)); @@ -1309,7 +2176,8 @@ impl Ctx<'_> { self.stopwatch(ui, r, id); let (rx, nx) = crate::widgets::hot_number( &mut vui, - egui::Id::new(("gfxp", self.clip.0, self.layer, id, "x")), + &format!("{label} X"), + egui::Id::unique(("gfxp", self.clip.0, self.layer, id, "x")), v.x, 1.0, (-100_000.0, 100_000.0), @@ -1320,7 +2188,8 @@ impl Ctx<'_> { vui.add_space(10.0); let (ry, ny) = crate::widgets::hot_number( &mut vui, - egui::Id::new(("gfxp", self.clip.0, self.layer, id, "y")), + &format!("{label} Y"), + egui::Id::unique(("gfxp", self.clip.0, self.layer, id, "y")), v.y, 1.0, (-100_000.0, 100_000.0), @@ -1367,7 +2236,8 @@ impl Ctx<'_> { ui.selectable_value(&mut sel, i, crate::i18n::t(o)); } }); - self.auto(id, r.response.rect, id); + let response = crate::widgets::accessible_name(r.response, id); + self.auto(id, response.rect, id); if sel != cur { self.set(id, json!(sel)); } @@ -1573,7 +2443,7 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.painter().text(pos2(r.min.x + 38.0, r.center().y), Align2::LEFT_CENTER, layer_display_name(e, li), Tokens::ui(12.0), t.text); // visibility let er = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(16.0, 16.0)); - let eresp = ui.interact(er, egui::Id::new(("gfx-eye", clip.0, li)), Sense::click()); + let eresp = ui.interact(er, egui::Id::unique(("gfx-eye", clip.0, li)), Sense::click()); icons::paint(ui.painter(), er.shrink(2.0), if e.enabled { Icon::Eye } else { Icon::EyeOff }, t.text_dim); autos.push((format!("graphics.layers.{li}.visible"), er, "Toggle visibility".into())); autos.push((format!("graphics.layers.{li}"), r, layer_display_name(e, li))); @@ -1648,7 +2518,7 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let (open, head) = section_header(ui, app, "Text", &t); // Text Properties: the layer type (point / paragraph) and text styling let wr = Rect::from_center_size(pos2(head.max.x - 16.0, head.center().y), vec2(22.0, 22.0)); - let wresp = ui.interact(wr, egui::Id::new(("gfx-text-props", clip.0, l)), Sense::click()); + let wresp = ui.interact(wr, egui::Id::unique(("gfx-text-props", clip.0, l)), Sense::click()); icons::paint(ui.painter(), wr.shrink(4.0), Icon::Wrench, if wresp.hovered() { t.text } else { t.text_dim }); cx.autos.push(("graphics.textProperties".into(), wr, "Text Properties".into())); if wresp.on_hover_text(tl!("Text Properties")).clicked() { @@ -1674,7 +2544,10 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let before = s.clone(); ui.horizontal(|ui| { ui.add_space(14.0); - let r = ui.add(egui::TextEdit::multiline(&mut s).desired_rows(2).desired_width(ui.available_width() - 8.0).font(Tokens::ui(12.0))); + let r = crate::widgets::accessible_name( + ui.add(egui::TextEdit::multiline(&mut s).desired_rows(2).desired_width(ui.available_width() - 8.0).font(Tokens::ui(12.0))), + "Source Text", + ); cx.autos.push(("graphics.sourceText".into(), r.rect, "Source Text".into())); }); if s != before { @@ -1700,7 +2573,8 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } } }); - cx.auto("font", r.response.rect, "Font"); + let response = crate::widgets::accessible_name(r.response, "Font"); + cx.auto("font", response.rect, "Font"); let styles: Vec = fams.iter().find(|(f, _)| f.eq_ignore_ascii_case(&family)).map(|(_, s)| s.clone()).unwrap_or_default(); let (_, mut vui) = cx.row(ui, tl!("Font Style")); let r = egui::ComboBox::from_id_salt(("gfx-style", clip.0, l)).selected_text(&style).width(170.0).show_ui(&mut vui, |ui| { @@ -1720,7 +2594,8 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } } }); - cx.auto("font_style", r.response.rect, "Font Style"); + let response = crate::widgets::accessible_name(r.response, "Font Style"); + cx.auto("font_style", response.rect, "Font Style"); cx.number(ui, tl!("Font Size"), "size", 0.5, (1.0, 2000.0), 0, ""); let align = match pv(e, "align", mt) { ParamValue::Choice(c) => c, @@ -1815,7 +2690,7 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { cx.check(&mut vui, on, ""); cx.color(&mut vui, col); vui.add_space(6.0); - cx.number_in(&mut vui, w, 0.2, (0.0, 1000.0), 1, ""); + cx.number_in(&mut vui, &format!("{label} width"), w, 0.2, (0.0, 1000.0), 1, ""); vui.add_space(6.0); cx.choice(&mut vui, kind, graphic::STROKE_OPTS, 70.0); } @@ -1845,7 +2720,7 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add(&id, r, &label); } let (down, press) = ui.ctx().input(|i| (i.pointer.any_down(), i.pointer.press_start_time())); - let key = egui::Id::new("gfx-props-drag-step"); + let key = egui::Id::unique("gfx-props-drag-step"); let begun = ui.ctx().data(|d| d.get_temp::>(key)).flatten(); let mut wrote = false; for (cmd, mut p) in actions { @@ -1874,7 +2749,7 @@ pub fn dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut close = crate::widgets::escape_closes(ctx); let mut ok = false; let name = |paragraph: bool| if paragraph { tl!("Paragraph Text") } else { tl!("Point Text") }; - let id = egui::Id::new("gfx-text-properties"); + let id = egui::Id::unique("gfx-text-properties"); egui::Window::new(tl!("Text Properties")).id(id).collapsible(false).resizable(false).centered(ctx).show(ctx, |ui| { ui.label(tl!("Text Layer Type")); let r = egui::ComboBox::from_id_salt("gfx-text-type").selected_text(name(d.paragraph)).width(190.0).show_ui(ui, |ui| { @@ -1926,3 +2801,101 @@ pub fn dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) { } app.ui.text_props_dialog = if ok || close { None } else { Some(d) }; } + +#[cfg(test)] +mod ime_byte_delete_tests { + use super::{ + GfxEdit, apply_surrounding_deletion, char_boundary_ceil, char_boundary_floor, clamp_edit_offsets, graphics_ime_target, next_n_chars, prev_n_chars, + surrounding_byte_deletion_ranges, + }; + + #[test] + fn byte_boundaries_never_split_a_code_point() { + let s = "aé😀z"; // 1 + 2 + 4 + 1 bytes + assert_eq!(char_boundary_floor(s, 2), 1); + assert_eq!(char_boundary_ceil(s, 2), 3); + assert_eq!(char_boundary_floor(s, 4), 3); + assert_eq!(char_boundary_ceil(s, 4), 7); + assert_eq!(char_boundary_floor(s, 999), s.len()); + } + + #[test] + fn deleting_surrounding_bytes_leaves_partial_characters() { + let s = "aé😀z"; // a=1, é=1..3, 😀=3..7, z=7..8 + // A cursor after "aé" (byte 3): one byte of "é" is not a whole character, so nothing is + // deleted; two bytes are exactly "é". + assert_eq!(surrounding_byte_deletion_ranges(s, 3, 3, 1, 0), (3..3, 3..3)); + assert_eq!(surrounding_byte_deletion_ranges(s, 3, 3, 2, 0), (1..3, 3..3)); + assert_eq!(&s[1..3], "é"); + // After the cursor: the 4-byte emoji is only deleted when all four bytes are reported. + for partial in 1..4 { + assert_eq!(surrounding_byte_deletion_ranges(s, 3, 3, 0, partial), (3..3, 3..3), "after_bytes={partial}"); + } + assert_eq!(surrounding_byte_deletion_ranges(s, 3, 3, 0, 4), (3..3, 3..7)); + // The selection itself is excluded; only its surroundings are deleted. + assert_eq!(surrounding_byte_deletion_ranges(s, 1, 3, 0, 0), (1..1, 3..3)); + assert_eq!(surrounding_byte_deletion_ranges(s, 1, 3, 1, 4), (0..1, 3..7)); + } + + #[test] + fn surrounding_deletion_keeps_reversed_selection_direction() { + let mut text = "aé😀z".to_string(); + let mut ed = GfxEdit { clip: 1, layer: 0, caret: 3, anchor: 1, ..Default::default() }; + let (before, after) = surrounding_byte_deletion_ranges(&text, 1, 3, 1, 4); + apply_surrounding_deletion(&mut text, &mut ed, before, after); + assert_eq!(text, "éz"); + assert_eq!((ed.caret, ed.anchor), (2, 0), "primary caret stays after the anchor"); + } + + #[test] + fn hostile_edit_offsets_are_clamped_to_char_boundaries() { + let s = "é😀"; + let mut ed = GfxEdit { clip: 1, layer: 0, caret: 1, anchor: 3, ..Default::default() }; + clamp_edit_offsets(s, &mut ed); + assert_eq!((ed.caret, ed.anchor), (0, 2), "offsets inside the two characters move to boundaries"); + assert_eq!(s.get(ed.caret..ed.anchor), Some("é")); + } + + #[test] + fn character_deletion_is_bounded() { + let s = "aé😀z".to_string(); + assert_eq!(prev_n_chars(&s, s.len(), 99), 0); + assert_eq!(next_n_chars(&s, 0, 2), "aé".len()); + assert_eq!(prev_n_chars(&s, 5, 1), 1); + assert_eq!(next_n_chars(&s, 1, 1), 3); + } + + #[test] + fn tracker_state_is_scoped_per_viewport_and_closed_viewports_are_dropped() { + use egui::ViewportId; + use egui::ime_context::{CommittedText, ImeContextTracker, ImeObservation}; + + let mut tracker = ImeContextTracker::new(); + let target = graphics_ime_target(7, 11, 3, 42, 5); + let first_viewport = ViewportId::ROOT; + let second_viewport = ViewportId::from_hash_of("filmcraft-ime-second-viewport"); + let observe = |tracker: &mut ImeContextTracker, viewport| { + tracker.observe(viewport, Some(ImeObservation::new(0, 0, target).committed(Some(CommittedText::new("one", 3, 3))))) + }; + let first = observe(&mut tracker, first_viewport).expect("first viewport is observed"); + let second = observe(&mut tracker, second_viewport).expect("second viewport is observed"); + assert_ne!(first.context_generation, second.context_generation, "each viewport owns its generation"); + let repeated = observe(&mut tracker, first_viewport).expect("first viewport remains observed"); + assert_eq!(repeated.context_generation, first.context_generation, "a second viewport does not disturb the first"); + tracker.end_viewport(second_viewport); + let reopened = observe(&mut tracker, second_viewport).expect("the viewport can be observed again"); + assert_ne!(reopened.context_generation, second.context_generation, "closed viewport state is dropped"); + } + + #[test] + fn ime_owner_uses_stable_component_identity_and_explicit_replacement() { + let a = graphics_ime_target(7, 11, 3, 42, 5); + let b = graphics_ime_target(7, 11, 9, 42, 5); + assert_eq!(a, b, "the stable layer uid, not the mutable layer index, is the component"); + assert_ne!(a, graphics_ime_target(7, 11, 3, 42, 6), "replacement epoch is explicit"); + assert_ne!(a, graphics_ime_target(7, 11, 3, 43, 5), "a different component is a different owner"); + assert_ne!(a, graphics_ime_target(8, 11, 3, 42, 5), "sequence identity is part of the owner"); + let no_uid = graphics_ime_target(7, 11, 3, 0, 5); + assert_ne!(no_uid, graphics_ime_target(7, 11, 4, 0, 5), "without a layer uid, the index is the identity"); + } +} diff --git a/crates/ui-egui/src/panels/graphics_templates.rs b/crates/ui-egui/src/panels/graphics_templates.rs index 0aafa2eec..483658fdd 100644 --- a/crates/ui-egui/src/panels/graphics_templates.rs +++ b/crates/ui-egui/src/panels/graphics_templates.rs @@ -86,7 +86,7 @@ type Elems = Vec<(String, Rect, String)>; struct Lib(Arc<(f64, Vec)>); fn lib_id() -> egui::Id { - egui::Id::new("gfx-template-library") + egui::Id::unique("gfx-template-library") } /// The template library, re-read at most every two seconds (or after [`invalidate`]). @@ -110,7 +110,7 @@ const THUMB_W: u32 = 192; fn thumb_texture(ctx: &egui::Context, e: &LibraryEntry) -> egui::TextureHandle { let px = thumbnail_cached(&e.template, THUMB_W); - let key = egui::Id::new(("gfx-thumb", e.template.id.as_str(), Arc::as_ptr(&px) as usize)); + let key = egui::Id::unique(("gfx-thumb", e.template.id.as_str(), Arc::as_ptr(&px) as usize)); if let Some(t) = ctx.data(|d| d.get_temp::(key)) { return t; } @@ -125,7 +125,7 @@ fn thumb_texture(ctx: &egui::Context, e: &LibraryEntry) -> egui::TextureHandle { // ------------------------------------------------------------------------------------------------ fn tab_button(ui: &mut egui::Ui, r: Rect, label: &str, on: bool, t: &Tokens) -> egui::Response { - let resp = ui.interact(r, egui::Id::new(("eg-tab", label)), Sense::click()); + let resp = ui.interact(r, egui::Id::unique(("eg-tab", label)), Sense::click()); if on { ui.painter().line_segment([pos2(r.min.x + 6.0, r.max.y - 1.5), pos2(r.max.x - 6.0, r.max.y - 1.5)], Stroke::new(2.0, t.accent)); } @@ -177,7 +177,10 @@ fn browse(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { b.set_clip_rect(rect); let st = &mut app.ui.gfx_templates; b.horizontal(|ui| { - let r = ui.add(egui::TextEdit::singleline(&mut st.query).hint_text(tl!("Search templates")).desired_width((ui.available_width() - 230.0).max(110.0))); + let r = crate::widgets::accessible_name( + ui.add(egui::TextEdit::singleline(&mut st.query).hint_text(tl!("Search templates")).desired_width((ui.available_width() - 230.0).max(110.0))), + "Search templates", + ); elems.push(("gfxTemplates.search".into(), r.rect, "Search".into())); let cur = if st.category.is_empty() { tl!("All Categories").to_string() } else { crate::i18n::t(&st.category).to_string() }; let r = egui::ComboBox::from_id_salt("gfx-cat").selected_text(cur).width(130.0).show_ui(ui, |ui| { @@ -186,7 +189,8 @@ fn browse(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.selectable_value(&mut st.category, c.clone(), crate::i18n::t(c)); } }); - elems.push(("gfxTemplates.category".into(), r.response.rect, "Category".into())); + let r = crate::widgets::accessible_name(r.response, "Category"); + elems.push(("gfxTemplates.category".into(), r.rect, "Category".into())); let r = ui.button(tl!("Install…")).on_hover_text(tl!("Install Motion Graphics Template (.fcgt)")); elems.push(("gfxTemplates.install".into(), r.rect, "Install".into())); install = r.clicked(); @@ -360,7 +364,10 @@ pub fn template_controls( let r = ui .horizontal(|ui| { ui.add_space(14.0); - ui.add(egui::TextEdit::multiline(&mut s).desired_rows(rows).desired_width(ui.available_width() - 8.0)) + crate::widgets::accessible_name( + ui.add(egui::TextEdit::multiline(&mut s).desired_rows(rows).desired_width(ui.available_width() - 8.0)), + c.name.clone(), + ) }) .inner; autos.push((id, r.rect, c.name.clone())); @@ -387,7 +394,7 @@ pub fn template_controls( _ => String::new(), }; let before = s.clone(); - let r = vui.add(egui::TextEdit::singleline(&mut s).desired_width(vui.available_width() - 4.0)); + let r = crate::widgets::accessible_name(vui.add(egui::TextEdit::singleline(&mut s).desired_width(vui.available_width() - 4.0)), c.name.clone()); autos.push((id, r.rect, c.name.clone())); if s != before { set(json!(s), actions); @@ -647,7 +654,7 @@ fn export_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut elems: Elems = Vec::new(); let mut action: Option = None; egui::Window::new(tl!("Export As Motion Graphics Template")) - .id(egui::Id::new("Export As Motion Graphics Template")) + .id(egui::Id::unique("Export As Motion Graphics Template")) .collapsible(false) .resizable(false) .default_width(420.0) @@ -655,15 +662,15 @@ fn export_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { .show(ctx, |ui| { egui::Grid::new("gfx-export-grid").num_columns(2).show(ui, |ui| { ui.label(tl!("Name:")); - let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(260.0)); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(260.0)), "Name"); elems.push(("exportTemplate.name".into(), r.rect, "Name".into())); ui.end_row(); ui.label(tl!("Category:")); - let r = ui.add(egui::TextEdit::singleline(&mut d.category).desired_width(260.0)); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut d.category).desired_width(260.0)), "Category"); elems.push(("exportTemplate.category".into(), r.rect, "Category".into())); ui.end_row(); ui.label(tl!("Description:")); - let r = ui.add(egui::TextEdit::singleline(&mut d.description).desired_width(260.0)); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut d.description).desired_width(260.0)), "Description"); elems.push(("exportTemplate.description".into(), r.rect, "Description".into())); ui.end_row(); }); @@ -674,7 +681,10 @@ fn export_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { ui.horizontal(|ui| { let r = ui.checkbox(&mut c.on, ""); elems.push((format!("exportTemplate.control.{n}"), r.rect, c.name.clone())); - ui.add(egui::TextEdit::singleline(&mut c.name).desired_width(220.0)); + crate::widgets::accessible_name( + ui.add(egui::TextEdit::singleline(&mut c.name).desired_width(220.0)), + format!("{} property name", c.param), + ); ui.label(egui::RichText::new(c.param.replace('_', " ")).weak()); }); } @@ -726,7 +736,7 @@ fn replace_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut elems: Elems = Vec::new(); let mut action: Option = None; egui::Window::new(tl!("Replace Fonts in Projects")) - .id(egui::Id::new("Replace Fonts in Projects")) + .id(egui::Id::unique("Replace Fonts in Projects")) .collapsible(false) .resizable(false) .default_width(440.0) diff --git a/crates/ui-egui/src/panels/import_mode.rs b/crates/ui-egui/src/panels/import_mode.rs index 2af12cada..8f0e42714 100644 --- a/crates/ui-egui/src/panels/import_mode.rs +++ b/crates/ui-egui/src/panels/import_mode.rs @@ -28,7 +28,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let cw = 260.0; for (i, (title, cmd, sub)) in cards.iter().enumerate() { let r = Rect::from_min_size(rect.min + vec2(24.0 + i as f32 * (cw + 16.0), 90.0), vec2(cw, 150.0)); - let resp = ui.interact(r, egui::Id::new(("imp", *cmd)), Sense::click()); + let resp = ui.interact(r, egui::Id::unique(("imp", *cmd)), Sense::click()); app.auto.add(&format!("import.{cmd}"), r, title); ui.painter().rect_filled(r, 8.0, if resp.hovered() { t.hover } else { t.tl_header_bg }); ui.painter().text(r.min + vec2(16.0, 110.0), Align2::LEFT_CENTER, *title, Tokens::semibold(14.0), t.text); @@ -56,7 +56,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let label = if primary { tl!("Join us on Discord") } else { crate::i18n::t(label) }; let g = ui.painter().layout_no_wrap(label.to_string(), Tokens::ui(13.0), t.text); let r = Rect::from_min_size(pos2(x, rect.min.y + y + 18.0), vec2(g.size().x + 44.0, 34.0)); - let resp = ui.interact(r, egui::Id::new(("imp-link", *id)), Sense::click()).on_hover_text(*url); + let resp = ui.interact(r, egui::Id::unique(("imp-link", *id)), Sense::click()).on_hover_text(*url); app.auto.add(&format!("import.link.{}", id.trim_start_matches("help.")), r, label); let (bg, fg) = if primary { (if resp.hovered() { t.accent_hover } else { t.accent }, Color32::WHITE) @@ -92,7 +92,7 @@ fn recent(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, y: f32) -> f32 let row = Rect::from_min_size(rect.min + vec2(16.0, y + 16.0 + i as f32 * RECENT_ROW_H), vec2(560.0_f32.min(rect.width() - 32.0).max(0.0), RECENT_ROW_H)); let name = crate::menus::recent_name(path); - let resp = ui.interact(row, egui::Id::new(("imp-recent", i)), Sense::click()).on_hover_text(path.as_str()); + let resp = ui.interact(row, egui::Id::unique(("imp-recent", i)), Sense::click()).on_hover_text(path.as_str()); app.auto.add(&format!("import.recent.{i}"), row, &name); if resp.hovered() { ui.painter().rect_filled(row, 4.0, t.hover); diff --git a/crates/ui-egui/src/panels/interchange_export.rs b/crates/ui-egui/src/panels/interchange_export.rs index 5095d6813..c83e55a6a 100644 --- a/crates/ui-egui/src/panels/interchange_export.rs +++ b/crates/ui-egui/src/panels/interchange_export.rs @@ -29,7 +29,7 @@ struct Draft { } fn draft_id() -> egui::Id { - egui::Id::new("interchange-export-draft") + egui::Id::unique("interchange-export-draft") } const RATES: [u32; 4] = [32_000, 44_100, 48_000, 96_000]; @@ -64,14 +64,14 @@ pub fn is_open(ctx: &egui::Context) -> bool { pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some(Some(mut d)) = ctx.data(|m| m.get_temp::>(draft_id())) else { return }; - crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("interchange-export-before-drag"), &mut d); + crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("interchange-export-before-drag"), &mut d); let p = if d.omf { "omfExport" } else { "aafExport" }; let mut close = false; let mut apply = false; let mut elems: Vec<(String, egui::Rect, String)> = Vec::new(); let title = if d.omf { "OMF Export Settings" } else { "AAF Export Settings" }; let shown = if d.omf { tl!("OMF Export Settings") } else { tl!("AAF Export Settings") }; - egui::Window::new(shown).id(egui::Id::new(title)).collapsible(false).resizable(false).centered(ctx).show(ctx, |ui| { + egui::Window::new(shown).id(egui::Id::unique(title)).collapsible(false).resizable(false).centered(ctx).show(ctx, |ui| { ui.set_min_width(380.0); egui::Grid::new("interchange-export-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { if d.omf { diff --git a/crates/ui-egui/src/panels/keyboard.rs b/crates/ui-egui/src/panels/keyboard.rs index bdf744196..3e72ad2f7 100644 --- a/crates/ui-egui/src/panels/keyboard.rs +++ b/crates/ui-egui/src/panels/keyboard.rs @@ -278,7 +278,7 @@ fn focus_find_box(app: &mut FilmcraftApp, ctx: &egui::Context) -> Value { let p = app.ui.focused; let rect = app.auto.find(&format!("panel.{}", p.id())).map(|e| egui::Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))); - ctx.data_mut(|d| d.insert_temp(egui::Id::new(FOCUS_SEARCH), rect.unwrap_or(egui::Rect::EVERYTHING))); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique(FOCUS_SEARCH), rect.unwrap_or(egui::Rect::EVERYTHING))); json!({"panel": p.title()}) } @@ -287,7 +287,7 @@ pub const FOCUS_SEARCH: &str = "fc-focus-search"; /// Called by search fields: take focus when Select Find Box targets the panel they sit in. pub fn take_search_focus(ui: &egui::Ui, field: egui::Rect, resp: &egui::Response) { - let id = egui::Id::new(FOCUS_SEARCH); + let id = egui::Id::unique(FOCUS_SEARCH); let Some(panel) = ui.ctx().data(|d| d.get_temp::(id)) else { return }; if panel.contains(field.center()) { resp.request_focus(); @@ -355,7 +355,8 @@ fn begin_text_editing(app: &mut FilmcraftApp) -> Result { let len = pick["text"].as_str().map_or(0, str::len); app.session.execute("graphics.selectLayer", json!({"clip": clip, "layers": [layer]})).map_err(|e| e.to_string())?; // like Premiere, the whole text is selected so typing replaces it - app.ui.gfx_edit = Some(crate::state::GfxEdit { clip, layer, caret: len, anchor: 0 }); + app.restart_ime_target(); + app.ui.gfx_edit = Some(crate::state::GfxEdit { clip, layer, caret: len, anchor: 0, ..Default::default() }); app.show_panel(PanelKind::Program); Ok(json!({"clip": clip, "layer": layer})) } diff --git a/crates/ui-egui/src/panels/lumetri.rs b/crates/ui-egui/src/panels/lumetri.rs index 084d7d81a..f7591e2ff 100644 --- a/crates/ui-egui/src/panels/lumetri.rs +++ b/crates/ui-egui/src/panels/lumetri.rs @@ -127,7 +127,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } if open_section(ui, app, &e, "Curves", "curves_on", "curves", &mut sections) { sub(ui, &t, tl!("RGB Curves")); - let key = egui::Id::new("lumetri-curve-channel"); + let key = egui::Id::unique("lumetri-curve-channel"); let mut ch: usize = ui.data(|d| d.get_temp(key)).unwrap_or(0); ui.horizontal(|ui| { for (i, c) in @@ -345,7 +345,7 @@ fn section(ui: &mut egui::Ui, app: &mut FilmcraftApp, name: &str, on: bool) -> ( ui.painter().text(pos2(r.min.x + 20.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(name), Tokens::semibold(13.0), t.text); // enable switch (section bypass) let sw = Rect::from_center_size(pos2(r.max.x - 18.0, r.center().y), vec2(26.0, 14.0)); - let sw_resp = ui.interact(sw.expand(3.0), egui::Id::new(("lumetri-switch", name)), Sense::click()); + let sw_resp = ui.interact(sw.expand(3.0), egui::Id::unique(("lumetri-switch", name)), Sense::click()); ui.painter().rect_filled(sw, 7.0, if on { t.accent } else { Color32::from_rgb(0x55, 0x55, 0x55) }); ui.painter().circle_filled(pos2(if on { sw.max.x - 7.0 } else { sw.min.x + 7.0 }, sw.center().y), 5.0, Color32::WHITE); app.auto.add(&format!("lumetri.switch.{name}"), sw.expand(3.0), &format!("{name} on/off")); @@ -421,7 +421,7 @@ fn slider( let f = ((v - soft_min) / (soft_max - soft_min)).clamp(0.0, 1.0) as f32; let kx = track.min.x + f * track.width(); let hit = Rect::from_min_max(pos2(track.min.x - 6.0, row.min.y), pos2(track.max.x + 6.0, row.max.y)); - let resp = ui.interact(hit, egui::Id::new(("lslider", clip.0, id)), Sense::click_and_drag()); + let resp = ui.interact(hit, egui::Id::unique(("lslider", clip.0, id)), Sense::click_and_drag()); ui.painter().circle_filled(pos2(kx, track.center().y), 5.0, t.panel_bg); ui.painter().circle_stroke( pos2(kx, track.center().y), @@ -443,8 +443,17 @@ fn slider( .max_rect(Rect::from_min_max(pos2(row.max.x - 52.0, row.min.y + 3.0), pos2(row.max.x, row.max.y - 3.0))) .layout(egui::Layout::right_to_left(egui::Align::Center)), ); - let (_, nv) = - crate::widgets::hot_number(&mut vui, egui::Id::new(("lval", clip.0, id)), v, (soft_max - soft_min) / 300.0, (min, max), decimals as usize, "", t); + let (_, nv) = crate::widgets::hot_number( + &mut vui, + label, + egui::Id::unique(("lval", clip.0, id)), + v, + (soft_max - soft_min) / 300.0, + (min, max), + decimals as usize, + "", + t, + ); if let Some(nv) = out.or(nv) { actions.push(json!({"clip": clip.0, "effect": idx, "param": id, "value": (nv * 10f64.powi(decimals as i32)).round() / 10f64.powi(decimals as i32)})); } @@ -510,7 +519,7 @@ fn curve_editor(ui: &mut egui::Ui, e: &EffectInstance, id: &str, hue: bool, col: }; let line: Vec = lut.iter().enumerate().map(|(i, y)| to_screen([i as f32 / 127.0, y.clamp(0.0, 1.0)])).collect(); p.add(egui::Shape::line(line, Stroke::new(1.5, col))); - let drag_key = egui::Id::new(("curve-drag", clip.0, id)); + let drag_key = egui::Id::unique(("curve-drag", clip.0, id)); let mut dragging: Option = ui.data(|d| d.get_temp(drag_key)); let mut new_pts = pts.clone(); let mut changed = false; @@ -612,7 +621,7 @@ fn color_wheel(ui: &mut egui::Ui, e: &EffectInstance, mt: Tick, id: &str, lid: & p.rect_filled(track, 2.0, Color32::from_rgb(0x4b, 0x4b, 0x4b)); let ky = track.center().y - l / 100.0 * track.height() / 2.0; p.circle_stroke(pos2(track.center().x, ky), 4.0, Stroke::new(1.5, Color32::from_rgb(0xb0, 0xb0, 0xb0))); - let lr = ui.interact(track.expand2(vec2(6.0, 0.0)), egui::Id::new(("wl", clip.0, lid)), Sense::click_and_drag()); + let lr = ui.interact(track.expand2(vec2(6.0, 0.0)), egui::Id::unique(("wl", clip.0, lid)), Sense::click_and_drag()); if (lr.dragged() || lr.clicked()) && let Some(pos) = lr.interact_pointer_pos() { @@ -627,8 +636,8 @@ fn color_wheel(ui: &mut egui::Ui, e: &EffectInstance, mt: Tick, id: &str, lid: & fn match_controls(ui: &mut egui::Ui, app: &mut FilmcraftApp, out: &mut Vec<(&'static str, Value)>) { let t = app.tokens; sub(ui, &t, tl!("Color Match")); - let key = egui::Id::new("lumetri-match-ref"); - let fd_key = egui::Id::new("lumetri-match-face"); + let key = egui::Id::unique("lumetri-match-ref"); + let fd_key = egui::Id::unique("lumetri-match-face"); let mut tc: String = ui.data(|d| d.get_temp(key)).unwrap_or_else(|| "00:00:00:00".to_string()); let mut face: bool = ui.data(|d| d.get_temp(fd_key)).unwrap_or(true); // Comparison View: the Program monitor shows the reference frame next to the current one, and @@ -645,7 +654,7 @@ fn match_controls(ui: &mut egui::Ui, app: &mut FilmcraftApp, out: &mut Vec<(&'st } ui.horizontal(|ui| { ui.add_sized(vec2(110.0, 20.0), egui::Label::new(egui::RichText::new(tl!("Reference")).color(t.text_dim))); - let r = ui.add(egui::TextEdit::singleline(&mut tc).desired_width(96.0)); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut tc).desired_width(96.0)), "Reference timecode"); app.auto.add("lumetri.match.reference", r.rect, "Reference timecode"); }); let r = ui.checkbox(&mut face, tl!("Face Detection (protect skin tones)")); diff --git a/crates/ui-egui/src/panels/masks.rs b/crates/ui-egui/src/panels/masks.rs index a25e93643..299852c23 100644 --- a/crates/ui-egui/src/panels/masks.rs +++ b/crates/ui-egui/src/panels/masks.rs @@ -57,7 +57,7 @@ pub(crate) fn effect_rows( .enumerate() { let br = Rect::from_center_size(pos2(r.min.x + 40.0 + i as f32 * 24.0, r.center().y), vec2(20.0, 18.0)); - let resp = ui.interact(br, egui::Id::new(("mask-new", clip.0, idx, kind)), Sense::click()).on_hover_text(tip); + let resp = ui.interact(br, egui::Id::unique(("mask-new", clip.0, idx, kind)), Sense::click()).on_hover_text(tip); if resp.hovered() { ui.painter().rect_filled(br, 3.0, t.hover); } @@ -90,7 +90,7 @@ pub(crate) fn effect_rows( crate::panels::effect_controls::row_line(ui, r, lane, &t); let tw = Rect::from_center_size(pos2(r.min.x + 26.0, r.center().y), vec2(10.0, 10.0)); icons::paint(ui.painter(), tw, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim); - let twresp = ui.interact(tw.expand(3.0), egui::Id::new(("mask-twirl", clip.0, idx, k)), Sense::click()); + let twresp = ui.interact(tw.expand(3.0), egui::Id::unique(("mask-twirl", clip.0, idx, k)), Sense::click()); // shape glyph: ellipse for smooth paths, square for polygons let path = m.path_at(mt); let glyph = Rect::from_center_size(pos2(r.min.x + 42.0, r.center().y), vec2(12.0, 12.0)); @@ -118,13 +118,15 @@ pub(crate) fn effect_rows( let mut mui = ui.new_child(egui::UiBuilder::new().max_rect(mr).layout(egui::Layout::left_to_right(egui::Align::Center))); let mut mode = m.mode; crate::panels::effect_controls::fit_to_row(&mut mui); - egui::ComboBox::from_id_salt(("mask-mode", clip.0, idx, k)).selected_text(crate::i18n::t(mode.label())).width(88.0).show_ui(&mut mui, |ui| { - for md in MaskMode::ALL { - if ui.selectable_value(&mut mode, md, crate::i18n::t(md.label())).changed() { - actions.push(("masks.set".into(), json!({"clip": clip.0, "effect": idx, "mask": k, "mode": md.label()}))); + let combo = + egui::ComboBox::from_id_salt(("mask-mode", clip.0, idx, k)).selected_text(crate::i18n::t(mode.label())).width(88.0).show_ui(&mut mui, |ui| { + for md in MaskMode::ALL { + if ui.selectable_value(&mut mode, md, crate::i18n::t(md.label())).changed() { + actions.push(("masks.set".into(), json!({"clip": clip.0, "effect": idx, "mask": k, "mode": md.label()}))); + } } - } - }); + }); + crate::widgets::accessible_name(combo.response, "Mask mode"); app.auto.add(&format!("{base}.mode"), mr, "Mask mode"); if twresp.clicked() { if open { @@ -166,7 +168,7 @@ pub(crate) fn effect_rows( let mut cui = ui.new_child(egui::UiBuilder::new().max_rect(cr).layout(egui::Layout::left_to_right(egui::Align::Center))); crate::panels::effect_controls::fit_to_row(&mut cui); let mut inv = m.inverted; - if cui.checkbox(&mut inv, "").changed() { + if crate::widgets::accessible_name(cui.checkbox(&mut inv, ""), "Invert").changed() { actions.push(("masks.set".into(), json!({"clip": clip.0, "effect": idx, "mask": k, "inverted": inv}))); } app.auto.add(&format!("{base}.inverted"), cr, "Inverted"); @@ -286,7 +288,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra } v }; - let gesture_id = egui::Id::new("mask-gesture"); + let gesture_id = egui::Id::unique("mask-gesture"); // one gesture (undo step) per pointer press let mut gesture: u64 = ui.data(|d| d.get_temp(gesture_id)).unwrap_or(0); if ui.input(|i| i.pointer.any_pressed()) { @@ -299,7 +301,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra }; // ---- whole mask (drag inside) let bb = Rect::from_points(&screen).expand(2.0).intersect(pic); - let body = ui.interact(bb, egui::Id::new("mask-body"), Sense::click_and_drag()); + let body = ui.interact(bb, egui::Id::unique("mask-body"), Sense::click_and_drag()); app.auto.add("program.mask.body", bb, &m.name); let ptr_in = |p: Option| p.is_some_and(|p| inside(&flat, to_clip.apply(Vec2::new(p.x as f64, p.y as f64)))); if body.drag_started_by(LEFT) { @@ -307,7 +309,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra ui.data_mut(|d| d.insert_temp(gesture_id.with("body"), ok)); } if body.dragged_by(LEFT) && ui.data(|d| d.get_temp::(gesture_id.with("body"))).unwrap_or(false) && body.drag_delta() != egui::Vec2::ZERO { - let d = drag_step(ui, egui::Id::new(("mask-body-drag", gesture)), body.drag_delta()); + let d = drag_step(ui, egui::Id::unique(("mask-body-drag", gesture)), body.drag_delta()); if d != egui::Vec2::ZERO { actions.push(("masks.translate".into(), with(json!({"delta": clip_delta(d), "merge": format!("body-{gesture}")})))); } @@ -345,7 +347,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra Stroke::NONE, )); } - let r = ui.interact(hr.expand(3.0), egui::Id::new(("mask-handle", which)), Sense::drag()); + let r = ui.interact(hr.expand(3.0), egui::Id::unique(("mask-handle", which)), Sense::drag()); app.auto.add(&format!("program.mask.{which}"), hr, if which == "feather" { "Mask Feather" } else { "Mask Expansion" }); if r.dragged_by(LEFT) && r.drag_delta() != egui::Vec2::ZERO { let d = clip_delta(r.drag_delta()); @@ -359,7 +361,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra // ---- corner roundness (masks with sharp corners: rectangles, polygons) actions.extend(roundness_handle(app, ui, &painter, &path, &to_screen, &target)); // ---- tangent handles and vertices - let sel_v_id = egui::Id::new(("mask-sel-vertex", sel.clip.0, sel.effect, sel.mask)); + let sel_v_id = egui::Id::unique(("mask-sel-vertex", sel.clip.0, sel.effect, sel.mask)); let sel_v: Option = ui.data(|d| d.get_temp(sel_v_id)); let alt = ui.input(|i| i.modifiers.alt); let cmd = ui.input(|i| i.modifiers.command); @@ -374,7 +376,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra painter.circle_filled(h, 3.5, Color32::WHITE); painter.circle_stroke(h, 3.5, Stroke::new(1.0, col)); let hr = Rect::from_center_size(h, vec2(8.0, 8.0)); - let r = ui.interact(hr.expand(2.0), egui::Id::new(("mask-tan", which, i)), Sense::drag()); + let r = ui.interact(hr.expand(2.0), egui::Id::unique(("mask-tan", which, i)), Sense::drag()); app.auto.add(&format!("program.mask.{which}.{i}"), hr, "Bezier handle"); if r.dragged_by(LEFT) && r.drag_delta() != egui::Vec2::ZERO { actions.push(( @@ -390,7 +392,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra let selected = sel_v == Some(i); painter.rect_filled(vr, 0.0, if selected { col } else { Color32::WHITE }); painter.rect_stroke(vr, 0.0, Stroke::new(1.0, col), egui::StrokeKind::Middle); - let r = ui.interact(vr.expand(3.0), egui::Id::new(("mask-vertex", i)), Sense::click_and_drag()).on_hover_text(if cfg!(target_os = "macos") { + let r = ui.interact(vr.expand(3.0), egui::Id::unique(("mask-vertex", i)), Sense::click_and_drag()).on_hover_text(if cfg!(target_os = "macos") { tl!("Drag: move · Alt-click: smooth/corner · Cmd-click: delete") } else { tl!("Drag: move · Alt-click: smooth/corner · Ctrl-click: delete") @@ -400,7 +402,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra ui.data_mut(|d| d.insert_temp(sel_v_id, i)); } if r.dragged_by(LEFT) && r.drag_delta() != egui::Vec2::ZERO { - let d = drag_step(ui, egui::Id::new(("mask-vertex-drag", gesture, i)), r.drag_delta()); + let d = drag_step(ui, egui::Id::unique(("mask-vertex-drag", gesture, i)), r.drag_delta()); if d != egui::Vec2::ZERO { actions.push(("masks.moveVertex".into(), with(json!({"vertex": i, "delta": clip_delta(d), "merge": format!("vertex-{gesture}")})))); } @@ -522,7 +524,7 @@ fn roundness_handle( (pa.x + pa.y).total_cmp(&(pb.x + pb.y)) })?; let max_radius = corners.iter().map(|c| c.max_radius).fold(0.0, f64::max); - let id = egui::Id::new("mask-roundness"); + let id = egui::Id::unique("mask-roundness"); let dist_id = id.with("dist"); let dragging = ui.ctx().is_being_dragged(id); let dist: f64 = if dragging { ui.data(|d| d.get_temp(dist_id)).unwrap_or(0.0) } else { 0.0 }; @@ -615,7 +617,7 @@ fn pen_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32 let Some(to_screen) = clip_to_screen(app, &it, mt, pic, frame) else { return }; let Some(to_clip) = to_screen.inverse() else { return }; let col = Color32::from_rgb(0x5a, 0xb4, 0xff); - let resp = ui.interact(pic, egui::Id::new("mask-pen"), Sense::click_and_drag()); + let resp = ui.interact(pic, egui::Id::unique("mask-pen"), Sense::click_and_drag()); app.auto.add("program.maskPen", pic, "Pen mask"); if resp.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); @@ -630,7 +632,7 @@ fn pen_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32 _ => p, }; let mut commit = false; - let pressed = resp.drag_started_by(LEFT) || (resp.clicked() && !ui.data(|m| m.get_temp::(egui::Id::new("mask-pen-placed")).unwrap_or(false))); + let pressed = resp.drag_started_by(LEFT) || (resp.clicked() && !ui.data(|m| m.get_temp::(egui::Id::unique("mask-pen-placed")).unwrap_or(false))); if pressed && let Some(p) = resp.interact_pointer_pos() { let near_first = d.points.first().is_some_and(|f| (sp(&to_screen, Vec2::new(f[0], f[1])) - p).length() < 8.0); if d.points.len() >= 3 && near_first { @@ -640,11 +642,11 @@ fn pen_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32 d.points.push([c.x, c.y, 0.0, 0.0]); // read the response before data_mut: it locks the context that drag_started_by reads let dragging = resp.drag_started_by(LEFT); - ui.data_mut(|m| m.insert_temp(egui::Id::new("mask-pen-placed"), dragging)); + ui.data_mut(|m| m.insert_temp(egui::Id::unique("mask-pen-placed"), dragging)); } } if resp.clicked() || resp.drag_stopped() { - ui.data_mut(|m| m.insert_temp(egui::Id::new("mask-pen-placed"), false)); + ui.data_mut(|m| m.insert_temp(egui::Id::unique("mask-pen-placed"), false)); } if resp.dragged_by(LEFT) && !commit diff --git a/crates/ui-egui/src/panels/media_browser.rs b/crates/ui-egui/src/panels/media_browser.rs index 180f44a33..d3b62294d 100644 --- a/crates/ui-egui/src/panels/media_browser.rs +++ b/crates/ui-egui/src/panels/media_browser.rs @@ -54,7 +54,7 @@ const CACHE_SECS: f64 = 3.0; const ROW_H: f32 = 22.0; fn cache_id() -> egui::Id { - egui::Id::new("media-browser-cache") + egui::Id::unique("media-browser-cache") } fn with_cache(ctx: &egui::Context, f: impl FnOnce(&mut Cache) -> R) -> R { @@ -106,7 +106,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ]; for (icon, id, tip, enabled) in nav { let r = Rect::from_min_size(pos2(x, bar.min.y), vec2(24.0, 24.0)); - let resp = ui.interact(r, egui::Id::new(("mb-nav", id)), Sense::click()).on_hover_text(tip); + let resp = ui.interact(r, egui::Id::unique(("mb-nav", id)), Sense::click()).on_hover_text(tip); app.auto.add(&format!("mediaBrowser.{id}"), r, tip); if resp.hovered() && enabled { ui.painter().rect_filled(r, 3.0, t.hover); @@ -122,7 +122,10 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let pw = (bar.max.x - x - 290.0).max(80.0); let pr = Rect::from_min_size(pos2(x + 4.0, bar.min.y + 1.0), vec2(pw, 22.0)); let mut path = app.ui.media_browser.path_edit.clone().unwrap_or_else(|| dir.clone()); - let resp = ui.put(pr, egui::TextEdit::singleline(&mut path).id(egui::Id::new("mb-path")).font(egui::FontId::proportional(11.5))); + let resp = crate::widgets::accessible_name( + ui.put(pr, egui::TextEdit::singleline(&mut path).id(egui::Id::unique("mb-path")).font(egui::FontId::proportional(11.5))), + "Path", + ); app.auto.add("mediaBrowser.path", pr, "Path"); if resp.changed() { app.ui.media_browser.path_edit = Some(path.clone()); @@ -138,7 +141,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // file types let fr = Rect::from_min_size(pos2(x, bar.min.y + 1.0), vec2(150.0, 22.0)); let label = mb::FILE_TYPES.iter().find(|f| f.0 == prefs.file_types).map(|f| f.1.to_string()).unwrap_or_else(|| format!(".{}", prefs.file_types)); - let fresp = crate::widgets::dropdown_text(ui, fr, &label, &t, egui::Id::new("mb-types")).on_hover_text(tl!("File Types Displayed")); + let fresp = crate::widgets::dropdown_text(ui, fr, &label, &t, egui::Id::unique("mb-types")).on_hover_text(tl!("File Types Displayed")); app.auto.add("mediaBrowser.fileTypes", fr, "File Types Displayed"); egui::Popup::menu(&fresp).show(|ui| { for (k, l) in mb::FILE_TYPES { @@ -162,7 +165,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { x = fr.max.x + 6.0; for (icon, v, tip) in [(Icon::ListView, "list", tl!("List View")), (Icon::IconView, "thumbnails", tl!("Thumbnail View"))] { let r = Rect::from_min_size(pos2(x, bar.min.y), vec2(24.0, 24.0)); - let resp = ui.interact(r, egui::Id::new(("mb-view", v)), Sense::click()).on_hover_text(tip); + let resp = ui.interact(r, egui::Id::unique(("mb-view", v)), Sense::click()).on_hover_text(tip); app.auto.add(&format!("mediaBrowser.view.{v}"), r, tip); if prefs.view == v { ui.painter().rect_filled(r, 3.0, t.pressed); @@ -203,7 +206,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let list_r = Rect::from_min_max(pos2(tree_r.max.x + 4.0, body.min.y), body.max); // splitter let split = Rect::from_min_max(pos2(tree_r.max.x, body.min.y), pos2(tree_r.max.x + 4.0, body.max.y)); - let sresp = ui.interact(split, egui::Id::new("mb-split"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal); + let sresp = ui.interact(split, egui::Id::unique("mb-split"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal); if sresp.dragged() { app.ui.media_browser.tree_width = (tw + sresp.drag_delta().x).clamp(100.0, (rect.width() * 0.7).max(100.0)); } @@ -233,7 +236,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let resp = ui.put(sr, egui::Slider::new(&mut sz, 60.0..=320.0).show_value(false)); app.auto.add("mediaBrowser.thumbnailSize", sr, "Thumbnail size"); // a drag ended by Escape goes back to the size it started from (#580) - let orig_id = egui::Id::new("media-browser-thumbnail-size-before-drag"); + let orig_id = egui::Id::unique("media-browser-thumbnail-size-before-drag"); if resp.drag_started() { ui.data_mut(|d| d.insert_temp(orig_id, prefs.thumbnail_size)); } @@ -573,12 +576,12 @@ fn list(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, entries: &[Entry], c let hp = ui.painter().with_clip_rect(header.intersect(ui.clip_rect())); hp.rect_filled(header, 0.0, t.panel_bg); hp.line_segment([header.left_bottom(), header.right_bottom()], Stroke::new(1.0, t.separator)); - let mut x = header.min.x + 4.0 - out.state.offset.x; + let mut x = header.min.x + 4.0 - out.state.clamped_offset().x; for (c, w) in columns.iter().zip(&widths) { let cr = Rect::from_min_size(pos2(x, header.min.y), vec2(*w, 20.0)); hp.text(pos2(cr.min.x + 4.0, cr.center().y), Align2::LEFT_CENTER, crate::i18n::t(c), Tokens::ui(11.0), t.text_dim); if cr.intersects(header) { - let resp = ui.interact(cr.intersect(header), egui::Id::new(("mb-col", c)), Sense::click()); + let resp = ui.interact(cr.intersect(header), egui::Id::unique(("mb-col", c)), Sense::click()); app.auto.add(&format!("mediaBrowser.header.{c}"), cr.intersect(header), c); resp.context_menu(|ui| { if ui.button(tl!("Edit Columns…")).clicked() { @@ -656,7 +659,7 @@ fn thumbnails(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, entries: &[Ent let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), cell.y), Sense::hover()); for (k, e) in chunk.iter().enumerate() { let tr = Rect::from_min_size(pos2(row.min.x + 8.0 + k as f32 * cell.x, row.min.y + 6.0), vec2(size, th_h)); - let resp = ui.interact(tr.expand(2.0), egui::Id::new(("mb-thumb", &e.path)), Sense::click_and_drag()); + let resp = ui.interact(tr.expand(2.0), egui::Id::unique(("mb-thumb", &e.path)), Sense::click_and_drag()); let sel = app.session.browser.selection.contains(&e.path); ui.painter().rect_filled(tr, 3.0, Color32::from_gray(14)); if sel { @@ -815,7 +818,7 @@ pub fn dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut close = false; let mut ok = false; egui::Window::new(tl!("Edit Columns")) - .id(egui::Id::new("mb-edit-columns")) + .id(egui::Id::unique("mb-edit-columns")) .collapsible(false) .resizable(false) .pivot(Align2::CENTER_CENTER) diff --git a/crates/ui-egui/src/panels/media_dialogs.rs b/crates/ui-egui/src/panels/media_dialogs.rs index 3a2dcb0ec..b91b98852 100644 --- a/crates/ui-egui/src/panels/media_dialogs.rs +++ b/crates/ui-egui/src/panels/media_dialogs.rs @@ -129,7 +129,7 @@ fn match_params(d: &LinkMediaDraft) -> Value { } fn preview_texture(app: &FilmcraftApp, ctx: &egui::Context, path: &str) -> Option { - let key = egui::Id::new(("link-preview", path)); + let key = egui::Id::unique(("link-preview", path)); if let Some(t) = ctx.data(|d| d.get_temp::>(key)) { return t; } @@ -161,7 +161,7 @@ fn link_media(app: &mut FilmcraftApp, ctx: &egui::Context) { let rows_max_h = (ctx.content_rect().height() * 0.3).clamp(90.0, 320.0); let width = (ctx.content_rect().width() - 40.0).clamp(1.0, 760.0); egui::Window::new(tl!("Link Media")) - .id(egui::Id::new("Link Media")) + .id(egui::Id::unique("Link Media")) .collapsible(false) .resizable(false) .default_width(width) @@ -217,7 +217,10 @@ fn link_media(app: &mut FilmcraftApp, ctx: &egui::Context) { ui.separator(); ui.horizontal_wrapped(|ui| { ui.label(tl!("Search in:")); - let r = ui.add(egui::TextEdit::singleline(&mut d.folder).desired_width((ui.available_width() * 0.5).min(360.0)).hint_text(tl!("Folder"))); + let r = crate::widgets::accessible_name( + ui.add(egui::TextEdit::singleline(&mut d.folder).desired_width((ui.available_width() * 0.5).min(360.0)).hint_text(tl!("Folder"))), + "Folder", + ); push(&mut elems, "linkMedia.folder", &r, "folder"); let r = ui.button(tl!("Browse…")); push(&mut elems, "linkMedia.browse", &r, "Browse…"); @@ -371,7 +374,7 @@ fn make_offline(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut ok = false; let n = app.session.state.project_selection.len(); egui::Window::new(tl!("Make Offline")) - .id(egui::Id::new("Make Offline")) + .id(egui::Id::unique("Make Offline")) .collapsible(false) .resizable(false) .pivot(egui::Align2::CENTER_CENTER) @@ -416,7 +419,7 @@ fn create_proxies(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut ok = false; let mut browse = false; egui::Window::new(tl!("Create Proxies")) - .id(egui::Id::new("Create Proxies")) + .id(egui::Id::unique("Create Proxies")) .collapsible(false) .resizable(false) .pivot(egui::Align2::CENTER_CENTER) @@ -435,8 +438,12 @@ fn create_proxies(app: &mut FilmcraftApp, ctx: &egui::Context) { ui.add_space(4.0); ui.label(RichText::new(tl!("Destination")).strong()); ui.horizontal(|ui| { - let r = ui - .add(egui::TextEdit::singleline(&mut d.destination).desired_width(320.0).hint_text(tl!("Next to the original media, in a Proxies folder"))); + let r = crate::widgets::accessible_name( + ui.add( + egui::TextEdit::singleline(&mut d.destination).desired_width(320.0).hint_text(tl!("Next to the original media, in a Proxies folder")), + ), + "Destination", + ); push(&mut elems, "proxies.destination", &r, "destination"); let r = ui.button(tl!("Browse…")); push(&mut elems, "proxies.browse", &r, "Browse…"); @@ -489,14 +496,14 @@ fn pm_params(d: &ProjectManagerDraft, dry: bool) -> Value { fn project_manager(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some(mut d) = app.ui.project_manager.clone() else { return }; - crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("project-manager-before-drag"), &mut d); + crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("project-manager-before-drag"), &mut d); let before = d.clone(); let mut elems: Elems = Vec::new(); let mut keep = true; let (mut ok, mut calc, mut browse) = (false, false, false); let seqs: Vec<(u64, String)> = app.session.project.sequences().map(|i| (i.id.0, i.name.clone())).collect(); egui::Window::new(tl!("Project Manager")) - .id(egui::Id::new("Project Manager")) + .id(egui::Id::unique("Project Manager")) .collapsible(false) .resizable(false) .pivot(egui::Align2::CENTER_CENTER) @@ -555,7 +562,7 @@ fn project_manager(app: &mut FilmcraftApp, ctx: &egui::Context) { ui.separator(); ui.label(RichText::new(tl!("Destination Path")).strong()); ui.horizontal(|ui| { - let r = ui.add(egui::TextEdit::singleline(&mut d.destination).desired_width(360.0)); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut d.destination).desired_width(360.0)), "Destination"); push(&mut elems, "pm.destination", &r, "destination"); let r = ui.button(tl!("Browse…")); push(&mut elems, "pm.browse", &r, "Browse…"); diff --git a/crates/ui-egui/src/panels/menu_dialogs.rs b/crates/ui-egui/src/panels/menu_dialogs.rs index 99151da72..10122f438 100644 --- a/crates/ui-egui/src/panels/menu_dialogs.rs +++ b/crates/ui-egui/src/panels/menu_dialogs.rs @@ -48,7 +48,9 @@ impl Default for Extras { type Elems = Vec<(String, egui::Rect, String)>; fn push(elems: &mut Elems, id: impl Into, r: &egui::Response, label: impl Into) { - elems.push((id.into(), r.rect, label.into())); + let label = label.into(); + crate::widgets::accessible_name(r.clone(), label.clone()); + elems.push((id.into(), r.rect, label)); } /// Dialog title, automation prefix and OK button text of a command's dialog. @@ -453,7 +455,7 @@ fn pairs(v: &[&str]) -> Vec<(String, String)> { /// Draw the open dialog, if any. pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some(mut d) = app.ui.extras.dialog.clone() else { return }; - crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("menu-dialog-before-drag"), &mut d); + crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("menu-dialog-before-drag"), &mut d); let Some((title, pre, ok_text)) = meta(&d.command) else { app.ui.extras.dialog = None; return; @@ -463,7 +465,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut browse: Option = None; let info_only = matches!(d.command.as_str(), "file.mediaProperties" | "clip.sourceSettings" | "help.systemCompatibilityReport"); // the title is translated; the window keeps one id whatever the interface language - let id = egui::Id::new(("menu-dialog", pre)); + let id = egui::Id::unique(("menu-dialog", pre)); egui::Window::new(title).id(id).collapsible(false).resizable(false).default_width(420.0).centered(ctx).show(ctx, |ui| { let p = &mut d.params; match d.command.as_str() { diff --git a/crates/ui-egui/src/panels/metadata.rs b/crates/ui-egui/src/panels/metadata.rs index 88312f1a1..94a46cf26 100644 --- a/crates/ui-egui/src/panels/metadata.rs +++ b/crates/ui-egui/src/panels/metadata.rs @@ -120,9 +120,12 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { /// An editable value: a text field whose edit is committed when it loses focus (Enter, Tab or a /// click elsewhere). Returns (field, new value) on commit. fn text_field(ui: &mut egui::Ui, item: ItemId, f: &Field, elems: &mut Vec<(String, Rect, String)>) -> Option<(String, String)> { - let id = egui::Id::new(("md-draft", item.0, f.name.clone())); + let id = egui::Id::unique(("md-draft", item.0, f.name.clone())); let mut draft: String = ui.data(|d| d.get_temp(id)).unwrap_or_else(|| f.value.clone()); - let r = ui.add(egui::TextEdit::singleline(&mut draft).desired_width(ui.available_width()).id_salt(("md-edit", item.0, f.name.clone()))); + let r = crate::widgets::accessible_name( + ui.add(egui::TextEdit::singleline(&mut draft).desired_width(ui.available_width()).id_salt(("md-edit", item.0, f.name.clone()))), + f.name.clone(), + ); elems.push((field_id(&f.name), r.rect, f.value.clone())); let mut out = None; if r.has_focus() { diff --git a/crates/ui-egui/src/panels/mixer.rs b/crates/ui-egui/src/panels/mixer.rs index 1d5046b38..0bf72e724 100644 --- a/crates/ui-egui/src/panels/mixer.rs +++ b/crates/ui-egui/src/panels/mixer.rs @@ -89,7 +89,7 @@ pub const INPUT_METER: u64 = u64::MAX - 1; /// strips), fed by the playing mix ([`filmcraft_render::mixer::LiveMix`]) with fast attack and a /// 20 dB/s fall. [`INPUT_METER`] holds the voice-over input. pub fn poll_meters(app: &mut FilmcraftApp, ui: &egui::Ui) -> HashMap> { - let id = egui::Id::new("mixer-meters"); + let id = egui::Id::unique("mixer-meters"); let frame = ui.ctx().cumulative_frame_nr(); let (last, mut st): (u64, HashMap>) = ui.data(|d| d.get_temp(id)).unwrap_or_default(); if last == frame && frame != 0 { @@ -223,7 +223,7 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let body = Rect::from_min_max(pos2(rect.min.x, rect.min.y + 2.0), pos2(rect.max.x, footer.min.y)); // effects / sends disclosure at the left edge let arrow = Rect::from_min_size(pos2(body.min.x + 2.0, body.min.y + 4.0), vec2(14.0, 18.0)); - let aresp = ui.interact(arrow, egui::Id::new("mixer-fx-toggle"), Sense::click()).on_hover_text(tl!("Show/Hide Effects and Sends")); + let aresp = ui.interact(arrow, egui::Id::unique("mixer-fx-toggle"), Sense::click()).on_hover_text(tl!("Show/Hide Effects and Sends")); icons::paint(ui.painter(), arrow.shrink(2.0), if app.ui.mixer_fx_open { Icon::ChevronDown } else { Icon::ChevronRight }, t.icon); app.auto.add("mixer.showEffects", arrow, "Show/Hide Effects and Sends"); if aresp.clicked() { @@ -231,10 +231,10 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } // panel menu: Show/Hide Tracks, Meter Input(s) Only let menu_r = Rect::from_min_size(pos2(body.min.x + 2.0, body.min.y + 26.0), vec2(14.0, 18.0)); - let mresp = ui.interact(menu_r, egui::Id::new("mixer-menu"), Sense::click()).on_hover_text(tl!("Audio Track Mixer menu")); + let mresp = ui.interact(menu_r, egui::Id::unique("mixer-menu"), Sense::click()).on_hover_text(tl!("Audio Track Mixer menu")); icons::paint(ui.painter(), menu_r.shrink(2.0), Icon::Hamburger, if mresp.hovered() { t.tab_text_active } else { t.icon }); app.auto.add("mixer.menu", menu_r, "Audio Track Mixer menu"); - let show_hide_id = egui::Id::new("mixer-show-hide-open"); + let show_hide_id = egui::Id::unique("mixer-show-hide-open"); let mut show_hide: bool = ui.data(|d| d.get_temp(show_hide_id)).unwrap_or(false); egui::Popup::menu(&mresp).show(|ui| { let r = ui.button(tl!("Show/Hide Tracks…")); @@ -252,7 +252,7 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if show_hide { let mut open = true; let mut close = false; - egui::Window::new(tl!("Show/Hide Tracks")).id(egui::Id::new("mixer-show-hide")).collapsible(false).resizable(false).open(&mut open).show( + egui::Window::new(tl!("Show/Hide Tracks")).id(egui::Id::unique("mixer-show-hide")).collapsible(false).resizable(false).open(&mut open).show( ui.ctx(), |ui| { for id in seq.audio_tracks.iter().chain(&seq.submix_tracks).map(|t| t.id) { @@ -294,7 +294,7 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // strips never get shorter than their controls plus a usable fader: scroll vertically instead let need = 4.0 + if app.ui.mixer_fx_open { 10.0 * SLOT_H + 10.0 } else { 0.0 } + 112.0 + 26.0 + 24.0 + 6.0 + 170.0 + 42.0; let clip = Rect::from_min_max(pos2(x0, body.min.y), pos2(body.max.x - 8.0, body.max.y - sb_h)); - let voff_id = egui::Id::new("mixer-vscroll"); + let voff_id = egui::Id::unique("mixer-vscroll"); let max_voff = (need - (clip.height() - 4.0)).max(0.0); let mut voff: f32 = ui.data(|d| d.get_temp(voff_id)).unwrap_or(0.0); if ui.rect_contains_pointer(body) && !ui.input(|i| i.modifiers.shift) { @@ -307,7 +307,7 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let kh = (bar.height() * clip.height() / (need + 4.0)).max(20.0); let ky = bar.min.y + (bar.height() - kh) * voff / max_voff; let knob = Rect::from_min_size(pos2(bar.min.x, ky), vec2(bar.width(), kh)); - let kr = ui.interact(knob, egui::Id::new("mixer-vscroll-knob"), Sense::drag()); + let kr = ui.interact(knob, egui::Id::unique("mixer-vscroll-knob"), Sense::drag()); ui.painter().rect_filled(knob, 3.0, if kr.dragged() { t.accent } else { t.fader_knob }); if kr.dragged() { voff = (voff + kr.drag_delta().y * (need + 4.0) / bar.height()).clamp(0.0, max_voff); @@ -343,7 +343,7 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { fn strip_scroll(app: &mut FilmcraftApp, ui: &egui::Ui, body: Rect, x0: f32, total: f32, avail: f32, key: &str) -> (f32, f32) { let t = app.tokens; let max_off = (total - avail).max(0.0); - let off_id = egui::Id::new(format!("{key}-hscroll")); + let off_id = egui::Id::unique(format!("{key}-hscroll")); let mut off: f32 = ui.data(|d| d.get_temp(off_id)).unwrap_or(0.0); if ui.rect_contains_pointer(body) { let sd = ui.input(|i| i.smooth_scroll_delta); @@ -356,7 +356,7 @@ fn strip_scroll(app: &mut FilmcraftApp, ui: &egui::Ui, body: Rect, x0: f32, tota let kw = (bar.width() * avail / total).max(20.0); let kx = bar.min.x + (bar.width() - kw) * off / max_off; let knob = Rect::from_min_size(pos2(kx, bar.min.y), vec2(kw, bar.height())); - let kr = ui.interact(knob, egui::Id::new(format!("{key}-hscroll-knob")), Sense::drag()); + let kr = ui.interact(knob, egui::Id::unique(format!("{key}-hscroll-knob")), Sense::drag()); ui.painter().rect_filled(knob, 4.0, if kr.dragged() { t.accent } else { t.fader_knob }); if kr.dragged() { off = (off + kr.drag_delta().x * total / bar.width()).clamp(0.0, max_off); @@ -401,10 +401,10 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s let r = Rect::from_min_size(pos2(x + 4.0, y), vec2(w - 8.0, SLOT_H - 2.0)); let fx = tr.effects.get(k); let text = match fx { - Some(e) => format!("{}{}", if e.post_fader { "▸ " } else { "" }, e.def().map(|d| d.name).unwrap_or(&e.effect)), + Some(e) => format!("{}{}", if e.post_fader { "> " } else { "" }, e.def().map(|d| d.name).unwrap_or(&e.effect)), None => String::new(), }; - let resp = slot_box(ui, r, &text, fx.is_some(), fx.is_some_and(|e| !e.enabled), &t, egui::Id::new((&ap, "fx", k))); + let resp = slot_box(ui, r, &text, fx.is_some(), fx.is_some_and(|e| !e.enabled), &t, egui::Id::unique((&ap, "fx", k))); app.auto.add(&format!("{ap}.fx.{k}"), r, &if text.is_empty() { format!("Effect slot {}", k + 1) } else { text.clone() }); let can_add = fx.is_none() && k == tr.effects.len(); egui::Popup::menu(&resp).show(|ui| { @@ -495,7 +495,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s y += SLOT_H; continue; } - let resp = slot_box(ui, r, &text, snd.is_some(), snd.is_some_and(|s| s.muted), &t, egui::Id::new((&ap, "send", k))); + let resp = slot_box(ui, r, &text, snd.is_some(), snd.is_some_and(|s| s.muted), &t, egui::Id::unique((&ap, "send", k))); app.auto.add(&format!("{ap}.send.{k}"), r, &if text.is_empty() { format!("Send slot {}", k + 1) } else { text.clone() }); let can_add = snd.is_none() && k == tr.mixer.sends.len(); let tg = targets(seq, id); @@ -558,7 +558,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s // ---- input / output if !is_master { let ir = Rect::from_min_size(pos2(x + 4.0, y), vec2(w - 8.0, 20.0)); - let iresp = crate::widgets::dropdown_text(ui, ir, crate::i18n::t(tr.mixer.input_map.label()), &t, egui::Id::new((&ap, "input"))); + let iresp = crate::widgets::dropdown_text(ui, ir, crate::i18n::t(tr.mixer.input_map.label()), &t, egui::Id::unique((&ap, "input"))); app.auto.add(&format!("{ap}.input"), ir, "Input channel mapping"); egui::Popup::menu(&iresp).show(|ui| { for m in InputMap::ALL { @@ -573,7 +573,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s let or = Rect::from_min_size(pos2(x + 4.0, y), vec2(w - 8.0, 20.0)); let out_label = tr.mixer.output.map(|o| seq.mix_track(o).map(|s| s.name.clone()).unwrap_or_else(|| tl!("Mix").into())).unwrap_or_else(|| tl!("Mix").into()); - let oresp = crate::widgets::dropdown_text(ui, or, &out_label, &t, egui::Id::new((&ap, "output"))); + let oresp = crate::widgets::dropdown_text(ui, or, &out_label, &t, egui::Id::unique((&ap, "output"))); app.auto.add(&format!("{ap}.output"), or, "Track output assignment"); let tg = targets(seq, id); egui::Popup::menu(&oresp).show(|ui| { @@ -599,7 +599,8 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s let pan = shown(app, &tr, LANE_PAN, cx.now); let kc = pos2(sr.center().x, y + 20.0); let kr = Rect::from_center_size(kc, vec2(38.0, 38.0)); - let kresp = ui.interact(kr, egui::Id::new((&ap, "pan")), Sense::click_and_drag()).on_hover_text(tl!("Pan / balance (drag; double-click: centre)")); + let kresp = + ui.interact(kr, egui::Id::unique((&ap, "pan")), Sense::click_and_drag()).on_hover_text(tl!("Pan / balance (drag; double-click: centre)")); let ring = if kresp.hovered() || kresp.dragged() { t.tab_text_active } else { t.control_handle_dim }; ui.painter().circle_stroke(kc, 16.0, Stroke::new(2.0, ring)); let ang = (pan / 100.0) as f32 * 135f32.to_radians(); @@ -622,7 +623,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s } let vr = Rect::from_center_size(pos2(kc.x, y + 48.0), vec2(56.0, 16.0)); let mut vui = ui.new_child(egui::UiBuilder::new().max_rect(vr).layout(egui::Layout::top_down(egui::Align::Center))); - let (vresp, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new((&ap, "panValue")), pan, 1.0, (-100.0, 100.0), 1, "", &t); + let (vresp, nv) = crate::widgets::hot_number(&mut vui, tl!("Pan"), egui::Id::unique((&ap, "panValue")), pan, 1.0, (-100.0, 100.0), 1, "", &t); app.auto.add(&format!("{ap}.panValue"), vresp.rect, &format!("{pan:.1}")); if let Some(v) = nv { cx.cmd("mixer.setValue", json!({"strip": sid, "lane": LANE_PAN, "value": v})); @@ -636,7 +637,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s // ---- automation mode let mr = Rect::from_min_size(pos2(x + 4.0, y), vec2(w - 8.0, 20.0)); let mode = tr.mixer.mode; - let mresp = crate::widgets::dropdown_text(ui, mr, crate::i18n::t(mode.label()), &t, egui::Id::new((&ap, "mode"))); + let mresp = crate::widgets::dropdown_text(ui, mr, crate::i18n::t(mode.label()), &t, egui::Id::unique((&ap, "mode"))); if cx.recording && mode.writes() { ui.painter().rect_stroke(mr, 4.0, Stroke::new(1.0, RED), StrokeKind::Inside); } @@ -658,7 +659,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s let bw = 18.0; let bx = sr.center().x - bw * 1.5 - 6.0; let b = |i: f32| Rect::from_min_size(pos2(bx + i * (bw + 6.0), y), vec2(bw, 16.0)); - let m_r = crate::widgets::letter_toggle(ui, b(0.0), "M", muted, MUTE_ON, &t, egui::Id::new((&ap, "M"))); + let m_r = crate::widgets::letter_toggle(ui, b(0.0), "M", muted, MUTE_ON, &t, egui::Id::unique((&ap, "M"))); app.auto.add(&format!("{ap}.mute"), b(0.0), "Mute Track"); if m_r.clicked() { if cx.recording && mode.writes() { @@ -669,21 +670,21 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s cx.cmd("mixer.setStrip", json!({"strip": sid, "muted": !tr.muted})); } } - let s_r = crate::widgets::letter_toggle(ui, b(1.0), "S", tr.solo, SOLO_ON, &t, egui::Id::new((&ap, "S"))); + let s_r = crate::widgets::letter_toggle(ui, b(1.0), "S", tr.solo, SOLO_ON, &t, egui::Id::unique((&ap, "S"))); app.auto.add(&format!("{ap}.solo"), b(1.0), "Solo Track"); if s_r.clicked() { cx.cmd("mixer.setStrip", json!({"strip": sid, "solo": !tr.solo})); } let is_sub = seq.submix_tracks.iter().any(|s| s.id == id); if is_sub { - let r_r = crate::widgets::letter_toggle(ui, b(2.0), "◆", tr.mixer.solo_safe, Color32::from_gray(0xc8), &t, egui::Id::new((&ap, "safe"))) + let r_r = crate::widgets::letter_toggle(ui, b(2.0), "◆", tr.mixer.solo_safe, Color32::from_gray(0xc8), &t, egui::Id::unique((&ap, "safe"))) .on_hover_text(tl!("Solo safe")); app.auto.add(&format!("{ap}.soloSafe"), b(2.0), "Solo Safe"); if r_r.clicked() { cx.cmd("mixer.setStrip", json!({"strip": sid, "soloSafe": !tr.mixer.solo_safe})); } } else { - let r_r = crate::widgets::letter_toggle(ui, b(2.0), "R", tr.mixer.record_arm, RED, &t, egui::Id::new((&ap, "R"))) + let r_r = crate::widgets::letter_toggle(ui, b(2.0), "R", tr.mixer.record_arm, RED, &t, egui::Id::unique((&ap, "R"))) .on_hover_text(tl!("Enable track for recording")); app.auto.add(&format!("{ap}.record"), b(2.0), "Enable Track for Recording"); if r_r.clicked() { @@ -715,7 +716,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s let cy = ypos(vol); let cap = Rect::from_center_size(pos2(track.center().x, cy), vec2(18.0, 26.0)); let fresp = - ui.interact(cap.expand(3.0), egui::Id::new((&ap, "fader")), Sense::click_and_drag()).on_hover_text(tl!("Volume (drag; double-click: 0 dB)")); + ui.interact(cap.expand(3.0), egui::Id::unique((&ap, "fader")), Sense::click_and_drag()).on_hover_text(tl!("Volume (drag; double-click: 0 dB)")); let held = app.session.previews.live.get(id, LANE_VOLUME).is_some(); let col = if fresp.dragged() || held { t.accent } else { t.control_handle }; ui.painter().rect_filled(cap, 3.0, t.control_handle_bg); @@ -758,7 +759,8 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s // fader value (hot text) let vr = Rect::from_min_size(pos2(x + 4.0, sr.max.y - name_h - value_h), vec2(56.0, value_h)); let mut vui = ui.new_child(egui::UiBuilder::new().max_rect(vr)); - let (vresp, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new((&ap, "value")), vol.max(-96.0), 0.1, (FADER_MIN_DB, FADER_MAX_DB), 1, "", &t); + let (vresp, nv) = + crate::widgets::hot_number(&mut vui, tl!("Volume"), egui::Id::unique((&ap, "value")), vol.max(-96.0), 0.1, (FADER_MIN_DB, FADER_MAX_DB), 1, "", &t); app.auto.add(&format!("{ap}.value"), vresp.rect, &db_text(vol)); if let Some(v) = nv { cx.cmd("mixer.setValue", json!({"strip": sid, "lane": LANE_VOLUME, "value": v})); @@ -786,7 +788,7 @@ fn pan51_panner(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, tr: &Tr let py = shown(app, tr, LANE_PAN51_Y, cx.now); let sq = Rect::from_min_size(pos2(r.min.x, r.min.y), vec2(46.0, 46.0)); let resp = - ui.interact(sq, egui::Id::new((ap, "pan51")), Sense::click_and_drag()).on_hover_text(tl!("5.1 panner (drag the puck; double-click: front centre)")); + ui.interact(sq, egui::Id::unique((ap, "pan51")), Sense::click_and_drag()).on_hover_text(tl!("5.1 panner (drag the puck; double-click: front centre)")); let p = ui.painter(); p.rect_filled(sq, 3.0, t.panner_bg); p.rect_stroke(sq, 3.0, Stroke::new(1.0, if resp.hovered() || resp.dragged() { t.tab_text_active } else { t.panner_border }), StrokeKind::Inside); @@ -826,7 +828,7 @@ fn pan51_panner(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, tr: &Tr small_text(ui, pos2(col_x, r.min.y + 6.0), Align2::LEFT_CENTER, tl!("Center"), 9.0, t.text_dim); let cr = Rect::from_min_size(pos2(col_x, r.min.y + 12.0), vec2(r.max.x - col_x, 14.0)); let mut cui = ui.new_child(egui::UiBuilder::new().max_rect(cr)); - let (cresp, nc) = crate::widgets::hot_number(&mut cui, egui::Id::new((ap, "pan51.center")), center, 1.0, (0.0, 100.0), 0, "%", &t); + let (cresp, nc) = crate::widgets::hot_number(&mut cui, tl!("Center"), egui::Id::unique((ap, "pan51.center")), center, 1.0, (0.0, 100.0), 0, "%", &t); app.auto.add(&format!("{ap}.pan51.center"), cresp.rect, &format!("Center {center:.0} %")); if let Some(v) = nc { cx.cmd("mixer.setValue", json!({"strip": sid, "lane": LANE_PAN51_CENTER, "value": v})); @@ -835,7 +837,7 @@ fn pan51_panner(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, tr: &Tr let lr = Rect::from_min_size(pos2(col_x, r.min.y + 38.0), vec2(r.max.x - col_x, 14.0)); let mut lui = ui.new_child(egui::UiBuilder::new().max_rect(lr)); let (lresp, nl) = - crate::widgets::hot_number(&mut lui, egui::Id::new((ap, "pan51.lfe")), lfe.max(FADER_MIN_DB), 0.5, (FADER_MIN_DB, FADER_MAX_DB), 1, "", &t); + crate::widgets::hot_number(&mut lui, "LFE", egui::Id::unique((ap, "pan51.lfe")), lfe.max(FADER_MIN_DB), 0.5, (FADER_MIN_DB, FADER_MAX_DB), 1, "", &t); app.auto.add(&format!("{ap}.pan51.lfe"), lresp.rect, &format!("LFE {}", db_text(lfe))); if let Some(v) = nl { cx.cmd("mixer.setValue", json!({"strip": sid, "lane": LANE_PAN51_LFE, "value": v})); @@ -863,7 +865,7 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, cx: &mut Ctx) let mut x = row.center().x - bw * buttons.len() as f32 / 2.0; for (icon, cmd, tip) in buttons { let r = Rect::from_min_size(pos2(x, row.min.y + 4.0), vec2(bw - 2.0, 26.0)); - let resp = ui.interact(r, egui::Id::new(("mixer-transport", cmd)), Sense::click()).on_hover_text(tip); + let resp = ui.interact(r, egui::Id::unique(("mixer-transport", cmd)), Sense::click()).on_hover_text(tip); app.auto.add(&format!("mixer.transport.{cmd}"), r, tip); if resp.hovered() { ui.painter().rect_filled(r, 4.0, t.hover); @@ -912,7 +914,7 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let meters = poll_meters(app, ui); let now = app.session.playhead(); let mut acts: Vec<(String, Value)> = Vec::new(); - let kf_id = egui::Id::new("clip-mixer-keyframes"); + let kf_id = egui::Id::unique("clip-mixer-keyframes"); let mut write_kf: HashMap = ui.data(|d| d.get_temp(kf_id)).unwrap_or_default(); // Like Premiere, the strips narrow so that every track fits the panel; below the narrowest // usable strip the row scrolls instead, so no track is out of reach (#1922). @@ -962,7 +964,7 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // pan knob let kc = pos2(sr.center().x, sr.min.y + 36.0); let kr = Rect::from_center_size(kc, vec2(30.0, 30.0)); - let kresp = ui.interact(kr, egui::Id::new((&ap, "pan")), Sense::click_and_drag()); + let kresp = ui.interact(kr, egui::Id::unique((&ap, "pan")), Sense::click_and_drag()); ui.painter().circle_stroke(kc, 14.0, Stroke::new(2.0, dimc(t.control_handle_dim))); let ang = (pan / 100.0) as f32 * 135f32.to_radians(); ui.painter().line_segment([kc, kc + vec2(ang.sin(), -ang.cos()) * 12.0], Stroke::new(2.0, dimc(t.control_handle_dim))); @@ -983,7 +985,7 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // automation mode (Clip Mixer) let mode = filmcraft_engine::mixer::clip_mode(&app.session, tr.id); let mdr = Rect::from_min_size(pos2(sr.min.x + 4.0, sr.min.y + 2.0), vec2(sr.width() - 8.0, 16.0)); - let mdresp = crate::widgets::dropdown_text(ui, mdr, crate::i18n::t(mode.label()), &t, egui::Id::new((&ap, "mode"))); + let mdresp = crate::widgets::dropdown_text(ui, mdr, crate::i18n::t(mode.label()), &t, egui::Id::unique((&ap, "mode"))); app.auto.add(&format!("{ap}.mode"), mdr, &format!("Clip automation mode: {}", mode.label())); egui::Popup::menu(&mdresp).show(|ui| { for m in AutomationMode::ALL { @@ -998,18 +1000,18 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let y = sr.min.y + 72.0; let step = ((sr.width() - 6.0 - 18.0) / 2.0).min(24.0); let b = |k: f32| Rect::from_min_size(pos2(sr.center().x - step - 9.0 + k * step, y), vec2(18.0, 16.0)); - let mr = crate::widgets::letter_toggle(ui, b(0.0), "M", tr.muted, MUTE_ON, &t, egui::Id::new((&ap, "M"))); + let mr = crate::widgets::letter_toggle(ui, b(0.0), "M", tr.muted, MUTE_ON, &t, egui::Id::unique((&ap, "M"))); app.auto.add(&format!("{ap}.mute"), b(0.0), "Mute Track"); if mr.clicked() { acts.push(("mixer.setStrip".into(), json!({"strip": tr.id.0, "muted": !tr.muted}))); } - let so = crate::widgets::letter_toggle(ui, b(1.0), "S", tr.solo, SOLO_ON, &t, egui::Id::new((&ap, "S"))); + let so = crate::widgets::letter_toggle(ui, b(1.0), "S", tr.solo, SOLO_ON, &t, egui::Id::unique((&ap, "S"))); app.auto.add(&format!("{ap}.solo"), b(1.0), "Solo Track"); if so.clicked() { acts.push(("mixer.setStrip".into(), json!({"strip": tr.id.0, "solo": !tr.solo}))); } - let kresp = - crate::widgets::icon_toggle(ui, b(2.0), Icon::Keyframe, kf, &t, egui::Id::new((&ap, "kf")), Some(t.accent)).on_hover_text(tl!("Write keyframes")); + let kresp = crate::widgets::icon_toggle(ui, b(2.0), Icon::Keyframe, kf, &t, egui::Id::unique((&ap, "kf")), Some(t.accent)) + .on_hover_text(tl!("Write keyframes")); app.auto.add(&format!("{ap}.keyframe"), b(2.0), "Write Keyframes"); if kresp.clicked() { write_kf.insert(tr.id.0, !kf); @@ -1026,7 +1028,7 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.painter().rect_filled(track, 2.0, t.control_well); let cy = ypos(vol); let cap = Rect::from_center_size(pos2(track.center().x, cy), vec2(18.0, 26.0)); - let fresp = ui.interact(cap.expand(3.0), egui::Id::new((&ap, "fader")), Sense::click_and_drag()); + let fresp = ui.interact(cap.expand(3.0), egui::Id::unique((&ap, "fader")), Sense::click_and_drag()); ui.painter().rect_filled(cap, 3.0, t.control_handle_bg); ui.painter().rect_stroke(cap, 3.0, Stroke::new(2.0, dimc(t.control_handle)), StrokeKind::Inside); ui.painter().line_segment([pos2(cap.min.x + 5.0, cy), pos2(cap.max.x - 5.0, cy)], Stroke::new(2.0, dimc(t.control_handle))); @@ -1050,7 +1052,8 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if !active { vui.set_opacity(0.4); } - let (vresp, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new((&ap, "value")), vol.max(-96.0), 0.1, (FADER_MIN_DB, FADER_MAX_DB), 1, "", &t); + let (vresp, nv) = + crate::widgets::hot_number(&mut vui, tl!("Volume"), egui::Id::unique((&ap, "value")), vol.max(-96.0), 0.1, (FADER_MIN_DB, FADER_MAX_DB), 1, "", &t); app.auto.add(&format!("{ap}.value"), vresp.rect, &db_text(vol)); if let (Some(v), true) = (nv, active) { set(&mut acts, "volume", "level", v, !vresp.dragged() || vresp.drag_started()); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 0f21158fc..babc3a7e7 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -124,7 +124,7 @@ enum DragPayload { } fn payload_id() -> egui::Id { - egui::Id::new("filmcraft-drag-payload") + egui::Id::unique("filmcraft-drag-payload") } pub fn start_drag_source(ui: &egui::Ui, source: source_drag::SourceDrag) { @@ -206,7 +206,7 @@ pub fn clear_drag(ui: &egui::Ui) { } fn cancelled_id() -> egui::Id { - egui::Id::new("filmcraft-drag-cancelled") + egui::Id::unique("filmcraft-drag-cancelled") } /// Abandon the drag in progress (Escape, #580): nothing is dropped, and nothing restarts it until @@ -255,7 +255,7 @@ pub fn drag_ghost(app: &FilmcraftApp, ui: &egui::Ui) { _ => filmcraft_project::find_effect(e).map(|d| d.name.to_string()).unwrap_or_default(), }, }; - let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("drag-ghost"))); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("drag-ghost"))); let r = Rect::from_min_size(p + egui::vec2(12.0, 8.0), egui::vec2(label.len() as f32 * 7.0 + 16.0, 20.0)); painter.rect_filled(r, 4.0, Color32::from_black_alpha(200)); painter.text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.5), Color32::WHITE); @@ -263,7 +263,7 @@ pub fn drag_ghost(app: &FilmcraftApp, ui: &egui::Ui) { if ctx.input(|i| i.pointer.any_released()) { // cleared next frame so drop targets see the release this frame let f = ctx.cumulative_frame_nr(); - let k = egui::Id::new("drag-release-frame"); + let k = egui::Id::unique("drag-release-frame"); match ctx.data(|d| d.get_temp::(k)) { Some(prev) if prev < f => { clear_drag(ui); @@ -284,7 +284,7 @@ pub fn panel_menu_popup(app: &mut FilmcraftApp, ui: &mut egui::Ui) { drag_ghost(app, ui); // bins opened in new windows (Project panel) project::floating(app, ui.ctx()); - let id = egui::Id::new("panel-menu"); + let id = egui::Id::unique("panel-menu"); let Some((p, pos)) = ui.ctx().data(|d| d.get_temp::<(PanelKind, egui::Pos2)>(id)) else { return }; let mut close = false; let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| { @@ -358,7 +358,7 @@ pub fn panel_menu_popup(app: &mut FilmcraftApp, ui: &mut egui::Ui) { }); // A click elsewhere or Escape closes the menu. The click that opened it is over the tab, not // the menu, and must not close it again in the same frame. - let fresh = ui.ctx().data(|d| d.get_temp::(egui::Id::new("panel-menu-opened"))) == Some(ui.ctx().cumulative_frame_nr()); + let fresh = ui.ctx().data(|d| d.get_temp::(egui::Id::unique("panel-menu-opened"))) == Some(ui.ctx().cumulative_frame_nr()); if close || (!fresh && area.response.clicked_elsewhere()) || crate::widgets::escape_closes(ui.ctx()) { ui.ctx().data_mut(|d| d.remove::<(PanelKind, egui::Pos2)>(id)); } diff --git a/crates/ui-egui/src/panels/monitor.rs b/crates/ui-egui/src/panels/monitor.rs index e1f4d4766..4e62b3ed1 100644 --- a/crates/ui-egui/src/panels/monitor.rs +++ b/crates/ui-egui/src/panels/monitor.rs @@ -285,11 +285,11 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) } // A click on the Program monitor around the picture lets go of what is selected on it (#683; // in the picture the overlays do that). Under the picture and the overlays, so they come first. - if which == Which::Program && show_picture && ui.interact(video_area, egui::Id::new((prefix, "background")), Sense::click()).clicked() { + if which == Which::Program && show_picture && ui.interact(video_area, egui::Id::unique((prefix, "background")), Sense::click()).clicked() { crate::panels::graphics::deselect_on_monitor(app, ui); } // Click/drag in the picture: the Hand tool pans a magnified picture, otherwise focus. - let pic_resp = ui.interact(pic, egui::Id::new((prefix, "pic")), if show_picture { Sense::click_and_drag() } else { Sense::hover() }); + let pic_resp = ui.interact(pic, egui::Id::unique((prefix, "pic")), if show_picture { Sense::click_and_drag() } else { Sense::hover() }); app.auto.add(&format!("{prefix}.picture"), pic.intersect(video_area), "picture"); // (Comparison View always fits both frames) if show_picture && !compare { @@ -326,7 +326,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) let tc = format_time(time, rate, drop_frame, TimeDisplay::Timecode, 48000); let tc_rect = Rect::from_min_size(row1.min, vec2(110.0, row1.height())); let min = if which == Which::Program { Tick::ZERO } else { origin }; - match crate::widgets::timecode_field(ui, egui::Id::new((prefix, "timecode")), tc_rect, &tc, time, min, rate, drop_frame, t.timecode) { + match crate::widgets::timecode_field(ui, egui::Id::unique((prefix, "timecode")), tc_rect, &tc, time, min, rate, drop_frame, t.timecode) { Some(Ok(to)) => { let cmd = if which == Which::Program { "playhead.set" } else { "source.setPlayhead" }; if let Err(e) = app.session.execute(cmd, json!({"time": to.0})) { @@ -353,7 +353,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) None => tl!("Fit").to_string(), Some(z) => format!("{}%", (z * 100.0).round() as i32), }; - let zresp = crate::widgets::dropdown_text(ui, zr, &zoom_label, &t, egui::Id::new((prefix, "zoom"))); + let zresp = crate::widgets::dropdown_text(ui, zr, &zoom_label, &t, egui::Id::unique((prefix, "zoom"))); app.auto.add(&format!("{prefix}.zoom"), zr, "Select Zoom Level"); monitor_view::zoom_menu(app, &zresp, which); let res = mv.res; @@ -371,7 +371,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) if separate_source_drag { crate::panels::source_drag::controls(app, ui, Rect::from_min_size(pos2(row1.center().x - 52.0, settings_y + 26.0), vec2(104.0, 24.0))); } - let rresp = crate::widgets::dropdown_text(ui, rr, crate::i18n::t(res.label()), &t, egui::Id::new((prefix, "res"))); + let rresp = crate::widgets::dropdown_text(ui, rr, crate::i18n::t(res.label()), &t, egui::Id::unique((prefix, "res"))); app.auto.add(&format!("{prefix}.resolution"), rr, "Select Playback Resolution"); egui::Popup::menu(&rresp).show(|ui| { for r in PlaybackRes::ALL { @@ -381,7 +381,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) } }); let wr = Rect::from_min_size(pos2(rr.max.x + 6.0, settings_y + 1.0), vec2(22.0, 22.0)); - let wresp = ui.interact(wr, egui::Id::new((prefix, "wrench")), Sense::click()); + let wresp = ui.interact(wr, egui::Id::unique((prefix, "wrench")), Sense::click()); icons::paint(ui.painter(), wr.shrink(4.0), Icon::Wrench, if wresp.hovered() { t.tab_text_active } else { t.icon }); app.auto.add(&format!("{prefix}.settings"), wr, "Settings"); egui::Popup::menu(&wresp).show(|ui| { @@ -527,7 +527,7 @@ const EYEDROPPER_SCALE: f32 = 0.5; fn eyedropper(app: &mut FilmcraftApp, ui: &mut egui::Ui, resp: &egui::Response, pic: Rect, target: Target, rate: filmcraft_time::FrameRate, time: Tick) { let Some(armed) = app.ui.eyedropper.clone() else { return }; let ctx = ui.ctx().clone(); - let pending_id = egui::Id::new("eyedropper-pending"); + let pending_id = egui::Id::unique("eyedropper-pending"); if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { app.ui.eyedropper = None; ctx.data_mut(|d| d.remove::(pending_id)); @@ -684,7 +684,7 @@ fn time_zoom_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, which: Wh } app.auto.add(&format!("{prefix}.zoomBar"), thumb, "zoom scroll bar"); app.auto.add(&format!("{prefix}.zoomBar.track"), bar, "zoom scroll bar track"); - let resp = ui.interact(bar, egui::Id::new((prefix, "zoomBar")), Sense::click_and_drag()); + let resp = ui.interact(bar, egui::Id::unique((prefix, "zoomBar")), Sense::click_and_drag()); // the start that brings the thumb's middle under `x` let jump = |x: f32| ((x - bar.min.x - w / 2.0) as f64 * per_point).clamp(0.0, range); let mut set = None; @@ -696,7 +696,7 @@ fn time_zoom_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, which: Wh } // (a drag is known to be one only after the pointer has moved: what was grabbed is what was // under the pointer when the button went down) - let drag_id = egui::Id::new((prefix, "zoomBar-drag")); + let drag_id = egui::Id::unique((prefix, "zoomBar-drag")); if resp.drag_started() && let Some(pos) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) { @@ -763,7 +763,7 @@ fn mini_timeline( let xof = |tk: Tick| bar.min.x + (((tk - shown.0).0 as f64 / dur) as f32).clamp(0.0, 1.0) * bar.width(); // a time off a zoomed ruler is not drawn (past either end of it all, it sits at that end) let shows = |tk: Tick| (tk >= shown.0 || shown.0 <= origin) && (tk <= shown.1 || shown.1 >= end); - let resp = ui.interact(bar, egui::Id::new((which as u8, "scrub")), Sense::click_and_drag()); + let resp = ui.interact(bar, egui::Id::unique((which as u8, "scrub")), Sense::click_and_drag()); app.auto.add(if which == Which::Program { "program.scrubBar" } else { "source.scrubBar" }, bar, "scrub bar"); let interaction = crate::panels::source_range::interact(app, ui, bar, which, shown); let p = ui.painter(); @@ -945,7 +945,7 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, which: Which) // Keep the existing button-editor affordance only when it has its own space. // Customize this monitor's transport separately from the other monitor. let r = Rect::from_min_size(pos2(row.max.x - 30.0, row.min.y + 5.0), vec2(22.0, 22.0)); - let resp = ui.interact(r, egui::Id::new((prefix, "btn-editor")), Sense::click()).on_hover_text(tl!("Button Editor")); + let resp = ui.interact(r, egui::Id::unique((prefix, "btn-editor")), Sense::click()).on_hover_text(tl!("Button Editor")); app.auto.add(&format!("{prefix}.transport.editor"), r, "Button Editor"); icons::paint(ui.painter(), r.shrink(5.0), Icon::Plus, if resp.hovered() { t.tab_text_active } else { t.text_dim }); let mut edited_hidden = hidden.clone(); @@ -1009,8 +1009,8 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, which: Which) } if !fit.overflow.is_empty() { let rect = Rect::from_min_size(pos2(x, row.min.y + 2.0), vec2(TRANSPORT_BUTTON, TRANSPORT_BUTTON)); - let response = ui.interact(rect, egui::Id::new((prefix, "transport-more")), Sense::click()).on_hover_text(tl!("More transport controls")); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("More transport controls"))); + let response = ui.interact(rect, egui::Id::unique((prefix, "transport-more")), Sense::click()).on_hover_text(tl!("More transport controls")); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("More transport controls"))); if response.hovered() { ui.painter().rect_filled(rect, 4.0, t.hover); } @@ -1043,7 +1043,7 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, which: Which) fn transport_button(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, prefix: &str, icon: Icon, command: &str, label: &str) -> bool { let t = app.tokens; - let response = ui.interact(rect, egui::Id::new((prefix, command)), Sense::click()).on_hover_text(label); + let response = ui.interact(rect, egui::Id::unique((prefix, command)), Sense::click()).on_hover_text(label); app.auto.add_response(&format!("{prefix}.transport.{command}"), &response, label); if response.hovered() { ui.painter().rect_filled(rect, 4.0, t.hover); diff --git a/crates/ui-egui/src/panels/monitor_view.rs b/crates/ui-egui/src/panels/monitor_view.rs index 5afc7e635..b4fa596c8 100644 --- a/crates/ui-egui/src/panels/monitor_view.rs +++ b/crates/ui-egui/src/panels/monitor_view.rs @@ -385,7 +385,7 @@ pub fn zoom_pan_input(app: &mut FilmcraftApp, ui: &mut egui::Ui, w: Which, area: } let mut d = Vec2::ZERO; // a middle-button drag that began over the picture pans it, whatever the tool - let held = egui::Id::new((prefix(w), "middle-pan")); + let held = egui::Id::unique((prefix(w), "middle-pan")); let (pressed, down, moved) = ui.input(|i| (i.pointer.button_pressed(egui::PointerButton::Middle), i.pointer.middle_down(), i.pointer.delta())); let panning = down && (ui.data(|m| m.get_temp::(held)).unwrap_or(false) || (pressed && ui.rect_contains_pointer(area))); ui.data_mut(|m| m.insert_temp(held, panning)); @@ -394,7 +394,7 @@ pub fn zoom_pan_input(app: &mut FilmcraftApp, ui: &mut egui::Ui, w: Which, area: d += moved; } if app.ui.tool == Tool::Hand { - let resp = ui.interact(area, egui::Id::new((prefix(w), "pan")), Sense::drag()); + let resp = ui.interact(area, egui::Id::unique((prefix(w), "pan")), Sense::drag()); if resp.hovered() || resp.dragged() { ui.ctx().set_cursor_icon(if resp.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab }); } @@ -506,9 +506,9 @@ pub fn rulers(app: &mut FilmcraftApp, ui: &mut egui::Ui, w: Which, top: Rect, le } } } - let drag_id = egui::Id::new((pfx, "new-guide")); + let drag_id = egui::Id::unique((pfx, "new-guide")); for (r, name, vertical) in [(top, "top", false), (left, "left", true)] { - let resp = ui.interact(r, egui::Id::new((pfx, "ruler", name)), Sense::drag()); + let resp = ui.interact(r, egui::Id::unique((pfx, "ruler", name)), Sense::drag()); app.auto.add(&format!("{pfx}.ruler.{name}"), r, if vertical { "left ruler" } else { "top ruler" }); if resp.hovered() { ui.ctx().set_cursor_icon(if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }); @@ -560,13 +560,13 @@ pub fn guides(app: &mut FilmcraftApp, ui: &mut egui::Ui, w: Which, area: Rect, p if v.lock_guides { continue; } - let resp = ui.interact(hit, egui::Id::new((pfx, "guide", i)), Sense::drag()); + let resp = ui.interact(hit, egui::Id::unique((pfx, "guide", i)), Sense::drag()); if resp.hovered() || resp.dragged() { ui.ctx().set_cursor_icon(if g.vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }); } let left = egui::PointerButton::Primary; // egui ends a drag on Escape: that stop puts the guide back where it was (#580) - let before_id = egui::Id::new((pfx, "guide-before-drag", i)); + let before_id = egui::Id::unique((pfx, "guide-before-drag", i)); if resp.drag_started_by(left) { ui.data_mut(|d| d.insert_temp(before_id, g.position)); } @@ -651,7 +651,7 @@ pub fn waveform( } let x = xof(time); p.line_segment([pos2(x, area.min.y), pos2(x, area.max.y)], Stroke::new(1.0, t.playhead)); - let resp = ui.interact(area, egui::Id::new("source-waveform"), Sense::click_and_drag()); + let resp = ui.interact(area, egui::Id::unique("source-waveform"), Sense::click_and_drag()); app.auto.add("source.waveform", area, "audio waveform"); // Horizontal waveform drags scrub; dragging vertically lifts the marked audio to the timeline. let delta = resp.drag_delta(); @@ -694,7 +694,7 @@ pub fn compare_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, rate: f ] { let r = Rect::from_min_max(pos2(x - 34.0, bar.min.y + 1.0), pos2(x - 2.0, bar.max.y - 1.0)); x -= 34.0; - let resp = ui.interact(r, egui::Id::new(("compare", key)), Sense::click()).on_hover_text(tip); + let resp = ui.interact(r, egui::Id::unique(("compare", key)), Sense::click()).on_hover_text(tip); ui.painter().rect_filled(r, 3.0, if resp.hovered() { t.hover } else { t.field_bg }); ui.painter().text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(10.0), t.text); app.auto.add(&format!("program.compare.{key}"), r, tip); @@ -801,7 +801,7 @@ fn push(e: &mut Elems, id: &str, r: &egui::Response, label: &str) { /// Add Guide… / Save Guides as Template… / Manage Guides… dialogs. pub fn dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some(mut d) = app.ui.guide_dialog.clone() else { return }; - crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("guide-dialog-before-drag"), &mut d); + crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("guide-dialog-before-drag"), &mut d); let mut elems: Elems = Vec::new(); let mut close = crate::widgets::escape_closes(ctx); let mut act: Option<(String, Value)> = None; @@ -812,7 +812,7 @@ pub fn dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) { GuideDialog::Manage { .. } => ("Manage Guides", tl!("Manage Guides")), }; egui::Window::new(shown) - .id(egui::Id::new(title)) + .id(egui::Id::unique(title)) .collapsible(false) .resizable(false) .pivot(Align2::CENTER_CENTER) diff --git a/crates/ui-egui/src/panels/multicam.rs b/crates/ui-egui/src/panels/multicam.rs index 34ffae02d..70f0ffe0d 100644 --- a/crates/ui-egui/src/panels/multicam.rs +++ b/crates/ui-egui/src/panels/multicam.rs @@ -95,7 +95,7 @@ fn methods() -> [(&'static str, &'static str); 5] { /// Draw the open Synchronize / Merge Clips / Create Multi-Camera dialog. pub fn show_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some(mut d) = app.ui.sync_dialog.clone() else { return }; - crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("sync-dialog-before-drag"), &mut d); + crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("sync-dialog-before-drag"), &mut d); let (title, pre) = match d.kind.as_str() { "merge" => (tl!("Merge Clips"), "merge"), "multicam" => (tl!("Create Multi-Camera Source Sequence"), "mcam"), @@ -106,7 +106,7 @@ pub fn show_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { let accent = app.tokens.accent; let names: Vec = d.items.iter().filter_map(|i| app.session.project.item(ItemId(*i)).map(|it| it.name.clone())).collect(); egui::Window::new(title) - .id(egui::Id::new(("sync-dialog", pre))) + .id(egui::Id::unique(("sync-dialog", pre))) .collapsible(false) .resizable(false) .default_width(440.0) @@ -116,7 +116,7 @@ pub fn show_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { if pre != "sync" { ui.horizontal(|ui| { ui.label(if pre == "merge" { tl!("Clip Name:") } else { tl!("Sequence Name:") }); - let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0)); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0)), "Sequence Name"); push(&mut elems, format!("{pre}.name"), &r, "name"); }); ui.label(RichText::new(tlf!("{n} clip(s): {names}", n = names.len(), names = names.join(", "))).weak().small()); @@ -141,9 +141,12 @@ pub fn show_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { push(&mut elems, format!("{pre}.ignoreHours"), &r, "Ignore Hours"); } "marker" => { - let r = ui.add_enabled( - d.method == "marker", - egui::TextEdit::singleline(&mut d.marker).hint_text(tl!("first marker")).desired_width(140.0), + let r = crate::widgets::accessible_name( + ui.add_enabled( + d.method == "marker", + egui::TextEdit::singleline(&mut d.marker).hint_text(tl!("first marker")).desired_width(140.0), + ), + "first marker", ); push(&mut elems, format!("{pre}.marker"), &r, "marker name"); } @@ -161,7 +164,10 @@ pub fn show_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { "sync" => { ui.horizontal(|ui| { ui.label(tl!("Reference track:")); - let r = ui.add(egui::TextEdit::singleline(&mut d.track).hint_text(tl!("lowest (V1…)")).desired_width(90.0)); + let r = crate::widgets::accessible_name( + ui.add(egui::TextEdit::singleline(&mut d.track).hint_text(tl!("lowest (V1…)")).desired_width(90.0)), + "Reference track", + ); push(&mut elems, "sync.track", &r, "reference track"); }); } @@ -354,7 +360,7 @@ pub fn grid(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect) { let cell_i = k - lay.first; let cell = Rect::from_min_size(pic.min + vec2((cell_i % cols) as f32 * cw, (cell_i / cols) as f32 * ch), vec2(cw, ch)); let name = cams.cameras.get(angle).map(|c| c.name.clone()).unwrap_or_default(); - let resp = ui.interact(cell, egui::Id::new(("multicam-angle", k)), Sense::click()).on_hover_text(tlf!("Camera {n} — {name}", n = k + 1, name)); + let resp = ui.interact(cell, egui::Id::unique(("multicam-angle", k)), Sense::click()).on_hover_text(tlf!("Camera {n} — {name}", n = k + 1, name)); app.auto.add(&format!("program.multicam.angle.{}", k + 1), cell, &name); if resp.clicked() { // modifiers held now, or carried by the click event (synthetic input) @@ -385,7 +391,7 @@ pub fn grid(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect) { for (id, label, dx, cmd) in [("pagePrev", "◀", -70.0, "multicam.prevPage"), ("pageNext", "▶", 70.0, "multicam.nextPage")] { let r = Rect::from_center_size(mid + vec2(dx, 0.0), vec2(26.0, 18.0)); let enabled = if cmd == "multicam.prevPage" { lay.page > 0 } else { lay.page + 1 < lay.pages }; - let resp = ui.interact(r, egui::Id::new(("multicam-page", id)), Sense::click()).on_hover_text(if dx < 0.0 { + let resp = ui.interact(r, egui::Id::unique(("multicam-page", id)), Sense::click()).on_hover_text(if dx < 0.0 { tl!("Previous page") } else { tl!("Next page") @@ -401,7 +407,7 @@ pub fn grid(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect) { // record indicator let rr = Rect::from_min_size(area.min + vec2(8.0, 8.0), vec2(64.0, 18.0)); let rec_on = app.ui.multicam_record; - let resp = ui.interact(rr, egui::Id::new("multicam-record"), Sense::click()).on_hover_text(tl!("Multi-Camera Record On/Off (0)")); + let resp = ui.interact(rr, egui::Id::unique("multicam-record"), Sense::click()).on_hover_text(tl!("Multi-Camera Record On/Off (0)")); app.auto.add("program.multicam.record", rr, "Multi-Camera Record"); let dot = if recording { Color32::from_rgb(0xe0, 0x3a, 0x3a) @@ -455,7 +461,7 @@ pub fn show_edit_cameras(app: &mut FilmcraftApp, ctx: &egui::Context) { let th = thumb_w * q.settings.height.max(1) as f32 / q.settings.width.max(1) as f32; let tokens = app.tokens; egui::Window::new(tl!("Edit Cameras")) - .id(egui::Id::new("Edit Cameras")) + .id(egui::Id::unique("Edit Cameras")) .collapsible(false) .resizable(false) .default_width(420.0) @@ -481,7 +487,10 @@ pub fn show_edit_cameras(app: &mut FilmcraftApp, ctx: &egui::Context) { } elems.push((format!("editCameras.thumb.{a}"), tr, format!("Camera {}", a + 1))); ui.label(format!("{}", a + 1)); - let r = ui.add(egui::TextEdit::singleline(&mut d.names[a]).desired_width(180.0)); + let r = crate::widgets::accessible_name( + ui.add(egui::TextEdit::singleline(&mut d.names[a]).desired_width(180.0)), + format!("Camera {}", a + 1), + ); push(&mut elems, format!("editCameras.name.{a}"), &r, "Camera name"); }); } diff --git a/crates/ui-egui/src/panels/notes.rs b/crates/ui-egui/src/panels/notes.rs index 740db1f46..c5dc51723 100644 --- a/crates/ui-egui/src/panels/notes.rs +++ b/crates/ui-egui/src/panels/notes.rs @@ -35,7 +35,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { }) .inner; // each focus of the editor starts a new undo step; the edits while it keeps focus join it - let typing = egui::Id::new("project-notes-typing"); + let typing = egui::Id::unique("project-notes-typing"); if r.gained_focus() { ui.data_mut(|d| { let n = d.get_temp::(typing).unwrap_or(0).wrapping_add(1); diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs index 9867de393..a61f6c041 100644 --- a/crates/ui-egui/src/panels/presets.rs +++ b/crates/ui-egui/src/panels/presets.rs @@ -103,12 +103,12 @@ pub fn save_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut open = true; let mut close = false; let mut apply = false; - egui::Window::new(tl!("Save Preset")).id(egui::Id::new("Save Preset")).open(&mut open).collapsible(false).resizable(false).default_width(340.0).show( + egui::Window::new(tl!("Save Preset")).id(egui::Id::unique("Save Preset")).open(&mut open).collapsible(false).resizable(false).default_width(340.0).show( ctx, |ui| { ui.horizontal(|ui| { ui.label(tl!("Name:")); - let r = ui.text_edit_singleline(&mut d.name); + let r = crate::widgets::accessible_name(ui.text_edit_singleline(&mut d.name), tl!("Name")); app.auto.add("savePreset.name", r.rect, "Name"); }); ui.add_space(4.0); @@ -127,7 +127,7 @@ pub fn save_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { } ui.add_space(4.0); ui.label(tl!("Description:")); - let r = ui.text_edit_multiline(&mut d.description); + let r = crate::widgets::accessible_name(ui.text_edit_multiline(&mut d.description), tl!("Description")); app.auto.add("savePreset.description", r.rect, "Description"); ui.add_space(6.0); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 6e3487e8d..103c040f9 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -310,7 +310,7 @@ fn tab_strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, strip: Rect, docked: &[u _ => false, }; let id = k.map_or("project.tab.project".to_string(), |k| format!("project.tab.{k}")); - let resp = ui.interact(r, egui::Id::new(&id), Sense::click()); + let resp = ui.interact(r, egui::Id::unique(&id), Sense::click()); app.auto.add(&id, r, &label); if is_active || resp.hovered() { ui.painter().rect_filled(r, 3.0, if is_active { t.pressed } else { t.hover }); @@ -321,7 +321,7 @@ fn tab_strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, strip: Rect, docked: &[u } if let Some(k) = k { let cr = Rect::from_center_size(pos2(r.max.x - 11.0, r.center().y), vec2(12.0, 12.0)); - let cresp = ui.interact(cr, egui::Id::new(("ptab-close", k)), Sense::click()).on_hover_text(tl!("Close")); + let cresp = ui.interact(cr, egui::Id::unique(("ptab-close", k)), Sense::click()).on_hover_text(tl!("Close")); app.auto.add(&format!("project.tab.{k}.close"), cr, "Close"); icons::paint(ui.painter(), cr.shrink(2.0), Icon::Close, if cresp.hovered() { t.text } else { t.text_dim }); if cresp.clicked() || resp.middle_clicked() { @@ -352,7 +352,7 @@ pub fn floating(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut open = true; let root = app.ui.project_panel.tabs[k].bin == app.session.project.root.id.0; egui::Window::new(if root { tlf!("Project: {name}", name = app.session.project.name) } else { tlf!("Bin: {name}", name) }) - .id(egui::Id::new(("bin-window", k))) + .id(egui::Id::unique(("bin-window", k))) .open(&mut open) .default_size(vec2(520.0, 360.0)) .min_size(vec2(320.0, 220.0)) @@ -397,7 +397,7 @@ fn body(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View, actions Inst::Tab(k) => BinId(app.ui.project_panel.tabs[k].bin), }; let up = Rect::from_min_size(pos2(head.min.x, head.center().y - 9.0), vec2(22.0, 18.0)); - let up_resp = ui.interact(up, egui::Id::new((&pre, "up")), Sense::click()).on_hover_text(tl!("Up one level")); + let up_resp = ui.interact(up, egui::Id::unique((&pre, "up")), Sense::click()).on_hover_text(tl!("Up one level")); app.auto.add(&format!("{pre}.up"), up, "Up one level"); let can_up = v.bin != top; if up_resp.hovered() && can_up { @@ -429,7 +429,7 @@ fn body(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View, actions app.ui.project_search = q; app.auto.add(&format!("{pre}.search"), search_rect, "Search"); let find = Rect::from_min_size(pos2(search_rect.max.x + 8.0, row.min.y), vec2(24.0, 22.0)); - let fr = ui.interact(find, egui::Id::new((&pre, "find")), Sense::click()).on_hover_text(tl!("Find…")); + let fr = ui.interact(find, egui::Id::unique((&pre, "find")), Sense::click()).on_hover_text(tl!("Find…")); app.auto.add(&format!("{pre}.find"), find, "Find…"); if fr.hovered() { ui.painter().rect_filled(find, 3.0, t.hover); @@ -562,7 +562,7 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, v: &View, action (Icon::Freeform, ViewMode::Freeform, tl!("Freeform View")), ] { let r = Rect::from_min_size(pos2(x, bar.min.y + 5.0), vec2(26.0, 22.0)); - let resp = ui.interact(r, egui::Id::new((pre, "pv", tip)), Sense::click()).on_hover_text(tip); + let resp = ui.interact(r, egui::Id::unique((pre, "pv", tip)), Sense::click()).on_hover_text(tip); app.auto.add(&format!("{pre}.view.{mode:?}"), r, tip); if v.mode == mode { ui.painter().rect_filled(r, 4.0, t.pressed); @@ -579,10 +579,10 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, v: &View, action let sw = (bar.width() - 300.0).clamp(40.0, 120.0); let sr = Rect::from_min_size(pos2(x + 8.0, bar.min.y + 8.0), vec2(sw, 16.0)); let mut sz = v.icon_size; - let resp = ui.put(sr, egui::Slider::new(&mut sz, 48.0..=400.0).show_value(false)); + let resp = crate::widgets::accessible_name(ui.put(sr, egui::Slider::new(&mut sz, 48.0..=400.0).show_value(false)), tl!("Zoom")); app.auto.add(&format!("{pre}.iconSize"), sr, "Zoom"); // a drag ended by Escape goes back to the size it started from (#580) - let orig_id = egui::Id::new((pre, "icon-size-before-drag")); + let orig_id = egui::Id::unique((pre, "icon-size-before-drag")); if resp.drag_started() { ui.data_mut(|d| d.insert_temp(orig_id, v.icon_size)); } @@ -606,7 +606,7 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, v: &View, action // Sort Icons (Icon view) if v.mode == ViewMode::Icon { let r = Rect::from_min_size(pos2(x, bar.min.y + 5.0), vec2(24.0, 22.0)); - let resp = ui.interact(r, egui::Id::new((pre, "sortIcons")), Sense::click()).on_hover_text(tl!("Sort Icons")); + let resp = ui.interact(r, egui::Id::unique((pre, "sortIcons")), Sense::click()).on_hover_text(tl!("Sort Icons")); app.auto.add(&format!("{pre}.sortIcons"), r, "Sort Icons"); if resp.hovered() { ui.painter().rect_filled(r, 3.0, t.hover); @@ -623,7 +623,7 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, v: &View, action (Icon::Automate, "clip.automateToSequence", tl!("Automate to Sequence")), ] { let r = Rect::from_min_size(pos2(rx - 24.0, bar.min.y + 5.0), vec2(24.0, 22.0)); - let resp = ui.interact(r, egui::Id::new((pre, "pb", id)), Sense::click()).on_hover_text(tip); + let resp = ui.interact(r, egui::Id::unique((pre, "pb", id)), Sense::click()).on_hover_text(tip); app.auto.add(&format!("{pre}.button.{id}"), r, tip); if resp.hovered() { ui.painter().rect_filled(r, 3.0, t.hover); diff --git a/crates/ui-egui/src/panels/project_dialogs.rs b/crates/ui-egui/src/panels/project_dialogs.rs index 933770387..13d5a134c 100644 --- a/crates/ui-egui/src/panels/project_dialogs.rs +++ b/crates/ui-egui/src/panels/project_dialogs.rs @@ -10,12 +10,13 @@ use crate::FilmcraftApp; use crate::panels::project::ProjectDialog; fn push(app: &mut FilmcraftApp, id: &str, r: &egui::Response, label: &str) { + crate::widgets::accessible_name(r.clone(), label); app.auto.add(id, r.rect, label); } pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some(mut d) = app.ui.project_panel.dialog.clone() else { return }; - crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("project-dialog-before-drag"), &mut d); + crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("project-dialog-before-drag"), &mut d); let mut close = false; let mut run: Option<(String, Value)> = None; let title = match &d { @@ -25,7 +26,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { ProjectDialog::FreeformOptions { .. } => tl!("Freeform View Options"), ProjectDialog::SaveArrangement { .. } => tl!("Save Arrangement"), }; - let mut win = egui::Window::new(title).id(egui::Id::new("project-dialog")).collapsible(false).resizable(false).centered(ctx); + let mut win = egui::Window::new(title).id(egui::Id::unique("project-dialog")).collapsible(false).resizable(false).centered(ctx); // a fixed size keeps the two-list dialog from shifting while its lists settle if matches!(d, ProjectDialog::MetadataDisplay { .. }) { win = win.fixed_size(egui::vec2(480.0, 470.0)); @@ -36,7 +37,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { ProjectDialog::SavePresetAs { name } => { ui.horizontal(|ui| { ui.label(tl!("Name:")); - let r = ui.add(egui::TextEdit::singleline(name).desired_width(240.0)); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(name).desired_width(240.0)), "Name"); push(app, "projectDialog.name", &r, "Name"); }); if ui.input(|i| i.key_pressed(egui::Key::Enter)) { @@ -74,7 +75,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { ProjectDialog::SaveArrangement { name, bin } => { ui.horizontal(|ui| { ui.label(tl!("Name:")); - let r = ui.add(egui::TextEdit::singleline(name).desired_width(240.0)); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(name).desired_width(240.0)), "Name"); push(app, "projectDialog.name", &r, "Name"); }); if ok_cancel(app, ui, &mut close) && !name.trim().is_empty() { @@ -124,7 +125,8 @@ fn metadata_display(app: &mut FilmcraftApp, ui: &mut egui::Ui, columns: &mut Vec let all = filmcraft_engine::project_panel::all_columns(&app.session.project); ui.horizontal(|ui| { ui.label(tl!("Show:")); - let r = ui.add(egui::TextEdit::singleline(filter).hint_text(tl!("Search fields")).desired_width(220.0)); + let r = + crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(filter).hint_text(tl!("Search fields")).desired_width(220.0)), "Search fields"); push(app, "projectDialog.filter", &r, "Search fields"); }); ui.add_space(4.0); @@ -217,7 +219,7 @@ fn manage_presets(app: &mut FilmcraftApp, ui: &mut egui::Ui, selected: &mut usiz let filled = presets.get(*selected).is_some_and(Option::is_some); ui.horizontal(|ui| { ui.label(tl!("Name:")); - let r = ui.add_enabled(filled, egui::TextEdit::singleline(name).desired_width(200.0)); + let r = crate::widgets::accessible_name(ui.add_enabled(filled, egui::TextEdit::singleline(name).desired_width(200.0)), "Name"); push(app, "projectDialog.name", &r, "Name"); let b = ui.add_enabled(filled, egui::Button::new(tl!("Rename"))); push(app, "projectDialog.rename", &b, "Rename"); diff --git a/crates/ui-egui/src/panels/project_views.rs b/crates/ui-egui/src/panels/project_views.rs index d908725a1..5183f8269 100644 --- a/crates/ui-egui/src/panels/project_views.rs +++ b/crates/ui-egui/src/panels/project_views.rs @@ -414,11 +414,14 @@ fn rename_field(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, item: Option return false; } let mut text = rn.text; - let id = egui::Id::new(("rename-field", pre, item, bin)); - let resp = ui.put(r, egui::TextEdit::singleline(&mut text).id(id).font(egui::FontId::proportional(font(app))).margin(vec2(3.0, 1.0))); + let id = egui::Id::unique(("rename-field", pre, item, bin)); + let resp = crate::widgets::accessible_name( + ui.put(r, egui::TextEdit::singleline(&mut text).id(id).font(egui::FontId::proportional(font(app))).margin(vec2(3.0, 1.0))), + "Rename", + ); app.auto.add(&format!("{pre}.rename"), r, "Rename"); // take keyboard focus the first frame the field is shown - let shown = egui::Id::new("project-rename-shown"); + let shown = egui::Id::unique("project-rename-shown"); if ui.ctx().data(|d| d.get_temp::(shown)) != Some(id) { ui.ctx().data_mut(|d| d.insert_temp(shown, id)); resp.request_focus(); @@ -444,7 +447,7 @@ fn rename_field(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, item: Option /// A second click on the name of the only selected item starts renaming it (after the /// double-click interval, so a double-click still opens it). fn slow_click_rename(app: &mut FilmcraftApp, ui: &egui::Ui, resp: &egui::Response, name_rect: Rect, id: ItemId, was_selected: bool) { - let key = egui::Id::new("project-rename-pending"); + let key = egui::Id::unique("project-rename-pending"); let now = ui.input(|i| i.time); if resp.double_clicked() { ui.ctx().data_mut(|d| d.remove::<(u64, f64)>(key)); @@ -513,13 +516,13 @@ pub fn list_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View let hp = ui.painter().with_clip_rect(hr.intersect(ui.clip_rect())); hp.rect_filled(hr, 0.0, t.panel_bg); hp.line_segment([hr.left_bottom(), hr.right_bottom()], Stroke::new(1.0, t.separator)); - let mut x = rect.min.x + LEAD - out.state.offset.x; + let mut x = rect.min.x + LEAD - out.state.clamped_offset().x; let sort = app.session.prefs.project_panel.view.sort.clone(); for (i, c) in lc.cols.iter().enumerate() { let cr = Rect::from_min_size(pos2(x, hr.min.y), vec2(c.width, header_h)); let vis = cr.intersect(hr); if vis.width() > 1.0 { - let id = egui::Id::new((&v.prefix, "col", &c.name)); + let id = egui::Id::unique((&v.prefix, "col", &c.name)); let resp = ui.interact(vis, id, Sense::click()).on_hover_text(crate::i18n::t(&c.name)); app.auto.add(&format!("{}.list.header.{}", v.prefix, c.name), vis, &c.name); if resp.hovered() { @@ -551,7 +554,7 @@ pub fn list_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View // resize handle on the column's right edge let hr2 = Rect::from_center_size(pos2(cr.max.x, hr.center().y), vec2(8.0, header_h)); if hr2.intersects(hr) { - let id = egui::Id::new((&v.prefix, "colsize", &c.name)); + let id = egui::Id::unique((&v.prefix, "colsize", &c.name)); let resp = ui.interact(hr2.intersect(hr), id, Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeColumn); app.auto.add(&format!("{}.list.resize.{}", v.prefix, c.name), hr2.intersect(hr), "Resize column"); hp.line_segment([pos2(cr.max.x, hr.min.y + 4.0), pos2(cr.max.x, hr.max.y - 4.0)], Stroke::new(1.0, t.separator)); @@ -574,7 +577,7 @@ pub fn list_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View /// sit on top). Starts the frame's [`note_hit`] list. fn background(ui: &egui::Ui, v: &View) -> egui::Response { ui.data_mut(|d| d.remove::>(hits_id(v))); - ui.interact(ui.clip_rect(), egui::Id::new((&v.prefix, "background")), Sense::click_and_drag()) + ui.interact(ui.clip_rect(), egui::Id::unique((&v.prefix, "background")), Sense::click_and_drag()) } /// Empty space under the rows / cards, and the gaps between them (`bg`): click deselects, @@ -598,7 +601,7 @@ fn empty_space(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, bg: &egui::R } fn hits_id(v: &View) -> egui::Id { - egui::Id::new((&v.prefix, "marquee-hits")) + egui::Id::unique((&v.prefix, "marquee-hits")) } /// Remember where an item's row or card was drawn this frame, for [`marquee`]. @@ -612,7 +615,7 @@ fn note_hit(ui: &egui::Ui, v: &View, id: ItemId, r: Rect) { fn marquee(app: &mut FilmcraftApp, ui: &egui::Ui, v: &View, bg: &egui::Response, origin: egui::Pos2, actions: &mut Actions) { // (press point relative to `origin`, selection when the drag began, adds to it) type Drag = (egui::Vec2, Vec, bool); - let key = egui::Id::new((&v.prefix, "marquee")); + let key = egui::Id::unique((&v.prefix, "marquee")); let hits: Vec<(u64, Rect)> = ui.data_mut(|d| d.remove_temp(hits_id(v))).unwrap_or_default(); let current: Vec = app.session.state.project_selection.iter().map(|i| i.0).collect(); // the left button only: right-click opens the menu, the middle button is not a selection @@ -824,7 +827,7 @@ fn hover_time(app: &mut FilmcraftApp, ui: &egui::Ui, resp: &egui::Response, r: R fn card(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, id: ItemId, pre: &str, show_name: bool, show_dur: bool, sense: Sense) -> Option { let t = app.tokens; let it = app.session.project.item(id).cloned()?; - let resp = ui.interact(r.expand(2.0), egui::Id::new((pre, "card", id.0)), sense); + let resp = ui.interact(r.expand(2.0), egui::Id::unique((pre, "card", id.0)), sense); let th = Rect::from_min_size(r.min, vec2(r.width(), r.width() * 9.0 / 16.0)); let selected = app.session.state.project_selection.contains(&id); let card_bg = Rect::from_min_max(th.min - vec2(6.0, 6.0), pos2(th.max.x + 6.0, r.max.y + 2.0)); @@ -912,7 +915,7 @@ fn card(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, id: ItemId, pre: &st fn bin_card(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, b: &Bin, v: &View, actions: &mut Actions) { let t = app.tokens; let th = Rect::from_min_size(r.min, vec2(r.width(), r.width() * 9.0 / 16.0)); - let resp = ui.interact(r.expand(2.0), egui::Id::new((&v.prefix, "bincard", b.id.0)), Sense::click_and_drag()); + let resp = ui.interact(r.expand(2.0), egui::Id::unique((&v.prefix, "bincard", b.id.0)), Sense::click_and_drag()); let selected = app.ui.project_panel.selected_bin == Some(b.id.0); let bg = Rect::from_min_max(th.min - vec2(6.0, 6.0), pos2(th.max.x + 6.0, r.max.y + 2.0)); ui.painter().rect_filled(bg, 4.0, if selected { t.tl_header_bg } else { t.panel_bg }); @@ -1000,7 +1003,7 @@ pub fn freeform_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: & let opts = app.session.prefs.project_panel.freeform.clone(); let mut cards: Vec = pp::freeform_layout(&app.session.project, Some(v.bin), &opts, rect.width()); cards.retain(|c| matches_filter(app, ItemId(c.item), filter)); - let drag_key = egui::Id::new((&v.prefix, "ff-drag")); + let drag_key = egui::Id::unique((&v.prefix, "ff-drag")); // (dragged item, offset so far) let dragging: Option<(u64, egui::Vec2)> = ui.ctx().data(|d| d.get_temp(drag_key)); let sel: Vec = app.session.state.project_selection.iter().map(|i| i.0).collect(); diff --git a/crates/ui-egui/src/panels/reference.rs b/crates/ui-egui/src/panels/reference.rs index 4c5c70f68..07f2ded34 100644 --- a/crates/ui-egui/src/panels/reference.rs +++ b/crates/ui-egui/src/panels/reference.rs @@ -68,7 +68,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { None => crate::dock::placeholder(ui, area, &t, "…"), } elems.push(("reference.picture".into(), area, "reference scopes".into())); - let resp = ui.interact(area, egui::Id::new("reference-area"), Sense::click()); + let resp = ui.interact(area, egui::Id::unique("reference-area"), Sense::click()); egui::Popup::context_menu(&resp).show(|ui| { for k in ScopeKind::ALL { let on = st.scopes.contains(&k); @@ -101,7 +101,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let w = if icon.is_some() { 24.0 } else { galley_w + 16.0 }; let r = Rect::from_min_size(pos2(x, bar.center().y - 11.0), vec2(w, 22.0)); x = r.max.x + 4.0; - let resp = ui.interact(r, egui::Id::new(("reference-btn", id.to_string())), Sense::click()); + let resp = ui.interact(r, egui::Id::unique(("reference-btn", id.to_string())), Sense::click()); if on { ui.painter().rect_filled(r, 4.0, t.pressed); } else if resp.hovered() { diff --git a/crates/ui-egui/src/panels/remix.rs b/crates/ui-egui/src/panels/remix.rs index 185bf74cb..c1fe31932 100644 --- a/crates/ui-egui/src/panels/remix.rs +++ b/crates/ui-egui/src/panels/remix.rs @@ -20,7 +20,7 @@ struct Draft { } fn draft_id() -> egui::Id { - egui::Id::new("remix-properties-draft") + egui::Id::unique("remix-properties-draft") } /// Open the dialog for the selected remixed clip (the menu entry without parameters). @@ -44,12 +44,12 @@ pub fn is_open(ctx: &egui::Context) -> bool { pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some(Some(mut d)) = ctx.data(|m| m.get_temp::>(draft_id())) else { return }; - crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("remix-dialog-before-drag"), &mut d); + crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("remix-dialog-before-drag"), &mut d); let mut close = false; let mut apply = false; let mut elems: Vec<(String, egui::Rect, String)> = Vec::new(); egui::Window::new(tl!("Remix Properties")) - .id(egui::Id::new("Remix Properties")) + .id(egui::Id::unique("Remix Properties")) .collapsible(false) .resizable(false) .pivot(egui::Align2::CENTER_CENTER) diff --git a/crates/ui-egui/src/panels/scopes.rs b/crates/ui-egui/src/panels/scopes.rs index f68b0575a..bf8747961 100644 --- a/crates/ui-egui/src/panels/scopes.rs +++ b/crates/ui-egui/src/panels/scopes.rs @@ -57,7 +57,7 @@ pub fn frame_signal(app: &mut FilmcraftApp, ctx: &egui::Context, slot: &str, fra let pq = hdr_seq && space == ColorSpace::Rec2100; let rate = q.settings.frame_rate; let rev = app.session.revision; - let id = egui::Id::new(("scope-signal", slot)); + let id = egui::Id::unique(("scope-signal", slot)); let cached: Option<(u64, Arc, bool)> = ctx.data(|d| d.get_temp(id)); let key = hash_of((seq_id.0, frame, rev, pq)); if let Some((k, s, p)) = &cached @@ -99,7 +99,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { None => crate::dock::placeholder(ui, area, &t, if app.session.state.active_sequence.is_some() { "…" } else { tl!("(no sequence)") }), } // right-click anywhere on the scopes: the settings menu - let resp = ui.interact(area, egui::Id::new("scopes-area"), Sense::click()); + let resp = ui.interact(area, egui::Id::unique("scopes-area"), Sense::click()); egui::Popup::context_menu(&resp).show(|ui| settings_menu(app, ui)); footer(app, ui, Rect::from_min_max(pos2(rect.min.x, area.max.y), rect.max), sf.as_ref().map(|s| s.space)); } @@ -134,7 +134,7 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, space: Option Arc { /// A texture computed from (frame, settings, scope), kept until either changes. fn cached_texture(ctx: &egui::Context, slot: String, key: u64, make: impl FnOnce() -> (usize, usize, Vec)) -> Option { - let id = egui::Id::new(("scope-tex", slot.clone())); + let id = egui::Id::unique(("scope-tex", slot.clone())); if let Some((k, tex)) = ctx.data(|d| d.get_temp::<(u64, TextureHandle)>(id)) && k == key { diff --git a/crates/ui-egui/src/panels/sequence_settings.rs b/crates/ui-egui/src/panels/sequence_settings.rs index 0bd2f5dec..d043286aa 100644 --- a/crates/ui-egui/src/panels/sequence_settings.rs +++ b/crates/ui-egui/src/panels/sequence_settings.rs @@ -299,7 +299,7 @@ fn general(ui: &mut egui::Ui, d: &mut SequenceSettingsDraft, cur: &SequenceSetti .fold(0.0, f32::max); let list_w = 260.0; row(ui, label_w, "Sequence Name:", true, |ui| { - let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(list_w)); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(list_w)), "Sequence Name"); elems.push(("sequenceSettings.name".into(), r.rect, d.name.clone())); }); row(ui, label_w, "Editing Mode:", false, |ui| fixed_combo(ui, elems, "editingMode", "Custom", list_w, NOT_YET)); @@ -475,7 +475,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { let audio_samples = app.session.project.settings.audio_display_samples; let accent = app.tokens.accent; let mut d = app.ui.sequence_settings.clone(); - crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("sequence-settings-before-drag"), &mut d); + crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("sequence-settings-before-drag"), &mut d); let mut keep = true; let mut apply = false; let mut elems: Elems = Vec::new(); @@ -487,7 +487,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { &[("general", "General"), ("color", "Color Management"), ("vr", "VR Properties")] }; egui::Window::new(title) - .id(egui::Id::new("Sequence Settings")) + .id(egui::Id::unique("Sequence Settings")) .collapsible(false) .resizable(false) .default_width(620.0) diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index b48f7d613..5838e3449 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -303,7 +303,7 @@ fn appearance_modes(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft) let mode = choice_text(get(&d.values, key)); ui.horizontal(|ui| { field_label(ui, "Appearance Mode"); - if let Some(v) = combo(app, ui, key, &mode, &static_opts(settings::APPEARANCE_MODES), 200.0) { + if let Some(v) = combo(app, ui, key, "Appearance Mode", &mode, &static_opts(settings::APPEARANCE_MODES), 200.0) { put(&mut d.values, key, json!(v)); } }); @@ -428,7 +428,7 @@ fn choice_text(v: &Value) -> String { } /// A dropdown of (value, label) options; returns the chosen value. -fn combo(app: &mut FilmcraftApp, ui: &mut Ui, key: &str, current: &str, opts: &[(String, String)], width: f32) -> Option { +fn combo(app: &mut FilmcraftApp, ui: &mut Ui, key: &str, label: &str, current: &str, opts: &[(String, String)], width: f32) -> Option { let shown = opts.iter().find(|o| o.0 == current).map(|o| o.1.clone()).unwrap_or_else(|| current.to_string()); let mut chosen = None; let mut items: Vec<(String, Rect, String)> = Vec::new(); @@ -447,7 +447,10 @@ fn combo(app: &mut FilmcraftApp, ui: &mut Ui, key: &str, current: &str, opts: &[ } } }); - app.auto.add(&format!("settings.{key}"), r.response.rect, &shown); + // The accessible name is the field's label, while the automation label stays the selected + // value so `ui.elements` reports what the dropdown shows. + let response = crate::widgets::accessible_name(r.response, crate::i18n::t(label)); + app.auto.add(&format!("settings.{key}"), response.rect, &shown); for (id, rect, l) in items { app.auto.add(&id, rect, &l); } @@ -522,13 +525,13 @@ fn draw_field(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, f: &Fi if let Some(x) = number(app, ui, f.key, cur.as_f64().unwrap_or(1.0), range, decimals, f.label) { put(&mut d.values, f.key, json!(if u == "frames" { x.round() } else { x })); } - if let Some(v) = combo(app, ui, unit_key, &u, &static_opts(settings::DURATION_UNITS), 90.0) { + if let Some(v) = combo(app, ui, unit_key, f.label, &u, &static_opts(settings::DURATION_UNITS), 90.0) { put(&mut d.values, unit_key, json!(v)); } } Kind::Choice(opts) => { field_label(ui, f.label); - if let Some(v) = combo(app, ui, f.key, &choice_text(&cur), &static_opts(opts), 300.0) { + if let Some(v) = combo(app, ui, f.key, f.label, &choice_text(&cur), &static_opts(opts), 300.0) { let x = if cur.is_number() { v.parse::().map(Value::from).unwrap_or(json!(v)) } else { json!(v) }; put(&mut d.values, f.key, x); } @@ -536,7 +539,7 @@ fn draw_field(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, f: &Fi Kind::Device(list) => { field_label(ui, f.label); let opts = device_opts(app, d, list); - if let Some(v) = combo(app, ui, f.key, &choice_text(&cur), &opts, 300.0) { + if let Some(v) = combo(app, ui, f.key, f.label, &choice_text(&cur), &opts, 300.0) { put(&mut d.values, f.key, json!(v)); } } @@ -549,7 +552,7 @@ fn draw_field(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, f: &Fi let c = settings::parse_hex(&choice_text(get(&d.values, &format!("labels.colors.{}.color", settings::label_id(l))))).unwrap_or(l.rgb()); ui.painter().rect_filled(sw, 2.0, Color32::from_rgb(c[0], c[1], c[2])); } - if let Some(v) = combo(app, ui, f.key, &cur_s, &opts, 160.0) { + if let Some(v) = combo(app, ui, f.key, f.label, &cur_s, &opts, 160.0) { put(&mut d.values, f.key, json!(v)); } } @@ -570,7 +573,7 @@ fn draw_field(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, f: &Fi } else { ui.available_width().clamp(60.0, 220.0) }; - let r = ui.add(egui::TextEdit::singleline(&mut s).desired_width(width).hint_text(hint)); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut s).desired_width(width).hint_text(hint)), f.label); app.auto.add(&format!("settings.{}", f.key), r.rect, f.label); if r.changed() { put(&mut d.values, f.key, json!(s)); @@ -608,9 +611,12 @@ fn color_field(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, key: } // While the field has focus it shows what is typed, even when that is not a colour yet (one // character into `#2f6bdf`); each valid colour is kept as it comes. Leaving shows the kept one. - let draft_id = egui::Id::new(("settings-hex", key)); + let draft_id = egui::Id::unique(("settings-hex", key)); let mut text = ui.data(|m| m.get_temp::(draft_id)).unwrap_or_else(|| choice_text(get(&d.values, key))); - let t = ui.add(egui::TextEdit::singleline(&mut text).desired_width(70.0).font(egui::TextStyle::Monospace)); + let t = crate::widgets::accessible_name( + ui.add(egui::TextEdit::singleline(&mut text).desired_width(70.0).font(egui::TextStyle::Monospace)), + format!("{label} hex"), + ); app.auto.add(&format!("settings.{key}.hex"), t.rect, label); if t.has_focus() { ui.data_mut(|m| m.insert_temp(draft_id, text.clone())); @@ -639,7 +645,7 @@ fn label_presets(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft) { ui.horizontal(|ui| { let opts: Vec<(String, String)> = presets.iter().map(|p| (p.name.clone(), p.name.clone())).collect(); let shown = if modified { tlf!("{name} (Modified)", name = labels.preset) } else { labels.preset.clone() }; - if let Some(name) = combo(app, ui, "labels.preset", &shown, &opts, 150.0) { + if let Some(name) = combo(app, ui, "labels.preset", "Label Color Presets", &shown, &opts, 150.0) { pick = presets.iter().find(|p| p.name == name).cloned(); } if preset_button(app, ui, "revert", Icon::Reset, tl!("Revert to the preset's colors and names"), modified) { @@ -730,7 +736,10 @@ fn custom(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, name: &str let id = settings::label_id(*l); let nk = format!("labels.colors.{id}.name"); let mut name = choice_text(get(&d.values, &nk)); - let r = ui.add_sized(vec2(136.0, 22.0), egui::TextEdit::singleline(&mut name)); + let r = crate::widgets::accessible_name( + ui.add_sized(vec2(136.0, 22.0), egui::TextEdit::singleline(&mut name)), + format!("{} name", l.name()), + ); app.auto.add(&format!("settings.{nk}"), r.rect, l.name()); if r.changed() { put(&mut d.values, &nk, json!(name)); @@ -845,11 +854,11 @@ fn rows(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, list: &[Row] /// Draw the dialog; returns whether it stays open. pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { let Some(mut d) = app.ui.settings.take() else { return false }; - crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("settings-dialog-before-drag"), &mut d); + crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("settings-dialog-before-drag"), &mut d); let t = app.tokens; let mut close = false; let mut apply = false; - let resp = egui::Modal::new(egui::Id::new("settings-modal")).frame(modal_frame(&t)).show(ctx, |ui| { + let resp = egui::Modal::new(egui::Id::unique("settings-modal")).frame(modal_frame(&t)).show(ctx, |ui| { ui.set_width(W); // title bar (Premiere keeps "Preferences" as the window title) let (bar, _) = ui.allocate_exact_size(vec2(W, 30.0), Sense::hover()); @@ -864,7 +873,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { ui.painter().rect(list, 2.0, t.field_bg, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside); for (i, c) in settings::categories().iter().enumerate() { let row = Rect::from_min_size(list.min + vec2(1.0, 1.0 + i as f32 * ROW_H), vec2(LIST_W - 2.0, ROW_H)); - let r = ui.interact(row, egui::Id::new(("settings-cat", c.id)), Sense::click()); + let r = ui.interact(row, egui::Id::unique(("settings-cat", c.id)), Sense::click()); let sel = d.page == c.id; if sel { ui.painter().rect_filled(row, 0.0, t.row_alt); diff --git a/crates/ui-egui/src/panels/shortcuts_dialog.rs b/crates/ui-egui/src/panels/shortcuts_dialog.rs index 62c50ddf3..005326f04 100644 --- a/crates/ui-egui/src/panels/shortcuts_dialog.rs +++ b/crates/ui-egui/src/panels/shortcuts_dialog.rs @@ -150,8 +150,8 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { let mut close: Option = None; // Some(true) = OK, Some(false) = Cancel let mut open = true; egui::Window::new(tl!("Keyboard Shortcuts")) - .id(egui::Id::new("Keyboard Shortcuts")) - .id(egui::Id::new("keyboard-shortcuts")) + .id(egui::Id::unique("Keyboard Shortcuts")) + .id(egui::Id::unique("keyboard-shortcuts")) .open(&mut open) .collapsible(false) .resizable(false) @@ -262,7 +262,7 @@ fn header(app: &mut FilmcraftApp, ui: &mut egui::Ui, t: &Tokens) { if let Some(mut name) = app.shortcut_editor.save_name.clone() { ui.horizontal(|ui| { ui.label(RichText::new(tl!("Save preset as:")).size(13.0).color(t.text_dim)); - let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(260.0)); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut name).desired_width(260.0)), "Preset name"); app.auto.add("shortcuts.saveAs.name", r.rect, "Preset name"); let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); if small_button(app, ui, "shortcuts.saveAs.ok", tl!("Save"), !name.trim().is_empty()) || enter { @@ -483,7 +483,7 @@ fn keyboard(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect, t: &Tokens) { (true, true) => format!("shortcuts.mod.{}.right", &name[1..]), _ => format!("shortcuts.key.{name}"), }; - let resp = ui.interact(r, egui::Id::new(&id), Sense::click()); + let resp = ui.interact(r, egui::Id::unique(&id), Sense::click()); let p = ui.painter(); let base = if is_mod { if mod_on { MOD_KEY } else { FREE_KEY.gamma_multiply(0.8) } @@ -594,7 +594,10 @@ fn command_list(app: &mut FilmcraftApp, ui: &mut egui::Ui, height: f32, t: &Toke let plat = Platform::current(); ui.horizontal(|ui| { let mut q = app.shortcut_editor.search.clone(); - let r = ui.add(egui::TextEdit::singleline(&mut q).hint_text(tl!("Search commands or keys")).desired_width(320.0)); + let r = crate::widgets::accessible_name( + ui.add(egui::TextEdit::singleline(&mut q).hint_text(tl!("Search commands or keys")).desired_width(320.0)), + "Search commands or keys", + ); app.auto.add("shortcuts.search", r.rect, "Search"); app.shortcut_editor.search = q; if let Some(sel) = app.shortcut_editor.selected.clone() { @@ -664,7 +667,7 @@ fn command_list(app: &mut FilmcraftApp, ui: &mut egui::Ui, height: f32, t: &Toke .collect::>() .join(", ") }; - let cresp = ui.interact(cell, egui::Id::new(("shortcut-cell", &id)), Sense::click()); + let cresp = ui.interact(cell, egui::Id::unique(("shortcut-cell", &id)), Sense::click()); if recording { ui.painter().rect(cell, 3.0, t.field_bg, Stroke::new(1.5, t.accent), StrokeKind::Inside); } else if cresp.hovered() { @@ -677,7 +680,7 @@ fn command_list(app: &mut FilmcraftApp, ui: &mut egui::Ui, height: f32, t: &Toke app.shortcut_editor.recording = Some((id.clone(), false)); } let plus = Rect::from_min_size(pos2(r.max.x - 30.0, r.min.y + 3.0), vec2(20.0, 20.0)); - let presp = ui.interact(plus, egui::Id::new(("shortcut-add", &id)), Sense::click()).on_hover_text(tl!("Add another shortcut")); + let presp = ui.interact(plus, egui::Id::unique(("shortcut-add", &id)), Sense::click()).on_hover_text(tl!("Add another shortcut")); ui.painter().text( plus.center(), Align2::CENTER_CENTER, diff --git a/crates/ui-egui/src/panels/source_drag.rs b/crates/ui-egui/src/panels/source_drag.rs index d705e0f2b..35f718118 100644 --- a/crates/ui-egui/src/panels/source_drag.rs +++ b/crates/ui-egui/src/panels/source_drag.rs @@ -42,8 +42,8 @@ pub fn controls(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect) { let left = row.center().x - (width * 3.0 + gap * 2.0) * 0.5; for (i, (id, label, video, audio, enabled)) in buttons.into_iter().enumerate() { let rect = Rect::from_min_size(pos2(left + i as f32 * (width + gap), row.min.y), vec2(width, row.height())); - let response = ui.add_enabled_ui(enabled, |ui| ui.interact(rect, egui::Id::new(id), Sense::drag())).inner; - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, label)); + let response = ui.add_enabled_ui(enabled, |ui| ui.interact(rect, egui::Id::unique(id), Sense::drag())).inner; + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, label)); let t = app.tokens; if response.hovered() || response.dragged() { ui.painter().rect_filled(rect, 2.0, t.hover); diff --git a/crates/ui-egui/src/panels/source_range.rs b/crates/ui-egui/src/panels/source_range.rs index d7bc66e9e..8551bef9c 100644 --- a/crates/ui-egui/src/panels/source_range.rs +++ b/crates/ui-egui/src/panels/source_range.rs @@ -22,7 +22,7 @@ struct Drag { revision: u64, } fn key(which: Which) -> egui::Id { - egui::Id::new(("monitor-range-edit", which as u8)) + egui::Id::unique(("monitor-range-edit", which as u8)) } fn self_item(app: &FilmcraftApp, which: Which) -> Option { if which == Which::Source { app.session.state.source_item } else { app.session.state.active_sequence } @@ -103,7 +103,7 @@ pub fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, which: Whi } app.auto.add(id, rect, label); let response = - ui.add_enabled_ui(enabled && rect.width() > 0.0, |ui| ui.interact(rect, egui::Id::new(id), Sense::click_and_drag())).inner.on_hover_text(label); + ui.add_enabled_ui(enabled && rect.width() > 0.0, |ui| ui.interact(rect, egui::Id::unique(id), Sense::click_and_drag())).inner.on_hover_text(label); let cursor = if edge == RangeEdge::Move { if cfg!(windows) { egui::CursorIcon::PointingHand } else { egui::CursorIcon::Grab } } else { @@ -197,7 +197,7 @@ pub fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, which: Whi let rect = Rect::from_min_max(pos2(x - 7.0, bar.max.y - 12.0), pos2(x + 7.0, bar.max.y)); let id = if which == Which::Source { "source.playhead" } else { "program.playhead" }; app.auto.add(id, rect, "Scrub playhead"); - let playhead = ui.interact(rect, egui::Id::new(id), Sense::click_and_drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal); + let playhead = ui.interact(rect, egui::Id::unique(id), Sense::click_and_drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal); if (playhead.dragged() || playhead.clicked()) && let Some(pos) = playhead.interact_pointer_pos() { diff --git a/crates/ui-egui/src/panels/text.rs b/crates/ui-egui/src/panels/text.rs index b6eaca3c9..1fcc611d6 100644 --- a/crates/ui-egui/src/panels/text.rs +++ b/crates/ui-egui/src/panels/text.rs @@ -37,7 +37,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let text_w = tab_text_width(ui.painter(), shown); let w = text_w + 16.0; let r = Rect::from_min_size(pos2(x, rect.min.y + 4.0), vec2(w, 24.0)); - let resp = ui.interact(r, egui::Id::new(("text-tab", tab)), Sense::click()); + let resp = ui.interact(r, egui::Id::unique(("text-tab", tab)), Sense::click()); let active = app.ui.text_tab == tab; ui.painter().text( pos2(r.min.x, r.center().y), @@ -65,7 +65,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { fn tool_button(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, icon: Icon, id: &str, label: &str, enabled: bool) -> bool { let t = app.tokens; - let resp = ui.interact(r, egui::Id::new(("text-tool", id)), if enabled { Sense::click() } else { Sense::hover() }); + let resp = ui.interact(r, egui::Id::unique(("text-tool", id)), if enabled { Sense::click() } else { Sense::hover() }); if enabled && resp.hovered() { ui.painter().rect_filled(r, 3.0, t.hover); } @@ -95,7 +95,7 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { .enumerate() { let r = Rect::from_center_size(c + vec2(0.0, 26.0 + i as f32 * 34.0), vec2(200.0, 26.0)); - let resp = ui.interact(r, egui::Id::new(id), Sense::click()); + let resp = ui.interact(r, egui::Id::unique(id), Sense::click()); ui.painter().rect_filled(r, 13.0, if i == 0 { if resp.hovered() { t.accent_hover } else { t.accent } } else { t.field_bg }); ui.painter().text(r.center(), Align2::CENTER_CENTER, label, Tokens::semibold(12.0), if i == 0 { Color32::WHITE } else { t.text }); app.auto.add(id, r, label); @@ -112,7 +112,7 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // the track shown: the one holding the first selected caption, else C1 let sel = app.session.state.caption_selection.clone(); let track_idx = sel.first().and_then(|c| seq.caption_tracks.iter().position(|tr| tr.caption(*c).is_some())).unwrap_or(0); - let track_idx = ui.ctx().data(|d| d.get_temp::(egui::Id::new("text-cap-track"))).filter(|i| *i < seq.caption_tracks.len()).unwrap_or(track_idx); + let track_idx = ui.ctx().data(|d| d.get_temp::(egui::Id::unique("text-cap-track"))).filter(|i| *i < seq.caption_tracks.len()).unwrap_or(track_idx); let track = &seq.caption_tracks[track_idx]; // ---- toolbar: search, track picker, add / split / merge / delete @@ -123,12 +123,12 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut x = bar.min.x + 180.0f32.min(bar.width() * 0.4 + 10.0); let picker = Rect::from_min_size(pos2(x, bar.min.y + 1.0), vec2(130.0, 22.0)); let label = format!("C{} · {}", track_idx + 1, track.name); - let presp = crate::widgets::dropdown_text(ui, picker, &label, &t, egui::Id::new("text-cap-track-picker")); + let presp = crate::widgets::dropdown_text(ui, picker, &label, &t, egui::Id::unique("text-cap-track-picker")); app.auto.add("text.captions.track", picker, "Caption track"); egui::Popup::menu(&presp).show(|ui| { for (i, tr) in seq.caption_tracks.iter().enumerate() { if ui.selectable_label(i == track_idx, format!("C{} · {} ({})", i + 1, tr.name, crate::i18n::t(tr.format.label()))).clicked() { - ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("text-cap-track"), i)); + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("text-cap-track"), i)); } } ui.separator(); @@ -209,14 +209,17 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } } for (edge, val) in [("in", c.start), ("out", c.end())] { - let key = egui::Id::new(("cap-tc", c.id.0, edge)); + let key = egui::Id::unique(("cap-tc", c.id.0, edge)); let mut buf = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| tc(val)); - let resp = ui.add( - egui::TextEdit::singleline(&mut buf) - .id(key.with("te")) - .desired_width(88.0) - .font(Tokens::mono(11.0)) - .text_color(if edge == "in" { t.hot_text } else { t.text_dim }), + let resp = crate::widgets::accessible_name( + ui.add( + egui::TextEdit::singleline(&mut buf) + .id(key.with("te")) + .desired_width(88.0) + .font(Tokens::mono(11.0)) + .text_color(if edge == "in" { t.hot_text } else { t.text_dim }), + ), + if edge == "in" { "Caption in" } else { "Caption out" }, ); app.auto.add(&format!("text.captions.{}.{edge}", c.id.0), resp.rect, if edge == "in" { "Caption in" } else { "Caption out" }); if resp.has_focus() { @@ -234,18 +237,21 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if let Some(sp) = &c.speaker { ui.label(egui::RichText::new(sp).size(11.0).color(t.text_dim).strong()); } - let key = egui::Id::new(("cap-text", c.id.0)); + let key = egui::Id::unique(("cap-text", c.id.0)); let mut buf = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| c.text.clone()); let resp = if crate::rtl::has_rtl(&buf) && !ui.memory(|m| m.has_focus(key.with("te"))) { reading_order(ui, &buf, key.with("te")) } else { - ui.add( - egui::TextEdit::multiline(&mut buf) - .id(key.with("te")) - .desired_rows(1) - .desired_width(ui.available_width()) - .font(Tokens::ui(12.5)) - .frame(egui::Frame::NONE), + crate::widgets::accessible_name( + ui.add( + egui::TextEdit::multiline(&mut buf) + .id(key.with("te")) + .desired_rows(1) + .desired_width(ui.available_width()) + .font(Tokens::ui(12.5)) + .frame(egui::Frame::NONE), + ), + "Caption text", ) }; app.auto.add(&format!("text.captions.{}.text", c.id.0), resp.rect, "Caption text"); @@ -346,7 +352,7 @@ fn graphics(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut actions: Vec<(String, Value)> = Vec::new(); // search let bar = Rect::from_min_size(rect.min + vec2(10.0, 2.0), vec2(rect.width() - 20.0, 26.0)); - let skey = egui::Id::new("text-gfx-search"); + let skey = egui::Id::unique("text-gfx-search"); let mut search = ui.data(|d| d.get_temp::(skey)).unwrap_or_default(); let w = 220.0f32.min(bar.width()); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_size(bar.min, vec2(w, 24.0)))); @@ -399,15 +405,18 @@ fn graphics(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.label(egui::RichText::new(label).size(11.0).color(t.text_dim)); }); ui.vertical(|ui| { - let tkey = egui::Id::new(("gfx-text", g.clip.0, g.layer)); + let tkey = egui::Id::unique(("gfx-text", g.clip.0, g.layer)); let mut buf = ui.data(|d| d.get_temp::(tkey)).unwrap_or_else(|| g.text.clone()); - let resp = ui.add( - egui::TextEdit::multiline(&mut buf) - .id(tkey.with("te")) - .desired_rows(1) - .desired_width(ui.available_width()) - .font(Tokens::ui(12.5)) - .frame(egui::Frame::NONE), + let resp = crate::widgets::accessible_name( + ui.add( + egui::TextEdit::multiline(&mut buf) + .id(tkey.with("te")) + .desired_rows(1) + .desired_width(ui.available_width()) + .font(Tokens::ui(12.5)) + .frame(egui::Frame::NONE), + ), + "Graphic text", ); app.auto.add(&format!("text.graphics.{key}.text"), resp.rect, "Graphic text"); if resp.has_focus() { @@ -463,9 +472,12 @@ pub fn caption_properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) let (hr, _) = bui.allocate_exact_size(vec2(bui.available_width(), 30.0), Sense::hover()); bui.painter().text(pos2(hr.min.x, hr.center().y), Align2::LEFT_CENTER, format!("C{} · {}", track_idx + 1, track_name), Tokens::semibold(12.5), t.text); bui.label(egui::RichText::new(tl!("Text")).size(12.0).color(t.text_dim)); - let key = egui::Id::new(("props-cap-text", c.id.0)); + let key = egui::Id::unique(("props-cap-text", c.id.0)); let mut buf = bui.data(|d| d.get_temp::(key)).unwrap_or_else(|| c.text.clone()); - let resp = bui.add(egui::TextEdit::multiline(&mut buf).id(key.with("te")).desired_rows(3).desired_width(bui.available_width()).font(Tokens::ui(12.5))); + let resp = crate::widgets::accessible_name( + bui.add(egui::TextEdit::multiline(&mut buf).id(key.with("te")).desired_rows(3).desired_width(bui.available_width()).font(Tokens::ui(12.5))), + "Caption text", + ); app.auto.add("properties.caption.text", resp.rect, &c.text); if resp.has_focus() { bui.data_mut(|d| d.insert_temp(key, buf.clone())); @@ -479,9 +491,12 @@ pub fn caption_properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) for (edge, label, val) in [("in", tl!("In"), c.start), ("out", tl!("Out"), c.end())] { bui.horizontal(|ui| { ui.add_sized(vec2(70.0, 22.0), egui::Label::new(egui::RichText::new(label).size(12.0).color(t.text_dim))); - let key = egui::Id::new(("props-cap-tc", c.id.0, edge)); + let key = egui::Id::unique(("props-cap-tc", c.id.0, edge)); let mut buf = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| tc(val)); - let resp = ui.add(egui::TextEdit::singleline(&mut buf).id(key.with("te")).desired_width(100.0).font(Tokens::mono(11.5))); + let resp = crate::widgets::accessible_name( + ui.add(egui::TextEdit::singleline(&mut buf).id(key.with("te")).desired_width(100.0).font(Tokens::mono(11.5))), + if edge == "in" { "In" } else { "Out" }, + ); app.auto.add(&format!("properties.caption.{edge}"), resp.rect, &tc(val)); if resp.has_focus() { ui.data_mut(|d| d.insert_temp(key, buf.clone())); @@ -537,7 +552,7 @@ fn transcript(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { }; ui.painter().text(c - vec2(0.0, 8.0), Align2::CENTER_CENTER, note, Tokens::ui(12.0), t.text_dim); let r = Rect::from_center_size(c + vec2(0.0, 26.0), vec2(200.0, 26.0)); - let resp = ui.interact(r, egui::Id::new("text.transcript.generate"), Sense::click()); + let resp = ui.interact(r, egui::Id::unique("text.transcript.generate"), Sense::click()); ui.painter().rect_filled(r, 13.0, if resp.hovered() { t.accent_hover } else { t.accent }); ui.painter().text(r.center(), Align2::CENTER_CENTER, tl!("Transcribe"), Tokens::semibold(12.0), Color32::WHITE); app.auto.add("text.transcript.generate", r, "Transcribe"); diff --git a/crates/ui-egui/src/panels/timecode.rs b/crates/ui-egui/src/panels/timecode.rs index 1dcfccccd..a9a818f2f 100644 --- a/crates/ui-egui/src/panels/timecode.rs +++ b/crates/ui-egui/src/panels/timecode.rs @@ -121,7 +121,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if show_name { ui.painter().text(pos2(r.center().x, vr.max.y + 2.0), Align2::CENTER_TOP, &caption, Tokens::ui(11.0), t.text_dim); } - let resp = ui.interact(r, egui::Id::new(("timecode-row", i)), Sense::click()); + let resp = ui.interact(r, egui::Id::unique(("timecode-row", i)), Sense::click()); elems.push((format!("timecode.row.{i}"), vr, value.clone())); egui::Popup::context_menu(&resp).show(|ui| { ui.set_min_width(200.0); @@ -169,7 +169,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } // "+" adds a row (same settings as the last one, Duration by default) let ar = Rect::from_min_size(pos2(rect.max.x - 28.0, rect.max.y - 26.0), vec2(22.0, 22.0)); - let aresp = ui.interact(ar, egui::Id::new("timecode-add"), Sense::click()); + let aresp = ui.interact(ar, egui::Id::unique("timecode-add"), Sense::click()); ui.painter().text(ar.center(), Align2::CENTER_CENTER, "+", Tokens::ui(16.0), if aresp.hovered() { t.tab_text_active } else { t.icon }); elems.push(("timecode.addRow".into(), ar, "Add Row".into())); if aresp.on_hover_text(tl!("Add a timecode row")).clicked() { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 33c243762..e8583b803 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -644,7 +644,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // divider between video and audio let div = Rect::from_min_max(pos2(rect.min.x, split_y), pos2(content.max.x, split_y + DIVIDER_H)); painter.rect_filled(div, 0.0, t.app_bg); - let div_resp = ui.interact(div, egui::Id::new("tl-divider"), Sense::drag()); + let div_resp = ui.interact(div, egui::Id::unique("tl-divider"), Sense::drag()); if div_resp.hovered() || div_resp.dragged() { ctx.set_cursor_icon(CursorIcon::ResizeVertical); } @@ -738,7 +738,7 @@ fn empty_state(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.painter().text(rect.center() + vec2(0.0, 50.0), Align2::CENTER_CENTER, hint, Tokens::ui(12.0), t.text_dim); } let b = Rect::from_center_size(rect.center() + vec2(0.0, 20.0), vec2(170.0, 26.0)); - let resp = ui.interact(b, egui::Id::new("tl-open-demo"), Sense::click()); + let resp = ui.interact(b, egui::Id::unique("tl-open-demo"), Sense::click()); app.auto.add("timeline.openDemo", b, "Open Demo Project"); ui.painter().rect_filled(b, 13.0, if resp.hovered() { t.accent_hover } else { t.accent }); ui.painter().text(b.center(), Align2::CENTER_CENTER, tl!("Open Demo Project"), Tokens::semibold(12.0), Color32::WHITE); @@ -1275,7 +1275,7 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows: p.rect_filled(hrect, 0.0, c.gamma_multiply(TRACK_HEADER_TINT)); p.rect_filled(Rect::from_min_max(strip.min, pos2(strip.min.x + 5.0, strip.max.y)), 0.0, c); } - let cresp = ui.interact(strip.intersect(visible), egui::Id::new(("trackColor", r.track.0)), Sense::click()).on_hover_text(tl!("Track Color")); + let cresp = ui.interact(strip.intersect(visible), egui::Id::unique(("trackColor", r.track.0)), Sense::click()).on_hover_text(tl!("Track Color")); app.auto.add(&format!("timeline.track.{}{}.color", if r.kind == TrackKind::Video { "V" } else { "A" }, r.index + 1), strip, "Track Color"); if cresp.hovered() && tr.label.is_none() { p.rect_filled(Rect::from_min_max(strip.min, pos2(strip.min.x + 5.0, strip.max.y)), 0.0, t.hover); @@ -1307,7 +1307,7 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows: let patched = if r.kind == TrackKind::Video { tg.video_dest == Some(r.track) } else { tg.audio_dest == Some(r.track) }; let pr = btn_rect(13.0); app.auto.add(&format!("timeline.track.{label}.sourcePatch"), pr, "Source patch"); - if patch_button(ui, pr, visible, &label, patched, false, egui::Id::new(("patch", r.track.0)), t).clicked() { + if patch_button(ui, pr, visible, &label, patched, false, egui::Id::unique(("patch", r.track.0)), t).clicked() { actions.push(("timeline.setTargeting".into(), json!({"track": r.track.0, "sourcePatch": !patched}))); } // 2. track lock @@ -1320,7 +1320,7 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows: if tr.locked { Icon::Lock } else { Icon::Unlock }, true, t, - egui::Id::new(("locked", r.track.0)), + egui::Id::unique(("locked", r.track.0)), Some(if tr.locked { t.icon_active } else { t.text_dim }), ); app.auto.add(&format!("timeline.track.{label}.locked"), lock_r, "Toggle Track Lock"); @@ -1335,14 +1335,21 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows: let targeted = tg.targeted.contains(&r.track); let trr = btn_rect(61.0); app.auto.add(&format!("timeline.track.{label}.target"), trr, "Toggle track targeting"); - if patch_button(ui, trr, visible, &label, targeted, true, egui::Id::new(("target", r.track.0)), t).clicked() { + if patch_button(ui, trr, visible, &label, targeted, true, egui::Id::unique(("target", r.track.0)), t).clicked() { actions.push(("timeline.setTargeting".into(), json!({"track": r.track.0, "targeted": !targeted}))); } // 4. upper line icons let mut x = 102.0; let sr = small(x, upper_y); - let sresp = - crate::widgets::icon_toggle(ui, sr.intersect(visible), Icon::SyncLock, tr.sync_lock, t, egui::Id::new(("syncLock", r.track.0)), Some(t.text_dim)); + let sresp = crate::widgets::icon_toggle( + ui, + sr.intersect(visible), + Icon::SyncLock, + tr.sync_lock, + t, + egui::Id::unique(("syncLock", r.track.0)), + Some(t.text_dim), + ); app.auto.add(&format!("timeline.track.{label}.syncLock"), sr, "Toggle Sync Lock"); if sresp.clicked() { actions.push(("timeline.setTrack".into(), json!({"track": r.track.0, "syncLock": !tr.sync_lock}))); @@ -1356,7 +1363,7 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows: if tr.enabled { Icon::Eye } else { Icon::EyeOff }, true, t, - egui::Id::new(("enabled", r.track.0)), + egui::Id::unique(("enabled", r.track.0)), Some(t.text_dim), ); app.auto.add(&format!("timeline.track.{label}.enabled"), er, "Toggle Track Output"); @@ -1372,7 +1379,7 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows: tr.muted, Color32::from_rgb(0x2d, 0x9d, 0x78), t, - egui::Id::new(("mute", r.track.0)), + egui::Id::unique(("mute", r.track.0)), ); app.auto.add(&format!("timeline.track.{label}.muted"), mr, "Mute Track"); if mresp.clicked() { @@ -1386,7 +1393,7 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows: tr.solo, Color32::from_rgb(0xf0, 0xf0, 0x4f), t, - egui::Id::new(("solo", r.track.0)), + egui::Id::unique(("solo", r.track.0)), ); app.auto.add(&format!("timeline.track.{label}.solo"), s2, "Solo Track"); if so.clicked() { @@ -1415,7 +1422,7 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows: continue; } app.auto.add(&format!("timeline.track.{label}.{name}"), edge, "Drag to change the track height"); - let eresp = ui.interact(edge, egui::Id::new(("trackh", name, r.track.0)), Sense::drag()); + let eresp = ui.interact(edge, egui::Id::unique(("trackh", name, r.track.0)), Sense::drag()); if eresp.hovered() || eresp.dragged() { ui.ctx().set_cursor_icon(CursorIcon::ResizeVertical); } @@ -1427,7 +1434,7 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows: } let resp = ui.interact( Rect::from_min_max(pos2(hrect.min.x + 90.0, hrect.min.y + 26.0), hrect.max).intersect(visible), - egui::Id::new(("hdr", r.track.0)), + egui::Id::unique(("hdr", r.track.0)), Sense::click(), ); if resp.double_clicked() { @@ -1473,7 +1480,7 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc // current timecode: Premiere's big blue timecode let tc = format_time(app.session.playhead(), rate, seq.settings.drop_frame, TimeDisplay::Timecode, seq.settings.sample_rate as i64); let tc_rect = Rect::from_min_size(pos2(rect.min.x + 14.0, rect.min.y + 4.0), vec2(hw - 20.0, 20.0)); - let tc_id = egui::Id::new(("timeline-tc", seq_id)); + let tc_id = egui::Id::unique(("timeline-tc", seq_id)); match crate::widgets::timecode_field(ui, tc_id, tc_rect, &tc, app.session.playhead(), Tick::ZERO, rate, seq.settings.drop_frame, t.timecode) { Some(Ok(time)) => { if let Err(e) = app.session.execute("playhead.set", json!({"time": time.0})) { @@ -1497,7 +1504,7 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc ]; for (icon, key, on, toggle, tip) in toggles { let r = Rect::from_min_size(pos2(x, y), vec2(28.0, 28.0)); - let resp = ui.interact(r, egui::Id::new(("tl-toggle", key)), Sense::click()).on_hover_text(tip); + let resp = ui.interact(r, egui::Id::unique(("tl-toggle", key)), Sense::click()).on_hover_text(tip); app.auto.add(&format!("timeline.toggle.{key}"), r, tip); if toggle && on { p.rect_filled(r, 4.0, t.pressed); @@ -1615,7 +1622,7 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc let hit = r.expand2(vec2(0.0, 2.0)); app.auto.add(&format!("timeline.renderBar.{i}"), hit, what); if ui.rect_contains_pointer(hit) { - egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new(("rb", i)), egui::PopupAnchor::Pointer).show(|ui| { + egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::unique(("rb", i)), egui::PopupAnchor::Pointer).show(|ui| { ui.label(what); }); } @@ -1635,7 +1642,7 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc let mr = marker_rect(layout, m.start); app.auto.add(&format!("timeline.marker.{}", m.id.0), mr, &m.name); if ui.rect_contains_pointer(mr) && !m.name.is_empty() { - egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new(("mk", m.id.0)), egui::PopupAnchor::Pointer).show(|ui| { + egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::unique(("mk", m.id.0)), egui::PopupAnchor::Pointer).show(|ui| { ui.label(&m.name); }); } @@ -1677,7 +1684,7 @@ fn zoom_scrollbar(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, seq_seco } app.auto.add("timeline.zoomBar", thumb, "zoom scroll bar"); app.auto.add("timeline.zoomBar.track", bar, "zoom scroll bar track"); - let resp = ui.interact(bar, egui::Id::new("tl-zoombar"), Sense::click_and_drag()); + let resp = ui.interact(bar, egui::Id::unique("tl-zoombar"), Sense::click_and_drag()); let range = (total - vis).max(0.0); // a press beside the thumb brings the thumb's middle under the pointer (and a drag goes on // from there) @@ -1767,7 +1774,7 @@ fn vertical_scrollbar(ui: &mut egui::Ui, bar: Rect, value: &mut f32, max: f32, i p.rect_filled(track, track.width() / 2.0, t.separator); let hover = ui.rect_contains_pointer(thumb); p.rect_filled(thumb, thumb.width() / 2.0, if hover { t.scroll_thumb_hover } else { t.scroll_thumb }); - let resp = ui.interact(bar, egui::Id::new(("tl-vs", id)), Sense::drag()); + let resp = ui.interact(bar, egui::Id::unique(("tl-vs", id)), Sense::drag()); if resp.dragged() { let d = resp.drag_delta().y / (bar.height() - h).max(1.0) * max; *value = (*value + if invert { -d } else { d }).clamp(0.0, max); @@ -2256,7 +2263,7 @@ fn scrub_playhead(app: &mut FilmcraftApp, seq: &Sequence, layout: &Layout, p: Po fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &Layout, rect: Rect) { let ctx = ui.ctx().clone(); let area = Rect::from_min_max(pos2(layout.content.min.x, layout.ruler.min.y), layout.content.max); - let resp = ui.interact(area, egui::Id::new("timeline-area"), Sense::click_and_drag()); + let resp = ui.interact(area, egui::Id::unique("timeline-area"), Sense::click_and_drag()); let pos = resp.hover_pos().or(resp.interact_pointer_pos()); let mods = ctx.input(|i| i.modifiers); let tool = app.ui.tool; @@ -2806,11 +2813,11 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & // ---- context menu on clips (right-clicking an unselected clip selects it first); on a clip's // edge, the edit point menu instead (right-clicking an unselected edit point selects it first) - let edit_point_menu_id = egui::Id::new("timeline.editPointMenu.target"); + let edit_point_menu_id = egui::Id::unique("timeline.editPointMenu.target"); // the clip right-clicked, remembered while its menu is open: Unlink leaves only it selected - let menu_clip_id = egui::Id::new("timeline.clipMenu.clip"); + let menu_clip_id = egui::Id::unique("timeline.clipMenu.clip"); // right-clicking empty time between clips selects the gap and opens its menu (#648, #668) - let gap_menu_id = egui::Id::new("timeline.gapMenu.open"); + let gap_menu_id = egui::Id::unique("timeline.gapMenu.open"); if resp.secondary_clicked() && let Some(p) = resp.interact_pointer_pos() { @@ -2825,7 +2832,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } } // on a marker of the ruler, the marker menu instead (right-clicking a marker selects it first) - let marker_menu_id = egui::Id::new("timeline.markerMenu.target"); + let marker_menu_id = egui::Id::unique("timeline.markerMenu.target"); if resp.secondary_clicked() && let Some(p) = resp.interact_pointer_pos() { diff --git a/crates/ui-egui/src/panels/timeline_automation.rs b/crates/ui-egui/src/panels/timeline_automation.rs index 4e4ecc086..8b3766692 100644 --- a/crates/ui-egui/src/panels/timeline_automation.rs +++ b/crates/ui-egui/src/panels/timeline_automation.rs @@ -97,9 +97,16 @@ fn band(r: &Row) -> Rect { pub fn header_button(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, r: &Row, rect: Rect, visible: Rect, label: &str, t: &Tokens) { let Some(tr) = seq.track(r.track) else { return }; let showing = app.ui.timeline.track_lanes.get(&r.track.0).cloned(); - let resp = - crate::widgets::icon_toggle(ui, rect.intersect(visible), Icon::Keyframe, showing.is_some(), t, egui::Id::new(("lanebtn", r.track.0)), Some(LANE_COL)) - .on_hover_text(tl!("Show Keyframes")); + let resp = crate::widgets::icon_toggle( + ui, + rect.intersect(visible), + Icon::Keyframe, + showing.is_some(), + t, + egui::Id::unique(("lanebtn", r.track.0)), + Some(LANE_COL), + ) + .on_hover_text(tl!("Show Keyframes")); app.auto.add(&format!("timeline.track.{label}.keyframes"), rect, "Show Keyframes"); let opts = lane_options(tr); egui::Popup::menu(&resp).show(|ui| { @@ -131,7 +138,7 @@ struct KfDrag { } fn drag_id() -> egui::Id { - egui::Id::new("tl-lane-kf-drag") + egui::Id::unique("tl-lane-kf-drag") } /// Draw the shown lanes (after clips) and handle their editing (after the timeline's own interaction, @@ -173,7 +180,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & // add keyframes: Pen tool, or ⌘-click with any tool let adding = app.ui.tool == Tool::Pen || ui.input(|i| i.modifiers.command); if adding { - let bresp = ui.interact(vis, egui::Id::new(("lane-band", r.track.0)), Sense::click()); + let bresp = ui.interact(vis, egui::Id::unique(("lane-band", r.track.0)), Sense::click()); if bresp.hovered() { ui.ctx().set_cursor_icon(CursorIcon::Crosshair); } @@ -199,7 +206,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & continue; } let hr = Rect::from_center_size(c, vec2(11.0, 11.0)); - let resp = ui.interact(hr, egui::Id::new(("lane-kf", r.track.0, i)), Sense::click_and_drag()); + let resp = ui.interact(hr, egui::Id::unique(("lane-kf", r.track.0, i)), Sense::click_and_drag()); let hot = resp.hovered() || dragging.is_some(); icons::paint(&p, Rect::from_center_size(c, vec2(9.0, 9.0)), Icon::Keyframe, if hot { Color32::WHITE } else { LANE_COL }); app.auto.add(&format!("timeline.track.{label}.lane.kf.{i}"), hr, &format!("{name} keyframe {v:.1}")); @@ -221,7 +228,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } if dragging.is_some() { let nv = denorm(tr, &key, ((b.max.y - c.y) / b.height()).clamp(0.0, 1.0)); - egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("lane-kf-tip"), egui::PopupAnchor::Pointer).show(|ui| { + egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::unique("lane-kf-tip"), egui::PopupAnchor::Pointer).show(|ui| { ui.label(if key == LANE_VOLUME { format!("{nv:.2} dB") } else { format!("{nv:.2}") }); }); } diff --git a/crates/ui-egui/src/panels/timeline_captions.rs b/crates/ui-egui/src/panels/timeline_captions.rs index 6b93456ae..643286adb 100644 --- a/crates/ui-egui/src/panels/timeline_captions.rs +++ b/crates/ui-egui/src/panels/timeline_captions.rs @@ -35,7 +35,7 @@ enum Gesture { } fn drag_id() -> egui::Id { - egui::Id::new("caption-drag") + egui::Id::unique("caption-drag") } /// Live drag state: (caption, gesture, delta ticks). @@ -85,7 +85,7 @@ pub fn paint(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, area: Re app.auto.add(&format!("timeline.captionTrack.{label}"), badge, &tr.name); // right-click the header: delete this track or every empty caption track (registered // before the lock / eye toggles so those stay on top) - let hresp = ui.interact(hrect, egui::Id::new(("cap-header", tr.id.0)), Sense::click()); + let hresp = ui.interact(hrect, egui::Id::unique(("cap-header", tr.id.0)), Sense::click()); hresp.context_menu(|ui| { for (key, text, cmd, p) in [ ("delete", tl!("Delete Caption Track"), "captions.deleteTrack", json!({"track": tr.id.0})), @@ -107,7 +107,7 @@ pub fn paint(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, area: Re if tr.locked { Icon::Lock } else { Icon::Unlock }, true, t, - egui::Id::new(("cap-lock", tr.id.0)), + egui::Id::unique(("cap-lock", tr.id.0)), Some(if tr.locked { t.icon_active } else { t.text_dim }), ); app.auto.add(&format!("timeline.captionTrack.{label}.locked"), lock_r, "Toggle Caption Track Lock"); @@ -121,7 +121,7 @@ pub fn paint(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, area: Re if tr.enabled { Icon::Eye } else { Icon::EyeOff }, true, t, - egui::Id::new(("cap-eye", tr.id.0)), + egui::Id::unique(("cap-eye", tr.id.0)), Some(t.text_dim), ); app.auto.add(&format!("timeline.captionTrack.{label}.enabled"), eye_r, "Toggle Caption Track Output"); @@ -198,7 +198,7 @@ fn interact_block( actions: &mut Vec<(String, Value)>, ) { let ctx = ui.ctx().clone(); - let resp = ui.interact(r, egui::Id::new(("caption-block", c.id.0)), Sense::click_and_drag()); + let resp = ui.interact(r, egui::Id::unique(("caption-block", c.id.0)), Sense::click_and_drag()); let edge = 5.0f32.min(r.width() / 4.0); let gesture_at = |x: f32| { if x - r.min.x < edge { @@ -240,7 +240,7 @@ fn interact_block( Gesture::TrimIn => 1, Gesture::TrimOut => 2, }; - ctx.data_mut(|d| d.insert_temp(egui::Id::new("caption-drag-origin"), p.x)); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique("caption-drag-origin"), p.x)); ctx.data_mut(|d| d.insert_temp(drag_id(), (c.id.0, g, 0i64))); if !app.session.state.caption_selection.contains(&c.id) { actions.push(("captions.select".into(), json!({"captions": [c.id.0]}))); @@ -250,7 +250,7 @@ fn interact_block( && let (Some((id, g, _)), Some(p)) = (drag_state(&ctx), resp.interact_pointer_pos()) && id == c.id.0 { - let x0 = ctx.data(|d| d.get_temp::(egui::Id::new("caption-drag-origin"))).unwrap_or(p.x); + let x0 = ctx.data(|d| d.get_temp::(egui::Id::unique("caption-drag-origin"))).unwrap_or(p.x); let pps = app.ui.timeline.pps; let d = rate.snap_nearest(Tick::from_seconds_f64((p.x - x0) as f64 / pps)); ctx.data_mut(|dd| dd.insert_temp(drag_id(), (id, g, d.0))); diff --git a/crates/ui-egui/src/panels/timeline_volume.rs b/crates/ui-egui/src/panels/timeline_volume.rs index 3b904ffdf..e0b486ed7 100644 --- a/crates/ui-egui/src/panels/timeline_volume.rs +++ b/crates/ui-egui/src/panels/timeline_volume.rs @@ -191,7 +191,7 @@ fn run(app: &mut FilmcraftApp, acts: Vec<(&str, Value)>) { /// The level next to the pointer while dragging: one line, always as wide, so it does not jump. fn tip(ui: &egui::Ui, db: f64) { let text = if db <= FADER_MIN_DB { format!("{:>7} dB", "-∞") } else { format!("{db:>+7.2} dB") }; - egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("tl-volume-tip"), egui::PopupAnchor::Pointer).show(|ui| { + egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::unique("tl-volume-tip"), egui::PopupAnchor::Pointer).show(|ui| { ui.add(egui::Label::new(egui::RichText::new(text).font(Tokens::mono(12.0))).wrap_mode(egui::TextWrapMode::Extend)); }); } @@ -206,7 +206,7 @@ struct KeyDrag { } fn key_drag_id() -> egui::Id { - egui::Id::new("tl-volume-key-drag") + egui::Id::unique("tl-volume-key-drag") } /// The keyframes on the lines of the audio clips in view: drag one in time and level, right-click @@ -256,7 +256,7 @@ pub fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layou continue; } let kr = Rect::from_center_size(c, vec2(11.0, 11.0)); - let resp = ui.interact(kr, egui::Id::new(("tl-volume-kf", it.id.0, i)), Sense::click_and_drag()); + let resp = ui.interact(kr, egui::Id::unique(("tl-volume-kf", it.id.0, i)), Sense::click_and_drag()); app.auto.add(&format!("timeline.clip.{}.volume.kf.{i}", it.id.0), kr, "Volume keyframe"); let mine = held.filter(|h| h.clip == it.id && h.index == i); if resp.hovered() || mine.is_some() { diff --git a/crates/ui-egui/src/panels/tools.rs b/crates/ui-egui/src/panels/tools.rs index 34106c6b9..d55fccf58 100644 --- a/crates/ui-egui/src/panels/tools.rs +++ b/crates/ui-egui/src/panels/tools.rs @@ -11,7 +11,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let mut y = rect.min.y + 4.0; let size = 32.0; - let held_id = egui::Id::new("tools-held"); + let held_id = egui::Id::unique("tools-held"); if ui.input(|i| i.pointer.any_pressed()) { ui.data_mut(|d| d.remove::(held_id)); } @@ -23,8 +23,8 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } else { format!("{} ({})", app.ui.language.tr(current.label()), current.shortcut()) }; - let resp = ui.interact(r, egui::Id::new(("tool", format!("{:?}", group[0]))), Sense::click_and_drag()).on_hover_text(tip); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, resp.enabled(), app.ui.tool == current, current.label())); + let resp = ui.interact(r, egui::Id::unique(("tool", format!("{:?}", group[0]))), Sense::click_and_drag()).on_hover_text(tip); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, resp.enabled(), app.ui.tool == current, current.label())); for tl in &group { if *tl == current { app.auto.add_response(&format!("tools.{tl:?}"), &resp, tl.label()); diff --git a/crates/ui-egui/src/panels/transform_handles.rs b/crates/ui-egui/src/panels/transform_handles.rs index c767e3ed3..1100eeaa4 100644 --- a/crates/ui-egui/src/panels/transform_handles.rs +++ b/crates/ui-egui/src/panels/transform_handles.rs @@ -398,7 +398,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra return; } let mut actions: Vec<(String, Value)> = Vec::new(); - let id = egui::Id::new("program-transform"); + let id = egui::Id::unique("program-transform"); let drag_id = id.with("drag"); let double = ui.input(|i| i.pointer.button_double_clicked(egui::PointerButton::Primary)); // the monitor around the picture as well as on it (its clip reaches past the picture area) diff --git a/crates/ui-egui/src/panels/transition_controls.rs b/crates/ui-egui/src/panels/transition_controls.rs index dcea6ad38..41cec06b7 100644 --- a/crates/ui-egui/src/panels/transition_controls.rs +++ b/crates/ui-egui/src/panels/transition_controls.rs @@ -179,7 +179,7 @@ pub fn effect_controls(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, id changes.push(c); } let divider = Rect::from_min_max(pos2(split - 2.0, rect.min.y), pos2(split + 4.0, rect.max.y)); - let dresp = ui.interact(divider, egui::Id::new("tr-ec-divider"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal); + let dresp = ui.interact(divider, egui::Id::unique("tr-ec-divider"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal); if dresp.dragged() { app.ui.effect_controls_split = (list_w + dresp.drag_delta().x).clamp(MIN_LIST_W, max_list); } @@ -252,7 +252,7 @@ fn timeline(app: &mut FilmcraftApp, ui: &mut egui::Ui, lane: Rect, id: Transitio let (_, cut, _) = trn::bounds(track, x)?; let clip_a = x.from.and_then(|c| track.item(c)).map(|it| (it.start, it.end(), it.name.clone())); let clip_b = x.to.and_then(|c| track.item(c)).map(|it| (it.start, it.end(), it.name.clone())); - let mem = egui::Id::new(("tr-ec-lane-drag", id.0)); + let mem = egui::Id::unique(("tr-ec-lane-drag", id.0)); let mut drag: Option = ui.data(|d| d.get_temp(mem)); let v = drag.map_or_else(|| view(x, cut, frame), |d| d.view); let painter = ui.painter().with_clip_rect(lane.intersect(ui.clip_rect())); @@ -311,7 +311,7 @@ fn timeline(app: &mut FilmcraftApp, ui: &mut egui::Ui, lane: Rect, id: Transitio elems.push(("effectControls.transition.timeline.ruler".into(), ruler, "time ruler".into())); // the ruler moves the playhead (once the sequence is no longer borrowed, below) let mut seek = None; - let rresp = ui.interact(ruler, egui::Id::new(("tr-ec-ruler", id.0)), Sense::click_and_drag()); + let rresp = ui.interact(ruler, egui::Id::unique(("tr-ec-ruler", id.0)), Sense::click_and_drag()); if (rresp.dragged() || rresp.clicked()) && let Some(pos) = rresp.interact_pointer_pos() { @@ -327,7 +327,7 @@ fn timeline(app: &mut FilmcraftApp, ui: &mut egui::Ui, lane: Rect, id: Transitio None } }; - let sresp = ui.interact(span, egui::Id::new(("tr-ec-span", id.0)), Sense::click_and_drag()); + let sresp = ui.interact(span, egui::Id::unique(("tr-ec-span", id.0)), Sense::click_and_drag()); if let Some(h) = sresp.hover_pos() { ui.ctx().set_cursor_icon(if grab(h.x).is_some() { egui::CursorIcon::ResizeColumn } else { egui::CursorIcon::Grab }); } @@ -426,7 +426,7 @@ fn param_widget(ui: &mut egui::Ui, elems: &mut Elems, pid: &str, kind: &ParamKin } (ParamKind::Bool, ParamValue::Bool(b)) => { let mut b = *b; - let r = ui.checkbox(&mut b, ""); + let r = crate::widgets::accessible_name(ui.checkbox(&mut b, ""), eid.clone()); elems.push((eid, r.rect, b.to_string())); r.changed().then(|| json!({"params": { pid: b }})) } @@ -444,7 +444,8 @@ fn param_widget(ui: &mut egui::Ui, elems: &mut Elems, pid: &str, kind: &ParamKin } }); elems.extend(options); - elems.push((eid, r.response.rect, shown.to_string())); + let response = crate::widgets::accessible_name(r.response, eid.clone()); + elems.push((eid, response.rect, shown.to_string())); picked.map(|i| json!({"params": { pid: i }})) } (ParamKind::Color, ParamValue::Color(c)) => { diff --git a/crates/ui-egui/src/panels/trim_monitor.rs b/crates/ui-egui/src/panels/trim_monitor.rs index ba65b9474..9522feca0 100644 --- a/crates/ui-egui/src/panels/trim_monitor.rs +++ b/crates/ui-egui/src/panels/trim_monitor.rs @@ -5,7 +5,7 @@ //! outgoing side, in the incoming picture the incoming side, between them rolls. //! //! J/K/L and Space are routed to dynamic trimming / loop playback in `menus::invoke`; the engine -//! state machine (`filmcraft_engine::trim`) is advanced here once per frame with the UI clock. +//! state machine (`filmcraft_engine::trim`) is advanced from logic with the monotonic host clock. //! //! Automation ids: `trimMonitor.outgoing`, `trimMonitor.incoming`, `trimMonitor.roll`, //! `trimMonitor.outTimecode`, `trimMonitor.inTimecode`, `trimMonitor.outShift`, @@ -35,21 +35,21 @@ pub fn trim_mode(app: &FilmcraftApp) -> bool { !app.session.state.edit_points.is_empty() && app.ui.focused != crate::dock::PanelKind::Source } -/// Advance dynamic trimming / loop playback to the current UI time (once per frame). -pub fn advance(app: &mut FilmcraftApp, ctx: &egui::Context) { +/// Advance dynamic trimming / loop playback to a monotonic host time. Called from logic; a +/// repeated call at the same time is idempotent because `trim.tick` receives an absolute clock. +pub fn advance_at(app: &mut FilmcraftApp, clock: f64) { if !app.session.trim_play.active() { return; } - let clock = ctx.input(|i| i.time); if let Err(e) = app.session.execute("trim.tick", json!({"clock": clock})) { app.ui.status = e.to_string(); } - ctx.request_repaint(); } /// Route a transport command in trim mode (J/K/L/Space/Shift+K). `None` = not handled. pub fn route_transport(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str) -> Option> { - let clock = ctx.input(|i| i.time); + let _ = ctx; + let clock = app.now(); let trimming = trim_mode(app); let (cmd, params) = match id { "playback.forward" if trimming => ("trim.shuttle", json!({"direction": "forward", "clock": clock})), @@ -239,7 +239,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // drag to trim: px → frames (6 px per frame), applied on release as one trim let fpp = 1.0 / 6.0; for (r, which) in [(lp, "out"), (rp, "in"), (mid, "roll")] { - let resp = ui.interact(r, egui::Id::new(("trim-monitor-drag", which)), Sense::drag()); + let resp = ui.interact(r, egui::Id::unique(("trim-monitor-drag", which)), Sense::drag()); if resp.dragged() || resp.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeColumn); } @@ -248,7 +248,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // the same one data_mut() holds, and that lock is not reentrant (deadlock) let dx = resp.drag_delta().x; let total = ui.ctx().data_mut(|d| { - let v = d.get_temp_mut_or_default::(egui::Id::new("trim-monitor-dx")); + let v = d.get_temp_mut_or_default::(egui::Id::unique("trim-monitor-dx")); *v += dx; *v }); @@ -256,7 +256,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.painter().text(pos2(r.center().x, r.max.y - 14.0), Align2::CENTER_CENTER, shift_text(n), Tokens::semibold(14.0), col); } if resp.drag_stopped() { - let total = ui.ctx().data_mut(|d| d.remove_temp::(egui::Id::new("trim-monitor-dx")).unwrap_or(0.0)); + let total = ui.ctx().data_mut(|d| d.remove_temp::(egui::Id::unique("trim-monitor-dx")).unwrap_or(0.0)); let n = (total * fpp).round() as i64; // egui ends a drag on Escape: that stop abandons it (#580) if n != 0 && !ui.input(|i| i.key_pressed(egui::Key::Escape)) { @@ -295,10 +295,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ]; let total: f32 = buttons.iter().map(|b| b.3 + 4.0).sum(); let mut x = bar.center().x - total / 2.0; - let ctx = ui.ctx().clone(); for (id, label, tip, w) in buttons { let r = Rect::from_min_size(pos2(x, bar.min.y), vec2(w, bar.height())); - let resp = ui.interact(r, egui::Id::new(id), Sense::click()).on_hover_text(tip); + let resp = ui.interact(r, egui::Id::unique(id), Sense::click()).on_hover_text(tip); let bg = if resp.is_pointer_button_down_on() { t.pressed } else if resp.hovered() { @@ -316,7 +315,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add(id, r, tip); if resp.clicked() { let res = match id { - "trimMonitor.playAround" => app.session.execute("trim.playAround", json!({"clock": ctx.input(|i| i.time), "toggle": true})), + "trimMonitor.playAround" => app.session.execute("trim.playAround", json!({"clock": app.now(), "toggle": true})), "trimMonitor.backwardMany" => app.session.execute("trim.backwardMany", json!({})), "trimMonitor.backward" => app.session.execute("trim.backward", json!({})), "trimMonitor.forward" => app.session.execute("trim.forward", json!({})), @@ -331,7 +330,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } // exit trim mode let r = Rect::from_min_size(pos2(rect.max.x - 30.0, bar.min.y + 3.0), vec2(22.0, 22.0)); - let resp = ui.interact(r, egui::Id::new("trimMonitor.exit"), Sense::click()).on_hover_text(tl!("Exit Trim Mode")); + let resp = ui.interact(r, egui::Id::unique("trimMonitor.exit"), Sense::click()).on_hover_text(tl!("Exit Trim Mode")); let c = if resp.hovered() { t.tab_text_active } else { t.text_dim }; ui.painter().line_segment([r.min + vec2(6.0, 6.0), r.max - vec2(6.0, 6.0)], Stroke::new(1.5, c)); ui.painter().line_segment([pos2(r.max.x - 6.0, r.min.y + 6.0), pos2(r.min.x + 6.0, r.max.y - 6.0)], Stroke::new(1.5, c)); diff --git a/crates/ui-egui/src/panels/tts.rs b/crates/ui-egui/src/panels/tts.rs index 8bc12e7cb..8e00bcfba 100644 --- a/crates/ui-egui/src/panels/tts.rs +++ b/crates/ui-egui/src/panels/tts.rs @@ -329,7 +329,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(Action::Run("tts.preview", json!({"sample": true, "voice": d.voice, "pitch": d.pitch.id(), "pace": d.pace}), true)); } ui.add_space(8.0); - let (hr, open) = crate::widgets::section_header(ui, egui::Id::new("tts-advanced"), "Advanced", d.advanced_open, &t, true); + let (hr, open) = crate::widgets::section_header(ui, egui::Id::unique("tts-advanced"), "Advanced", d.advanced_open, &t, true); auto.add("tts.advanced", visible(ui, hr.rect), "Advanced"); d.advanced_open = open; if open { @@ -378,7 +378,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { .desired_width(w) .desired_rows(8) .char_limit(filmcraft_tts::MAX_TEXT_BYTES); - let r = ui.add(te); + let r = crate::widgets::accessible_name(ui.add(te), "Text"); auto.add("tts.text", visible(ui, r.rect), "Text"); }); @@ -445,7 +445,7 @@ fn label(ui: &mut egui::Ui, t: &Tokens, text: &str) { #[allow(clippy::too_many_arguments)] fn button(ui: &mut egui::Ui, t: &Tokens, r: Rect, icon: Icon, text: &str, primary: bool, active: bool, id: &str) -> egui::Response { - let resp = ui.interact(r, egui::Id::new(id), Sense::click()); + let resp = ui.interact(r, egui::Id::unique(id), Sense::click()); let p = ui.painter(); let (bg, fg) = match (primary, active) { (true, true) => (if resp.hovered() { t.accent_hover } else { t.accent }, egui::Color32::WHITE), diff --git a/crates/ui-egui/src/panels/voiceover.rs b/crates/ui-egui/src/panels/voiceover.rs index 1a80990f4..deb3080e3 100644 --- a/crates/ui-egui/src/panels/voiceover.rs +++ b/crates/ui-egui/src/panels/voiceover.rs @@ -38,13 +38,13 @@ struct Draft { } fn draft_id() -> egui::Id { - egui::Id::new("voiceover-settings-draft") + egui::Id::unique("voiceover-settings-draft") } /// The microphone button of an audio track header. Returns the requested action. pub fn header_button(app: &mut FilmcraftApp, ui: &mut egui::Ui, track: TrackId, r: Rect, visible: Rect, label: &str, t: &Tokens) -> Option { let recording_here = app.session.voiceover.rec.as_ref().is_some_and(|x| x.track == track); - let resp = ui.interact(r.intersect(visible), egui::Id::new(("vo", track.0)), Sense::click()); + let resp = ui.interact(r.intersect(visible), egui::Id::unique(("vo", track.0)), Sense::click()); if resp.hovered() { ui.painter().rect_filled(r, 2.0, t.hover); } @@ -220,7 +220,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { tl!("● Recording").into() }; let r = Rect::from_center_size(pos2(screen.center().x, screen.min.y + 90.0), vec2(220.0, 64.0)); - egui::Area::new(egui::Id::new("voiceover-countdown")).order(egui::Order::Foreground).fixed_pos(r.min).interactable(false).show(ctx, |ui| { + egui::Area::new(egui::Id::unique("voiceover-countdown")).order(egui::Order::Foreground).fixed_pos(r.min).interactable(false).show(ctx, |ui| { let p = ui.painter(); p.rect_filled(r, 8.0, Color32::from_black_alpha(180)); p.rect_stroke(r, 8.0, Stroke::new(2.0, RED), egui::StrokeKind::Inside); @@ -232,18 +232,18 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { fn settings_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some(Some(mut d)) = ctx.data(|m| m.get_temp::>(draft_id())) else { return }; - crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("voiceover-settings-before-drag"), &mut d); + crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("voiceover-settings-before-drag"), &mut d); let mut elems: Vec<(String, Rect, String)> = Vec::new(); let mut close = false; let mut apply = false; let mut discard = false; let accent = app.tokens.accent; // the title is translated; the window keeps one id whatever the interface language - let id = egui::Id::new("voiceover-settings"); + let id = egui::Id::unique("voiceover-settings"); egui::Window::new(tl!("Voice-Over Record Settings")).id(id).collapsible(false).resizable(false).centered(ctx).show(ctx, |ui| { egui::Grid::new("voiceover-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { ui.label(tl!("Name:")); - let r = ui.add(egui::TextEdit::singleline(&mut d.prefs.name).desired_width(220.0)); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut d.prefs.name).desired_width(220.0)), "Name"); elems.push(("voiceover.name".into(), r.rect, d.prefs.name.clone())); ui.end_row(); ui.label(tl!("Source:")); diff --git a/crates/ui-egui/src/panels/workspaces.rs b/crates/ui-egui/src/panels/workspaces.rs index 5678bd171..9a9b031a6 100644 --- a/crates/ui-egui/src/panels/workspaces.rs +++ b/crates/ui-egui/src/panels/workspaces.rs @@ -181,7 +181,7 @@ pub fn dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) { WorkspaceDialog::Edit { .. } => ("Edit Workspaces", tl!("Edit Workspaces")), }; egui::Window::new(shown) - .id(egui::Id::new(title)) + .id(egui::Id::unique(title)) .collapsible(false) .resizable(false) .pivot(Align2::CENTER_CENTER) @@ -218,7 +218,7 @@ pub fn dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) { let own = sel.as_deref().is_some_and(|s| !is_builtin(s)); ui.horizontal(|ui| { ui.label(tl!("Name:")); - let r = ui.add_enabled(own, egui::TextEdit::singleline(name)); + let r = crate::widgets::accessible_name(ui.add_enabled(own, egui::TextEdit::singleline(name)), "Name"); push("workspaces.edit.name", &r, "Name"); let r = ui.add_enabled(own && sel.as_deref() != Some(name.trim()), egui::Button::new(tl!("Rename"))); push("workspaces.edit.rename", &r, "Rename"); diff --git a/crates/ui-egui/src/rtl.rs b/crates/ui-egui/src/rtl.rs index 7b71769cf..cf927f8e2 100644 --- a/crates/ui-egui/src/rtl.rs +++ b/crates/ui-egui/src/rtl.rs @@ -4,21 +4,16 @@ //! last fallbacks of every UI font family. A build without them looks up one installed face per //! script once (Noto Sans Arabic, SF Arabic, Segoe UI…) and reuses it across theme changes. //! -//! Reading order: egui shapes each font run with harfrust, so a word's letters join and run right -//! to left, but it places the runs left to right in logical order with no bidi pass: in -//! "كلمة أخرى" the second word comes out first. [`Lines`] lays out document text (captions) in -//! reading order for display. Lines are broken on the logical text as egui wraps it, each line's -//! runs are put in visual order by the Unicode Bidirectional Algorithm (UAX #9, as the text engine -//! does for titles), and every right-to-left word becomes its own layout section, which egui's -//! shaper draws right to left. Text being edited keeps egui's logical order: `TextEdit` maps the -//! caret by character index, so a reordered galley would put it in the wrong place. +//! Reading order: the egui 0.37 fork performs UAX #9 shaping, run ordering and bracket mirroring. +//! [`Lines`] lays out document text (captions) with one logical `LayoutJob` and +//! `TextDirection::Auto`, so the text engine produces visual order itself. Keeping the old +//! word-by-word reversal would reverse Arabic/Hebrew twice. Text being edited keeps egui's logical +//! order: `TextEdit` maps the caret by character index. -use std::borrow::Cow; -use std::ops::Range; use std::sync::{Arc, OnceLock}; -use egui::epaint::text::{ByteIndex, LayoutJob, LayoutSection, TextFormat}; -use egui::{Color32, FontData, FontDefinitions, FontId, Galley, Painter, Rect, Vec2, pos2}; +use egui::epaint::text::{LayoutJob, TextDirection, TextFormat}; +use egui::{Align, Color32, FontData, FontDefinitions, FontId, Galley, Painter, Rect, Vec2, pos2}; use filmcraft_text::fonts::{CraftFont, Face}; use unicode_bidi::{BidiClass, BidiInfo, bidi_class}; @@ -89,7 +84,7 @@ fn system_fonts() -> &'static [(String, Arc)] { let Some(data) = face.data() else { continue; }; - let font = FontData { font: Cow::Owned(data), index: face.info.index, tweak: Default::default() }; + let font = FontData::from_blob(Arc::new(data), face.info.index); out.push((format!("system-{}", s.code.to_lowercase()), Arc::new(font))); chosen.push(face); } @@ -133,116 +128,7 @@ pub(crate) fn has_rtl(text: &str) -> bool { text.chars().any(|c| matches!(bidi_class(c), BidiClass::R | BidiClass::AL)) } -/// Characters that stay together, in logical order, in a right-to-left word: letters, marks and -/// joiners. egui shapes such a word as one right-to-left run. -fn in_word(c: char) -> bool { - matches!(bidi_class(c), BidiClass::R | BidiClass::AL | BidiClass::NSM | BidiClass::BN) -} - -/// A bracket shown right to left takes its mirrored glyph (UAX #9 rule L4). -fn mirror(c: char) -> char { - match c { - '(' => ')', - ')' => '(', - '[' => ']', - ']' => '[', - '{' => '}', - '}' => '{', - '<' => '>', - '>' => '<', - '«' => '»', - '»' => '«', - '‹' => '›', - '›' => '‹', - _ => c, - } -} - -/// The layout sections of one line of `bidi.text` (byte range `line`, inside one paragraph), left -/// to right. A left-to-right run stays whole, except that Arabic-Indic digits get a section each -/// so the shaper cannot run them right to left. A right-to-left run is split into words -/// ([`in_word`]) and single other characters (mirrored), listed in reverse: egui shapes each word -/// right to left itself. -fn line_sections(bidi: &BidiInfo<'_>, line: Range) -> Vec { - let text = bidi.text; - let Some(whole) = text.get(line.clone()).filter(|s| !s.is_empty()) else { - return Vec::new(); - }; - let Some(para) = bidi.paragraphs.iter().find(|p| p.range.start <= line.start && line.end <= p.range.end) else { - return vec![whole.to_owned()]; - }; - let (levels, runs) = bidi.visual_runs(para, line); - let mut out = Vec::new(); - for run in runs { - let Some(s) = text.get(run.clone()) else { - continue; - }; - if levels.get(run.start).is_some_and(|l| l.is_rtl()) { - let mut units = Vec::new(); - let mut word = String::new(); - for c in s.chars() { - if in_word(c) { - word.push(c); - continue; - } - if !word.is_empty() { - units.push(std::mem::take(&mut word)); - } - units.push(mirror(c).to_string()); - } - if !word.is_empty() { - units.push(word); - } - out.extend(units.into_iter().rev()); - } else { - let mut plain = String::new(); - for c in s.chars() { - if bidi_class(c) == BidiClass::AN { - if !plain.is_empty() { - out.push(std::mem::take(&mut plain)); - } - out.push(c.to_string()); - } else { - plain.push(c); - } - } - if !plain.is_empty() { - out.push(plain); - } - } - } - out -} - -/// Byte ranges of the lines egui broke `text` into in `galley` (newlines left out), or None when -/// the rows do not account for every character. -fn line_ranges(text: &str, galley: &Galley) -> Option>> { - let bounds: Vec = text.char_indices().map(|(i, _)| i).chain(std::iter::once(text.len())).collect(); - let mut out = Vec::with_capacity(galley.rows.len()); - let mut at = 0usize; - for row in &galley.rows { - let end = at.checked_add(row.glyphs.len())?; - out.push(*bounds.get(at)?..*bounds.get(end)?); - at = end.checked_add(usize::from(row.ends_with_newline))?; - } - (at == bounds.len().saturating_sub(1)).then_some(out) -} - -/// Byte ranges of the lines of `text` between newlines. -fn paragraph_ranges(text: &str) -> Vec> { - let mut out = Vec::new(); - let mut start = 0; - for (i, c) in text.char_indices() { - if c == '\n' { - out.push(start..i); - start = i + 1; - } - } - out.push(start..text.len()); - out -} - -/// Document text laid out for display in reading order, one galley per line. +/// Document text laid out for display in reading order by the fork's bidi pass. pub(crate) struct Lines { rows: Vec>, /// The text starts right to left (its first strong letter is Arabic or Hebrew): lines align right. @@ -254,33 +140,19 @@ impl Lines { /// Lay out `text` in `font`, wrapped at `wrap_width` (`f32::INFINITY`: only at `\n`). Text /// without right-to-left letters is laid out as egui always does, in one galley. pub fn new(painter: &Painter, text: &str, font: FontId, color: Color32, wrap_width: f32) -> Self { - let logical = painter.layout(text.to_owned(), font.clone(), color, wrap_width); - if !has_rtl(text) { - return Self { size: logical.size(), rows: vec![logical], rtl: false }; - } - let bidi = BidiInfo::new(text, None); - let rtl = bidi.paragraphs.first().is_some_and(|p| p.level.is_rtl()); - let lines = line_ranges(text, &logical).unwrap_or_else(|| paragraph_ranges(text)); - let rows: Vec> = lines - .into_iter() - .map(|line| { - let mut job = LayoutJob::default(); - for s in line_sections(&bidi, line) { - let start = job.text.len(); - job.text.push_str(&s); - // pushed, not appended: `append` merges sections of equal format, and each - // right-to-left word must be shaped on its own - job.sections.push(LayoutSection { - leading_space: 0.0, - byte_range: ByteIndex(start)..ByteIndex(job.text.len()), - format: TextFormat::simple(font.clone(), color), - }); - } - if job.sections.is_empty() { painter.layout_no_wrap(String::new(), font.clone(), color) } else { painter.layout_job(job) } - }) - .collect(); - let size = rows.iter().fold(Vec2::ZERO, |s, g| Vec2::new(s.x.max(g.size().x), s.y + g.size().y)); - Self { rows, rtl, size } + // The egui fork performs UAX #9 paragraph direction, bidi runs, visual ordering and + // bracket mirroring itself (`TextDirection::Auto`). Reversing words here as well would + // reverse them twice; document text keeps one logical section and one layout job. + let rtl = has_rtl(text) && BidiInfo::new(text, None).paragraphs.first().is_some_and(|p| p.level.is_rtl()); + let mut job = LayoutJob::default(); + job.wrap.max_width = wrap_width; + job.direction = TextDirection::Auto; + job.halign = if rtl { Align::RIGHT } else { Align::LEFT }; + job.halign_min_is_start = true; + job.append(text, 0.0, TextFormat::simple(font, color)); + let galley = painter.layout_job(job); + let size = galley.size(); + Self { rows: vec![galley], rtl, size } } /// Paint the lines down from the top of `rect`: from its left edge, or from its right edge when @@ -307,60 +179,43 @@ pub(crate) fn paint_line(painter: &Painter, rect: Rect, text: &str, font: FontId mod tests { use super::*; - /// The sections of each line of `text` (split at newlines), left to right. - fn visual(text: &str) -> Vec> { - let bidi = BidiInfo::new(text, None); - paragraph_ranges(text).into_iter().map(|l| line_sections(&bidi, l)).collect() + fn drawn_chars(row: &egui::epaint::text::Row) -> Vec { + row.visual_order().map(|i| row.glyphs[i].chr).filter(|c| !c.is_control()).collect() } #[test] - fn rtl_words_are_listed_last_word_first() { - assert_eq!(visual("كلمة أخرى"), [["أخرى", " ", "كلمة"]]); - assert_eq!(visual("שלום עולם"), [["עולם", " ", "שלום"]]); + fn rtl_paragraph_is_flagged_and_visually_ordered() { + let ctx = egui::Context::default(); + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); + let painter = Painter::new(ctx.clone(), egui::LayerId::background(), Rect::EVERYTHING); + let lines = Lines::new(&painter, "كلمة أخرى", FontId::proportional(13.0), Color32::WHITE, f32::INFINITY); + assert!(lines.rtl); + let row = lines.rows.first().and_then(|g| g.rows.first()).expect("one row"); + assert!(row.rtl_paragraph); + let order: String = drawn_chars(row).into_iter().collect(); + // Glyphs retain logical characters; the visual iterator walks them left to right. + // Both Arabic words therefore appear reversed in this diagnostic string. + assert_eq!(order, "ىرخأ ةملك", "the second logical word must draw first"); + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); } #[test] - fn marks_and_joiners_stay_in_their_word() { - // fatha, shadda and a zero-width non-joiner belong to the letters around them - assert_eq!(visual("كَتَبَ مُحَمَّد"), [["مُحَمَّد", " ", "كَتَبَ"]]); - assert_eq!(visual("می\u{200c}خواهم بود"), [["بود", " ", "می\u{200c}خواهم"]]); - } - - #[test] - fn latin_and_numbers_inside_rtl_keep_their_order() { - assert_eq!(visual("قال FilmCraft 0.6 جيد"), [["جيد", " ", "FilmCraft 0.6", " ", "قال"]]); - // Arabic-Indic digits read left to right, one section each so the shaper keeps them so - assert_eq!(visual("سنة ١٤٤٥"), [["١", "٤", "٤", "٥", " ", "سنة"]]); - } - - #[test] - fn rtl_punctuation_and_brackets() { - assert_eq!(visual("نعم، (نص)"), [["(", "نص", ")", " ", "،", "نعم"]]); - assert_eq!(visual("لماذا؟"), [["لماذا؟"]]); - } - - #[test] - fn ltr_paragraph_with_an_rtl_phrase() { - assert_eq!(visual("Lesson كلمة أخرى end"), [["Lesson ", "أخرى", " ", "كلمة", " end"]]); - assert!(!has_rtl("Lesson 3: (draft)")); + fn ltr_paragraph_with_an_rtl_phrase_keeps_its_direction() { + let ctx = egui::Context::default(); + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); + let painter = Painter::new(ctx.clone(), egui::LayerId::background(), Rect::EVERYTHING); + let lines = Lines::new(&painter, "Lesson كلمة أخرى end", FontId::proportional(13.0), Color32::WHITE, f32::INFINITY); + assert!(!lines.rtl); assert!(has_rtl("Lesson كلمة")); + let row = lines.rows.first().and_then(|g| g.rows.first()).expect("one row"); + assert!(!row.rtl_paragraph); + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); } #[test] - fn each_line_is_reordered_on_its_own() { - assert_eq!(visual("سطر أول\nسطر ثان"), [["أول", " ", "سطر"], ["ثان", " ", "سطر"]]); - assert_eq!(visual("نص\n\nEnglish"), [vec!["نص"], vec![], vec!["English"]]); - } - - #[test] - fn hostile_ranges_give_nothing() { - let text = "كلمة"; - let bidi = BidiInfo::new(text, None); - assert!(line_sections(&bidi, 1..3).is_empty(), "not on a char boundary"); - assert!(line_sections(&bidi, 0..99).is_empty(), "past the end"); - #[allow(clippy::reversed_empty_ranges)] - let backwards = 4..2; - assert!(line_sections(&bidi, backwards).is_empty()); + fn hostile_ranges_do_not_panic() { + assert!(!has_rtl("Lesson 3: (draft)")); + assert!(has_rtl("كلمة")); } /// With an Arabic face installed, captions lay out with real glyphs in reading order: in @@ -380,36 +235,16 @@ mod tests { let font = FontId::proportional(13.0); let lines = Lines::new(&painter, "كلمة أخرى", font.clone(), Color32::WHITE, f32::INFINITY); assert!(lines.rtl); - let row = lines.rows.first().and_then(|g| g.rows.first()).map(|r| r.glyphs.clone()).unwrap_or_default(); - // egui shapes each word on its own (letters joined, in visual order) and pads the row with - // empty glyphs so every char keeps a cursor position: only drawn glyphs count here - let drawn: Vec<_> = row.iter().filter(|g| !g.uv_rect.is_nothing()).collect(); - let order = drawn.iter().map(|g| g.chr).collect::(); + let row = lines.rows.first().and_then(|g| g.rows.first()).expect("one row"); + let drawn: Vec<_> = row.visual_order().map(|i| &row.glyphs[i]).filter(|g| !g.uv_rect.is_nothing()).collect(); + assert!(!drawn.is_empty(), "Arabic text must draw some glyphs"); let xs = |word: &str| drawn.iter().filter(|g| word.contains(g.chr)).map(|g| g.pos.x).collect::>(); let (first, second) = (xs("كلمة"), xs("أخرى")); - assert!(!first.is_empty() && !second.is_empty(), "{order:?}"); - assert!(second.iter().fold(f32::MIN, |a, &b| a.max(b)) < first.iter().fold(f32::MAX, |a, &b| a.min(b)), "{order:?}"); - let leftmost = drawn.iter().min_by(|a, b| a.pos.x.total_cmp(&b.pos.x)).map(|g| g.chr); - assert_eq!(leftmost, Some('ى'), "{order:?}"); + assert!(!first.is_empty() && !second.is_empty()); + assert!(second.iter().fold(f32::MIN, |a, &b| a.max(b)) < first.iter().fold(f32::MAX, |a, &b| a.min(b))); let missing = painter.layout_no_wrap("\u{fffd}".into(), font, Color32::WHITE); let missing_uv = missing.rows.first().and_then(|r| r.glyphs.first()).map(|g| g.uv_rect); assert!(drawn.iter().all(|g| Some(g.uv_rect) != missing_uv), "an Arabic letter drew as a missing glyph"); ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); } - - /// Lines broken by wrapping are each reordered: the first line holds the first words. - #[test] - fn line_ranges_follow_the_rows() { - let ctx = egui::Context::default(); - ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); - let painter = Painter::new(ctx.clone(), egui::LayerId::background(), Rect::EVERYTHING); - let text = "one two three\nfour"; - let galley = painter.layout(text.to_owned(), FontId::proportional(13.0), Color32::WHITE, 40.0); - let ranges = line_ranges(text, &galley).unwrap_or_default(); - assert!(ranges.len() >= 3, "{ranges:?}"); - let joined: Vec<&str> = ranges.iter().filter_map(|r| text.get(r.clone())).collect(); - assert_eq!(joined.concat().replace(' ', ""), "onetwothreefour"); - assert_eq!(ranges.last().and_then(|r| text.get(r.clone())), Some("four")); - ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); - } } diff --git a/crates/ui-egui/src/scrub.rs b/crates/ui-egui/src/scrub.rs index 392ce7866..714e9731c 100644 --- a/crates/ui-egui/src/scrub.rs +++ b/crates/ui-egui/src/scrub.rs @@ -51,7 +51,7 @@ impl ScrubAudio { impl FilmcraftApp { /// Once a frame: play a grain when the playhead moved under a held mouse button. pub(crate) fn scrub_audio(&mut self, ctx: &egui::Context) { - let (now, down) = ctx.input(|i| (i.time, i.pointer.any_down())); + let (now, down) = (self.now(), ctx.input(|i| i.pointer.any_down())); if self.scrub_tick(now, down) { ctx.request_repaint_after(std::time::Duration::from_secs_f64(LINGER_S)); } diff --git a/crates/ui-egui/src/source_playback.rs b/crates/ui-egui/src/source_playback.rs index df1054f69..e8d20b986 100644 --- a/crates/ui-egui/src/source_playback.rs +++ b/crates/ui-egui/src/source_playback.rs @@ -213,7 +213,7 @@ impl FilmcraftApp { self.ui.status = error; return; } - let now = ctx.input(|i| i.time); + let now = self.now(); let time = self.session.state.source_playhead; if time != self.source_playback.shown { self.source_playback.clock.anchor_tick = time; @@ -257,7 +257,7 @@ impl FilmcraftApp { if let Some(audio) = self.audio.as_mut() { audio.stop(); } - self.ui.status = "Source audio output stopped responding: playing without sound (check Settings ▸ Audio Hardware)".into(); + self.ui.status = "Source audio output stopped responding: playing without sound (check Settings > Audio Hardware)".into(); } let elapsed = match reading { Some((frames, sr)) if clock.audio_clock && sr > 0 => frames as f64 / f64::from(sr), diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index e0710236c..c45dc9785 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -942,6 +942,14 @@ pub struct GfxEdit { pub layer: usize, pub caret: usize, pub anchor: usize, + /// Stable layer uid (`0` = the project layer has none). + #[serde(default)] + pub uid: u64, + /// Compact identity of a uid-less layer, so the edit target follows its layer through a + /// reorder, a deletion below it, or undo/redo instead of editing whatever moves into the + /// numeric index. + #[serde(default)] + pub fingerprint: u64, } fn shown() -> bool { diff --git a/crates/ui-egui/src/theme_favorites.rs b/crates/ui-egui/src/theme_favorites.rs index e797df9fd..563e08f19 100644 --- a/crates/ui-egui/src/theme_favorites.rs +++ b/crates/ui-egui/src/theme_favorites.rs @@ -1,7 +1,7 @@ //! Original theme favorite star artwork, MIT OR Apache-2.0. pub(crate) fn star(ui: &mut egui::Ui, favorite: bool, visible: bool, accent: egui::Color32, label: &str) -> egui::Response { let (rect, response) = ui.allocate_exact_size(egui::vec2(22.0, 22.0), egui::Sense::click()); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); if visible { let color = if favorite { accent } else { ui.visuals().weak_text_color() }; let center = rect.center(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index f40ec0365..f9caa667b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -6,16 +6,45 @@ use filmcraft_time::{FrameRate, Tick, parse_timecode}; use crate::icons::{self, Icon}; use crate::theme::Tokens; +/// Give a widget an explicit accessible name without changing its visible hint text. +/// +/// `egui_kittest` 0.37 hard-fails visible input roles that have neither a label nor a placeholder. +/// Text fields that sit beside a drawn label (or that have no room for a placeholder) still need a +/// stable name for screen readers and `get_by_label`. +pub fn accessible_name(response: Response, name: impl Into) -> Response { + let name = name.into(); + if response.ctx.has_accesskit_node(response.id) { + response.ctx.accesskit_node_builder(response.id, |node| { + if node.label().is_none_or(|label| label.trim().is_empty()) { + node.set_label(name); + } + }); + } + response +} + /// Premiere "hot text": a blue number you drag horizontally to scrub, click to type a number or a -/// calculation ([`crate::calc`]). +/// calculation ([`crate::calc`]). `label` is the property's accessible name (for example +/// "Position X"), used when the field switches to a text editor so screen readers can tell the +/// value fields apart. /// Returns (response, Some(new value) when changed). -pub fn hot_number(ui: &mut Ui, id: egui::Id, value: f64, speed: f64, range: (f64, f64), decimals: usize, suffix: &str, t: &Tokens) -> (Response, Option) { +pub fn hot_number( + ui: &mut Ui, + label: &str, + id: egui::Id, + value: f64, + speed: f64, + range: (f64, f64), + decimals: usize, + suffix: &str, + t: &Tokens, +) -> (Response, Option) { let editing_id = id.with("editing"); let edit_id = id.with("edit"); let select_all_id = id.with("select-all"); let mut editing: Option = ui.data(|d| d.get_temp(editing_id)); if let Some(buf) = editing.as_mut() { - let r = ui.add(egui::TextEdit::singleline(buf).id(edit_id).desired_width(56.0).font(Tokens::ui(12.0))); + let r = accessible_name(ui.add(egui::TextEdit::singleline(buf).id(edit_id).desired_width(56.0).font(Tokens::ui(12.0))), label); let mut out = None; if r.lost_focus() { // the field's own unit can be typed anywhere; calc ignores the common ones @@ -109,7 +138,8 @@ pub fn timecode_field( st.store(ui.ctx(), edit_id); } } - let r = ui.put(rect, egui::TextEdit::singleline(&mut buf).id(edit_id).font(Tokens::timecode()).desired_width(rect.width())); + let r = + accessible_name(ui.put(rect, egui::TextEdit::singleline(&mut buf).id(edit_id).font(Tokens::timecode()).desired_width(rect.width())), "Timecode"); if r.lost_focus() { ui.data_mut(|d| d.remove::(editing_id)); if ui.input(|i| i.key_pressed(egui::Key::Escape)) { @@ -272,7 +302,10 @@ pub fn search_field(ui: &mut Ui, text: &mut String, hint: &str, width: f32, t: & icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.min.x + 12.0, rect.center().y), vec2(12.0, 12.0)), Icon::Search, t.text_dim); let inner = Rect::from_min_max(pos2(rect.min.x + 22.0, rect.min.y + 2.0), pos2(rect.max.x - 8.0, rect.max.y - 2.0)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner)); - let resp = child.add(egui::TextEdit::singleline(text).hint_text(hint).frame(egui::Frame::NONE).desired_width(inner.width()).font(Tokens::ui(12.0))); + let resp = accessible_name( + child.add(egui::TextEdit::singleline(text).hint_text(hint).frame(egui::Frame::NONE).desired_width(inner.width()).font(Tokens::ui(12.0))), + hint, + ); // Select Find Box (Shift+F) / Open Search (Cmd+Shift+F) crate::panels::keyboard::take_search_focus(ui, rect, &resp); resp @@ -364,16 +397,40 @@ mod tests { let input = egui::RawInput { events, ..Default::default() }; // no renderer takes the font atlas upload here; drop it explicitly (epaint panics otherwise) ctx.run_ui(input, |ui| { - hot_number(ui, id, 123.5, 1.0, (0.0, 1000.0), 1, "", &t); + hot_number(ui, "Value", id, 123.5, 1.0, (0.0, 1000.0), 1, "", &t); }) .textures_delta .clear(); } + /// The edit field behind a hot number uses the caller's property label, not the generic + /// "Value", so Effect Controls and graphics fields are distinguishable by screen readers. + #[test] + fn a_hot_number_edit_field_uses_the_parameter_label() { + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + let id = egui::Id::unique("hot-labelled"); + let mut harness = Harness::builder().with_size(egui::vec2(240.0, 60.0)).build_ui_state( + move |ui, _: &mut ()| { + let t = Tokens::for_kind(ThemeKind::Dark); + ui.data_mut(|d| { + d.insert_temp(id.with("editing"), "12.5".to_string()); + d.insert_temp(id.with("select-all"), false); + }); + hot_number(ui, "Position X", id, 12.5, 1.0, (-1000.0, 1000.0), 1, "", &t); + }, + (), + ); + harness.run_steps(2); + harness.check_accessibility(); + assert!(harness.query_by_label("Position X").is_some(), "the field is reachable by its parameter label"); + assert!(harness.query_by_label("Value").is_none(), "the generic label is gone"); + } + #[test] fn clicking_a_hot_number_selects_its_whole_value() { let ctx = egui::Context::default(); - let id = egui::Id::new("hot"); + let id = egui::Id::unique("hot"); let pos = egui::pos2(14.0, 12.0); let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; frame(&ctx, vec![egui::Event::PointerMoved(pos)], id); diff --git a/crates/ui-egui/tests/it/clipboard_paste_ui.rs b/crates/ui-egui/tests/it/clipboard_paste_ui.rs index c01d2e165..f5b09173c 100644 --- a/crates/ui-egui/tests/it/clipboard_paste_ui.rs +++ b/crates/ui-egui/tests/it/clipboard_paste_ui.rs @@ -3,7 +3,10 @@ use std::cell::Cell; use std::rc::Rc; -use std::sync::mpsc::{Sender, channel}; +use std::sync::{ + Arc, + mpsc::{Sender, channel}, +}; use egui_kittest::Harness; use filmcraft_engine::Session; @@ -140,6 +143,46 @@ fn ctrl_v_pastes_an_image_above_the_footage() { assert_eq!(d.clips(), before); } +/// A paste delivered by `Event::PasteImage` carries the image bytes: paste uses that payload even +/// with no clipboard-media hook (the web host installs none) and without re-reading the OS. +#[test] +fn paste_image_event_imports_its_own_payload_without_a_clipboard_hook() { + let mut d = Driver::demo("paste-image-event"); + d.ok("ui.set", json!({"focused": "Timeline"})); + assert!(d.app().hooks.clipboard_media.is_none()); + let before = d.clips(); + let image = egui::ColorImage::filled([8, 6], egui::Color32::from_rgb(10, 20, 30)); + d.harness.input_mut().events.extend([egui::Event::ModifiersChanged(egui::Modifiers::COMMAND), egui::Event::PasteImage(Arc::new(image))]); + d.frames(1); + d.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + d.frames(2); + let new: Vec<_> = d.clips().into_iter().filter(|c| !before.contains(c)).collect(); + assert_eq!(new.iter().map(|c| c.3.as_str()).collect::>(), ["Pasted Image 1.png"]); + assert!(d.dir.join("Pasted Image 1.png").is_file(), "the event payload was written, not an empty clipboard"); +} + +/// The saved PNG stores straight alpha. `Color32` is premultiplied internally, so a +/// semi-transparent clipboard pixel must keep its colour through the paste. +#[test] +fn paste_image_event_writes_straight_alpha_rgba() { + let mut d = Driver::demo("paste-image-alpha"); + d.ok("ui.set", json!({"focused": "Timeline"})); + let before = d.clips(); + // Four half-transparent red pixels; premultiplied storage would be (128, 0, 0, 128). + let rgba = [255, 0, 0, 128, 255, 0, 0, 128, 255, 0, 0, 128, 255, 0, 0, 128]; + let image = egui::ColorImage::from_rgba_unmultiplied([2, 2], &rgba); + d.harness.input_mut().events.extend([egui::Event::ModifiersChanged(egui::Modifiers::COMMAND), egui::Event::PasteImage(Arc::new(image))]); + d.frames(1); + d.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + d.frames(2); + let new: Vec<_> = d.clips().into_iter().filter(|c| !before.contains(c)).collect(); + assert_eq!(new.iter().map(|c| c.3.as_str()).collect::>(), ["Pasted Image 1.png"]); + let png = std::fs::read(d.dir.join("Pasted Image 1.png")).expect("the pasted PNG"); + let decoded = image::load_from_memory(&png).expect("decode the pasted PNG").to_rgba8(); + assert_eq!(decoded.get_pixel(0, 0).0, [255, 0, 0, 128], "straight alpha, not the premultiplied colour"); + assert_eq!(decoded.get_pixel(1, 1).0, [255, 0, 0, 128]); +} + /// Files copied in the file manager paste the same way, even when the clipboard also holds text /// (Ctrl+V then arrives as a paste event); Ctrl+Shift+V inserts them on the targeted track. #[test] diff --git a/crates/ui-egui/tests/it/deselect_ui.rs b/crates/ui-egui/tests/it/deselect_ui.rs index 66095e652..4c4de2a24 100644 --- a/crates/ui-egui/tests/it/deselect_ui.rs +++ b/crates/ui-egui/tests/it/deselect_ui.rs @@ -103,7 +103,7 @@ fn clicking_another_panel_ends_text_editing() { let clip = d.exec("graphics.newText", json!({"text": "Title", "time": at}))["clip"].as_u64().unwrap(); d.ok("ui.panel.show", json!({"panel": "Properties"})); let edit = |d: &mut Driver| { - d.app().ui.gfx_edit = Some(GfxEdit { clip, layer: 0, caret: 0, anchor: 0 }); + d.app().ui.gfx_edit = Some(GfxEdit { clip, layer: 0, caret: 0, anchor: 0, ..Default::default() }); d.app().ui.focused = filmcraft_ui_egui::dock::PanelKind::Program; d.frames(3); }; diff --git a/crates/ui-egui/tests/it/graphics_handles_ui.rs b/crates/ui-egui/tests/it/graphics_handles_ui.rs index f3c03fb51..b850368f4 100644 --- a/crates/ui-egui/tests/it/graphics_handles_ui.rs +++ b/crates/ui-egui/tests/it/graphics_handles_ui.rs @@ -75,6 +75,24 @@ impl Driver { std::fs::create_dir_all(&dir).unwrap(); img.save(dir.join(format!("{name}.png"))).unwrap(); } + + fn ime(&self) -> Option { + self.harness.output().platform_output.ime.clone() + } + + /// Start point-text editing on the Program picture and return its clip id. + fn start_point_text(&mut self) -> u64 { + self.ok("ui.set", json!({"tool": "Type"})); + self.frames(3); + let pic = self.rect("program.picture"); + self.ok("ui.click", json!({"x": pic[0] + pic[2] * 0.3, "y": pic[1] + pic[3] * 0.2})); + self.frames(3); + self.exec("graphics.list", json!({}))["clip"].as_u64().unwrap() + } +} + +fn ime_payload(ime: &egui::output::IMEOutput) -> &egui::output::IMESurroundingTextPayload { + ime.surrounding_text.available().expect("surrounding text is available") } impl Driver { @@ -357,6 +375,839 @@ fn text_committed_by_an_input_method_is_typed_on_the_monitor() { assert_eq!(list["layers"][0]["text"], "A你好"); } +/// The Program monitor's custom IME producer reports committed logical text, a reversed UTF-8 +/// selection, ignores preedit-only updates, and labels an accepted commit `InputMethod` while +/// ordinary typing remains `Other`. +#[test] +fn ime_output_reports_committed_text_selection_and_cause() { + let mut d = Driver::new(); + let clip = d.start_point_text(); + d.ok("ui.type", json!({"text": "café😀"})); + d.frames(3); + + let first = d.ime().expect("the focused text layer publishes an IME context"); + let first_payload = ime_payload(&first); + assert_eq!(first_payload.text, "café😀"); + assert_eq!(first_payload.cursor, "café😀".len()); + assert_eq!(first_payload.anchor, "café😀".len()); + assert_eq!(first.change_cause, egui::output::ImeChangeCause::Other); + let (owner, generation) = (first.owner, first.context_generation); + + // Primary caret 5 (after é), anchor 2: a reversed selection of "fé". + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 5, "anchor": 2}})); + d.frames(3); + let selected = d.ime().expect("selection keeps the context"); + assert_eq!(selected.owner, owner); + assert_eq!(selected.context_generation, generation, "ordinary caret/selection moves keep the generation"); + let p = ime_payload(&selected); + assert_eq!((p.cursor, p.anchor), (5, 2), "direction is preserved"); + + // A pre-edit alone is not committed text and must not be sent. + d.harness.input_mut().events.push(egui::Event::Ime(egui::ImeEvent::Preedit { text: "に".into(), active_range_chars: Some(0..1) })); + d.frames(2); + let preedit = d.ime().expect("preedit does not remove the context"); + let p = ime_payload(&preedit); + assert_eq!(p.text, "café😀"); + assert_eq!((p.cursor, p.anchor), (5, 2)); + + // The commit replaces the selected "fé"; only this accepted mutation is InputMethod. + d.harness.input_mut().events.push(egui::Event::Ime(egui::ImeEvent::Commit("語".into()))); + d.frames(1); + let committed = d.ime().expect("the commit is observed after its action"); + assert_eq!(committed.owner, owner); + assert_eq!(committed.context_generation, generation); + assert_eq!(committed.change_cause, egui::output::ImeChangeCause::InputMethod); + let p = ime_payload(&committed); + assert_eq!(p.text, "ca語😀"); + assert_eq!((p.cursor, p.anchor), (5, 5)); + + // An empty (no-op) commit must not claim InputMethod for the unchanged snapshot. + d.harness.input_mut().events.push(egui::Event::Ime(egui::ImeEvent::Commit(String::new()))); + d.frames(1); + let no_op = d.ime().expect("a no-op commit leaves the context in place"); + assert_eq!(no_op.change_cause, egui::output::ImeChangeCause::Other); + + // Repeated passes rebuild the same post-mutation descriptor without resetting the context. + d.frames(2); + let repeated = d.ime().expect("the committed editor is still observed"); + assert_eq!(repeated.owner, owner); + assert_eq!(repeated.context_generation, generation); + let p = ime_payload(&repeated); + assert_eq!(p.text, "ca語😀"); + assert_eq!((p.cursor, p.anchor), (5, 5)); + + // Ordinary typing in a later frame is application-origin, never InputMethod. + d.ok("ui.type", json!({"text": "X"})); + d.frames(2); + let typed = d.ime().expect("typing keeps the context"); + assert_eq!(typed.context_generation, generation); + assert_eq!(typed.change_cause, egui::output::ImeChangeCause::Other); + assert_eq!(ime_payload(&typed).text, "ca語X😀", "typing inserts at the retained caret before the emoji"); + assert_eq!((ime_payload(&typed).cursor, ime_payload(&typed).anchor), (6, 6)); +} + +/// Delete-surrounding excludes the selection, rounds partial UTF-8 ranges inward, and keeps a +/// reversed selection's direction while the queued edit is accepted. +#[test] +fn ime_delete_surrounding_keeps_selection_direction_and_unicode() { + let mut d = Driver::new(); + let clip = d.start_point_text(); + d.ok("ui.type", json!({"text": "aé😀z"})); + d.frames(3); + // Select é (bytes 1..3), primary caret at its end (reversed). + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 3, "anchor": 1}})); + d.frames(3); + d.harness.input_mut().events.push(egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 4 })); + d.frames(1); + let ime = d.ime().expect("delete-surrounding publishes the edited context"); + assert_eq!(ime.change_cause, egui::output::ImeChangeCause::InputMethod); + let p = ime_payload(&ime); + assert_eq!(p.text, "éz", "one byte before and the four-byte emoji after are deleted; é is kept"); + assert_eq!((p.cursor, p.anchor), (2, 0), "primary caret stays after the anchor"); + assert_eq!(d.exec("graphics.list", json!({}))["layers"][0]["text"], "éz"); +} + +/// An available empty layer is `Available("", 0, 0)`, while a selection over the 4000-byte +/// protocol limit is an explicit `Unavailable`, never a truncated or fabricated snapshot. +#[test] +fn ime_surrounding_text_distinguishes_empty_from_unavailable_and_is_bounded() { + let mut d = Driver::new(); + let clip = d.start_point_text(); + let empty = d.ime().expect("an empty new text layer is still a focused IME owner"); + let p = ime_payload(&empty); + assert!(p.text.is_empty()); + assert_eq!((p.cursor, p.anchor), (0, 0)); + + d.exec("graphics.setText", json!({"clip": clip, "layer": 0, "text": "a".repeat(5001)})); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 4000, "anchor": 0}})); + d.frames(3); + let at_limit = d.ime().expect("the edit context remains focused"); + let p = ime_payload(&at_limit); + assert_eq!(p.text.len(), 4000); + assert_eq!((p.cursor, p.anchor), (4000, 0)); + + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 4001, "anchor": 0}})); + d.frames(3); + let over = d.ime().expect("an unavailable snapshot still has an owner and generation"); + assert!(over.surrounding_text.available().is_none(), "a too-wide selection is explicitly Unavailable"); + assert_eq!(over.owner, at_limit.owner); +} + +/// Losing focus ends the output; reopening the same layer keeps the stable owner but resets the +/// generation exactly once. A plain caret move does not reset it. +#[test] +fn ime_owner_is_stable_across_edits_and_resets_on_refocus() { + let mut d = Driver::new(); + d.start_point_text(); + d.ok("ui.type", json!({"text": "Title"})); + d.frames(3); + let first = d.ime().expect("text editing has an IME context"); + let (owner, generation) = (first.owner, first.context_generation); + + d.ok("ui.key", json!({"key": "ArrowLeft"})); + d.frames(2); + let moved = d.ime().expect("caret movement keeps the context"); + assert_eq!(moved.owner, owner); + assert_eq!(moved.context_generation, generation, "ordinary caret movement does not reset"); + + d.ok("ui.key", json!({"key": "Escape"})); + d.frames(2); + assert!(d.ime().is_none(), "an unfocused, closed editor publishes no descriptor"); + + let pic = d.rect("program.picture"); + d.ok("ui.click", json!({"x": pic[0] + pic[2] * 0.3, "y": pic[1] + pic[3] * 0.2})); + d.frames(3); + let reopened = d.ime().expect("the reopened editor publishes again"); + assert_eq!(reopened.owner, owner, "the sequence/clip/component owner is stable"); + assert_ne!(reopened.context_generation, generation, "a replacement session resets the generation once"); +} + +/// An ordinary text event in the same pass as an accepted commit makes the whole frame `Other`. +#[test] +fn ime_mixed_application_and_ime_mutation_is_other() { + let mut d = Driver::new(); + d.start_point_text(); + d.frames(2); + let before = d.ime().expect("the empty editor is focused"); + d.harness.input_mut().events.extend([egui::Event::Text("A".into()), egui::Event::Ime(egui::ImeEvent::Commit("B".into()))]); + d.frames(1); + let mixed = d.ime().expect("the mixed edit is observed"); + assert_eq!(ime_payload(&mixed).text, "AB"); + assert_eq!(mixed.change_cause, egui::output::ImeChangeCause::Other); + assert_eq!(mixed.context_generation, before.context_generation); + + // A toolbar/control edit of the same target is application-origin too, even after a commit. + let clip = d.exec("graphics.list", json!({}))["clip"].as_u64().unwrap(); + d.exec("graphics.setText", json!({"clip": clip, "layer": 0, "text": "toolbar"})); + d.frames(2); + let toolbar = d.ime().expect("the toolbar edit keeps the owner"); + assert_eq!(ime_payload(&toolbar).text, "toolbar"); + assert_eq!(toolbar.change_cause, egui::output::ImeChangeCause::Other); +} + +fn ime_preedit(text: &str) -> egui::Event { + egui::Event::Ime(egui::ImeEvent::Preedit { text: text.into(), active_range_chars: None }) +} + +fn ime_commit(text: &str) -> egui::Event { + egui::Event::Ime(egui::ImeEvent::Commit(text.into())) +} + +fn key_press(key: egui::Key) -> egui::Event { + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE } +} + +fn layer_texts(d: &mut Driver, clip: u64) -> Vec { + d.exec("graphics.list", json!({"clip": clip}))["layers"] + .as_array() + .map(|layers| layers.iter().map(|layer| layer["text"].as_str().unwrap_or("").to_string()).collect()) + .unwrap_or_default() +} + +fn new_text_layer(d: &mut Driver, clip: Option, text: &str, x: f64) -> u64 { + let mut p = json!({"text": text, "position": [x, 400], "size": 100}); + if let Some(clip) = clip { + p["clip"] = json!(clip); + } + d.exec("graphics.newText", p)["clip"].as_u64().unwrap() +} + +/// A commit from a composition whose target was replaced (`ui.set.gfxEdit`) must be dropped: the +/// text belongs to the old layer, not to whatever layer the new edit target names. +#[test] +fn ime_commit_after_target_replacement_is_dropped() { + let mut d = Driver::new(); + let clip = new_text_layer(&mut d, None, "alpha", 500.0); + new_text_layer(&mut d, Some(clip), "beta", 900.0); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 5, "anchor": 5}})); + d.frames(3); + d.harness.input_mut().events.push(ime_preedit("に")); + d.frames(1); + // The edit target moves to the other layer while the IME is still composing. + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 1, "caret": 4, "anchor": 4}})); + d.frames(2); + d.harness.input_mut().events.push(ime_commit("x")); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["alpha".to_string(), "beta".to_string()]); +} + +/// A composition whose target was replaced can emit another non-empty pre-edit before its final +/// commit. That late pre-edit must not revive the old composition on the replacement layer, while +/// a genuinely new composition in a later raw batch must still work. +#[test] +fn ime_late_preedit_after_target_replacement_cannot_retarget() { + let mut d = Driver::new(); + let clip = new_text_layer(&mut d, None, "alpha", 500.0); + new_text_layer(&mut d, Some(clip), "beta", 900.0); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 5, "anchor": 5}})); + d.frames(3); + d.harness.input_mut().events.push(ime_preedit("に")); + d.frames(1); + // The edit target moves to the other layer while the old composition is still live. + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 1, "caret": 4, "anchor": 4}})); + d.frames(2); + // A late pre-edit from the replaced composition. The input method commits it in a later raw + // batch, so the tombstone must survive the pre-edit batch. + d.harness.input_mut().events.push(ime_preedit("にに")); + d.frames(1); + d.harness.input_mut().events.push(ime_commit("x")); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["alpha".to_string(), "beta".to_string()], "the late pre-edit and paired commit stayed on the old target"); + assert!( + d.ime().is_none_or(|ime| ime.change_cause != egui::output::ImeChangeCause::InputMethod), + "a rejected stale composition is not an InputMethod receipt" + ); + + // The rejected composition's commit ended the tombstone: a fresh composition on the + // replacement layer is legitimate and must be accepted. + d.harness.input_mut().events.extend([ime_preedit("よ"), ime_commit("y")]); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["alpha".to_string(), "betay".to_string()], "a new composition on the current target still works"); +} + +/// An empty pre-edit clears the visible composition but not ownership of its pending terminal +/// commit. Switching targets before that commit arrives must still reject it. +#[test] +fn ime_empty_preedit_keeps_pending_commit_from_retargeting() { + let mut d = Driver::new(); + let clip = new_text_layer(&mut d, None, "alpha", 500.0); + new_text_layer(&mut d, Some(clip), "beta", 900.0); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 5, "anchor": 5}})); + d.frames(3); + d.harness.input_mut().events.push(ime_preedit("に")); + d.frames(1); + // The empty pre-edit dismisses the visible composition. Its commit is a separate event. + d.harness.input_mut().events.push(ime_preedit("")); + d.frames(1); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 1, "caret": 4, "anchor": 4}})); + d.frames(2); + d.harness.input_mut().events.push(ime_commit("x")); + d.frames(2); + assert_eq!( + layer_texts(&mut d, clip), + vec!["alpha".to_string(), "beta".to_string()], + "the pending commit of the dismissed composition stayed on the old target" + ); + + // The rejected commit ended that composition; a direct commit on the current target works. + d.harness.input_mut().events.push(ime_commit("y")); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["alpha".to_string(), "betay".to_string()], "a direct commit after the terminal boundary is accepted"); +} + +/// A rejected old commit is a terminal boundary even when the same raw batch immediately starts a +/// new pre-edit. That new composition must survive to its later commit. +#[test] +fn ime_preedit_after_stale_commit_in_one_batch_is_accepted() { + let mut d = Driver::new(); + let clip = new_text_layer(&mut d, None, "alpha", 500.0); + new_text_layer(&mut d, Some(clip), "beta", 900.0); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 5, "anchor": 5}})); + d.frames(3); + d.harness.input_mut().events.push(ime_preedit("に")); + d.frames(1); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 1, "caret": 4, "anchor": 4}})); + d.frames(2); + // Wayland can coalesce the old terminal events and a new pre-edit into one raw batch. + d.harness.input_mut().events.extend([ime_preedit(""), ime_commit("x"), ime_preedit("よ")]); + d.frames(1); + assert_eq!(layer_texts(&mut d, clip), vec!["alpha".to_string(), "beta".to_string()], "the old commit was still rejected"); + d.harness.input_mut().events.push(ime_commit("y")); + d.frames(2); + assert_eq!( + layer_texts(&mut d, clip), + vec!["alpha".to_string(), "betay".to_string()], + "the pre-edit after the old terminal commit started a new composition" + ); +} + +/// A same-pass control text mutation replaces the text the composition belonged to. The old +/// descriptor must not be published (existing behaviour) and the composition's next commit must +/// not be inserted into the replacement text. +#[test] +fn ime_same_pass_control_text_mutation_invalidates_composition() { + let mut d = Driver::new(); + let clip = d.start_point_text(); + d.ok("ui.type", json!({"text": "one"})); + d.frames(3); + d.harness.input_mut().events.push(ime_preedit("に")); + d.frames(1); + // `ui.menu.invoke` is drained after the overlay drew and changes the committed text. + d.ok("ui.menu.invoke", json!({"id": "graphics.setText", "params": {"clip": clip, "layer": 0, "text": "changed"}})); + assert!(d.ime().is_none(), "the old cursor rectangle must not be published for the changed text"); + + d.harness.input_mut().events.push(ime_commit("x")); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["changed".to_string()], "the old composition's commit must not edit the replacement text"); + assert!(d.ime().is_none_or(|ime| ime.change_cause != egui::output::ImeChangeCause::InputMethod), "a rejected stale commit is not an InputMethod receipt"); + // The control replacement keeps the editor's existing numeric byte offsets (the editor only + // clamps them into the new text, as it did before this migration), so place the caret at the + // end explicitly: this regression tests that the rejected composition's tombstone retires, + // not the unrelated caret policy. + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 7, "anchor": 7}})); + d.frames(1); + + // The rejected commit ended the tombstone: the next composition on the same target is + // legitimate and must still be accepted. + d.harness.input_mut().events.extend([ime_preedit("よ"), ime_commit("z")]); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["changedz".to_string()], "a new composition still starts after the text mutation"); +} + +/// A same-pass control request can change layout (font size, style, alignment, spacing, box, +/// transform) without changing the committed text or the layer identity. The pass must not +/// publish the cursor rectangle of the previous layout. +#[test] +fn ime_control_layout_change_does_not_publish_stale_cursor_rect() { + let mut d = Driver::new(); + let clip = d.start_point_text(); + d.ok("ui.type", json!({"text": "one"})); + d.frames(3); + let before = d.ime().expect("the editor is observed before the layout change"); + d.ok("ui.menu.invoke", json!({"id": "graphics.set", "params": {"clip": clip, "layer": 0, "props": {"tracking": 200.0}}})); + assert!(d.ime().is_none(), "the old cursor rectangle must not be published with the new layout"); + d.frames(1); + let after = d.ime().expect("the next pass observes the new layout"); + assert_ne!(after.cursor_rect, before.cursor_rect, "the candidate rectangle moved with the wider tracking"); +} + +/// An explicit `ui.set.gfxEdit` names the layer at that numeric index now. A uid belonging to a +/// layer that a reorder moved elsewhere must not silently retarget the request. +#[test] +fn explicit_gfx_edit_index_selects_the_current_layer_not_a_stale_uid() { + let mut d = Driver::new(); + let clip = new_text_layer(&mut d, None, "one", 400.0); + new_text_layer(&mut d, Some(clip), "two", 900.0); + let uids: Vec = d.exec("graphics.list", json!({"clip": clip}))["layers"].as_array().unwrap().iter().map(|l| l["uid"].as_u64().unwrap()).collect(); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 3, "anchor": 3}})); + d.frames(2); + assert_eq!(d.harness.state().ui.gfx_edit.as_ref().map(|e| (e.layer, e.uid)), Some((0, uids[0]))); + + d.exec("graphics.arrangeLayer", json!({"clip": clip, "layer": 0, "to": "front"})); + d.frames(2); + assert_eq!(d.harness.state().ui.gfx_edit.as_ref().map(|e| (e.layer, e.uid)), Some((1, uids[0])), "the edit follows the reordered layer"); + + // Index 0 now holds "two", but the request still carries the old "one" uid. The explicit + // index wins: the current layer at index 0 is selected. + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 3, "anchor": 3, "uid": uids[0]}})); + d.frames(2); + assert_eq!(d.harness.state().ui.gfx_edit.as_ref().map(|e| (e.layer, e.uid)), Some((0, uids[1])), "the stale uid must not retarget the request"); + d.ok("ui.type", json!({"text": "X"})); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["twoX".to_string(), "one".to_string()]); +} + +/// The fork sends `DeleteSurroundingBytes` immediately before the `Commit` it belongs to. A stale +/// delete must not lift the tombstone, or the paired commit would be inserted into the replacement +/// layer. The tombstone is bounded to this input batch: a later direct commit (no pre-edit) on the +/// new target is legitimate and must still be accepted. +#[test] +fn stale_delete_and_commit_pair_cannot_retarget_the_replacement_layer() { + let mut d = Driver::new(); + let clip = new_text_layer(&mut d, None, "alpha", 500.0); + new_text_layer(&mut d, Some(clip), "beta", 900.0); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 5, "anchor": 5}})); + d.frames(3); + d.harness.input_mut().events.push(ime_preedit("に")); + d.frames(1); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 1, "caret": 4, "anchor": 4}})); + d.frames(2); + d.harness.input_mut().events.extend([egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 0, after_bytes: 0 }), ime_commit("x")]); + d.frames(2); + assert_eq!( + layer_texts(&mut d, clip), + vec!["alpha".to_string(), "beta".to_string()], + "neither the delete nor its paired commit touched the replacement layer" + ); + assert!(d.ime().is_none_or(|ime| ime.change_cause != egui::output::ImeChangeCause::InputMethod), "a rejected stale pair is not an InputMethod receipt"); + + // The tombstone ends with that batch: a later commit-only composition is accepted on the + // layer the user is editing now. + d.harness.input_mut().events.push(ime_commit("y")); + // One pass: the receipt belongs to the frame that observed the change. A later unchanged + // frame reports `Other`, per the tracker contract. + d.frames(1); + // Reading through the control channel advances a frame; capture the receipt first. + let receipt = d.ime().expect("the accepted direct commit is observed"); + assert_eq!(layer_texts(&mut d, clip), vec!["alpha".to_string(), "betay".to_string()]); + assert_eq!(receipt.change_cause, egui::output::ImeChangeCause::InputMethod); +} + +/// An empty pre-edit is the input method dismissing its composition. When it arrives after the +/// target was replaced it must not clear the tombstone before the paired commit, but it also must +/// not poison the next legitimate commit-only composition. +#[test] +fn stale_empty_preedit_and_commit_pair_cannot_retarget_the_replacement_layer() { + let mut d = Driver::new(); + let clip = new_text_layer(&mut d, None, "alpha", 500.0); + new_text_layer(&mut d, Some(clip), "beta", 900.0); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 5, "anchor": 5}})); + d.frames(3); + d.harness.input_mut().events.push(ime_preedit("に")); + d.frames(1); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 1, "caret": 4, "anchor": 4}})); + d.frames(2); + d.harness.input_mut().events.extend([ime_preedit(""), ime_commit("x")]); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["alpha".to_string(), "beta".to_string()]); + assert!(d.ime().is_none_or(|ime| ime.change_cause != egui::output::ImeChangeCause::InputMethod)); + + d.harness.input_mut().events.push(ime_commit("y")); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["alpha".to_string(), "betay".to_string()], "later commit-only input still works"); +} + +/// A composition belongs to its layer's uid, not to the numeric index it was started at: a real +/// reorder moves the editor (and the commit) with the same layer. +#[test] +fn composition_follows_a_reordered_layer_by_uid() { + let mut d = Driver::new(); + let clip = new_text_layer(&mut d, None, "one", 400.0); + new_text_layer(&mut d, Some(clip), "two", 900.0); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 3, "anchor": 3}})); + d.frames(3); + d.harness.input_mut().events.push(ime_preedit("に")); + d.frames(1); + d.exec("graphics.arrangeLayer", json!({"clip": clip, "layer": 0, "to": "front"})); + d.frames(2); + assert_eq!(d.harness.state().ui.gfx_edit.as_ref().map(|e| e.layer), Some(1)); + d.harness.input_mut().events.push(ime_commit("x")); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["two".to_string(), "onex".to_string()], "the composition stayed on the layer it began on"); +} + +/// `DeleteSurroundingBytes { 0, 0 }` followed by an empty commit is the IME's way of replacing a +/// selection with nothing. The selection must be deleted and the mutation labelled `InputMethod`. +#[test] +fn empty_ime_commit_replaces_the_selection() { + let mut d = Driver::new(); + let clip = d.start_point_text(); + d.ok("ui.type", json!({"text": "abc"})); + d.frames(2); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 0, "anchor": 3}})); + d.frames(2); + d.harness.input_mut().events.extend([egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 0, after_bytes: 0 }), ime_commit("")]); + d.frames(1); + // Capture the receipt before graphics.list pumps the next control-channel frame. + let ime = d.ime().expect("the accepted empty commit is observed"); + assert_eq!(layer_texts(&mut d, clip), vec![String::new()], "the selected text is replaced by the empty commit"); + assert_eq!(ime.change_cause, egui::output::ImeChangeCause::InputMethod); + assert_eq!(ime_payload(&ime).text, ""); +} + +/// Escape in the same raw batch as a commit cancels the edit; the later commit must not be typed. +#[test] +fn escape_before_a_commit_in_the_same_batch_cancels_it() { + let mut d = Driver::new(); + let clip = d.start_point_text(); + d.ok("ui.type", json!({"text": "a"})); + d.frames(2); + d.harness.input_mut().events.extend([key_press(egui::Key::Escape), ime_commit("x")]); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["a".to_string()]); + assert!(d.harness.state().ui.gfx_edit.is_none(), "Escape ended the edit"); + assert!(d.ime().is_none(), "the closed editor publishes no descriptor"); +} + +/// A pointer press later in the batch is handled after `text_edit`; a commit after that press +/// belongs to the new target and must not be inserted into the old one. +#[test] +fn pointer_press_before_a_commit_does_not_edit_the_old_layer() { + let mut d = Driver::new(); + let clip = new_text_layer(&mut d, None, "alpha", 500.0); + new_text_layer(&mut d, Some(clip), "beta", 900.0); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 5, "anchor": 5}})); + d.frames(3); + let rect = d.rect(&format!("program.layer.{clip}.1")); + d.harness.input_mut().events.extend([ + egui::Event::PointerButton { + pos: egui::pos2((rect[0] + rect[2] / 2.0) as f32, (rect[1] + rect[3] / 2.0) as f32), + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }, + ime_commit("x"), + ]); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["alpha".to_string(), "beta".to_string()]); +} + +/// Moving within the same layer keeps its owner, but a known composition belongs to the old +/// caret. Real press/release input moves it; the late replacement arrives in a separate batch. +#[test] +fn ime_pointer_caret_move_rejects_next_batch_old_composition() { + for terminal_pending in [false, true] { + for bytes in [false, true] { + let mut d = Driver::new(); + let clip = new_text_layer(&mut d, None, "alpha", 500.0); + d.ok("ui.set", json!({"tool": "Type", "gfxEdit": {"clip": clip, "layer": 0, "caret": 5, "anchor": 5}})); + d.frames(3); + let rect = d.rect(&format!("program.layer.{clip}.0")); + let pos = egui::pos2((rect[0] + 1.0) as f32, (rect[1] + rect[3] / 2.0) as f32); + d.harness.input_mut().events.push(ime_preedit("に")); + d.frames(1); + if terminal_pending { + d.harness.input_mut().events.push(ime_preedit("")); + d.frames(1); + } + let before = d.ime().expect("a live graphics owner"); + d.harness.input_mut().events.extend([ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }, + ]); + d.frames(1); + // A focus loss already disables the platform context; otherwise record its + // one-shot interruption even when the release frame no longer repeats it. + let press_interrupted = d.ime().is_none_or(|ime| ime.should_interrupt_composition); + d.harness.input_mut().events.push(egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + d.frames(1); + let edit = d.harness.state().ui.gfx_edit.as_ref().expect("same-layer edit remains active"); + let caret = edit.caret; + assert!(caret < 5 && edit.anchor == caret, "the real click must actually move the caret"); + let moved = d.ime().expect("same owner after pointer movement"); + assert_eq!(moved.owner, before.owner, "moving the caret does not replace the layer owner"); + assert!(press_interrupted || moved.should_interrupt_composition, "the platform cancels the known old composition"); + let undo = d.harness.state().session.history.undo.len(); + let delete = if bytes { + egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 0, after_bytes: 1 }) + } else { + egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars: 0, after_chars: 1 }) + }; + d.harness.input_mut().events.extend([delete, ime_commit("stale")]); + d.frames(1); + assert_eq!(layer_texts(&mut d, clip), vec!["alpha".to_string()], "old composition cannot edit the clicked caret"); + assert_eq!(d.harness.state().session.history.undo.len(), undo, "rejection creates no Undo transaction"); + // A fresh composition after the rejected terminal commit still applies to that caret. + d.harness.input_mut().events.extend([ime_preedit("fresh"), ime_commit("X")]); + d.frames(1); + assert_eq!(layer_texts(&mut d, clip), vec![format!("{}X{}", &"alpha"[..caret], &"alpha"[caret..])]); + } + } +} + +/// Control and keyboard caret moves are application-origin too, without changing stable owner +/// identity. A direct commit remains valid when no old composition was interrupted. +#[test] +fn ime_same_owner_selection_move_rejects_only_a_known_composition() { + for control_move in [false, true] { + for composing in [false, true] { + let mut d = Driver::new(); + let clip = new_text_layer(&mut d, None, "alpha", 500.0); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 5, "anchor": 5}})); + d.frames(3); + let before = d.ime().expect("initial owner"); + if composing { + d.harness.input_mut().events.push(ime_preedit("に")); + d.frames(1); + } + if control_move { + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 0, "anchor": 0}})); + } else { + d.harness.input_mut().events.push(key_press(egui::Key::Home)); + d.frames(1); + } + assert_eq!(d.harness.state().ui.gfx_edit.as_ref().map(|e| (e.caret, e.anchor)), Some((0, 0))); + let moved = d.ime().expect("same owner after caret movement"); + assert_eq!((moved.owner, moved.context_generation), (before.owner, before.context_generation)); + assert_eq!(moved.should_interrupt_composition, composing); + let undo = d.harness.state().session.history.undo.len(); + d.harness.input_mut().events.push(ime_commit("X")); + d.frames(1); + assert_eq!(layer_texts(&mut d, clip), vec![if composing { "alpha" } else { "Xalpha" }.to_string()]); + if composing { + assert_eq!(d.harness.state().session.history.undo.len(), undo); + } + } + } +} + +/// A focused custom text widget consumes raw Ctrl/Cmd+A itself, rather than the menu helper. +/// Its new selection must not receive the terminal event of an older composition next batch. +#[test] +fn ime_raw_select_all_rejects_old_terminal_input_and_preserves_direct_and_fresh_commits() { + for phase in ["none", "active", "terminal_pending"] { + for bytes in [false, true] { + let mut d = Driver::new(); + let clip = new_text_layer(&mut d, None, "alpha", 500.0); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 5, "anchor": 5}})); + d.frames(3); + if phase != "none" { + d.harness.input_mut().events.push(ime_preedit("に")); + d.frames(1); + if phase == "terminal_pending" { + d.harness.input_mut().events.push(ime_preedit("")); + d.frames(1); + } + } + let before = d.ime().expect("the custom graphics editor is focused"); + assert_eq!(d.harness.ctx.memory(|m| m.focused()), Some(egui::Id::unique("gfx-text-edit"))); + assert!(d.harness.ctx.egui_wants_keyboard_input(), "raw Select All must reach the focused-widget path"); + let command = egui::Modifiers { ctrl: !cfg!(target_os = "macos"), mac_cmd: cfg!(target_os = "macos"), command: true, ..egui::Modifiers::NONE }; + d.harness.input_mut().events.extend([ + egui::Event::ModifiersChanged(command), + egui::Event::Key { key: egui::Key::A, physical_key: Some(egui::Key::A), pressed: true, repeat: false, modifiers: command }, + ]); + d.frames(1); + assert_eq!(d.harness.state().ui.gfx_edit.as_ref().map(|e| (e.anchor, e.caret)), Some((0, 5)), "the raw key really selects all text"); + let selected = d.ime().expect("the same custom owner reports its selection"); + assert_eq!((selected.owner, selected.context_generation), (before.owner, before.context_generation)); + assert_eq!(selected.should_interrupt_composition, phase != "none", "only the known old composition is interrupted"); + let undo = d.harness.state().session.history.undo.len(); + let deletion = if bytes { + egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 }) + } else { + egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars: 1, after_chars: 0 }) + }; + // Surrounding deletion excludes the selected range; the terminal commit would replace + // the whole selection if the old composition were not rejected. Release is in this + // later batch too, so no control/menu invocation can stand in for the actual key. + d.harness.input_mut().events.extend([ + egui::Event::Key { key: egui::Key::A, physical_key: Some(egui::Key::A), pressed: false, repeat: false, modifiers: command }, + egui::Event::ModifiersChanged(egui::Modifiers::NONE), + deletion, + ime_commit("X"), + ]); + d.frames(1); + if phase == "none" { + assert_eq!(layer_texts(&mut d, clip), vec!["X".to_string()], "without old preedit a direct commit replaces the selection"); + assert_eq!(d.harness.state().session.history.undo.len(), undo + 1); + } else { + assert_eq!(layer_texts(&mut d, clip), vec!["alpha".to_string()], "old terminal input cannot replace the new selection"); + assert_eq!(d.harness.state().session.history.undo.len(), undo, "rejected input creates no Undo transaction"); + assert!(!d.ime().expect("the unchanged selected editor remains focused").should_interrupt_composition, "the interruption is one-shot"); + d.harness.input_mut().events.extend([ime_preedit("fresh"), ime_commit("Y")]); + d.frames(1); + assert_eq!(layer_texts(&mut d, clip), vec!["Y".to_string()], "a fresh composition replaces the selection normally"); + assert_eq!(d.harness.state().session.history.undo.len(), undo + 1); + } + } + } +} + +/// A surrounding replacement at the original composing caret is still accepted and classified +/// as the input method's own change; the interruption guard is not a blanket commit filter. +#[test] +fn ime_uninterrupted_same_owner_replacement_still_applies() { + let mut d = Driver::new(); + let clip = new_text_layer(&mut d, None, "alpha", 500.0); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 5, "anchor": 5}})); + d.frames(3); + d.harness.input_mut().events.push(ime_preedit("に")); + d.frames(1); + d.harness.input_mut().events.extend([egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 }), ime_commit("X")]); + d.frames(1); + let ime = d.ime().expect("accepted replacement observation"); + assert_eq!(ime.change_cause, egui::output::ImeChangeCause::InputMethod); + assert!(!ime.should_interrupt_composition); + assert_eq!(layer_texts(&mut d, clip), vec!["alphX".to_string()]); +} + +/// The concrete collision the uid-less fingerprint missed: two layers with identical static +/// geometry (position, size, no name) but different text. Reordering, deleting and undoing must +/// keep typing in the layer the user started editing, not in whatever now occupies its index. +#[test] +fn identical_layers_with_different_text_keep_the_edit_on_the_started_layer() { + let mut d = Driver::new(); + let clip = d.exec("graphics.newText", json!({"text": "one", "position": [960, 540], "size": 100}))["clip"].as_u64().unwrap(); + d.exec("graphics.newText", json!({"text": "two", "position": [960, 540], "size": 100, "clip": clip})); + let uids: Vec = d.exec("graphics.list", json!({"clip": clip}))["layers"].as_array().unwrap().iter().map(|l| l["uid"].as_u64().unwrap()).collect(); + assert_eq!(uids.len(), 2); + assert!(uids.iter().all(|uid| *uid != 0)); + assert_ne!(uids[0], uids[1], "identical geometry and different text get distinct identities"); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 3, "anchor": 3}})); + d.frames(3); + assert_eq!(d.harness.state().ui.gfx_edit.as_ref().map(|e| (e.layer, e.uid)), Some((0, uids[0]))); + + // Move the edited layer to the front. The other layer now sits at index 0 (same fingerprint). + d.exec("graphics.arrangeLayer", json!({"clip": clip, "layer": 0, "to": "front"})); + d.frames(2); + assert_eq!(d.harness.state().ui.gfx_edit.as_ref().map(|e| (e.layer, e.uid)), Some((1, uids[0]))); + d.ok("ui.type", json!({"text": "X"})); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["two".to_string(), "oneX".to_string()], "typing followed the uid, not index 0"); + + // Delete the layer below it; the edit stays on "oneX" and keeps typing there. + d.exec("graphics.deleteLayer", json!({"clip": clip, "layer": 0})); + d.frames(2); + assert_eq!(d.harness.state().ui.gfx_edit.as_ref().map(|e| (e.layer, e.uid)), Some((0, uids[0]))); + d.ok("ui.type", json!({"text": "Y"})); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["oneXY".to_string()]); + + // Undo the typing and then the deletion: the same uid is edited at its restored index. + d.exec("edit.undo", json!({})); + d.frames(2); + d.exec("edit.undo", json!({})); + d.frames(2); + assert_eq!(d.harness.state().ui.gfx_edit.as_ref().map(|e| (e.layer, e.uid)), Some((1, uids[0]))); + d.ok("ui.type", json!({"text": "Z"})); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["two".to_string(), "oneXZ".to_string()]); +} + +/// Every graphic layer created by `graphics.newText` has a stable uid. The edit target follows +/// that uid through a real reorder, a real deletion below it, and undo/redo. +#[test] +fn edit_target_follows_reorder_delete_and_undo() { + let mut d = Driver::new(); + let clip = new_text_layer(&mut d, None, "one", 400.0); + new_text_layer(&mut d, Some(clip), "two", 800.0); + new_text_layer(&mut d, Some(clip), "three", 1200.0); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 3, "anchor": 3}})); + d.frames(3); + let uid = d.harness.state().ui.gfx_edit.as_ref().map(|e| e.uid).unwrap_or(0); + assert_ne!(uid, 0, "the created layer has a stable uid"); + assert_eq!(d.harness.state().ui.gfx_edit.as_ref().map(|e| e.layer), Some(0)); + + // Reorder layer 0 (the edited "one") to the front, then type: the text must follow it. + d.exec("graphics.arrangeLayer", json!({"clip": clip, "layer": 0, "to": "front"})); + d.frames(2); + assert_eq!(d.harness.state().ui.gfx_edit.as_ref().map(|e| e.layer), Some(2), "the edit follows the reordered layer"); + d.ok("ui.type", json!({"text": "A"})); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["two".to_string(), "three".to_string(), "oneA".to_string()]); + + // Undo the typing and then the arrange: the edit follows the same layer back to index 0. + d.exec("edit.undo", json!({})); + d.frames(2); + d.exec("edit.undo", json!({})); + d.frames(2); + assert_eq!(d.harness.state().ui.gfx_edit.as_ref().map(|e| e.layer), Some(0)); + d.ok("ui.type", json!({"text": "B"})); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["oneB".to_string(), "two".to_string(), "three".to_string()]); + + // Edit "two" (index 1), delete the layer below it, and keep typing into "two". + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 1, "caret": 3, "anchor": 3}})); + d.frames(2); + d.exec("graphics.deleteLayer", json!({"clip": clip, "layer": 0})); + d.frames(2); + assert_eq!(d.harness.state().ui.gfx_edit.as_ref().map(|e| e.layer), Some(0)); + d.ok("ui.type", json!({"text": "D"})); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["twoD".to_string(), "three".to_string()]); +} + +/// Replacing the project clears the edit and the IME owner even though `file.newProject` emits +/// only `ProjectChanged`, and a project with no sequence has no editor to observe. +#[test] +fn new_project_without_a_sequence_clears_the_ime_edit() { + let mut d = Driver::new(); + d.start_point_text(); + d.ok("ui.type", json!({"text": "x"})); + d.frames(2); + assert!(d.ime().is_some(), "the text editor owns the IME before the replacement"); + d.exec("file.newProject", json!({"name": "Fresh"})); + d.frames(3); + assert!(d.harness.state().ui.gfx_edit.is_none(), "the replaced document has no edit target"); + assert!(d.ime().is_none(), "no sequence means no visible editor and no descriptor"); +} + +/// `engine.execute`/`ui.menu.invoke` run after the UI pass. A same-pass text or caret change +/// leaves the drawn cursor rectangle stale, so that pass publishes Unavailable; the next pass +/// draws and observes the new text at the new caret geometry. +#[test] +fn ime_observation_follows_control_text_mutations_in_the_same_pass() { + let mut d = Driver::new(); + let clip = d.start_point_text(); + d.ok("ui.type", json!({"text": "one"})); + d.frames(3); + let before = d.ime().expect("the editor is observed"); + assert_eq!(ime_payload(&before).text, "one"); + // `ui.menu.invoke` is one of the requests drained after the frame, unlike `engine.execute`. + d.ok("ui.menu.invoke", json!({"id": "graphics.setText", "params": {"clip": clip, "layer": 0, "text": "a much longer line"}})); + assert!(d.ime().is_none(), "the old cursor rectangle must not be published with the changed text"); + d.frames(1); + let after = d.ime().expect("the next pass observes the new layout"); + assert_eq!(ime_payload(&after).text, "a much longer line", "the final observation runs after the control request"); + assert_ne!(after.cursor_rect, before.cursor_rect, "the candidate window moved with the longer line"); + d.frames(1); + let stable = d.ime().expect("a settled pass observes again"); + assert_eq!(stable.cursor_rect, after.cursor_rect, "the published geometry matches the next pass"); +} + +/// A hostile `ui.set.gfxEdit` offset is reported as the explicit `Unavailable` snapshot before +/// the next pass clamps it, rather than being silently floored before observation. +#[test] +fn invalid_gfx_edit_offsets_are_unavailable_before_clamping() { + let mut d = Driver::new(); + let clip = d.start_point_text(); + d.ok("ui.type", json!({"text": "é😀"})); + d.frames(3); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 1, "anchor": 2}})); + let ime = d.ime().expect("the editor is still observed"); + assert!(ime.surrounding_text.available().is_none(), "an offset inside a code point is Unavailable"); + d.frames(1); + let clamped = d.ime().expect("the clamped editor is observed"); + assert!(clamped.surrounding_text.available().is_some(), "the editor clamps on its next pass"); + assert_eq!(layer_texts(&mut d, clip), vec!["é😀".to_string()]); +} + /// #2430: a click (no drag) in the text being edited on the monitor puts the caret there; before, /// only a press that moved a little did, and typing went on at the end. #[test] diff --git a/crates/ui-egui/tests/it/masks_ui.rs b/crates/ui-egui/tests/it/masks_ui.rs index 4d90bfca3..c4ea443f8 100644 --- a/crates/ui-egui/tests/it/masks_ui.rs +++ b/crates/ui-egui/tests/it/masks_ui.rs @@ -387,10 +387,13 @@ fn presets_bin_drag_and_save_dialog() { d.ok("ui.click", json!({"id": format!("effectControls.effect.{fx}.twirl")})); d.frames(2); } + // Effect Controls: property fields must be reachable by name as well. + d.harness.check_accessibility(); d.ok("ui.click", json!({"id": "effectControls.effect.brightness_contrast", "button": "right"})); d.frames(2); d.ok("ui.click", json!({"id": "effectControls.effect.brightness_contrast.savePreset"})); d.frames(2); + d.harness.check_accessibility(); d.rect("savePreset.name"); d.ok("ui.click", json!({"id": "savePreset.name"})); d.ok("ui.key", json!({"key": "Cmd+A"})); diff --git a/crates/ui-egui/tests/it/mixer_ui.rs b/crates/ui-egui/tests/it/mixer_ui.rs index 603578392..da3127d79 100644 --- a/crates/ui-egui/tests/it/mixer_ui.rs +++ b/crates/ui-egui/tests/it/mixer_ui.rs @@ -483,7 +483,7 @@ fn paused_meters_repaint_without_input_and_settle_after_release() { // Inspect both values that drive the bars: a silent level alone must not strand its // slower peak hold. No new frames or pointer events are injected to read this state. let (_, meters): (u64, std::collections::HashMap>) = - d.harness.ctx.data(|data| data.get_temp(egui::Id::new("mixer-meters"))).unwrap(); + d.harness.ctx.data(|data| data.get_temp(egui::Id::unique("mixer-meters"))).unwrap(); assert!(!meters.is_empty()); assert!(meters.values().flatten().all(|meter| *meter == [-90.0; 2]), "{workspace}: meters stopped after {steps} requested frames: {meters:?}"); assert_ne!( diff --git a/crates/ui-egui/tests/it/monitor_geometry.rs b/crates/ui-egui/tests/it/monitor_geometry.rs index 700657e19..d8329278a 100644 --- a/crates/ui-egui/tests/it/monitor_geometry.rs +++ b/crates/ui-egui/tests/it/monitor_geometry.rs @@ -34,7 +34,7 @@ fn harness_at_scale(width: f32, kind: ThemeKind, which: Which, scale: f32) -> Ha monitor::show(app, ui, group.content, which); } let source = filmcraft_ui_egui::panels::dragged_source(ui); - ui.ctx().data_mut(|data| data.insert_temp(egui::Id::new("observed-source-drag"), source)); + ui.ctx().data_mut(|data| data.insert_temp(egui::Id::unique("observed-source-drag"), source)); }, FilmcraftApp::new(session), ); @@ -175,6 +175,7 @@ fn transport_editor_keeps_its_own_space_and_customizes_compact_controls() { h.event(Event::PointerMoved(scroll.center())); h.event(Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Wheel, delta: vec2(0.0, -1200.0), modifiers: Modifiers::NONE, phase: egui::TouchPhase::Move, @@ -232,7 +233,7 @@ fn source_drag_choices(width: f32) { h.run_steps(2); let source = h .ctx - .data(|data| data.get_temp::>(egui::Id::new("observed-source-drag"))) + .data(|data| data.get_temp::>(egui::Id::unique("observed-source-drag"))) .flatten() .expect("drag must create the production Source payload"); assert_eq!((source.video, source.audio), (video, audio)); diff --git a/crates/ui-egui/tests/it/panels_ui.rs b/crates/ui-egui/tests/it/panels_ui.rs index 150f4d0bf..3c246cb6c 100644 --- a/crates/ui-egui/tests/it/panels_ui.rs +++ b/crates/ui-egui/tests/it/panels_ui.rs @@ -248,6 +248,7 @@ fn wheel_over_a_tab_strip_switches_tabs() { for step in 0..5 { d.harness.input_mut().events.push(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Wheel, delta: egui::vec2(0.0, -10.0), modifiers: egui::Modifiers::NONE, phase: egui::TouchPhase::Move, diff --git a/crates/ui-egui/tests/it/shared_docking_ui.rs b/crates/ui-egui/tests/it/shared_docking_ui.rs index 7d087bcb4..edb35c083 100644 --- a/crates/ui-egui/tests/it/shared_docking_ui.rs +++ b/crates/ui-egui/tests/it/shared_docking_ui.rs @@ -206,7 +206,7 @@ fn real_panel_drag_merges_splits_floats_and_redocks_without_editing_documents() assert!(h.state().ui.floating[0].rect[2] > original[2] + 25.0); assert!(h.state().ui.floating[0].rect[3] > original[3] + 15.0); action_log.push(action_state(&h, "resized")); - let grip_id = egui::Id::new(("floating-panel", PanelKind::Info.id())).with("resize"); + let grip_id = egui::Id::unique(("floating-panel", PanelKind::Info.id())).with("resize"); assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(grip_id)); let before_keys = h.state().ui.floating[0].rect; for key in [egui::Key::ArrowRight, egui::Key::ArrowDown] { @@ -502,7 +502,7 @@ fn narration_selection_reveals_covered_floating_tts_once_without_editing_the_pro step(&mut h); } } - let layer = |panel: PanelKind| egui::LayerId::new(egui::Order::Middle, egui::Id::new(("floating-panel", panel.id()))); + let layer = |panel: PanelKind| egui::LayerId::new(egui::Order::Middle, egui::Id::unique(("floating-panel", panel.id()))); let covered = rect(&h, "panel.TextToSpeech").center(); assert!(rect(&h, "panel.Info").contains(covered)); ctx.move_to_top(layer(PanelKind::Info)); diff --git a/crates/ui-egui/tests/it/ui_control_input.rs b/crates/ui-egui/tests/it/ui_control_input.rs index 83b3c054e..b8aae493c 100644 --- a/crates/ui-egui/tests/it/ui_control_input.rs +++ b/crates/ui-egui/tests/it/ui_control_input.rs @@ -108,7 +108,7 @@ fn background_divider(isolated: bool) { d.frames(3, true); let before_project = d.h.state().session.project.clone(); let before_playheads = d.h.state().session.state.playheads.clone(); - let divider = Id::new(("dock-gutter", String::new())); + let divider = Id::unique(("dock-gutter", String::new())); d.h.ctx.memory_mut(|memory| memory.request_focus(divider)); d.frames(2, true); d.h.input_mut().events.push(Event::WindowFocused(false)); @@ -156,15 +156,7 @@ fn isolation_preserves_close_and_screenshot_delivery_without_claiming_os_focus() let viewport = raw.viewports.get_mut(&egui::ViewportId::ROOT).unwrap(); viewport.focused = Some(false); viewport.events.push(egui::ViewportEvent::Close); - raw.events = vec![ - Event::Text("unrelated typing".into()), - Event::WindowFocused(false), - Event::Screenshot { - viewport_id: egui::ViewportId::ROOT, - user_data: Default::default(), - image: Arc::new(egui::ColorImage::new([1, 1], vec![egui::Color32::WHITE])), - }, - ]; + raw.events = vec![Event::Text("unrelated typing".into()), Event::WindowFocused(false)]; raw.hovered_files.push(Default::default()); #[derive(Debug)] struct UnrelatedFile; @@ -187,7 +179,10 @@ fn isolation_preserves_close_and_screenshot_delivery_without_claiming_os_focus() assert!(raw.focused); assert_eq!(raw.viewports[&egui::ViewportId::ROOT].focused, Some(false)); assert_eq!(raw.viewports[&egui::ViewportId::ROOT].events, vec![egui::ViewportEvent::Close]); - assert!(matches!(raw.events.as_slice(), [Event::Screenshot { .. }, Event::ModifiersChanged(Modifiers::NONE)])); + assert!( + matches!(raw.events.as_slice(), [Event::ModifiersChanged(Modifiers::NONE)]), + "native input is dropped; screenshot results use callbacks, not raw events" + ); assert!(raw.hovered_files.is_empty() && raw.dropped_files.is_empty()); let state = filmcraft_ui_egui::control::inspect(d.h.state(), &ctx); assert_eq!(state["input"]["nativeWindowFocused"], false); diff --git a/crates/ui-egui/tests/it/ui_dock_resize.rs b/crates/ui-egui/tests/it/ui_dock_resize.rs index 2057ce262..4e434c594 100644 --- a/crates/ui-egui/tests/it/ui_dock_resize.rs +++ b/crates/ui-egui/tests/it/ui_dock_resize.rs @@ -64,7 +64,7 @@ fn focused_dock_resize_owns_arrows_and_keeps_pointer_dragging() { frames(&mut h, 3); let before_project = h.state().session.project.clone(); let before_playheads = h.state().session.state.playheads.clone(); - let id = Id::new(("dock-gutter", String::new())); + let id = Id::unique(("dock-gutter", String::new())); h.ctx.memory_mut(|memory| memory.request_focus(id)); frames(&mut h, 2); assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(id)); @@ -131,7 +131,7 @@ fn space_plays_after_a_pane_is_resized_with_the_pointer() { frames(&mut h, 3); assert_eq!(first_size(&h), 420.0); // The divider keeps keyboard focus so arrows can nudge it; that must not cost the editor its shortcuts. - assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(Id::new(("dock-gutter", String::new())))); + assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(Id::unique(("dock-gutter", String::new())))); press(&mut h, Key::Space); assert!(h.state().playback.playing, "Space must start playback after resizing a pane (#1482)"); press(&mut h, Key::Space); diff --git a/crates/ui-egui/tests/it/ui_keyboard_focus.rs b/crates/ui-egui/tests/it/ui_keyboard_focus.rs index 8685d4933..f03b01ca6 100644 --- a/crates/ui-egui/tests/it/ui_keyboard_focus.rs +++ b/crates/ui-egui/tests/it/ui_keyboard_focus.rs @@ -32,9 +32,9 @@ fn tab_traversal_and_space_select_only_the_focused_tool_with_visible_focus() { h.run_steps(4); h.state_mut().ui.dock = DockNode::Tabs { panels: vec![PanelKind::Tools], active: 0 }; h.run_steps(3); - let first = Id::new(("tool", "Selection".to_string())); - let second = Id::new(("tool", "TrackSelectForward".to_string())); - let third = Id::new(("tool", "Ripple".to_string())); + let first = Id::unique(("tool", "Selection".to_string())); + let second = Id::unique(("tool", "TrackSelectForward".to_string())); + let third = Id::unique(("tool", "Ripple".to_string())); for _ in 0..128 { key(&mut h, Key::Tab, Modifiers::NONE); if h.ctx.memory(|m| m.focused()) == Some(first) { @@ -76,8 +76,8 @@ fn dock_tab_traversal_and_enter_change_the_visible_panel_without_editing() { h.state_mut().ui.show_status_bar = false; h.state_mut().ui.dock = DockNode::Tabs { panels: vec![PanelKind::Project, PanelKind::Effects], active: 0 }; h.run_steps(3); - let first = Id::new(("tab", String::new(), 0usize)); - let second = Id::new(("tab", String::new(), 1usize)); + let first = Id::unique(("tab", String::new(), 0usize)); + let second = Id::unique(("tab", String::new(), 1usize)); for _ in 0..128 { key(&mut h, Key::Tab, Modifiers::NONE); if h.ctx.memory(|m| m.focused()) == Some(second) { diff --git a/docs/egui-037-migration-plan.md b/docs/egui-037-migration-plan.md new file mode 100644 index 000000000..7e2b2f923 --- /dev/null +++ b/docs/egui-037-migration-plan.md @@ -0,0 +1,1563 @@ +# FilmCraft — egui 0.37 migration plan + +Status: local implementation revision after the independent adversarial reviews of +`plan-reviews/filmcraft-v1.md` and `filmcraft-v2.md`. The owned checkout now contains the +0.37 migration work; commits, pushes, publications, and final immutable pins/lock remain +gated. This document is the implementation contract, not publication acceptance. Every +statement about runtime behavior below is source inspection at the recorded revisions unless +it is explicitly labelled a proposed test or a proposed command. + +This revision resolves the review findings inline, including the dependency DAG. Section 17 +is a disposition index only; it does not override any section. + +## 1. Scope and exact baseline + +| Item | Value | +|---|---| +| Repository | `storytold/filmcraft` | +| Assigned checkout | `/home/codex/work/CraftApps/egui037-migration-20261022/filmcraft-implementation` | +| Current branch HEAD | `fa550ce2d07bdfee6b1d7b6835e8abc8c6913d00` (2026-10-24; migration work is uncommitted on this base) | +| Planning baseline (historical) | `abf44fda9015f4b7a96dc338f6042499bf8e9d9f` (2026-10-22, "Reject non-hex characters in colour hex parsing (#2600)") | +| `Cargo.toml` SHA-256 (current checkout) | `b41244b83b0aff71ab6e122826d20a49a2d59c35e87da57c8dfd228a962ec2cf` | +| `Cargo.lock` SHA-256 (current checkout) | `67528fa8486c963dadbaf04e487ab5a8af59ac8f98dac97e1d51f40b953b5bb5` | +| Workspace | resolver 3, edition 2024, Rust 1.95, 50 crates plus 3 apps and `xtask` | +| Current UI stack | `egui`, `eframe`, `egui-wgpu`, `egui-winit`, `egui_glow`, `egui_kittest`, `emath`, `epaint`, `epaint_default_fonts` at `0.37.0`; `winit 0.30.13`; `wgpu 30.0.1`; `accesskit 0.24.1`; `arboard 3.6.1` | +| Shared UI dependency | Current manifest: `craft-ui` at `5c874d51eeb3de70e735c8ce87a59a7dedd47675`, `craft-media` at `f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4`, `craft-docpath` at `82a24b006eefa036b43c09a67fb9a3fbd17ffe63`. Reconciled PR #199 base: `3832c47ef68a0e6214f2c1e85f3526b6adcb686e`; final immutable pins and lock remain G-PINS/G-CL2F/G-CRAFT-LIBS gates. | +| Target release | `egui 0.37.0` = upstream commit `841a328ba2dd8f6996beb7189ebb7fc00e668a2a` | +| Research candidate | `aiongg/egui` `craft/0.37` = `a4f9a17f64a6bae61350fb85804c9df0d9865916`; `aiongg/winit` `craft/0.30.13-lang` = `8b35388bd719e8cc86a95971ba0bc026c9a8f058` | +| Production fork pins | **Unknown and deliberately not invented.** They become valid only after reviewed, published immutable `storytold/egui` and `storytold/winit` commits exist. See §3.2 and §15 gate G-PINS. | +| Evidence directory | `/home/codex/work/ssd2/task-evidence/egui037-migration-20261022` | +| Historical diagnostics | `/home/codex/work/ssd2/task-evidence/egui037-audit-20261022/{fork,stock}/filmcraft.errors` — older checkout (FilmCraft manifest 0.10.0), clues only, never acceptance | + +The existing `App::logic` / `App::ui` split and root-`Ui` architecture are **not** introduced +by 0.37 and must not be rewritten as if they were. This plan changes where frame side effects +run and how input is handled across layout passes; it does not change the command, undo, +project or persistence model. Do not add a FilmCraft-specific `egui` compatibility crate. Use +the new APIs directly, and add a helper to `craft-ui` only for semantics that at least two +consumers need with the same contract. + +This repository has: + +- one root workspace (`Cargo.toml`) with `members = ["crates/*", "apps/*", "xtask"]`; +- one workspace lockfile (`Cargo.lock`, 708 packages); +- no `[patch]` section, no `vendor/` directory, no source replacement and no local crate copy; + `.cargo/config.toml` contains only the `xtask` alias; +- exactly four manifests that mention `egui`: the workspace, `crates/ui-egui/Cargo.toml`, + `apps/filmcraft/Cargo.toml` and `apps/filmcraft-web/Cargo.toml`; +- a native entry point (`apps/filmcraft/src/main.rs`), a wasm entry point + (`apps/filmcraft-web/src/lib.rs`), a CLI app and a web app; +- no `egui_plot`, `egui_extras` or `egui_dock`; all other crate manifests scanned for `egui`, + `eframe`, `winit`, `rfd` and `egui_*` contain none; +- `crates/ui-egui/Cargo.toml` has the only relevant target cfgs: `rustix` for Unix + thread/process clocks and `cpu-time` for Windows; +- `craft-libs` vendors `krilla`/`hayro` for PDF work, unrelated to the windowing stack; this + plan does not touch them. + +### 1.1 Reverse dependency: EffectCraft + +EffectCraft (`9581b53c3f8945683252af0eb5fac79e0e43aed4` in the owned checkout) pins fifteen +`filmcraft-*` crates to `2c5ba7a72619935f0481f99d59409e0d1844b234` in `effectcraft/Cargo.toml`. +That revision is not an ancestor of FilmCraft baseline `abf44fda`; it is reachable from +`origin/effectcraft/webm-alpha`. None of those crates (`media`, `codecs`, `frame`, `color`, +`time`, `project`, `interchange`, `geom`, the encoders/decoders and the muxer) contains +`egui`, and EffectCraft does not depend on `filmcraft-ui-egui`. + +Decision: EffectCraft needs no change for this migration. Retain its separate branch pin. +Re-audit only if (a) the pin moves to FilmCraft main or another revision that changes a +shared public type, or (b) EffectCraft starts consuming `filmcraft-ui-egui` (directly or +through another crate). The re-audit must check the actual Cargo identities, not just a +version string. This is the required explicit cross-repository check; it is not a silent +deferral of FilmCraft's own compatibility work. + +## 2. Current architecture and lifecycle + +### 2.1 Entry points + +| Target | Entry point / symbol | Notes | +|---|---|---| +| Native desktop | `apps/filmcraft/src/main.rs:80` `main`, `:139` `eframe::run_native`, `:168` `native_options`, `:190` `app_creator` | wgpu only; features `default_fonts`, `wgpu`, `accesskit`, `wayland`, `x11`; no `system_fonts`, no `links` | +| macOS native | `apps/filmcraft/Cargo.toml` target deps `muda`, `objc2-*`, `winit`; `main.rs:430` `agent_event_loop`; `native_menu.rs` | custom titlebar and menu; `--control` must not activate the app | +| Linux native | `apps/filmcraft/Cargo.toml` `zbus`; `main.rs` `run_dialog_off_thread` | X11 and Wayland at runtime; XDG portal dialogs | +| Windows native | `apps/filmcraft/Cargo.toml` `winresource`; `graphics.rs` DX12/default/software ladder | DX12 FXC shader compilation is relevant to startup measurement | +| Browser | `apps/filmcraft-web/src/lib.rs:125` `start`, `:156` `WebRunner::start`, `:92` `impl eframe::App for WebApp` | wasm32 only; WebGPU with WebGL2 fallback; WebCodecs; OPFS | +| Headless UI | `crates/ui-egui/tests/scripted.rs` and the other `tests/*_ui.rs` `Harness::builder().build_eframe(...)` | `egui_kittest` drives the same `FilmcraftApp` | + +### 2.2 The app object + +`FilmcraftApp` is in `crates/ui-egui/src/lib.rs`. The UI code is already split: + +- `crates/ui-egui/src/lib.rs:2133` `impl eframe::App for FilmcraftApp::logic`; +- `crates/ui-egui/src/lib.rs:2193` `impl eframe::App for FilmcraftApp::ui`; +- `crates/ui-egui/src/lib.rs:1807` private `FilmcraftApp::frame(&mut self, ui: &mut egui::Ui)`, + called from `App::ui` and wrapped in `catch_unwind`; +- `cargo xtask` only builds/tests; FilmCraft does not implement a second `update` path; +- the main workspace is custom-drawn: `App::ui` receives the root `Ui`, then calls + `header::show`, `dock_area`, `panels::*` and dialog functions. It does not use + `egui::TopBottomPanel`, `SidePanel` or `CentralPanel`; +- floating panels are `egui::Area`s managed by `crates/ui-egui/src/dock.rs` and `dock_ui.rs`, + not native viewports. There is no `show_viewport_deferred` and no child-window dock; +- dialogs use `egui::Window::new(...).show(ctx, ...)`, valid only inside a pass. + +### 2.3 Where side effects live (exhaustive inventory) + +These are the behaviours that must survive hidden logic-only calls, forced hidden paint and +multi-pass layout without duplication. All clock-driven entries use a monotonic host clock +after migration; no hidden work may read `ctx.input(|i| i.time)`. + +| Effect | Current location | Required migration decision | +|---|---|---| +| Theme/font installation and language fallback | `lib.rs:2133` `logic` (`theme::install`, `i18n::install_japanese_font`, `chinese_font_available`, `korean_font_available`) | Keep in logic; once-per-process state, not per pass; never call `Context::root_ui` | +| Control channel, deferred requests, input waiters | `lib.rs:1672` `drain_control`, retry deadlines `:1682`, `now - last_ui_time > 0.25` raise heuristic `:1679` | Logic; consumed `try_recv` queue; monotonic `now`; `ui.*` requests that need composition send `ViewportCommand::RequestPaintWhileHidden` (see §4.5) | +| Screenshot settle / issue / collect | `lib.rs:1702` settle timestamp, `:1714` `issue_screenshots`, `:1744` `collect_screenshots`; `queued_screenshots` `*frames += 1` (`lib.rs:1717-1720`), `monitor_inexact` `mem::take` (`:2182`), `timeline_still` increment (`:2163`) | Logic; monotonic deadline; `ScreenshotCallback` plus the CL-2 completion slot. Settle counters advance once per rendered frame/pass identity, not per logic call; a queued-but-not-yet-issued hidden request repeats `RequestPaintWhileHidden` until issued, after which the screenshot command itself forces the paint. | +| FPS, frame time, close interception | `lib.rs:2133` `logic` (`ctx.input(i.time)`, `visible()`, `close_requested()`) | Logic; monotonic host clock; viewport metadata is fresh in `run_logic`, input time is not | +| Frame worker pump | `lib.rs:2193` `ui` calls `self.frames.pump(...)` | Logic/background tick; do not rely on paint | +| Persistence polling | `lib.rs:1825` `frame` calls `session.poll_persistence()` | Logic; once per state transition, not per pass | +| Engine event drain | `lib.rs:1826` `session.drain_events()` | Logic; drain the one-shot queue; an event is consumed exactly once regardless of pass count | +| Toast timestamp | `lib.rs:1840` `Event::Toast` stamps `ctx.input(i.time)` while `drain_events` moves to logic | Stamp with the monotonic host clock; repeated logic calls must not re-time or duplicate the toast | +| Media refresh and pool sync | `lib.rs:1822` `sync_pool`, `:1825` `tick_media_refresh` (`:1777`) | Logic; monotonic deadlines and `request_repaint_after`; consumed scan state | +| Preferences application | `lib.rs:717` `apply_prefs`, called from `frame` | Logic or known UI pass 0; idempotent; font/visual side effects once | +| Drop import | `lib.rs:1473` `handle_drops` | `App::ui` pass 0 only; uses `RawInput` events; never `run_logic` | +| Native menu / command inbox | `lib.rs:1846` command inbox and `menus::invoke`; `:1860` `sync_native_menu` | Command inbox in logic (consumed channel); native menu sync in UI pass 0; `menus::invoke` must not run twice per frame | +| Keyboard shortcuts and focus/IME | `lib.rs:1546` `handle_shortcuts` | `App::ui` pass 0 only; `consume_key` remains the anti-duplication mechanism; fork E19 Latin-layout handling | +| Playback/source playback/scrub audio | `lib.rs:1128` `advance_playback`, `advance_source_playback`, `scrub_audio` | Logic; monotonic host clock; set playhead from anchor + elapsed, never accumulate per call | +| Undo press transaction | `lib.rs:2000` `begin_press` | `App::ui` pass 0 before UI; one press transaction per user gesture | +| Escape drag rollback | `lib.rs:2011` `cancel_value_drag` | Latch Escape and the press mark in UI pass 0; apply rollback exactly once after the final UI pass (§4.4) | +| TTS selection following | `lib.rs:1865` `panels::tts::follow_selection` | Logic or UI pass 0; idempotent selection identity | +| Job watch / auto-play after render | `lib.rs:1916` `watch_jobs` | Logic; consumed job results; status-bar drawing remains in UI | +| Auto-save dirty poll | `lib.rs:1818` `session.is_dirty()` and `request_repaint_after` | Logic; monotonic deadlines; worker stays alive while hidden | +| Dynamic trim / loop playback (`trim.tick`) | `lib.rs:1817` `panels::trim_monitor::advance`; `panels/trim_monitor.rs:39-48` executes `session.execute("trim.tick", {"clock": ctx.input(i.time)})` on every frame | Move to logic; monotonic host clock; compute an idempotent target time (same target called twice is a no-op), not one tick per pass; keep `request_repaint` | +| Metadata selection follow | `lib.rs:1814` `panels::metadata::follow_selection` | UI pass 0 or idempotent logic state; follows selection identity | +| Auto/undo frame bookkeeping | `lib.rs:1813` `self.auto.begin_frame()` | UI pass 0 once; no second call in a discarded pass | +| Timeline view sync | `lib.rs:1844` `self.sync_timeline_view()` | UI pass 0; idempotent | +| Native menu sync | `lib.rs:1860` `self.sync_native_menu()` | UI pass 0; idempotent | +| `last_ui_time` update | `lib.rs:2213` `self.last_ui_time = ctx.input(i.time)` | UI pass 0; monotonic host clock; drives the control raise heuristic and screenshot settle heuristics | +| Input waiters drain | `lib.rs:2193` `App::ui` tail and synthetic queue | UI pass; consumed waiter vector; respond once | +| Synthetic input / control input | `raw_input_hook` `lib.rs:2092`, synthetic queue | `raw_input_hook` runs before every `run_ui` frame and before `update_logic_only` ticks. Keep one unapplied synthetic gesture step in app state across repeated hidden logic ticks until a full UI pass consumes it; never collapse a press into its release or inject the same step twice. | + +Additional deadline/side-effect calls found by the review and covered by this table: +`drain_control`'s `now + 3.0` retry deadline, its `now - last_ui_time > 0.25` raise decision, +`issue_screenshots`'s `settle` and timeout, `tick_media_refresh`'s persisted `last` timestamp, +the auto-save `request_repaint_after(1s)`, TTS `request_repaint_after(100ms)`, the synthetic +input repaint, and the `monitor_inexact`/`timeline_still` settle counters. None may use stale +`ctx.input(i.time)` after the lifecycle split; the toast stamp and settle counters have explicit +rows above. + +### 2.4 Actual multi-pass exposure + +- FilmCraft does not call `Context::request_discard` (verified by grep). +- egui 0.37 components used by FilmCraft can request it: `Grid` on first show + (`crates/egui/src/grid.rs`, "new Grid" path), `Aligned` on size change + (`containers/aligned.rs`), and `Resize` on a sizing pass (`containers/resize.rs`). FilmCraft + uses `Grid` in many dialogs, `ResizeHandle` through `craft_ui::layout`, and `Aligned` only if + a dependency introduces it. +- Both native `crates/eframe/src/native/epi_integration.rs::update` and web + `crates/eframe/src/web/app_runner.rs` call `app.logic` **and** `app.ui` inside the + `Context::run_ui` closure. Both call logic once per layout pass, not once per frame. + When the root window/tab is hidden and no paint is requested, native + `update_logic_only` and the web runner call `Context::run_logic` with no UI pass. +- `run_ui` may run a second pass after a discard. `RawInput::take()` moves `events` out, so + pass 1+ receives an empty event list; `InputState` recomputes pointer clicks and + `key_pressed` only from `new.events`. A click or key seen in pass 0 is therefore not + re-derived in pass 1. +- After a normally completed `run_ui`, `current_pass_index()` is **zero**, including after + multiple passes: `end_pass` takes the output. Do not claim or test a stale nonzero index. + A pass-index first-pass guard is valid only for genuinely once-per-layout-frame UI state + under this pinned integration, and needs a regression proving hidden calls still run. It + must not suppress fresh asynchronous results, control commands or widget actions merely + because a later pass has a nonzero index. +- `InputState::viewport().visible() == Some(false)` does **not** prove that the current + callback has no fresh input. A pending screenshot or `RequestPaintWhileHidden` can force a + real UI pass while minimized/occluded, and that pass can carry fresh input. Do not infer a + "logic-only" phase from visibility. + +## 3. Dependency and pin strategy + +### 3.1 Shared `craft-libs` baseline — corrected facts + +- The reconciled shared-library base is the merged PR #199 squash + `3832c47ef68a0e6214f2c1e85f3526b6adcb686e` on `storytold/craft-libs`. The implementation + checkouts use that base; `49190884278f16c9a9f5823d7698a8ffb5deb062` and its verification + below are historical planning evidence. +- GitHub `repos/storytold/craft-libs/branches/main` returned + `49190884278f16c9a9f5823d7698a8ffb5deb062` at approximately 06:25 UTC on 2026-10-23. This was + the shared-library baseline at planning time. +- The local `origin/main` ref (`b1401e3`) is stale. A stale remote-tracking ref **does not + prove** that `4919088` is unpublished. The review's "unpublished local/audit branch" claim + is rejected; the corrected evidence is the GitHub main verification above. +- FilmCraft's current manifest pins `craft-ui` at + `5c874d51eeb3de70e735c8ce87a59a7dedd47675`, `craft-media` at + `f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4`, and `craft-docpath` separately at + `82a24b006eefa036b43c09a67fb9a3fbd17ffe63`. The old `5c874d51` commit was fetched exactly + into the owned `craft-libs` checkout for inspection without changing HEAD or any source. + These manifest pins are not the final publication pins. +- Between `5c874d51` and `4919088`, `crates/ui` changes seven files, **607 insertions and two + deletions**: `README`, `docking/model.rs`, `docking/surface.rs`, `docking/tests.rs`, + `lib.rs`, new `printing.rs` and `printing/tests.rs`. `Cargo.toml` is unchanged. + - The docking change is the protected-group fix: `MoveFloating` now requires every + floating-group member to be movable, including the resize grip. Preserve the tests from + `d66e345`, `fd9cf54` and `c67b1e1`; this intentional security behavior must not be + reverted to match the old FilmCraft baseline. + - Printing is additive and lazy. Enabling the updated crate must not start discovery at + startup. Relevant print commits include `b700ae7`, `d27646a`, `4cae45d`, `2bbef53` and + `4f85efb`. +- `craft-docpath` has no `egui` dependency. Keep it at `82a24b0` unless a later actual + cross-type or feature reason requires the move; do not fold it into the `craft-ui` bump. +- PR 199 is **not** mergeable as a tree. Its head `b68de8c` and historical audit tree + `ec8840a` are stale relative to `4919088`, omit current `craft-platform`, and revert + security, settings, docking and printer fixes. Reuse only the API port hunks from + `8614e4a04fae516fba16c22b31ff1008cb6be10b` and the relevant README changes from + `1764bca`, applied onto current `4919088`. Preserve intervening fixes and provenance. + Never merge `b68de8c` or `ec8840a` as a tree. +- The shared 0.37 port and the CL-2 capture helper will produce a later published + `craft-libs` revision. FilmCraft's final compatibility review must compare its actual old + API/use sites against that final published revision, not against `b68de8c` or a branch + point. + +### 3.2 One coherent production graph, not a stock-0.37 stage + +Stock 0.37 is a **local comparison** and CI control only. It is not a landable production +stage. The production landing uses one coherent graph: the final reviewed immutable +`storytold/egui` family, the final reviewed immutable `storytold/winit`, and the matching +published `craft-libs` revision. The previous coherent 0.36.2 graph remains the rollback +target. + +The exact production pins are **not known yet** and must not be invented. `a4f9a17f` and +`8b35388b` are aiongg research candidates and comparison sources, not approved production +pins. Gate G-PINS (§15) blocks the entire landing stage until: + +1. the complete reviewed/published `storytold/egui` P8 commit is immutable: E1-E21, + P2b bounded async W15P routing, P7 held-key pairing, P7b/E21b, P7c selected #8763/#8748 + fixes and justified performance fixes; +2. the completed reviewed/published `storytold/winit` commit contains the bounded W15P + transfer path, the W27 owner/support lifecycle and the required W1-W27 behavior; +3. the matching published `storytold/craft-libs` commit contains CL-1 (the PR-199 API hunks + and one coherent fork graph) and the frozen CL-2F helper; +4. the exact SHAs, feature sets, provenance and test mappings are recorded in the suite pin + manifest and the ledger. + +Until then, no `[patch]` block in FilmCraft names a fabricated or guessed revision. The +eventual block must name the same immutable revision for every egui-family package that can +resolve, plus `winit`; a patch section inside `craft-libs` does not propagate into this root. +`egui_extras` is unused. `egui_plot` does not exist in FilmCraft; do not add it because its +version numbering differs. `winit` stays exactly `0.30.13`. + +The final production manifest will look like the following shape, with `REPLACE_WITH_*` +resolved only after G-PINS: + +```toml +[patch.crates-io] +egui = { git = "https://github.com/storytold/egui", rev = "REPLACE_WITH_APPROVED_EGUI_SHA" } +eframe = { git = "https://github.com/storytold/egui", rev = "REPLACE_WITH_APPROVED_EGUI_SHA" } +egui-winit = { git = "https://github.com/storytold/egui", rev = "REPLACE_WITH_APPROVED_EGUI_SHA" } +egui-wgpu = { git = "https://github.com/storytold/egui", rev = "REPLACE_WITH_APPROVED_EGUI_SHA" } +egui_glow = { git = "https://github.com/storytold/egui", rev = "REPLACE_WITH_APPROVED_EGUI_SHA" } +egui_kittest = { git = "https://github.com/storytold/egui", rev = "REPLACE_WITH_APPROVED_EGUI_SHA" } +epaint = { git = "https://github.com/storytold/egui", rev = "REPLACE_WITH_APPROVED_EGUI_SHA" } +emath = { git = "https://github.com/storytold/egui", rev = "REPLACE_WITH_APPROVED_EGUI_SHA" } +ecolor = { git = "https://github.com/storytold/egui", rev = "REPLACE_WITH_APPROVED_EGUI_SHA" } +epaint_default_fonts = { git = "https://github.com/storytold/egui", rev = "REPLACE_WITH_APPROVED_EGUI_SHA" } +winit = { git = "https://github.com/storytold/winit", rev = "REPLACE_WITH_APPROVED_WINIT_SHA" } +``` + +If a stock-0.37 comparison is needed, it lives in a separate local/CI branch or harness with +its own lockfile and evidence. It is never merged to main as an intermediate production +state, and it does not change the production pin policy. + +### 3.3 Capture-helper sequence (CL-2 -> adapters -> CL-2F -> consumers) + +The suite capture contract is one completion slot per admitted request: the app holds it +strongly and the callback holds it weakly. + +1. **CL-2** in `craft-libs` (`crates/ui/src/capture.rs`): provisional bounded completion-slot + prototype and tests. The app owns the request ID, path, deadline, settling policy, + capture-frame geometry, DPI/scale identity and reply schema. +2. **DesignCraft and GridCraft adapters** against that exact provisional revision. DesignCraft + exercises multiple viewports; GridCraft exercises a single window. Their tokens, paths, + settle delays, deadlines and replies remain app-owned. +3. **CL-2F** freezes the helper API after joint review of those adapters. +4. **FilmCraft and the other consumers** pin the published revision containing CL-1 and + CL-2F in one atomic dependency stage, not a branch head and not a path dependency. + +All fourteen capture consumers adopt this contract. The eleven apps that already declare +`craft-ui` (CADCraft, DesignCraft, EffectCraft, FilmCraft, GridCraft, LightCraft, PdfCraft, +PhotoCraft, SoundCraft, VectorCraft and WordCraft) import the helper from the published +revision that contains both CL-1 and CL-2F in the same atomic dependency stage. DesignCraft +and GridCraft are the pilot adapters against the reviewed CL-2 provisional revision before +CL-2F freezes the API. DeckCraft, FigCraft and ArtCraft Native do not declare `craft-ui`; +they use contract-compatible local adapters unless they choose to add the dependency. Any +consumer that already carries an incompatible `craft-ui` bump must reconcile it in that same +atomic dependency stage, not a separate registry-only state. The earlier "thirteen `craft-ui` +consumers" wording was based on substring matches and is superseded by the exact TOML-key +inventory. + +The helper contract, which every adapter must satisfy: + +- the app's live request record owns the completion slot and a strong viewport waker; the + callback holds only a `Weak` reference to that slot, never a strong `Context` or app + reference and never a cycle back into the owning context. `ScreenshotCallback::new` does + not request a new frame when data arrives (`egui/src/data/screenshot_callback.rs:22-24`), + so the live slot's waker must request the repaint after the slot lock is released; +- the request record owns one slot; correlation is by ownership, not callback order or a + second global token map; +- admission uses the shared defaults of **four outstanding requests, 128 MiB per image and + 256 MiB aggregate reserved in-flight/completed bytes**; a stricter documented app cap is + permitted (the shared SoundCraft example is 64 MiB/image and 192 MiB aggregate with the + same four-request count). Check the count and aggregate budget before issue, reserve + checked `width * height * 4` bytes, and do not evict an earlier admitted request; +- record the target-UI logical bounds and a geometry epoch at issuance and bind the returned + pixels to that epoch. Cancel/retry on any intervening resize, DPI, zoom or surface-epoch + change before save, **even a change back**; with an unchanged epoch derive pixel scale + from the returned image dimensions and verify aspect/rounding. A callback carries pixels + only, not capture metadata; +- cancel, close, drop and late callbacks release the image and send no reply/file; release + the slot lock before waking the owning viewport. The callback has no repaint request of its + own; the live slot's waker is the only wake path and runs after unlocking; +- the app keeps the request ID, output path, serialization, timeout, settling and reply + schema; the callback does no PNG encoding, file I/O, command execution or long locking; +- retained image bytes return to zero after drain/shutdown; a post-hoc size check does not + substitute for pre-issue accounting. + +### 3.4 Lockfile and reachability checks + +Before publication, run these against a clean checkout and a fresh `CARGO_HOME` (or with +`--locked` after fetching). These are proposed checks, not executed here: + +```sh +cargo metadata --locked --format-version 1 > /dev/null +cargo tree -i egui@0.36.2 # must report no match +cargo tree -i egui # exactly one 0.37.0 source +cargo tree -i winit # exactly one pinned source +cargo tree -d -i winit # no duplicate winit +cargo tree -d -i wgpu # one resolved wgpu source/version +cargo tree -e features -i egui | grep -E 'default_fonts|monochrome_emoji_fonts|clipboard' +``` + +Add a CI/`xtask` check that fails if: + +- an egui-family package has a source other than the chosen immutable fork; +- `winit` has more than one source or a version other than 0.30.13; +- a published manifest contains `path = "/home/`, `path = "/Users/` or an uncommitted local + checkout; +- a git pin changes revision without the same change in the suite pin record; +- the required `clipboard` or `monochrome_emoji_fonts` feature is absent from the selected + native/egui feature set. + +The lockfile is committed. A fresh clone with `cargo fetch --locked` is the publication +reachability test. + +### 3.5 Feature policy + +The current feature lists are explicit. Migrate to 0.37 with these decisions: + +- workspace `egui`: `default-features = false` and features `["default_fonts", + "monochrome_emoji_fonts"]`; +- native `apps/filmcraft`: keep `default_fonts`, `wgpu`, `accesskit`, `wayland`, `x11`, and + **add `clipboard`**. Without `clipboard`, eframe 0.37's `egui-winit` uses the in-app + fallback clipboard, so cross-application text copy/paste silently stops working; +- `crates/ui-egui`: keep `default_fonts`, `wgpu`, `accesskit`; the native app root is + authoritative for OS clipboard; +- web `apps/filmcraft-web`: keep `default_fonts`, `wgpu`; the eframe `clipboard` feature is + native (`egui-winit`) and is not added to wasm. Verify browser clipboard behavior in the + browser workflow instead; +- do **not** add `system_fonts`; do **not** add `links` or `web_screen_reader` by accident. + Record them as deliberate opt-outs in the PR (`links` is not used; `web_screen_reader` is + a separate speech/accessibility decision, not part of this migration). Revisit only with + measured evidence. + +`monochrome_emoji_fonts` is not implied by `default_fonts` in 0.37. It must be explicit; +otherwise the bundled `NotoEmoji-Regular`/`emoji-icon-font` faces disappear and symbols such +as `✔ ✓ ✖ ⚡ ⚠ ⚙ ♪ ☑` can become tofu or switch to a platform face. + +These feature decisions apply to production roots. They do **not** flow through +`egui_kittest`: its normal `egui` dependency is `default-features = false`, and only its own +dev-dependencies enable fonts, which do not propagate. Test roots must enable the features +explicitly or install controlled fonts (§11.1). + +## 4. Lifecycle and root-Ui design + +### 4.1 Rule: logic is idempotent background; UI is composition; input is pass-0 only + +Introduce private `FilmcraftApp::run_logic_frame(&mut self, ctx: &egui::Context)` and +`FilmcraftApp::run_ui_frame(&mut self, ui: &mut egui::Ui)`: + +- `App::logic` runs `run_logic_frame` on **every** call. Under this pinned eframe/egui + integration, native and web call `App::logic` once per `run_ui` layout pass, and also call + it through `run_logic` for hidden ticks. Therefore `run_logic_frame` must be safe to call + more than once per frame: + - background queues are consumed (`try_recv`, `drain`, `Option::take`), so a second call + sees no work; + - playback/trim/autosave/deadline work compares a monotonic host time and computes an + idempotent target; calling it twice at the same instant does not advance twice; + - engine events are drained once and processed as values, not replayed; + - no logic path reads UI input events, `consume_key`, `Response`, or stale input time. +- `App::ui` runs only `run_ui_frame`. Fresh-input handling (drops, shortcuts, `begin_press`, + Escape, IME/focus, synthetic queue, screenshot request dispatch) runs in **pass 0** of a + UI pass, where events are fresh. Pass 1+ has no events and must not re-derive them. +- `raw_input_hook` runs before each UI frame **and** before each `update_logic_only` tick + (eframe `prepare_raw_input`). It must therefore advance at most one synthetic gesture step + per call and leave that step in app state until a full UI pass consumes it; repeated hidden + logic ticks must not drain a whole press/release sequence or inject it twice. +- `run_logic_frame` is wrapped in `catch_unwind` with a latch: an unwinding logic pass sets + `ui_error`, stops playback and does not re-run the same panicking code every tick or + re-log it every pass. `run_ui_frame` keeps its catch around `frame(ui)` and the graphics + notice. +- `on_exit` stays in `FilmcraftApp` and calls `session.shutdown()` exactly once. + +No `ui_pass`/`hidden_tick` predicate based on `viewport.visible()` is used. Visibility is +used only for presentation policy (for example whether a hidden window should be raised), +never to infer that the current pass has no input or that background work is complete. + +### 4.2 Hidden work and monotonic time + +`Context::run_logic` leaves `InputState` events and time at the previous pass. Therefore: + +- all background code uses a monotonic host clock (`web_time::Instant` on all targets, or a + small `Clock` wrapper around it); no hidden work uses `ctx.input(|i| i.time)`; +- background code never reads `i.raw.events`, never calls `consume_key`, and never polls + `Response`/`read_response`; +- input-dependent work is in `App::ui` pass 0, not in logic and not inferred from a + visibility flag; +- `Window::show`, `Area::show`, and any other widget construction run only inside a UI pass; + they are never called from `run_logic_frame`; +- control `ui.*` requests and gesture stages that need composition while hidden use + `ViewportCommand::RequestPaintWhileHidden` (see §4.5). A screenshot request already causes + the needed paint. + +A regression test exercises visible single/multipass, repeated hidden logic ticks, a forced +hidden UI paint, restore with buffered events, a background completion between passes, and +one gesture yielding one command/undo. + +### 4.3 Do not use `Context::root_ui` recursively + +`Context::root_ui` panics if called while `App::ui`/`Context::run_ui` already holds the root +`Ui` (upstream #8535). FilmCraft does not need it. Keep passing the existing `&mut egui::Ui` +to `header::show`, `dock_area`, `panels::*` and dialogs. Where a helper takes +`&egui::Context` only to add widgets, change it to take `&mut egui::Ui` (or return an +action/shape), not a `Context` bridge. + +### 4.4 Multi-pass actions: no clearing on a no-event pass + +Immediate mode plus `request_discard` means a UI handler can run in a pass whose output is +discarded. FilmCraft's project mutations must run once, and must not be lost. + +Design: + +- Add a frame-scoped `pending_ui_actions` queue and an `input_generation` counter in + `crates/ui-egui/src/lib.rs`. Each action carries a stable `ActionKey` (command, target + identity, payload digest) and the generation in which it was queued. +- Input-driven handlers queue only in UI pass 0. Pass 1+ receives no events, so it cannot + re-derive a click or key. +- **Never clear the queue at the start of pass 1+.** There is no reliable way to predict a + future discard from inside pass 0; `ctx.will_discard()` describes the current pass's + output, not the next pass. +- Dispatch policy: + - input-driven actions execute at the end of pass 0 (the pass that saw the events); the + empty-event pass cannot recreate them, so this is exactly once; + - actions that must observe final layout (dialog OK/Apply/close, dock reflow, actions that + close a panel) stay queued across discarded passes and are drained at the end of the + first pass whose `ctx.will_discard()` is false; + - a duplicate enqueue in a later pass coalesces by `ActionKey`; it is never used as a + reason to drop the original; + - if a frame ends with deferred actions still pending (for example an abnormal maximum-pass + exit or a caught panic), do not silently drop them: keep them, surface a visible status + error, and dispatch once at the next safe pass. A test forces this path. +- `cancel_value_drag` (Escape drag rollback) is also pass-0-aware. In pass 0, if + `pointer.any_down() && key_pressed(Escape)`, latch `escape_cancel` together with the undo + mark. Apply the rollback once after the final UI pass (or after pass 0 when no discard + occurred), then clear the latch. The final pass has no key event, so it must use the latch, + not re-read `ctx.input`. The latch is scoped to the active press transaction; it does not + cancel unrelated queued actions. +- `begin_press` runs before UI in pass 0; `cancel_value_drag` sees the corresponding mark and + runs once. + +Tests: click + forced discard = exactly one command/undo step; click on a `Grid` first-show +frame; key shortcut + forced discard; Escape drag + forced discard; two queued actions with +the same key coalesce; an abnormal pending queue is surfaced and dispatched exactly once. + +This replaces the reviewed design that cleared the queue on `pass_index > 0`; that design +would have lost the user's click on every discarded frame. + +### 4.5 Screenshot callback, hidden paint and CL-2 + +0.37 removes `egui::UserData` and `Event::Screenshot`; `ViewportCommand::Screenshot` takes a +`ScreenshotCallback` (`crates/egui/src/data/screenshot_callback.rs`). + +Replace `lib.rs:1714 issue_screenshots` and `lib.rs:1744 collect_screenshots` with: + +- one CL-2 completion slot per request, owned by the request record; the live slot holds the + strong viewport waker (`Context` clone plus `ViewportId`, or `request_repaint` callback), + and the callback holds only a `Weak` slot; +- `ViewportCommand::Screenshot(ScreenshotCallback::new(move |image| { /* complete the weak slot once, unlock, then wake */ }))`; +- `ScreenshotCallback::new` does not request a frame when the data arrives. Completion must + unlock the slot and then call the stored waker; the callback has no repaint path of its own + and does not hold a strong `Context` or app reference; +- `collect_screenshots` drains the result in logic and calls `control::save_screenshot(ctx, + &image, path, crop)` as today; after timeout/close/cancel, release the slot and ignore a + late image; +- token/correlation still required: two queued screenshots may complete out of order; use + ownership plus the request ID, not callback order; +- admission and memory limits from §3.3: four outstanding requests, 128 MiB per image, 256 + MiB aggregate (a stricter documented cap is allowed), checked `width * height * 4` + reservation before issue, and revalidation/rejection after any resize/DPI/zoom epoch + change; +- the issuance record binds the target-UI logical bounds and geometry epoch to the returned + pixels; a change back to the old geometry does not restore validity. + +Hidden-window semantics are the 0.37 behavior, not the old assumption: + +- `ViewportCommand::Screenshot` maps to `ActionRequested::Screenshot`/`PaintWhileHidden` and + makes the integration run a UI pass even when hidden; the callback is then completed from + that forced paint. A hidden pending screenshot is therefore expected to deliver an image. +- A request that is still queued and not yet issued — settling frames or a hidden logic tick + — has no screenshot command to force its paint. Logic must send + `ViewportCommand::RequestPaintWhileHidden` while such a request is pending so the UI pass + that advances settling can run; once issued, the screenshot command itself forces paint. +- `ViewportCommand::RequestPaintWhileHidden` is also the supported way to force a UI pass for + other stages that need composition/gesture handling while hidden (for example control + `ui.*` requests that need fresh layout or settling without a screenshot). +- The screenshot timeout (`SCREENSHOT_TIMEOUT_S`) is a renderer/readback failure guard, not a + "hidden never presents" path. A test must not encode "hidden returns timeout" as normal + behavior. Test both branches: hidden + pending screenshot → image delivered, including the + queued-but-not-yet-issued settling case; hidden + no capture request → no capture (and, + where a request is admitted but the renderer cannot complete, the documented timeout + error). +- `raw_input_hook` (`lib.rs:2092`) no longer preserves `Event::Screenshot`; it clears input + events as it already clears hovered/dropped files. Update + `crates/ui-egui/tests/ui_control_input.rs:153`: assert close/viewport metadata survive and + no screenshot event is expected; add a native/VM check that the callback still delivers. +- `raise_for_control` (`lib.rs:1665-1673`) remains for occluded macOS raising without focus; + pair it with `RequestPaintWhileHidden` where a UI pass is required, rather than assuming a + hidden window never runs UI. + +### 4.6 Playback, audio, autosave, jobs, trim and deadlines + +- `advance_playback` (`lib.rs:1128`) sets the playhead from `anchor_tick + elapsed`; use a + monotonic host clock and do not accumulate dt. Repeated calls at the same time are + idempotent. +- `playback_mix` remains the live project snapshot; audio callbacks are outside egui. Logic + keeps polling `audio.played_frames()` while hidden. +- `FilmcraftApp::frames.pump` moves from `App::ui` to logic. The web runner's `WebApp::logic` + also pumps jobs/audio; its per-frame work must be idempotent because web calls logic per + pass. +- Auto-save: `Session::poll_persistence` and `session.is_dirty` move to logic; the recovery + journal worker must not depend on painted frames. +- `watch_jobs` splits: `poll_jobs` in logic handles completion and `play_after_render`; the + status-bar `job_status` remains in UI. +- `trim_monitor::advance` (`lib.rs:1817`; `panels/trim_monitor.rs:39-48`) moves to logic and + uses a monotonic clock. `trim.tick` receives an idempotent target time/step, so a second + logic call for the same pass does not advance edits twice; it still requests repaint. +- Metadata follow, `auto.begin_frame`, `sync_timeline_view` and `sync_native_menu` run once + in UI pass 0 (or are made idempotent if moved to logic). +- Screenshot settling still waits for `monitor_inexact` and `timeline_still`, both updated in + logic/UI with the monotonic clock. +- Add a CI grep that rejects direct `session.execute`, `menus::invoke` and `trim.tick` + execution from UI-only modules outside the queue/dispatch module and an explicit + allow-list. Until that check is green, the one-action guarantee is not claimed. + +## 5. Concrete API migration catalogue + +These are the source-grounded incompatibilities. Line numbers are baseline line numbers. + +### 5.1 `FontData` is a shared blob + +0.37 `epaint` has `pub type Blob = Arc + Send + Sync>` and +`FontData::from_blob(Blob, index)` (`crates/epaint/src/text/font_data.rs`). Replace literal +`FontData { font: Cow::... }` in: + +| File:symbol | Current code | Migration | +|---|---|---| +| `crates/ui-egui/src/cjk.rs:53` `find_system_font` | `FontData { font: Cow::Owned(face.data()?), index: face.info.index, ... }` | `FontData::from_blob(Arc::new(face.data()?), face.info.index)` | +| `crates/ui-egui/src/i18n/mod.rs:292` `system_japanese_font` | `FontData { font: Cow::Borrowed(bytes), index: face.info.index, ... }` | `FontData::from_static(bytes)` (bytes are already leaked and shared) | +| `crates/ui-egui/src/rtl.rs:92` `system_fonts` | `FontData { font: Cow::Owned(data), index: face.info.index, ... }` | `FontData::from_blob(Arc::new(data), face.info.index)` | + +Keep `Arc` in `font_data` maps; `FontData` is cheap to clone now. Add a regression +that a TTC face index is preserved (non-zero `info.index`), because the literal construction +was the only place that carried it. + +### 5.2 `MouseWheel` gained `source` + +0.37 `Event::MouseWheel` has a required `source: MouseWheelSource`. Add +`source: MouseWheelSource::Wheel` for the control-channel synthetic wheel in +`crates/ui-egui/src/control.rs:370`, and to the two tests that construct it: +`crates/ui-egui/tests/monitor_geometry.rs:176` and `crates/ui-egui/tests/panels_ui.rs:249`. +Use `Wheel` rather than `Unknown` so ScrollArea behavior and kinetic-scroll policies match a +real mouse wheel. Add a test that the timeline wheel path (`panels/timeline.rs:2221`) still +steps one tab per notch and does not start kinetic scrolling in headless tests. + +### 5.3 `WidgetType` -> `Role`, and one role per control + +`WidgetType` is a deprecated alias of `accesskit::Role`; `SelectableLabel` no longer exists +and `TextEdit` is `TextInput`. Replace the ten sites: + +| File:line | Current role | New role | +|---|---|---| +| `crates/ui-egui/src/dock.rs:673` | `WidgetType::Button` | `Role::Button` | +| `crates/ui-egui/src/dock.rs:725` | `WidgetType::SelectableLabel` | `Role::Tab` with `selected(active)`; the code already overrides the node role to `Tab` | +| `crates/ui-egui/src/dock.rs:765`, `:779` | `Button` | `Button` | +| `crates/ui-egui/src/dock_ui.rs:326` | `Button` (floating-panel resize grip) | `Role::Button` with the accessible name "Resize panel". Do **not** expose `Splitter` here; a floating-window grip is not a pane divider. Dock split dividers use `Role::Splitter`; update `crates/ui-egui/tests/shared_docking_ui.rs:201` to the chosen role in the same PR | +| `crates/ui-egui/src/panels/graphics.rs:1028` | `TextEdit` | `Role::TextInput` with a name | +| `crates/ui-egui/src/panels/tools.rs:27` | `SelectableLabel` | `Role::Button` with `selected`; a radio-group model is a later accessibility change | +| `crates/ui-egui/src/panels/monitor.rs:1013` | `Button` | `Button` | +| `crates/ui-egui/src/panels/source_drag.rs:46` | `Button` | `Button` | +| `crates/ui-egui/src/theme_favorites.rs:4` | `Button` | `Button` (favorite toggle; `CheckBox` is a later semantic change) | + +The previous `Button` vs `Splitter` contradiction is resolved: floating grip = `Button`; +dock pane divider = `Splitter`. The test is updated once, not left ambiguous. + +### 5.4 `ScrollArea::State::offset` is private + +0.37 provides `clamped_offset()` and `unclamped_offset()`; the old `offset` field is private. +Replace `crates/ui-egui/src/panels/media_browser.rs:576` and +`crates/ui-egui/src/panels/project_views.rs:516` (`out.state.offset.x`) with +`clamped_offset().x` for header alignment. Add a test that scrolls the project list +horizontally to the end and asserts the header’s first column x matches the last visible +row’s column x within one point. + +### 5.5 `Context::run_ui` callback is `&mut Ui` + +`crates/ui-egui/src/lib.rs:2411` currently writes: + +```rust +let mut out = ctx.run_ui(raw, |ctx| secondary = ctx.input(|i| i.pointer.secondary_clicked())); +``` + +Change to: + +```rust +let mut out = ctx.run_ui(raw, |ui| secondary = ui.ctx().input(|i| i.pointer.secondary_clicked())); +``` + +The other `run_ui` call sites already use `|ui|`. Do not call `Context::root_ui` inside these +closures. + +### 5.6 `Id::new` and `Ui::id` + +0.37 deprecates `Id::new` and `Ui::id` (`Id::unique` / `Ui::scope_id`). FilmCraft has 403 +`Id::new` occurrences across `crates/ui-egui/src` and tests. Under `clippy -- -D warnings`, +deprecation is a hard failure. + +- `egui::Id::new(x)` -> `egui::Id::unique(x)` for every root id; same hash, same identity. +- `Id::new(parent).with(child)` -> `Id::unique(parent).with(child)`. +- `UiBuilder::id(x)` -> `UiBuilder::scope_id(x)`; FilmCraft uses `id_salt` and has no + `UiBuilder::id` (verified), but `craft-ui` PR #199 performs its own conversion. +- `Ui::id()` -> `Ui::scope_id()`; FilmCraft has no `ui.id()` call (verified). +- Keep `id_salt` for child UIs; do not replace every `id_salt` with a global id. + +Add a grep-based CI check that no `Id::new(` remains in FilmCraft or its tests. + +### 5.7 `Event::PasteImage` + +0.37 emits `Event::PasteImage(Arc)` when the clipboard has no text. Update +`crates/ui-egui/src/lib.rs:2299` `clipboard_events_as_keys` to map `PasteImage` with +`command` to `Key::V`, with `shift` to `Key::Insert`, and plain to `Key::Paste`, exactly as +`Event::Paste`. Add tests alongside `ctrl_c_x_v_become_key_presses`. + +The native app’s `clipboard_media` hook (arboard) still supplies files/images to the Paste +command. On wasm, either keep the existing “FilmCraft clips only” path or extend +`ClipboardMedia` with the `PasteImage` bytes; that is a separate feature decision. Do not +double-read the clipboard if the event already carries an image. + +### 5.8 `Event::Screenshot` / `UserData` + +Covered in §4.5. Remove `egui::UserData::new` at `lib.rs:1721` and the `Event::Screenshot` +matches at `lib.rs:1753` and `lib.rs:2100`. + +### 5.9 Other 0.37 changes that are compile-compatible but visible + +- `default_fonts` off by default: explicit feature; `monochrome_emoji_fonts` explicit (§3.5, + §6.1). +- `clipboard` opt-in in eframe/egui-winit: native feature added (§3.5). +- `Id` deprecations (§5.6). +- `Panel` constructors take `impl AsIdSalt`: FilmCraft does not use `egui::Panel`, but + `craft-ui` and any new panel code must use the new form. +- `UiBuilder::scope_id`: see PR #199. +- `ComboBox` is built from atoms and takes `IntoAtoms`; existing `selected_text`/`show_ui` + calls remain source-compatible, but styling/measurement changes must be reviewed. +- `WidgetType::TextEdit` renamed to `Role::TextInput`; `TextEdit` itself uses + `Role::TextInput`. +- `Context::root_ui`/`run_pass`: not adopted; no recursive root UI. +- `ViewportCommand::Screenshot` callback and `RequestPaintWhileHidden`: adopted as above. +- `egui::Event::PasteImage`, `MouseWheelSource`, PRIMARY selection: adopted as above. +- kittest 0.37 hard-fails input widgets without accessible names (#8589/#8611). The first + green compatibility stage must name all input widgets, including all 56 `TextEdit` + construction sites; do not disable strict naming globally. + +## 6. Fonts, glyph coverage, language and RTL + +### 6.1 Font installation and bundled coverage + +`crates/ui-egui/src/theme.rs:420` `install` builds `FontDefinitions`, inserts Inter/JetBrains +Mono, then appends Japanese craft-fonts (`add_craft_fonts`), Chinese (`cjk::install`) and RTL +(`rtl::install`). Keep that order. + +The explicit `default_fonts` feature preserves egui’s built-in fallback, but in 0.37 the +monochrome emoji faces moved behind `monochrome_emoji_fonts`. Enable it explicitly. FilmCraft +starts from `FontDefinitions::default()` and uses `✔ ✓ ✖ ⚡ ⚠ ⚙ ♪ ☑` (20 occurrences across +10 files); with `system_fonts` off, those symbols can otherwise become tofu or switch to a +platform face. Add a glyph-coverage assertion for exactly these characters plus a screenshot +diff. + +`FontData` construction is §5.1. Keep the process-wide caches (`OnceLock`) and the single +system scan; do not call `filmcraft_text::fonts::scan_system()` in a UI pass. + +`eframe` 0.37 adds an optional `system_fonts` feature (fontique provider, #8512) and a +`system_font_fallback` option. **Do not enable it in the migration.** It changes startup cost +and the fallback face chosen for a missing glyph; the current explicit CJK/RTL fallback is +part of FilmCraft’s visual contract. Record it as an opt-in follow-up with measured startup +and glyph coverage, especially for color emoji; do not accept clamp-only as a substitute for +texture wrap/filter semantics. + +Pinned test fixtures are the exact set from the egui plan §10.6, fetched into the task’s +evidence fixture directory with their licence files. **Do not copy font binaries into this +repository.** The fixture set is: + +| Fixture | Source | SHA-256 | License | +|---|---|---|---| +| Arabic | craft-fonts `2481a8a324b986a45954e90045ed1f8f2efaaf3c` `fonts/noto-sans-arabic/NotoSansArabic.ttf` | `63111b5b2e074dd48cc67692e0a2726d86ee94c1c37fe8598257b7b4e87e869e` | OFL-1.1, adjacent `OFL.txt` | +| Thai | same craft-fonts `fonts/noto-sans-thai/NotoSansThai.ttf` | `5a1c559bb539583c8a1fd99d1c5b9491e5e14478c9cd2bd0970d5c3096cc9ef8` | OFL-1.1 | +| CJK SC | same craft-fonts `fonts/noto-sans-cjk-sc/NotoSansCJKsc-Regular.otf` | `2c76254f6fc379fddfce0a7e84fb5385bb135d3e399294f6eeb6680d0365b74b` | OFL-1.1 | +| Japanese | same craft-fonts `fonts/biz-ud-pgothic/BIZUDPGothic-Regular.ttf` | `258d7156c165f2ff774b6efee637c22c3b950de0d8a10e501137061bc8085d01` | OFL-1.1 | +| Hebrew | google/fonts `8b0a1d0f5983c89bc2b93f1b5fb55f9e252744b5` `ofl/notosanshebrew/NotoSansHebrew[wdth,wght].ttf` | `7ef36a2c3593758cdb622e1bdef4f84523e92fbc3ccc667438dd80ff54c2de88` | OFL-1.1; fix axes weight 400/width 100 | +| Color emoji | googlefonts/noto-emoji `22e564626297b4df0a40570ad81d6c05cc7c38bd` `fonts/NotoColorEmoji.ttf` | `39ee3c587e10e89669b9ff32703261d10d5f9c4dd5ad147b6b5a1c5200591817` | OFL-1.1 | +| Bundled Latin/emoji | candidate `epaint_default_fonts` `Ubuntu-Light.ttf` | `80307b8da7649aa4ee4d484b232140e3ce1ec0ca093073d3c53c8f5a5ced7a70` | Ubuntu Font License, adjacent `UFL.txt` | +| Bundled mono emoji | candidate `epaint_default_fonts` `NotoEmoji-Regular.ttf` | `415dc6290378574135b64c808dc640c1df7531973290c4970c51fdeb849cb0c5` | OFL-1.1 | + +The fixture bytes and licence hashes enter the evidence manifest. A controlled comparison +uses a fixed common set; a separate default-policy visual comparison records each +baseline/candidate policy. System-provider tests are a nondeterministic integration row with +OS/font inventory recorded, not pixel-golden input. + +### 6.2 UI language and AccessKit language + +FilmCraft’s `i18n::Language` is a private list (`crates/ui-egui/src/i18n/mod.rs`, +`Language::ALL`; 12 languages, no Arabic/Hebrew UI). Add `Language::bcp47()` returning proper +tags (`en`, `ja`, `es`, `pt-BR`, `uk`, `zh-CN`, `de`, `ru`, `fr`, `id`, `it`, `ko`). Call +`ctx.set_language(Some(tag))` when the language changes and `frame.set_ui_language(Some(tag))` +in `App::ui` (fork E18) so the AccessKit root and the web hidden IME input use the same tag. +In kittest, `Frame::set_ui_language` is a no-op; the `ctx.set_language` call is the tested +path. + +Do not enable `Style::right_to_left` (fork E9) yet: FilmCraft offers no RTL interface +language, and mirroring the custom-drawn workspace without a full RTL audit would be worse +than leaving it LTR. Document RTL UI as a future feature gated on real translated layouts. + +### 6.3 Document RTL and the fork’s bidi work + +`crates/ui-egui/src/rtl.rs` currently compensates for 0.36’s lack of bidi: `line_sections` +splits RTL runs into words, reverses them, mirrors brackets and gives each word its own +`LayoutSection`. The fork (E5/E12) performs UAX #9 shaping and visual ordering itself. Keeping +both is a double-reversal bug. + +In P1, in the same stage that pins the fork, change `rtl::Lines`: + +- keep `has_rtl` and the `rtl` alignment flag; +- build one `LayoutJob` with `direction: TextDirection::Auto`, + `halign: if rtl { Align::RIGHT } else { Align::LEFT }`, `halign_min_is_start: true`, one + section, and the requested wrap width; +- delete `line_sections`, `mirror`, `in_word`, `line_ranges` and their unit tests once the + fork is pinned; replace them with tests on `Row::rtl_paragraph`, `Row::visual_order`, glyph + order and right alignment; +- keep `paint`/`paint_line` API so `timeline_captions.rs:152` and `text.rs:239` do not change + contracts. + +The fork's E5/E12 path and the removal of the manual reversal must ship in the same P1 +commit set; there is no intermediate state with both active. A negative regression first +demonstrates the double ordering (Arabic caption with the fork produces reversed words), then +P1 removes it. Test `"كلمة أخرى"`, `"قال FilmCraft 0.6 جيد"`, Arabic-Indic digits, brackets +and an LTR paragraph containing an RTL phrase; assert the leftmost drawn glyph of the first +visual word, not just “some Arabic glyph exists”. + +`TextEdit` keeps logical order; fork E13/E16 add grapheme/bidi-safe caret movement. Test +caption text editing (logical caret, Delete/Backspace, Home/End) and selection rendering. + +## 7. Input, IME, clipboard and PRIMARY + +### 7.1 Keyboard + +`handle_shortcuts` (`lib.rs:1546`) already uses `i.consume_key` and a panel/app-wide +specificity sort. Fork E19 adds `RawInput::keyboard_layout_is_latin` and +`latin_layout_chars`; `handle_shortcuts` must use the Latin-layout character for letter +shortcuts when the active layout is not Latin, so `Ctrl+Z` still undoes on a +Russian/Arabic/Hebrew layout and an unmodified `Z` does not become a tool shortcut. P7 +held-key pairing keeps a key's emitted egui identity for its release when the layout or +modifiers change, clears it on focus loss/IME/destruction, and does not redefine winit's +live `logical_key`. Add a kittest that constructs `RawInput` with +`keyboard_layout_is_latin: Some(false)` and +`latin_layout_chars: vec![(Key::Q, 'q'), ...]`, sends a physical key, and asserts the command +fires only for a Latin-letter key. Do not hard-code a platform. + +Input handling remains in `App::ui` pass 0, where the event list is fresh; pass 1+ must not +re-run it. + +### 7.2 IME and outgoing surrounding text (E21b/P7b) + +Fork E6 (request_focus does not interrupt composition), E8 (TextEdit glyph clipping), E21 +(`ImeEvent::DeleteSurroundingBytes`) and winit W18-W27 cover Windows/X11/Wayland/macOS IME. +The candidate has a confirmed gap: winit W27 exposes +`WindowExtWayland::set_ime_surrounding_text`, but egui-winit never calls it, and +`egui::IMEOutput` carries no logical text. E21 alone carries incoming deletion/commit only. + +This migration therefore requires **E21b/P7b** before acceptance. The authoritative +contract is the complete E21b section of `egui/docs/egui-037-migration-plan.md` plus +`winit-review-triage.md`; these bullets are FilmCraft's adoption summary, not a weaker +restatement: + +- `IMEOutput` carries one atomic descriptor: owner/context-generation identity plus the + surrounding-text state and payload. The owner generation is present **even when the state + is `Unavailable`**; an available empty document is distinct from unavailable support and + is sent as an explicit empty payload, not omitted. +- add a bounded transient payload of at most **4000 UTF-8 bytes**, extracted at valid UTF-8 + boundaries **without allocating a full document copy**, with indices remapped after + excluding preedit. If the selection cannot fit, emit explicit `Unavailable` rather than + presenting a truncated selection as complete. Keep `Clone`/`Eq`, update `Copy`-dependent + callers, and keep transient text out of Debug, serialization and persistence. +- built-in `TextEdit` produces committed logical text after applying deletion/commit/cursor + updates, excluding preedit, with UTF-8 byte cursor/anchor indices; password fields emit no + text. Normal-to-password, no-IME, focus-loss/reset, owner-change and unavailable + transitions clear old backend text; each payload belongs to the current IME-owning + widget/viewport. +- W27 gains an explicit owner/support transition: clear its cache and commit a + disable/enable boundary before publishing a new owner or support state. Merely changing + purpose or sending empty text does not clear protocol state. Available-empty maps to + `set_surrounding_text("", 0, 0)` plus commit; unavailable omits `set_surrounding_text` and + uses the committed clear boundary; when support returns, enable plus the full payload + precedes commit. Cancel the old preedit without inserting it into the new owner. +- egui-winit appends the latest completed pass's IME descriptor, owner and payload together, + including an authoritative `None`; a previous payload must not survive a later + non-text/password/`None` pass. The same finalized output and payload are appended + together across passes. +- on a current-epoch text-input-v3 `done` with a mismatched serial, apply the incoming edits + **once** and defer outgoing state until a matching serial. Obsolete owner/focus epochs + invalidate their pending batch; a current-generation serial mismatch is not a reason to + drop valid edits. +- the egui-winit Wayland adapter calls W27 with the transient payload on text, selection or + IME-owner change, per window and owner; macOS NSRange conversion remains a separate W26 + path, not a claim of a new macOS API. + +FilmCraft’s own custom `IMEOutput` producer is at +`crates/ui-egui/src/panels/graphics.rs:933-940`. It must supply the logical model text, the +preedit, and UTF-8 byte cursor/anchor ranges from its own scalar/grapheme coordinates; it must +not pass display-bidi text or a `None` placeholder. Its integration test must reject a +split-UTF-8 range and verify the model range maps to the emitted byte range. Built-in +`TextEdit` consumers in FilmCraft (caption, notes, search, metadata) inherit the producer and +need tests rather than custom adapters; the same inheritance applies to LightCraft’s built-in +consumers. The suite inventory of custom producers is WordCraft `canvas.rs` (two), +VectorCraft `canvas.rs`, FigCraft `shortcuts.rs`, EffectCraft `viewer_text.rs`, FilmCraft +`graphics.rs`, DesignCraft `canvas.rs`, DeckCraft `canvas.rs` and PhotoCraft `type_tool.rs`; +each needs its own adapter and test. + +Required tests: outgoing text changes immediately after delete+commit and deletion-only +batches; scalar/grapheme vs byte indices; supplementary emoji/combining marks/RTL logical +order; preedit exclusion and caret movement; long selection/window limits; same-window field +switch; two windows; focus loss/reset/late serial; initial empty, available-empty → non-empty +and unavailable/password → available transitions; multi-pass final-output selection where +pass 1 is text and pass 2 is password, non-text or `None`. Native Wayland Vietnamese/ +predictive IME verifies the protocol trace and editor content/undo. Headless tests cannot +prove native IME focus/composition. + +#### Film graphics IME ownership lifecycle + +Incoming `ImeEvent` carries no owner or epoch, so the custom Program-monitor editor keeps a +local causal lifecycle: `Active(owner)`, `TerminalPending(owner)` and `Stale`. A non-empty +preedit records `Active`; an empty preedit clears the visible composition but records +`TerminalPending`, because its commit/delete is a separate event. A target change or editor +close turns either state into `Stale`, so that pending terminal event cannot edit the +replacement target. A rejected stale commit is itself the terminal boundary and consumes the +tombstone immediately, allowing a preedit later in the same raw batch to start a new +composition. A stale empty preedit or delete is only tentatively terminal: the tombstone is +kept through the batch so a paired old delete+commit is rejected, and is consumed at batch end +when no commit follows. A lone stale non-empty preedit keeps the tombstone across batches, so a +preedit/commit pair split across batches cannot retarget either. This uses observed event order +only, never frame/time heuristics. Regressions cover an empty preedit followed by a target +switch before its commit, and a coalesced stale commit followed by a new preedit in the same raw +batch. An entirely unobserved platform reset remains indistinguishable from a new composition; +the first ambiguous composition after it may be dropped rather than claiming provenance. + +Platform rows: + +- Windows: Japanese and Korean IME in a caption/notes/search `TextEdit`; caret follows + composition; a key the IME consumes is not also a tool shortcut; commit adds exactly one + undo step. +- X11: Vietnamese Telex; one input context per window; no duplicate letters. +- Wayland: text-input-v3 surrounding text and delete-surrounding; no double commit; outgoing + bytes observed after commit/delete. +- macOS: press-and-hold accents, dictation, ideographic space, first Korean key; real + hardware required. +- Web: named keys are not typed as text; `lang` on the hidden input; IME commit in a text + field. + +### 7.3 Clipboard and PRIMARY + +0.37 adds PRIMARY selection (#8428). The production graph must integrate **both** E4 and +W15P: + +- W15P adds a winit-owned PRIMARY path on the existing Wayland connection/event queue and + per-seat lifecycle, using SCTK 0.19.2’s existing `PrimarySelectionManagerState`, device, + source and offer support. PRIMARY is a distinct protocol object; do not bind a second + regular `wl_data_device`. +- Egui 0.37 `get_primary`/`set_primary` routes through that path. E4 preserves password + exclusion and does not reclaim ownership for an unchanged selection. +- FilmCraft also uses `arboard` directly for files/images (`apps/filmcraft/src/main.rs` + `clipboard_media`). The arboard/smithay PRIMARY path is a measured fallback only if the + winit path cannot meet a proven requirement; it is not the planned end state and it is not + accepted merely by documenting a GNOME limitation. + +Required native matrix, on X11 and Wayland, under wlroots (Hyprland/Sway), KWin and Mutter +with supported capabilities recorded: + +- keyboard- and pointer-triggered selection plus middle-click paste at the clicked position. + A PRIMARY request uses a fresh serial from the event that triggered it; the protocol + explicitly allows pointer-driven selection and `set_selection` takes the triggering + event's serial. Do not impose a keyboard-only serial rule; +- serial freshness, owner/seat changes, empty/absent offers, self-paste; +- password selection never offered; +- unchanged selection does not reclaim ownership; +- bounded asynchronous transfer: total deadline **2 s**, **16 MiB** cap, at most **4 read + workers** and **2 writer threads** with queue depth **8**. Copy/PRIMARY ownership claims + stay synchronous on the fresh triggering input turn (validate serial/seat, reserve bytes, + create the source, call `set_selection`); only pipe serving and potentially blocking reads + are asynchronous; +- a caller-owned live completion handle records the target window, input generation and an + owner-specific waker, wakes only that viewport, and is held weakly by workers. Canceled, + stale or superseded results are discarded and release their resources; no arbitrary + first-seat fallback; +- slow, trickling, oversized, canceled and failed transfers release/complete exactly once; +- text copy/cut/paste and Paste/Paste Insert/Paste Attributes; +- file list and image paste through arboard, not duplicated by egui’s image paste; +- Windows `Ctrl+Insert`, `Shift+Insert`, `Shift+Delete` still map through + `clipboard_events_as_keys`; +- `Event::PasteImage` maps to `Key::V` and does not produce two Paste commands. + +## 8. Panels, geometry, docking, viewports and GPU resources + +- `dock_ui.rs:190` `dock_area` calls `dock::layout_with_actions` for a manual rect tree, then + `panel_body` (`:144`) creates `ui.new_child(... id_salt(...))` and sets clip rects. 0.37’s + `Panel` API is not used; keep the manual layout, but verify that `Ui::new_child`/`scope_id` + and the improved panel clip rect (#8562) do not change body rects. +- Floating groups use `egui::Area` with `Order::Middle`; layer order is read through + `ctx.memory(|m| m.layer_ids())` (`dock_ui.rs:173` `floating_order`). Check the 0.37 layer + iteration API and z-order after the bump; `ui_dock_resize.rs` and `shared_docking_ui.rs` + cover focus, resize, detach, close/reopen and tab reorder. +- `dock.rs` draws custom tabs and registers automation ids. Keep stable ids + (`panel.tab.*`, `timeline.tab.*`, `panel.menu.*`, `panel.float.*.resize`) and use + `Role::Tab`/`Role::Button` semantics. The `Tabs` tab strip is not an `egui::TabBar`; do not + replace it. +- `ViewportCommand` usage remains root-only: `Close`, `InnerSize`, `Focus`, `CancelClose`, + `Maximized`, `Fullscreen`, `StartDrag`, `CursorPosition`, `Screenshot`, and the new + `RequestPaintWhileHidden` for hidden composition stages. `ui.resize` and `ui.focus` + control methods keep their behavior; `control_input_only` must still preserve close and + viewport lifecycle metadata. +- GPU: `FilmcraftApp::set_wgpu` receives `CreationContext::wgpu_render_state`; + `TextureHandle` and `GpuState`/`uploadedBytes` remain. `egui-wgpu` fork E2/E3 add surface + fitting and error scopes; they are relevant to large/odd windows. Do not change the + FilmCraft `wgpu 30.0.1` pin or the GPU compositor in the migration PR. The candidate’s own + lock resolves `wgpu 30.0.0`; FilmCraft’s lock is authoritative. Record the actual resolved + version and prove there is one source; do not claim the candidate lock’s version. + `perf.stats` `gpu.uploadedBytes`, transitions, `submitMs` and frame-cache counters are the + upload/GPU observables. + +## 9. Commands, undo, save and reopen + +The public contracts are: + +- every interactive action is a command registered in `crates/engine`; UI, CLI, MCP and the + control channel use the same ids; +- `menus::invoke` dispatches UI-only commands and engine commands; +- drag gestures are one undo step via `begin_press` and `cancel_value_drag`; +- `file.save`, `file.saveAs`, `file.open`, `file.closeProject`, `file.revert` and recovery are + unchanged; +- UI state (tool, workspace, dock layout, zoom, scroll, monitor settings) is serde in + `crates/ui-egui/src/state.rs`; project state is not UI state. + +Migration rules: + +- `begin_press` runs in UI pass 0 before any UI; `cancel_value_drag` runs once after the + final UI pass using the pass-0 Escape latch (§4.4). +- Input-driven UI mutations dispatch once in pass 0; actions that must see final layout are + queued and drained on the first non-discarded pass. Engine commands from control/MCP/CLI + remain one-shot channel work in logic. +- A forced two-pass click adds exactly one undo step and one project revision. A forced + two-pass click on a first-show `Grid` does the same. A forced discard during a drag with + Escape cancels once. +- Save/reopen preserves an edit, an effect, a caption, and the dock/tab layout. Existing + `timeline_tabs_ui.rs:258` and `scripted.rs:548` patterns are the starting point; add an + effect/trim/save/reopen assertion and compare `sequence.inspect` and `project.inspect`. + +## 10. Fork and vendor patch provenance + +FilmCraft has no vendor directory. The suite forks carry behavior that FilmCraft either uses +or must consciously not use. Keep the pins; do not delete unrelated fixes. The tables below +name the aiongg research candidate for provenance only; production pins are G-PINS. + +### 10.1 `aiongg/egui` `craft/0.37` (`a4f9a17f`, research) + +| Patch | Decision in this plan | Validation | +|---|---|---| +| E2+E3 egui-wgpu surface fit/error scopes | Keep; use for large windows and odd surfaces | Windows/Linux resize/VM; `ui.resize` to a large size; no `Wgpu` error | +| E4 Wayland clipboard/PRIMARY | Keep; must be paired with W15P; the filmcraft#1115 problem is exactly this | Hyprland/KWin/Mutter copy/paste, PRIMARY matrix, file/image paste | +| E5, E8, E12, E13, E14, E15, E16, E17, E20 | Keep; use them; remove FilmCraft’s manual bidi | Arabic/Hebrew captions, editing, alignment, screenshots | +| E6, E21 IME | Keep; E21 alone is incoming only; E21b is mandatory | native IME rows plus outgoing-state tests | +| E7, E19 non-Latin shortcuts and P7 held-key pairing | Keep; adapt `handle_shortcuts` to Latin-layout chars; retain the emitted egui key identity for a held physical key across layout/modifier changes and clear it on focus loss/IME/destruction | kittest + native layouts, repeat/unidentified keys | +| E9 RTL UI mirroring | Keep in fork but do not enable `Style::right_to_left` | no RTL UI language yet | +| E10 web key/lang fixes | Keep; test browser IME and named keys | `smoke.mjs` + manual browser | +| E11 vello_cpu multithreading | Not exercised by FilmCraft; no local feature change | none | +| E1 viewport hooks | Not used by FilmCraft; keep the fork patch | no FilmCraft test | +| E18 `Frame::set_ui_language` | Use with `ctx.set_language` | language switch + AccessKit root | +| E15 `LineBreaker`/`LayoutJob::language` | Optional; use only if document line breaking needs it | caption wrapping tests | +| E21b/P7b outgoing IME surrounding text (P7b) | **Required new prerequisite**: the complete shared owner/support/serial contract, 4000-byte bounded payload without a full-document copy, TextEdit producer, Film graphics adapter and Wayland delivery | §7.2 tests; native Wayland trace | +| P7c selected #8763/#8748 fixes | P7c owns the selected, tested #8763 selection-action and #8748 hidden parent/child fixes. #8764 remains an unmerged proposal at `6eb460a2a6d537d312a80579cb801895509deaed`, not a release fix | selection/pointer/keyboard and hidden-parent regressions | + +There is no E22. Do not invent fork APIs. A local workaround for #8763 needs a specific +reason and removal condition. + +### 10.2 `aiongg/winit` `craft/0.30.13-lang` (`8b35388b`, research) + +Keep the whole pinned branch because E4/E19/E21/E21b require W15/W15P/W25/W26/W27, +including the bounded W15P transfer path and the W27 owner/support lifecycle. For FilmCraft, +exercise at least: + +- W1 mixed-DPI window rect; W3/W4 Windows IME/hidden maximized; W5/W20/W21 X11 IME; + W6-W10 macOS IME; W11 X11 DPI; W12 macOS titlebar drag; W13 Wayland scroll phase; + W15 Wayland drops/clipboard; W15P PRIMARY; W18/W19 Windows IME; W22-W27 language/IME. +- W15P is required with E4 as described in §7.3, including per-seat lifecycle, password + exclusion, unchanged-owner behavior and slow/canceled transfer tests. +- W14 macOS cancelable termination is opt-in; FilmCraft does not call that platform extension, + and its own close interception remains. +- Do not enable W16 pinch/rotation or tablet input; no `craft-tablet` dependency exists. + +If the suite later replaces these forks with upstream releases containing the fixes, drop the +corresponding patch rows only after the same tests pass; do not remove a patch just because a +release number moved. + +### 10.3 `craft-libs` port + +Port only the selected PR-199 API hunks (`8614e4a`) and README changes (`1764bca`) onto the +current published `4919088` tree. Preserve newer security/settings/docking/printing fixes, +including the protected-group docking tests. Never merge `b68de8c` or `ec8840a` as trees. The +CL-2/CL-2F helper is a separate reviewed addition, not part of the stale PR tree. + +## 11. Tests and observable assertions + +### 11.1 Required repository gates (managed, proposed) + +Every new host build/test and owned-VM command must use +`maintenance/task.py run/status/finish` with its own task, job ID, source and evidence. The +commands below are proposals for the parent to submit; they were not executed during this +planning pass. Capacity must be inspected first; the suite snapshot had less than one default +64 GiB build reservation available after existing reservations. + +```sh +python3 /home/codex/work/maintenance/task.py run \ + --job-id filmcraft-egui037-ci-01 --kind build \ + --task filmcraft-egui037-migration \ + --source /home/codex/work/CraftApps/egui037-migration-20261022/filmcraft-implementation \ + --evidence /home/codex/work/ssd2/task-evidence/egui037-migration-20261022/filmcraft \ + -- cargo xtask ci +python3 /home/codex/work/maintenance/task.py status filmcraft-egui037-ci-01 --tail-bytes 8192 +python3 /home/codex/work/maintenance/task.py finish filmcraft-egui037-ci-01 --artifact-manifest /home/codex/work/ssd2/task-evidence/egui037-migration-20261022/filmcraft/artifacts.json +# after inspecting the plan and preserving evidence: +python3 /home/codex/work/maintenance/task.py finish filmcraft-egui037-ci-01 \ + --artifact-manifest /home/codex/work/ssd2/task-evidence/egui037-migration-20261022/filmcraft/artifacts.json \ + --apply --owner-confirmed --checks-complete --evidence-preserved +python3 /home/codex/work/maintenance/task.py status filmcraft-egui037-ci-01 --tail-bytes 8192 +``` + +Every job uses one unique resource task/fresh job ID/`--source`/`--evidence`; poll `status` +until both the command and the finish outcome are known, and never treat `ok: true` alone as +success. + +`cargo xtask ci` already runs fmt, clippy, tests, layers, assets and wasm; keep it as the +authoritative gate. Also run, as separate managed jobs where a selected feature is not in +default CI: + +- `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, + `cargo test --workspace`, `cargo xtask layers`, `cargo xtask assets`, `cargo xtask wasm`; +- with and without `CRAFT_FONTS_DIR` (pinned craft-fonts `abb83316d96aa59c1cf64784289e378fe9fa5695`); +- `FILMCRAFT_REQUIRE_ORACLES=1` when ffmpeg is available; +- `cargo metadata --locked`, the graph checks from §3.4, and the native `clipboard` + + `monochrome_emoji_fonts` feature assertions. + +Strict kittest naming and glyph behavior is part of the first green compatibility stage. +Do not add a global `allow`/disable for missing names or missing glyphs. If a name/glyph is +missing, fix the widget or fixture. + +**Font features do not propagate through kittest.** `egui_kittest`'s normal `[dependencies] +egui` uses `default-features = false`; only kittest's own `[dev-dependencies]` enable +`default_fonts` and `monochrome_emoji_fonts`, and a dependency's dev-dependencies do not +propagate into a consumer. Every kittest/FilmCraft test root that needs the bundled faces must +enable those egui features explicitly or install controlled fonts, and each actual graph must +be inspected. The production feature policy in §3.5 does not reach the harness by itself. + +Windows uses a uniquely owned clone and the VM run/status/finish workflow, with the clone’s +`Build-DesignCraft.ps1` equivalent for FilmCraft or the repository’s documented build helper. +The baseline VM is not adopted or reused across tasks. + +### 11.2 UI, lifecycle and workflow tests + +Existing anchors to extend or keep: + +- `crates/ui-egui/tests/scripted.rs` `Driver::demo`, `frames`, `call`, `ok`, `exec`; add + multi-pass and save/reopen cases. +- `crates/ui-egui/tests/ui_control_input.rs:153` screenshot/control isolation. +- `crates/ui-egui/tests/ui_dock_resize.rs`, `shared_docking_ui.rs`, `timeline_tabs_ui.rs`. +- `crates/ui-egui/tests/interface_zoom_ui.rs`, `settings_ui.rs`, `panels_ui.rs`, + `monitor_geometry.rs`, `timeline_trim_ui.rs`, `clipboard_paste_ui.rs`, `project_panel_ui.rs`. +- `crates/ui-egui/tests/perf_ui.rs` monitor lag. +- `crates/ui-egui/tests/robustness_ui.rs` hostile UI state. + +New/extended assertions: + +1. **Lifecycle:** visible single/multipass; logic called per pass but background effects are + idempotent; hidden `run_logic` ticks advance playback/autosave/workers/jobs without reading + input; forced hidden paint with a screenshot delivers an image; buffered input survives a + hidden tick and is handled once on the next visible pass; repeated hidden logic ticks keep + one synthetic gesture step pending and do not collapse press/release; a forced two-pass + click (including a first-show `Grid`) adds exactly one undo step; Escape drag cancels once. +2. **Root UI/geometry:** at 1600×980 and 1920×1080, `ui.elements` rects for + `panel.Timeline`, `panel.Program`, `panel.Project`, `tools.Razor`, dock split handles and + floating grips match the baseline within 1 point; panel bodies never overlap the + header/status bar; tab overflow still reaches every tab. +3. **Screenshot callback:** two queued `ui.screenshot` requests with different panel crops + return matching images/paths; a hidden screenshot delivers both when already issued and + when still settling (queued-but-not-issued), using `RequestPaintWhileHidden` in the latter + case; the weak callback wakes the live slot's waker after unlocking and never wakes itself; + a callback after timeout/close is ignored; the completion slot is dropped exactly once; any + resize/DPI/zoom change, including a change back, either retries within the deadline or + errors, never pairs old pixels with new geometry. Native VM test; headless test of the + correlation helper. +4. **Scroll header sync:** after horizontal scroll, media-browser/project-list headers align + with rows (≤1 point); after rubber-band overshoot, the header uses clamped offset. +5. **Accessibility:** for the demo workspace assert `Role::Tab`/selected on panel tabs, + `Role::Button`/selected on tool buttons, `Role::Button` "Resize panel" on the floating grip, + `Role::Splitter` on dock split dividers, and `Role::TextInput` with a non-empty accessible + name on caption/metadata/search fields. Use `Harness::get_by_label`, + `accesskit_node().role()`, `label()`, `toggled()`. All 56 `TextEdit` sites must be named; + add a negative control that removes a name and fails the strict kittest gate. +6. **Fonts:** with `CRAFT_FONTS_DIR` and without, assert Japanese/Chinese/Korean/Arabic/Hebrew + sample text produces no missing-glyph UV and preserves the expected family/weight; assert + `✔ ✓ ✖ ⚡ ⚠ ⚙ ♪ ☑` coverage; assert a non-zero TTC face index survives `FontData` + construction. Fixtures and licences are pinned in §6.1. +7. **RTL:** Arabic/Hebrew caption visual order, right alignment, bracket mirroring, digits and + a mixed LTR paragraph; editing caret/Home/End/Delete; compare glyph order, not just “text + present”. +8. **IME/clipboard:** outgoing surrounding text after commit/deletion, preedit exclusion, + grapheme/byte boundaries, password clearing, same-window switch, two windows, late serial, + owner generation present when unavailable, available-empty vs unavailable and a + pass-1-text/pass-2-`None` final pass; synthetic `PasteImage` maps to Paste once; native + PRIMARY (keyboard and pointer serials, bounded transfer) and text/file/image paste matrix + from §7.3. +9. **Save/reopen:** edit + effect + caption + tab layout, `file.saveAs`, `file.open`, equal + `sequence.inspect`/`project.inspect` and reopened tab order; undo/redo count exactly. +10. **Mutation/negative probes:** a deliberately broken test-only flag that repeats a queued + action must fail the one-undo-step assertion; a screenshot with a wrong token/slot must not + be delivered to the wrong request; a scroll offset beyond content must clamp; a font face + with the wrong index must produce a distinct glyph or missing glyph and fail the coverage + test; removing an accessible name must fail strict kittest. + +### 11.3 Visual comparisons and tolerances + +Do not add committed screenshots unless they are needed; if added, they are original +FilmCraft captures and need `.attribution` sidecars and `ATTRIBUTION.md` rows +(`cargo xtask assets`). Prefer writing review PNGs to the durable evidence directory. + +For before/after review: + +- workspace chrome is custom-painted: mask only volatile monitor/video regions; use + PSNR ≥ 45 dB, max channel difference ≤ 12, p99 ≤ 2 for unchanged regions, and exact layout + rects within 1 point. These thresholds are provisional until the baseline report records + the noise floor of an unchanged build; record the reference measurement that sets them. +- egui-drawn dialogs may move/resize because 0.37 changes button spacing and corner radius + (#8617, #8445): mask the dialog region or use p99 ≤ 8/max ≤ 32, and require human visual + review; do not auto-bless a changed golden. +- Arabic/Hebrew captions get both a pixel check and a glyph-order assertion; pixel-only + comparison would hide double reversal. +- A changed golden is never accepted merely to make CI pass; re-run and review. + +### 11.4 Native and browser workflows + +Native Linux (proposed, through the managed adapter; no raw `cargo run` outside task.py): + +```sh +python3 /home/codex/work/maintenance/task.py run \ + --job-id filmcraft-egui037-linux-ui-01 --kind build \ + --task filmcraft-egui037-migration \ + --source /home/codex/work/CraftApps/egui037-migration-20261022/filmcraft-implementation \ + --evidence /home/codex/work/ssd2/task-evidence/egui037-migration-20261022/filmcraft \ + -- cargo run --release -p filmcraft -- --control 9876 +python3 /home/codex/work/maintenance/task.py status filmcraft-egui037-linux-ui-01 --tail-bytes 8192 +# after the UI workflow, evidence capture and review: +python3 /home/codex/work/maintenance/task.py finish filmcraft-egui037-linux-ui-01 \ + --apply --owner-confirmed --checks-complete --evidence-preserved +python3 /home/codex/work/maintenance/task.py status filmcraft-egui037-linux-ui-01 --tail-bytes 8192 +``` + +Drive `ui.inspect`, `ui.screenshot`, `ui.resize`, dock actions, clipboard/PRIMARY, and review +every PNG. Repeat under X11 and Wayland (and Hyprland for E4/W15P). Native Windows through a +uniquely owned clone: build the task checkout, run `filmcraft-ui-egui` UI tests, launch with +`--control`, capture screenshots, exercise IME and DX12/software graphics modes. macOS: real +hardware for Cmd shortcuts, titlebar drag, Japanese/Korean IME and header drag; if no Mac is +available, record macOS as unresolved, not passed. + +Browser (proposed; `cargo xtask web --serve` is a long-running server, so all steps run in +one managed `task.py run` job with a trap that stops the server, then `status` and an attested +`finish` after evidence review): + +```sh +# payload inside the managed job, with exact --task/--source/--evidence and a fresh job ID: +cargo xtask web --serve 8765 & +SERVER=$! +trap 'kill "$SERVER" 2>/dev/null' EXIT +ffmpeg -f lavfi -i testsrc2=size=640x360:rate=30 -f lavfi -i sine -t 3 -c:v libx264 -pix_fmt yuv420p -c:a aac target/web/dist/web-test.mp4 +node apps/filmcraft-web/tests/smoke.mjs --url http://127.0.0.1:8765/ --media web-test.mp4 --out target/web/smoke +``` + +Run WebGPU and `?webgl`; inspect `report.json` and steps `01-demo` … `05-modes`; check +`filmcraft.info()` for backend/compositor. A wasm compile is not runtime evidence; headless +browser tests do not prove native focus/IME/file pickers. + +### 11.5 Environment provisioning gates + +No current runtime acceptance exists. The following are gates, not passes: + +| Environment | Current evidence | Required before claiming a pass | +|---|---|---| +| Linux X11 | No inherited `DISPLAY`; no Xvfb/window manager observed | Provision an isolated display/window manager, record versions, qualify native focus/clipboard/PRIMARY | +| Linux Wayland | No inherited `WAYLAND_DISPLAY`; no compositor/input-method observed | Provision Weston/Sway/Hyprland/KWin/Mutter and an IME, record capabilities; run text-input and PRIMARY matrix | +| Linux GPU | Parent inspection found i915 PCI `8086:3E96` and readable/writable `/dev/dri/renderD128`; worker sandbox may hide it; **do not claim the server has no GPU** | Managed renderer/device probe must identify the actual driver/backend; access alone is not GPU acceptance | +| Windows | Sealed baseline uses Microsoft Basic Render Driver; a full clone may not fit current capacity | Allocate a unique full clone or a baseline-backed overlay with an adequate reservation; run visible edited/saved/reopened workflows, IME, DX12/software | +| Browser | Wasm compile is not runtime evidence | Pin browser and adapter; run interactive WebGPU and WebGL2 fixtures; record raw/compressed wasm sizes and backend identity | +| macOS / physical Windows audio/video | No qualified environment in this task | Real hardware access and owned evidence; software VM results do not qualify these rows | + +These rows remain explicit rollout blockers where unavailable. Do not report a GPU absent, +a current runtime pass, or a platform gate as satisfied from source inspection alone. + +## 12. Performance: baseline, paired runs, thresholds + +### 12.1 Baseline before production edits + +Record before changing production code, on the exact baseline SHA and lockfile, as managed +`task.py run` jobs (fresh job ID, exact `--source`/`--evidence`, `status`, then attested +`finish --apply`). Submit each payload as +`task.py run --job-id --kind build --task filmcraft-egui037-migration --source + --evidence -- `; the commands below are payloads, not a +raw-command exception: + +- `cargo xtask bench` with `--sections timeline,playback,scrub`, `--label + filmcraft-egui036-baseline`; GPU and `--cpu` runs. The timeline harness currently defaults + to 120 frames per view, 8 warmups (the `--repeat` value is a **run count**, not a sample + count) and emits only p50/p95, so extend `crates/ui-egui/examples/bench/sections.rs` to + accept the frame/warmup counts and emit p99 with exact sample counts **before** using it as + acceptance evidence. Run at least 10 warmups, 30 timed iterations per steady configuration + and at least 1,000 actual captured frames per view for any p99; +- `cargo xtask bench-playback` for `demo`, `stack3`, `stack3-blend`, `h264-1080`, `h264-2160`, + `after-preview`, `seek-storm`, `--res full,half`, GPU and `--cpu`, with 10 warmups / 30 + timed iterations per configuration where the harness supports them; p99 requires at least + 1,000 frames; +- native idle: no playback, no input, 60 s; record repaint count, process CPU, RSS; +- startup: first present and `Renderer::new` timing on Windows DX12 and Linux Vulkan, from + 20 fresh processes; +- `perf.stats` before/after a fixed operation script (open demo, razor, undo, apply effect, + play 5 s, screenshot); +- wasm raw and compressed sizes for the exact feature set. + +Record: exact SHA, `Cargo.lock` hash, feature set, `CRAFT_FONTS_DIR` revision, fixture names +and hashes, OS/driver, GPU adapter/backend, DPI/scale factor, viewport size, profile (release), +thread count, theme, warmups and repetitions, raw samples, median/p95/p99, peak RSS, idle +repaint/CPU, operation latency, `gpu.uploadedBytes`, frame-cache entries, shapes/vertices, and +load average. For allocation counts use `heaptrack` or `valgrind --tool=massif` on the Linux +bench process; record peak/total allocations separately from product metrics. GPU uploads are +reported by `perf.stats` (`gpu.uploadedBytes`) and frame-cache counters. + +### 12.2 Paired candidate runs + +Run the same commands against the candidate on the same machine and environment, alternating +baseline/candidate where possible. Separate software-renderer (`--cpu`, llvmpipe/WARP) from +real-GPU results; do not mix rows. Never benchmark concurrently with builds or another noisy +workload and claim a small improvement. Preserve raw samples and the exact source/lock +identity for both sides. + +Where the operation is equivalent, include a **stock 0.37 control** alongside 0.36 and the +selected fork. Fork-only app code need not compile unchanged on stock; use the same operation +in a pinned minimal adapter/harness and explain remaining semantic differences. Record all +three source/lock identities; unavailable equivalence is an attribution limitation, not proof +that an upstream regression is unavoidable. + +### 12.3 Predefined thresholds and noise policy + +These are the shared suite minimums; FilmCraft may tighten them, never silently loosen them. +The stock-0.37 equivalent control and the first baseline report are required before any +regression claim; thresholds and goldens are never auto-widened or auto-blessed to make a run +pass. + +- **Sampling:** at least 10 warmups and 30 timed iterations for steady operations; at least 20 + fresh processes for startup; at least **1,000 actual captured frames** per configuration for + any frame-distribution p99. A p99 from 20/30/50 samples is not a tail estimate. Report the + exact frame count and sample count in the report schema. +- **Timeline UI** (headless kittest, 1000 clips/20 tracks, 1920×1080): median update/tessellation + ≤ +5%, p95 ≤ +10%, p99 reported and reviewed. Vertices/shapes must not grow without + explanation. +- **Playback CPU** (`bench-playback` CPU ms/frame, worker/source columns): ≤ +3% median; + shown/dropped may vary with load and is not a gate alone. +- **Peak RSS:** FilmCraft gate is ≤ +5% for the same scenario after subtracting the measured + noise floor; investigate any repeatable increase above the floor. Report absolute MB. +- **Idle:** no extra repaint requests and no extra CPU when nothing is playing; a hidden window + keeps auto-save/playback semantics without busy-looping. +- **Startup:** report first-present and `Renderer::new`; on Windows DX12 a +20 ms median or + more is a regression to investigate against #8736; do not silently accept it. +- **Wasm:** record raw and compressed sizes; a size regression is reviewed, not hidden by a + compile-only check. + +If a threshold is exceeded, re-run alternating; if still exceeded, profile (`perf`, `tracy`, +`heaptrack`/massif) and report. Never auto-accept a changed golden or call compile time UI +performance. + +### 12.4 Separate workload rows + +Do not merge these into one number: + +- **#8731 text tessellation:** plain-text timeline/fixture workload (1000 clips/20 tracks, + 1920×1080), compare unchanged 0.36, stock 0.37 and the fork; measure tessellation time and + text-heavy frame time separately. If confirmed, evaluate a row-level fast path and cached + color-glyph metadata; test mixed/color rows. Do not bless a regression. +- **#8736 DX12 FXC shader compile:** Windows VM, measure `Renderer::new`/pipeline creation and + first present separately from steady frames, with predictable filtering disabled. Compare an + override-constant design with semantically equivalent wrap arithmetic only if needed; + clamp-only is not a valid Repeat/MirroredRepeat fix. +- **#8512 system-font startup:** measure discovery/startup separately. The migration keeps + `system_fonts` off, so this is a controlled opt-in experiment, not a default change; preserve + glyph coverage and record provider inventory. +- **Wasm/browser:** raw and compressed wasm size plus WebGPU/WebGL2 runtime frame timings from + the pinned browser environment; compile time is not UI performance. + +## 13. Implementation PR stages and dependency DAG + +No production stock-0.37 stage exists. The first production stage is the coherent fork graph. + +| Stage | Contents | Depends on | Rollback | +|---|---|---|---| +| P0 | This plan only; no code | — | delete document | +| **P1** | **Atomic compatibility landing on the final fork graph.** Manifests/lockfile: final approved `storytold/egui` P8 + `storytold/winit` pins, final `storytold/craft-libs` revision (CL-1 + CL-2F), explicit `default_fonts`, `monochrome_emoji_fonts`, native `clipboard`, no `system_fonts`/`links`. API migration: `FontData`, `Role`/names, `ScrollArea`, `MouseWheelSource`, `run_ui` signature, `Id::unique`, screenshot callback + CL-2 helper, `PasteImage`, E21b custom IME adapter, bounded E4/W15P clipboard/PRIMARY routing, lifecycle split/multi-pass queue, strict kittest names/glyphs, and **removal of the manual `rtl::Lines` reversal and its unit tests**. Tests from §11. | P0; **G-PINS**, **G-CL2F**, published/reachable pins; capacity for gates | revert to the coherent 0.36.2 graph (`Cargo.toml`/`Cargo.lock` and source); keep old craft-libs `5c874d51` and `craft-docpath 82a24b0` | +| P2 | Language and the full native input matrix: Latin-layout shortcuts, `ctx.set_language`/`set_ui_language`, and the full W15P/E4 PRIMARY and E21b native rows against the P1 integration. Fork bidi is already active in P1; P2 does not introduce a second bidi state. | P1 (fork already pinned) | revert P2; restore prior language/input behavior without changing the P1 dependency graph or re-introducing manual reversal | +| P3 | Accessibility semantics beyond first-green naming: radio-group/tool semantics, richer role/value/label assertions, AccessKit language screenshots. | P1 | revert P3 | +| P4 | Evidence hardening: visual/geometry/screenshot regression harness, mutation probes, performance baseline/candidate and stock control, wasm sizes, environment provisioning. | P1–P3 | revert P4; keep earlier behavior | +| P5 | Documentation/rollout notes and optional measured experiments (`system_fonts`, color emoji, macOS). | P4 | revert P5 | + +Publication is not part of P0. Each future push/new PR requires fresh direct human approval +after the parent shows the exact contents and validation. Do not include agent/model/tool +signatures in PR text or commits. + +### Rollback set + +Keep the old coherent set recoverable: + +- `egui/eframe/egui_kittest = 0.36.2`; +- `craft-ui` rev `5c874d51eeb3de70e735c8ce87a59a7dedd47675`; +- `craft-docpath` rev `82a24b006eefa036b43c09a67fb9a3fbd17ffe63`; +- registry `winit 0.30.13`, `wgpu 30.0.1`. + +A rollback is `git revert` of the stage commits plus restoring the baseline +`Cargo.toml`/`Cargo.lock` (or `git checkout abf44fda -- Cargo.toml Cargo.lock` after reverting +source changes). Do not keep a machine-local path patch as a fallback. A rollback rehearsal +uses the preserved baseline executable/source and a separate managed allocation. + +## 14. CI, compatibility and publication checks + +- `.github/workflows/ci.yml` runs `cargo xtask ci` with pinned `CRAFT_FONTS_REF`; add the pin + checks from §3.4 and a `cargo metadata --locked` step. +- Keep `freebsd.yml`’s skip-tests policy and `windows-arm64.yml` as-is; do not weaken them. +- `cargo xtask wasm` must still check `filmcraft-ui-egui` and `filmcraft-web`; `egui_kittest` + is a dev dependency and remains native-only. +- The web crate’s `wasm-bindgen` exact pin (`=0.2.129`) must match the installed CLI. The + candidate’s own lock uses `wasm-bindgen 0.2.126` and `web-sys 0.3.103`; FilmCraft currently + pins `web-sys 0.3.106` and `wasm-bindgen 0.2.129`. The `0.2`/`0.3` ranges are compatible, + but the final FilmCraft lock is authoritative: record the resolved versions and run + `wasm-bindgen --version` before `cargo xtask web`. +- `rfd 0.17`, `arboard 3.6.1`, `wgpu 30.0.1`, `accesskit 0.25`/`0.24` transitive and `winit + 0.30.13` compatibility were checked from manifests; a fresh `cargo metadata --locked` and a + clean lockfile resolution are still required. +- The candidate’s own lock resolves `wgpu 30.0.0`; FilmCraft pins 30.0.1. Record the actual + resolved candidate/FilmCraft version in the evidence and ensure one source; do not claim the + candidate lock is FilmCraft’s. +- New 0.37 dependencies include `wl-clipboard-rs`, `tree_magic_mini`, `petgraph`, `nom`, + `send_wrapper`, `unicode-properties`, `vello_cpu 0.2`, `glifo 0.3`, `kittest 0.5` and updated + `accesskit*`/`phf`. Review their licences (all expected MIT/Apache-2.0/BSD/ISC); no GPL/LGPL + may enter. +- Do not commit local screenshots as assets without sidecars/index rows. Keep review PNGs + under the task evidence directory. +- No agent/model/tool attribution in PR titles, descriptions, commits or comments. Contributor + credit stays username-only (`@skylartaylor`); do not add a name entry. + +## 15. Blocking unknowns and acceptance criteria + +These are gates, not future nice-to-haves. A failed or unavailable gate is an explicit blocker, +not a passing result. + +1. **G-PINS — production fork pins.** The reviewed, published, immutable `storytold/egui` + commit (E1-E21 + E21b/P7b + selected #8763/#8748 fixes) and `storytold/winit` commit + (W15P + W27 + required W1-W27 behavior) are not known at planning time. Do not invent a SHA, + do not use an aiongg branch as a production pin, and do not land a stock-0.37 intermediate. + The exact SHAs, feature sets and provenance must be recorded before P1. +2. **G-CL2F — shared capture helper.** `craft-libs` must publish the CL-2 helper, DesignCraft + and GridCraft adapters must be reviewed against it, and CL-2F must freeze the API before + FilmCraft pins the revision. A branch head or path dependency is not acceptable. +3. **G-CRAFT-LIBS — shared baseline and port.** The final published `craft-libs` revision must + build on the merged PR #199 base `3832c47ef68a0e6214f2c1e85f3526b6adcb686e` with the + protected-group docking fix and lazy printing preserved; the PR-199 hunks + (`8614e4a`/README `1764bca`) are historical provenance. `5c874d51` is the old FilmCraft + manifest pin; `4919088` is historical GitHub main. `craft-docpath` stays at `82a24b0` + unless a concrete cross-type reason appears. +4. **G-IME — native IME and E21b.** E21b/P7b must exist in the pinned fork with the complete + shared contract: owner generation present even when `Unavailable`, available-empty distinct + and explicitly sent, committed W27 disable/enable transitions with cache clear, + authoritative completed-pass `None`, current-epoch serial-mismatch edits applied once with + outgoing state deferred, and a 4000-byte payload extracted without a full-document copy. + The FilmCraft graphics adapter must supply logical model/preedit/UTF-8 bytes, and Windows + Japanese/Korean, X11 Vietnamese, Wayland text-input-v3 and macOS rows must run on real + environments. No headless test is a substitute. macOS without hardware is explicitly + unresolved. +5. **G-PRIMARY — W15P + E4.** The winit-owned PRIMARY path must be integrated, with native + per-seat keyboard- **and pointer-triggered** selection using a fresh triggering serial, + owner/seat changes, empty/absent offers, password exclusion, unchanged-owner non-reclaim, + self-paste, and slow/trickling/oversized/canceled transfers under wlroots, KWin and Mutter. + The transfer path is bounded (2 s total deadline, 16 MiB, 4 read workers, 2 writers with + queue depth 8), claims ownership synchronously on the fresh input turn, serves + asynchronously, and discards stale/canceled completions. The arboard/smithay path is a + measured fallback only. +6. **G-SEL — P7c #8763/#8748.** Reproduce selection loss with a caption/metadata field and a + custom toolbar action; P7c owns the selected, tested #8763 and #8748 fixes. #8764 is an + unmerged proposal, not a release fix. A local workaround needs a reason and removal + condition. +7. **G-PERF — performance and stock control.** Baseline and candidate runs must meet §12 + sampling and thresholds, with separate #8731 text, #8736 DX12, #8512 font and wasm rows, and + a stock-0.37 control where equivalent. Compile duration is not UI performance. +8. **G-ENV — real environments.** Linux X11/Wayland compositors and IME, a browser with a + pinned adapter, a unique Windows clone, and macOS hardware must be provisioned. The i915 + device’s presence is availability evidence only; the worker sandbox may hide it. No current + runtime pass is claimed. +9. **G-ACCESS — accessibility names and glyphs.** All 56 `TextEdit` sites and other input + widgets have names; strict kittest naming/glyph behavior is green without a global disable; + monochrome emoji coverage is asserted. +10. **G-LIFECYCLE — multi-pass audit.** The input-action design, Escape latch, monotonic + deadlines, one-step synthetic gesture retention across hidden logic ticks, idempotent + toast/settle counters and grep allow-list are implemented and tested; until then the + one-action and hidden-work guarantees are not met. +11. **G-SCREENSHOT — capture matrix.** The callback is delivered for a visible capture and + for a hidden capture, including queued-but-not-yet-issued settling; timeout/close/ + late-callback behavior is correct. The CL-2 slot holds the strong waker, the callback is + weak and does not wake itself, and admission bounds four outstanding requests, 128 MiB per + image and 256 MiB aggregate with checked `width * height * 4` before issue and after any + resize/DPI/zoom epoch change (including a change back). +12. **G-RTL — bidi.** P1 must land the fork's E5/E12 together with the simplified + `rtl::Lines`, and validate visual order/alignment/editing. Do not ship both the manual + reorder and fork bidi, and do not leave the removal to P2. + +## 16. Limitations of this planning pass + +- No build, test, benchmark, screenshot or app launch was run. All behavior statements are + source review and upstream documentation, not fresh validation. No command in §11 was + executed. +- The historical compiler diagnostics are from an earlier checkout and only identify likely + sites; current line numbers and new errors must be discovered by implementation gates. +- The production fork pins and the final `craft-libs` revision are unknown until the fork and + shared helper are published; no SHA is invented. +- The i915 device was observed on the host but no display session is inherited and the worker + sandbox may hide it. Windows baseline is software-rendered. macOS, browser runtime and real + Wayland compositor behavior remain unproven until the environment gates run. +- The exact stock-0.37 control harness may need small adapters for fork-only operations; such + differences are recorded as attribution limitations, not ignored. + +## 17. Review finding dispositions + +This table is an index; the body sections above are authoritative. + +| Finding | Disposition | Where resolved | +|---|---|---| +| F1 deferred-action clearing loses clicks | **Accepted.** No clear on pass 1+; input actions dispatch once in the pass with events; deferred actions persist and drain on the first non-discarded pass; duplicates coalesce; no silent drop. | §4.4, §9, §11.2 | +| F2 Escape rollback cannot fire on a multi-pass frame | **Accepted.** Latch Escape and the undo mark in pass 0; apply once after the final pass. | §2.3, §4.4, §9 | +| F3 hidden window never presents | **Accepted.** The screenshot command already requests paint while hidden; `RequestPaintWhileHidden` also covers queued-but-not-issued settling and other composition stages; timeout is not the normal hidden path; no visibility-based phase inference. | §4.2, §4.5, §11.2, §11.5 | +| F4 OS clipboard feature lost | **Accepted.** Add native `clipboard`; cross-application paste tests on X11/Wayland/Windows; web checked separately. | §3.5, §7.3, §11.1 | +| F5 stock 0.37 landable stage | **Accepted.** Stock is a local/CI comparison only; one coherent fork landing; final pins gated. | §3.2, §13 | +| F6 `default_fonts` not monochrome emoji | **Accepted.** Explicit `monochrome_emoji_fonts`, exact glyph set, screenshot diff. | §3.5, §6.1, §11.2 | +| F7 E21b/P7b IME gap | **Accepted.** Full owner/support/serial contract, 4000-byte no-full-copy payload, TextEdit producer, Film graphics adapter, tests; native rows required. | §7.2, §10.1, §11.2, §15 G-IME | +| F8 PRIMARY downgraded | **Accepted.** W15P + E4 required; keyboard/pointer serials, bounded transfer, native per-seat/password/unchanged-owner/slow-cancel matrix; arboard fallback measured only. | §7.3, §10.1-10.2, §15 G-PRIMARY | +| F9 effects catalogue incomplete | **Accepted.** Enumerates `trim.tick`, native menus, metadata/auto/sync sites, all monotonic deadline calls; trim idempotent; grep guard. | §2.3, §4.1, §4.6 | +| F10 performance protocol weak | **Accepted.** 10 warmups, 30 steady, 20 startup, ≥1,000 actual frames for p99; shared 5%/10%; stock control; separate #8731/#8736/#8512 rows; wasm sizes. | §12 | +| F11 unmanaged commands | **Accepted.** All validation uses `task.py run/status/finish` with task/source/evidence; proposals only. | §11.1, §11.4 | +| F12 EffectCraft reverse dependency absent | **Accepted.** Exact `2c5ba7a` pin, UI-free proof, separate branch retained, re-audit triggers. | §1.1 | +| F13 4919088 unpublished / PR 199 premise | **Premise rejected.** GitHub main verified `4919088`; stale `origin/main` `b1401e3` does not prove otherwise. Old `5c874d51` fetched; seven-file +607/−2 diff audited; printing and protected-group docking preserved; `craft-docpath` stays separate; PR 199 only hunks `8614e4a`/README `1764bca`. The actionable audit requirements are accepted. | §3.1, §10.3, §15 | +| F14 accessibility role contradiction | **Accepted.** Floating grip = `Button` "Resize panel"; dock split divider = `Splitter`; test updated once. | §5.3, §11.2 | +| F15 minor accuracy/robustness | **Accepted.** Catch-unwind latch; no `Window::show` in logic; wgpu/candidate versions stated; thresholds require a noise reference; strict kittest names are an ordering constraint. | §4.1, §4.2, §11.1, §11.3, §12.1, §14 | + +### 17.1 Fresh v2 review findings + +| Finding | Disposition | Where resolved | +|---|---|---| +| v2-1 P1 lands manual bidi reversal with fork bidi | **Accepted.** P1 now pins the fork, removes `rtl::Lines` reversal and its tests, and validates visual order; P2 no longer ships a second bidi state. | §6.3, §13, §15 G-RTL | +| v2-2 E21b contract incomplete | **Accepted.** Full owner/context identity, available-empty vs unavailable, committed W27 transitions, authoritative `None`, serial-mismatch-once/deferred-outgoing, no full-document copy. | §7.2, §10.1, §15 G-IME | +| v2-3 keyboard-only PRIMARY and no bounded transfer | **Accepted.** Keyboard and pointer triggering serials; 2 s/16 MiB/4 readers/2 writers/queue 8; synchronous claim, async serving, owner-specific waker, stale discard. | §7.3, §15 G-PRIMARY | +| v2-4 no screenshot wakeup/collection path | **Accepted.** Strong waker in the live slot, weak callback, unlock before wake, callback has no repaint path of its own; queued hidden requests use `RequestPaintWhileHidden`. | §3.3, §4.5, §15 G-SCREENSHOT | +| v2-5 performance commands contradict sampling rule | **Accepted.** Harness must emit p99 and exact counts, 1,000+ frames/10 warmups/30 iterations; `--repeat` is not a sample count. | §12.1, §12.3 | +| v2-6 validation mixes managed and raw commands | **Accepted.** Bench and browser payloads run inside `task.py run/status/finish`; the Linux UI job has an attested finish and the long-running server has a stop step. | §11.1, §11.4, §12.1 | +| v2-7 effect catalogue misses Toast and settle counters | **Accepted.** Toast uses monotonic time; settle counters are per rendered frame/pass identity; synthetic hook retains one step across hidden logic ticks. | §2.3, §4.1, §4.5, §15 G-LIFECYCLE | +| v2-8 consumer count and P7c traceability | **Accepted.** Fourteen capture consumers (eleven craft-ui + three local adapters); P7c explicitly owns #8763/#8748. | §3.3, §8, §10.1, §15 G-SEL | + +## 18. Primary sources reviewed + +- Upstream `egui 0.37.0` changelog and release commit `841a328b`; source inspected through + `git -C /home/codex/work/CraftApps/egui037-migration-20261022/egui show 0.37.0:…`. +- Upstream API changes: emilk/egui#8489 (screenshot callback), #8555 (`WidgetType` -> `Role`), + #8583 (`default_fonts` off), #8534 (Id deprecations), #8535 (`root_ui`), #8428 (PRIMARY), + #8512 (system fonts), #8631/#8748 (logic-only/overdue windows), #8577 (bidi), #8731 (text + tessellation), #8736 (DX12 shader compile), #8763 (TextEdit click-away) and proposed #8764 + (`6eb460a2a6d537d312a80579cb801895509deaed`, open/unmerged). +- Lifecycle source audit: `/home/codex/work/ssd2/task-evidence/egui037-migration-20261022/lifecycle-source-audit.md`. +- IME gap: `/home/codex/work/ssd2/task-evidence/egui037-migration-20261022/ime-surrounding-gap.md`, + `.../winit-review-triage.md` for the W15P/W27/E21b serial and owner/support corrections, and + egui plan §10.6 for pinned font fixtures. +- Shared baseline audit: `/home/codex/work/ssd2/task-evidence/egui037-migration-20261022/filmcraft-shared-baseline-audit.md`. +- Suite rollout: `craft-libs/docs/egui-037-suite-rollout.md` and + `craft-libs/docs/egui-037-migration-plan.md` (CL-2/CL-2F, §7.8, §10.6). +- Fork provenance: `aiongg/egui` `craft/0.37` `PATCHES.md` (E1-E21) and `aiongg/winit` + `craft/0.30.13-lang` `PATCHES.md` (W1-W27), inspected read-only in + `/home/codex/work/CraftApps/egui037-migration-20261022/{egui,winit}`. +- Shared migration: `storytold/craft-libs#199` (port hunks `8614e4a`, README `1764bca`; + stale heads `b68de8c`/`ec8840a` are not mergeable trees), current main `4919088`, old + FilmCraft pin `5c874d51`, language work `storytold/craft-libs#198`. +- Local evidence: `/home/codex/work/ssd2/task-evidence/craft-fork-plan-20261020/PLAN.md`, + `/home/codex/work/ssd2/task-evidence/craft-fork-ai-lines-20261022/SURVEY.md`, + `/home/codex/work/ssd2/task-evidence/craft-fork-egui037-20261021/RESULTS.md`, + `/home/codex/work/ssd2/task-evidence/egui037-migration-20261022/upstream-discussions.json`, + `upstream-8764-fresh.json`, `craft-ui-consumers-exact.json`. +- Repository rules and commands: `AGENTS.md`, `CLAUDE.md`, `docs/architecture.md`, + `docs/testing.md`, `docs/web.md`, `docs/agents.md`, `.github/workflows/ci.yml`, + `xtask/src/main.rs`. + + +## Local implementation update — 2026-10-23 + +The user clarified that reviewed plans remain local and implementation proceeds before publication. The earlier requirement to publish/land this plan first is superseded; pushes and PR publication still require explicit approval. + +The owned checkout's current branch HEAD is `fa550ce2d07bdfee6b1d7b6835e8abc8c6913d00`; the migration work is uncommitted on that base. The 2026-10-23 note that the checkout was fast-forwarded to `c5c16939896277ab332280a6d3f1d7283d0d0c4e` and earlier baseline SHAs above are retained as historical review evidence. Use the current HEAD and its exact manifest/lock hashes (table in §1) for local integration and paired measurements; remote main is not asserted here. The original refs remain reachable. + +Preserve isolated MCP screenshot temporary directories and caller-path desktop captures; rejected synthetic gestures enqueue nothing (click 0–10, drag 2–1024). Retain the new typography/contrast/clip regressions and random benchmark output directories. + +The complete static delta review and exact old/new object identities are retained in the task evidence: `canonical-diffs-refresh-20261023.json` and `overnight-consumer-review-20261023.md`. This amendment claims no new build, native GUI or performance acceptance. + + +## Final source review repair — 2026-10-24 + +The reviewed cumulative source identity and two findings are preserved in the external +`final-landing/filmcraft-final-review.md/json` evidence. Same-owner application caret/selection +moves (pointer, keyboard, Select All and explicit gfxEdit offsets) and another field taking +focus now invalidate only a known active/terminal-pending graphics composition before its late +replacement can mutate the moved selection. Stable owner/generation identity is preserved; +commit-only input without an old composition and uninterrupted replacement chains stay valid. +A private one-shot interruption asks the platform to cancel the old composition and survives +discarded passes until a final observation. No public IME protocol was added. + +Automation lookup now uses the current registry whenever it is populated, so a removed control +cannot resolve to its previous-pass coordinates. The pre-draw previous-registry fallback stays. +New regressions cover removed IDs, real pointer press/release plus next-batch char/byte deletion +and commit, terminal-pending composition, same-owner control/keyboard moves, direct-commit and +valid-chain positive controls. These checks and fresh independent review are pending managed +validation; the earlier18-test result predates this repair. Canonical manifests/lock are unchanged. diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 47f743d4f..bd5d67e6b 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -120,7 +120,6 @@ if has appimage; then esac APPDIR="$WORK/FilmCraft.AppDir" cp -R "$STAGE" "$APPDIR" - mv "$APPDIR/usr/share/doc" "$WORK/doc-unused" ln -s usr/bin/filmcraft "$APPDIR/AppRun" cp "$HERE/$APP_ID.desktop" "$APPDIR/$APP_ID.desktop" cp "$ROOT/assets/app-icon/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png" diff --git a/packaging/windows/filmcraft.wxs b/packaging/windows/filmcraft.wxs index 91eb71de6..44114a4b3 100644 --- a/packaging/windows/filmcraft.wxs +++ b/packaging/windows/filmcraft.wxs @@ -7,6 +7,9 @@ Per-machine install into Program Files and a Start Menu shortcut. No file associations. --> + + + + + + + + + + + diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index faa4b8638..786fea80e 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -90,11 +90,25 @@ Copy-Item (Join-Path $Bin 'filmcraft.exe'), (Join-Path $Bin 'filmcraft-cli.exe') & (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage 'filmcraft.exe') (Join-Path $Stage 'filmcraft-cli.exe') +# ---- font notices for the MSI ------------------------------------------------------------------ +# The portable ZIP already carries these notices; stage the complete files before MSI creation too. +$Docs = Join-Path $Stage 'font-licences' +New-Item -ItemType Directory -Force -Path $Docs | Out-Null +$HasCraftFontLicences = 0 +if ($env:CRAFT_FONTS_DIR) { + foreach ($ofl in Get-ChildItem -Path (Join-Path $env:CRAFT_FONTS_DIR 'fonts\*\OFL.txt') -File -ErrorAction SilentlyContinue) { + Copy-Item -LiteralPath $ofl.FullName -Destination (Join-Path $Docs "OFL-$($ofl.Directory.Name).txt") + $HasCraftFontLicences = 1 + } +} +# ---- end font notices for the MSI -------------------------------------------------------------- + # ---- MSI --------------------------------------------------------------------------------------- $Msi = Join-Path $Dist "filmcraft-$Version-windows-$Arch.msi" Invoke-Native 'wix build' { wix build (Join-Path $PSScriptRoot 'filmcraft.wxs') -arch $Arch ` -d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\filmcraft.ico')" ` + -d "DocsDir=$Docs" -d "HasCraftFontLicences=$HasCraftFontLicences" ` -o $Msi } # wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets. From 365557844457319e6c9a70e27666e5874b51cf5a Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 20:56:09 +0000 Subject: [PATCH 042/113] docking: craft-libs' one docking model; Tools and Audio Meters are ordinary panels Pin craft-libs integration-dock (9e34c06). FilmCraft's dock style sets the split-tree settings: drops on all four window edges, Ctrl/Cmd-drag undocks, a release over no target cancels, and a floating window's title bar only moves it. Tools and the Audio Meters have tab strips, no maximum width and no grip, and the built-in workspaces use ratio splits. Floating windows hold trees; their dividers and the four-way handles get automation ids. (cherry picked from commit fc1e919004420cc3fd6e0dd0a2f73a2ae8cc6685) --- crates/ui-egui/src/dock.rs | 73 +++++++++++---------- crates/ui-egui/src/dock_ui.rs | 80 +++++++++++------------ crates/ui-egui/src/panels/keyboard.rs | 2 +- crates/ui-egui/tests/meters_ui.rs | 3 +- crates/ui-egui/tests/shared_docking_ui.rs | 32 +++++++-- crates/ui-egui/tests/ui_control_input.rs | 7 +- crates/ui-egui/tests/ui_dock_resize.rs | 71 +++++++++++--------- 7 files changed, 148 insertions(+), 120 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index e1a30cb84..034fe602f 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -1,5 +1,5 @@ -//! FilmCraft's panels and workspaces. craft-ui's docking (Frame profile) owns the frame tree, -//! the tab strips, floating windows and gestures (`dock_ui.rs` draws it); this module names the +//! FilmCraft's panels and workspaces. craft-ui's docking owns the frame tree, the tab strips, +//! floating windows and gestures (`dock_ui.rs` draws it); this module names the //! panels, builds the built-in workspaces and draws the Timeline's sequence tabs. use egui::{Align2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; @@ -11,11 +11,6 @@ use craft_ui::layout::{SplitAxis, SplitSize}; use crate::icons::{self, Icon}; use crate::theme::Tokens; -/// Width of the Tools panel in the factory layouts. -pub const TOOLS_WIDTH: f32 = 64.0; -/// Width of the Audio Meters in the factory layouts. -pub const METERS_WIDTH: f32 = 116.0; - #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum PanelKind { Project, @@ -133,18 +128,6 @@ impl PanelKind { _ => None, } } - /// Narrow chrome-less panels (no tab strip; a slim grip instead). - pub fn compact(self) -> bool { - matches!(self, PanelKind::Tools | PanelKind::AudioMeters) - } - /// The widest the panel may be: Tools and the Audio Meters stay at their factory widths. - pub fn max_width(self) -> Option { - match self { - PanelKind::Tools => Some(TOOLS_WIDTH), - PanelKind::AudioMeters => Some(METERS_WIDTH), - _ => None, - } - } } /// A FilmCraft panel arrangement: craft-ui's frame tree and floating windows. @@ -381,8 +364,10 @@ pub fn workspace(name: &str) -> Layout { fn workspace_root(name: &str) -> Node { use PanelKind::*; - use SplitSize::{FixedFirst as FixedA, FixedSecond as FixedB, Ratio}; - let bottom_editing = || hsplit(FixedA(TOOLS_WIDTH), tabs(&[Tools], 0), hsplit(FixedB(METERS_WIDTH), tabs(&[Timeline], 0), tabs(&[AudioMeters], 0))); + use SplitSize::Ratio; + // Tools | Timeline | Audio Meters. The ratios start Tools about 64 pt and the meters about + // 116 pt wide in a 1600 pt window; all three resize like any other group. + let bottom_editing = |tools: f32, timeline: f32| hsplit(Ratio(tools), tabs(&[Tools], 0), hsplit(Ratio(timeline), tabs(&[Timeline], 0), tabs(&[AudioMeters], 0))); match name { "Assembly" => vsplit( Ratio(0.52), @@ -391,41 +376,41 @@ fn workspace_root(name: &str) -> Node { tabs(&[Project, MediaBrowser, Libraries, Info, Effects, Markers, History], 0), hsplit(Ratio(0.5), tabs(&[Source, EffectControls, AudioClipMixer, Metadata], 0), tabs(&[Program], 0)), ), - bottom_editing(), + bottom_editing(0.04, 0.92), ), "Color" => hsplit( - FixedB(340.0), + Ratio(0.79), vsplit( Ratio(0.58), hsplit(Ratio(0.42), tabs(&[LumetriScopes, Source, EffectControls], 0), tabs(&[Program], 0)), - hsplit(Ratio(0.3), tabs(&[Project, Info, Markers, History], 0), bottom_editing()), + hsplit(Ratio(0.3), tabs(&[Project, Info, Markers, History], 0), bottom_editing(0.07, 0.86)), ), tabs(&[LumetriColor, Effects], 0), ), "Effects" => hsplit( - FixedB(320.0), + Ratio(0.8), vsplit( Ratio(0.5), hsplit(Ratio(0.5), tabs(&[Source, EffectControls, AudioClipMixer], 1), tabs(&[Program], 0)), - hsplit(Ratio(0.3), tabs(&[Project, MediaBrowser, Info, Markers, History], 0), bottom_editing()), + hsplit(Ratio(0.3), tabs(&[Project, MediaBrowser, Info, Markers, History], 0), bottom_editing(0.07, 0.86)), ), vsplit(Ratio(0.5), tabs(&[Effects], 0), tabs(&[EssentialGraphics], 0)), ), "Audio" => hsplit( - FixedB(320.0), + Ratio(0.8), vsplit( Ratio(0.5), hsplit(Ratio(0.5), tabs(&[AudioTrackMixer, Source, AudioClipMixer, EffectControls], 0), tabs(&[Program], 0)), - hsplit(Ratio(0.3), tabs(&[Project, MediaBrowser, Effects, Markers, History], 0), bottom_editing()), + hsplit(Ratio(0.3), tabs(&[Project, MediaBrowser, Effects, Markers, History], 0), bottom_editing(0.07, 0.86)), ), tabs(&[EssentialSound, TextToSpeech], 0), ), "Captions and Graphics" => hsplit( - FixedB(330.0), + Ratio(0.79), vsplit( Ratio(0.5), hsplit(Ratio(0.4), tabs(&[Text, Source, EffectControls], 0), tabs(&[Program], 0)), - hsplit(Ratio(0.3), tabs(&[Project, MediaBrowser, Libraries], 0), bottom_editing()), + hsplit(Ratio(0.3), tabs(&[Project, MediaBrowser, Libraries], 0), bottom_editing(0.07, 0.86)), ), tabs(&[EssentialGraphics, Properties], 0), ), @@ -436,21 +421,21 @@ fn workspace_root(name: &str) -> Node { tabs(&[Source, EffectControls, AudioClipMixer, Metadata, LumetriScopes, Text], 0), hsplit(Ratio(0.6), tabs(&[Program], 0), tabs(&[LumetriColor, EssentialGraphics, EssentialSound, TextToSpeech, Properties, AudioTrackMixer], 0)), ), - hsplit(Ratio(0.26), tabs(&[Project, MediaBrowser, Libraries, Info, Effects, Markers, History], 0), bottom_editing()), + hsplit(Ratio(0.26), tabs(&[Project, MediaBrowser, Libraries, Info, Effects, Markers, History], 0), bottom_editing(0.055, 0.9)), ), // Editing (default), Learning, Review — Premiere 26 factory layout: // top: Source group | Program | Properties; bottom: Project group | Tools | Timeline | Meters. _ => vsplit( Ratio(0.57), hsplit( - FixedB(450.0), + Ratio(0.72), hsplit(Ratio(0.5), tabs(&[Source, EffectControls, AudioClipMixer, Metadata], 0), tabs(&[Program], 0)), tabs(&[Properties, EssentialGraphics, Text], 0), ), hsplit( Ratio(0.29), tabs(&[Project, MediaBrowser, Libraries, Info, Effects, Markers, History], 0), - hsplit(FixedA(TOOLS_WIDTH), tabs(&[Tools], 0), hsplit(FixedB(METERS_WIDTH), tabs(&[Timeline], 0), tabs(&[AudioMeters], 0))), + bottom_editing(0.06, 0.89), ), ), } @@ -495,7 +480,9 @@ impl LayoutExt for Layout { if let Some(root) = &self.root { walk(root, &mut out); } - out.extend(self.floating.iter().flat_map(|window| window.groups.iter().filter_map(|group| group.panels.get(group.active).copied()))); + for window in &self.floating { + walk(&window.root, &mut out); + } out } @@ -507,7 +494,7 @@ impl LayoutExt for Layout { match n { Node::Split { first, second, .. } => beside_meters(first) || beside_meters(second), Node::Tabs { panels, .. } if panels.as_slice() == [PanelKind::AudioMeters] => { - *n = hsplit(SplitSize::FixedSecond(METERS_WIDTH), tabs(&[PanelKind::Timeline], 0), tabs(&[PanelKind::AudioMeters], 0)); + *n = hsplit(SplitSize::Ratio(0.85), tabs(&[PanelKind::Timeline], 0), tabs(&[PanelKind::AudioMeters], 0)); true } _ => false, @@ -763,6 +750,22 @@ mod tests { } } + fn fixed_splits(node: &Node) -> usize { + match node { + Node::Split { size, first, second, .. } => usize::from(!matches!(size, SplitSize::Ratio(_))) + fixed_splits(first) + fixed_splits(second), + _ => 0, + } + } + + /// Every group of a built-in workspace resizes with the window: no split keeps a fixed size. + #[test] + fn workspaces_use_ratio_splits() { + for w in WORKSPACES { + let layout = workspace(w); + assert_eq!(layout.root.as_ref().map_or(0, fixed_splits), 0, "{w}"); + } + } + /// #284: with Project and Program closed (or every panel closed) a panel still opens. #[test] fn open_near_falls_back_to_the_first_group() { diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index a9cdaff1b..d397499f2 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -1,12 +1,10 @@ -//! FilmCraft's panels in craft-ui's shared docking, Frame profile. craft-ui draws the frames, tab -//! strips, floating windows and drag previews and owns the gestures; FilmCraft draws the panel +//! FilmCraft's panels in craft-ui's shared docking. craft-ui draws the frames, tab strips, +//! floating windows and drag previews and owns the gestures; FilmCraft draws the panel //! bodies, the ≡ panel menu, the panel items of each tab's context menu and the Timeline's //! sequence tabs, and maps its control commands onto //! craft-ui's `DockCommand`. -use craft_ui::docking::{ - Action, Divider, DockArea, DockCommand, DockContent, DockEvent, DockStyle, FloatSpace, Hit, Layout, PanelLimits, Profile, ResizeEdge, Workspace, -}; +use craft_ui::docking::{Action, Divider, DockArea, DockCommand, DockContent, DockEvent, DockStyle, FloatSpace, Hit, Layout, ResizeEdge, Workspace}; use egui::{Rect, Sense, Stroke, StrokeKind, vec2}; use serde_json::{Value, json}; @@ -25,8 +23,6 @@ fn bodies_id() -> egui::Id { area_id().with("bodies") } -/// Height of the move grip above a panel without a tab strip. -const GRIP_H: f32 = 8.0; /// Width of the ≡ panel menu button at the end of each tab strip. const MENU_W: f32 = 26.0; @@ -318,9 +314,11 @@ impl FilmcraftApp { /// Automation ids for craft-ui's gesture sources: `panel.tab.`, `panel.drag.` /// (the group's drag bar), `panel.tabs.more.`, `panel.float..move` and `.resize`, - /// `dock.gutter.` (`a`/`b` per split level), and `dock.gutter.group.`, - /// `.stack.` and `.column.` for the dividers below a group or accordion entry and - /// beside a column, named by the panel they hold first. + /// `dock.gutter.` (`a`/`b` per split level), `dock.corner..` + /// where two dividers meet, the same inside floating window `n` as `panel.float..gutter.…` + /// and `panel.float..corner.…`, and `dock.gutter.group.`, `.stack.` and + /// `.column.` for the dividers below a group or accordion entry and beside a column, + /// named by the panel they hold first. fn register_dock_hits(&mut self, hits: &[(Hit, Rect)]) { let window = |panel: &PanelKind| self.ui.dock.floating.iter().position(|window| window.contains(panel)); let mut found = Vec::new(); @@ -338,12 +336,19 @@ impl FilmcraftApp { Some(index) => (format!("panel.float.{index}.resize.{edge:?}"), "Resize floating panel"), None => continue, }, - Hit::Divider(Divider::Split(path)) => { - (format!("dock.gutter.{}", path.iter().map(|second| if *second { 'b' } else { 'a' }).collect::()), "gutter") - } + Hit::Divider(Divider::Split(path)) => (format!("dock.gutter.{}", path_id(path)), "gutter"), Hit::Divider(Divider::Group(panel)) => (format!("dock.gutter.group.{}", panel.id()), "gutter"), Hit::Divider(Divider::Stack(panel)) => (format!("dock.gutter.stack.{}", panel.id()), "gutter"), Hit::Divider(Divider::Column(panel)) => (format!("dock.gutter.column.{}", panel.id()), "gutter"), + Hit::Divider(Divider::Intersection(outer, inner)) => (format!("dock.corner.{}.{}", path_id(outer), path_id(inner)), "gutter corner"), + Hit::Divider(Divider::WindowSplit(panel, path)) => match window(panel) { + Some(index) => (format!("panel.float.{index}.gutter.{}", path_id(path)), "gutter"), + None => continue, + }, + Hit::Divider(Divider::WindowIntersection(panel, outer, inner)) => match window(panel) { + Some(index) => (format!("panel.float.{index}.corner.{}.{}", path_id(outer), path_id(inner)), "gutter corner"), + None => continue, + }, _ => continue, }; found.push((entry, *rect, DockArea::hit_id(area_id(), hit))); @@ -360,9 +365,28 @@ impl FilmcraftApp { } } +/// A split path as `a`/`b` per level (first/second child). +fn path_id(path: &[bool]) -> String { + path.iter().map(|second| if *second { 'b' } else { 'a' }).collect() +} + +/// FilmCraft's window is all groups: drops on all four window edges, Ctrl/Cmd-drag undocks, a +/// release over no target cancels, and a floating window's title bar only moves it. fn dock_style(ui: &egui::Ui, t: &Tokens) -> DockStyle { let mut style = DockStyle::from_ui(ui); - style.profile = Profile::Frame; + style.side_edge_zones = true; + style.new_dock_zones = false; + style.maximize_item = true; + style.close_others_item = true; + style.command_press_floats = true; + style.float_on_empty_drop = false; + style.title_bar_docks = false; + style.float_hint = true; + style.wheel_selects_tabs = true; + style.middle_click_closes_tabs = true; + style.float_label = "Undock Panel".into(); + style.float_group_label = "Undock Panel Group".into(); + style.close_group_label = "Close Panel Group".into(); style.tab_height = t.tab_h; style.gap = t.gap; style.background = t.panel_bg; @@ -395,20 +419,7 @@ impl DockContent for FilmContent<'_> { fn body(&mut self, ui: &mut egui::Ui, panel: &PanelKind) { let t = self.tokens; let frame = ui.max_rect(); - let mut rect = frame; - // Tools and the audio meters have no tab strip: a slim grip on top moves them. - if panel.compact() { - let grip = Rect::from_min_size(rect.min, vec2(rect.width(), GRIP_H.min(rect.height()))); - let response = ui.interact(grip, egui::Id::new(("panel-grip", panel.id())), Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab); - self.app.auto.add_response(&format!("panel.drag.{}", panel.id()), &response, "Move panel"); - for dx in [-4.0, 0.0, 4.0] { - ui.painter().circle_filled(grip.center() + vec2(dx, 0.0), 1.0, if response.hovered() { t.tab_text_active } else { t.text_faint }); - } - if response.drag_started() { - craft_ui::docking::begin_drag(ui.ctx(), area_id(), *panel, frame); - } - rect.min.y = grip.max.y; - } + let rect = frame; self.bodies.push((*panel, rect, ui.layer_id())); self.app.auto.add(&format!("panel.{}", panel.id()), rect, panel.title()); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).id_salt(("panel", panel.id()))); @@ -422,14 +433,6 @@ impl DockContent for FilmContent<'_> { } } - fn limits(&self, panel: &PanelKind) -> PanelLimits { - let mut limits = PanelLimits::default(); - if let Some(width) = panel.max_width() { - limits.max.x = width; - } - limits - } - fn logical_label(&self, panel: &PanelKind) -> String { crate::i18n::t(panel.title()).to_string() } @@ -467,11 +470,6 @@ impl DockContent for FilmContent<'_> { } } - /// Tools and the audio meters are narrow strips without a tab strip. - fn bare(&self, panel: &PanelKind) -> bool { - panel.compact() - } - /// The ≡ menu, and the Timeline's sequence tabs before it while the Timeline is shown. /// The sequence tabs take at most two thirds of the strip, so the group's own tabs keep room; /// past that they scroll into their » list. diff --git a/crates/ui-egui/src/panels/keyboard.rs b/crates/ui-egui/src/panels/keyboard.rs index 403e0a838..bfbea5306 100644 --- a/crates/ui-egui/src/panels/keyboard.rs +++ b/crates/ui-egui/src/panels/keyboard.rs @@ -258,7 +258,7 @@ fn panel_under_pointer(app: &FilmcraftApp, ctx: &egui::Context) -> Option Vec { use crate::dock::LayoutExt as _; - app.ui.dock.visible_panels().into_iter().filter(|p| !p.compact()).collect() + app.ui.dock.visible_panels() } fn cycle_panel(app: &mut FilmcraftApp, next: bool) -> Value { diff --git a/crates/ui-egui/tests/meters_ui.rs b/crates/ui-egui/tests/meters_ui.rs index cbd24fd7e..9b3363344 100644 --- a/crates/ui-egui/tests/meters_ui.rs +++ b/crates/ui-egui/tests/meters_ui.rs @@ -89,8 +89,7 @@ impl Driver { fn label(&mut self, id: &str) -> String { self.element(id).and_then(|e| e["label"].as_str().map(str::to_string)).unwrap_or_default() } - /// Give the Audio Meters panel `width` points (it sits in a fixed-width split beside the - /// Timeline). + /// Give the Audio Meters panel `width` points (it sits in a split beside the Timeline). fn meters_width(&mut self, width: f32) { fn set(n: &mut Node, width: f32) -> bool { match n { diff --git a/crates/ui-egui/tests/shared_docking_ui.rs b/crates/ui-egui/tests/shared_docking_ui.rs index 5f6904e5d..5a7359266 100644 --- a/crates/ui-egui/tests/shared_docking_ui.rs +++ b/crates/ui-egui/tests/shared_docking_ui.rs @@ -39,7 +39,7 @@ impl Docked for Layout { fn layout() -> Layout { root(split( false, - SplitSize::FixedFirst(400.0), + SplitSize::Ratio(0.3), tabs(&[PanelKind::Project, PanelKind::Info, PanelKind::History], 1), tabs(&[PanelKind::Source, PanelKind::Program], 1), )) @@ -334,11 +334,30 @@ fn dropping_on_the_window_edge_docks_beside_the_whole_workspace() { false, SplitSize::Ratio(0.25), tabs(&[PanelKind::Info], 0), - split(false, SplitSize::FixedFirst(400.0), tabs(&[PanelKind::Project, PanelKind::History], 1), tabs(&[PanelKind::Source, PanelKind::Program], 1)), + split(false, SplitSize::Ratio(0.3), tabs(&[PanelKind::Project, PanelKind::History], 1), tabs(&[PanelKind::Source, PanelKind::Program], 1)), )) ); } +/// A release over no drop target (the menu bar, the gap between two groups) cancels the drag: +/// nothing floats and nothing moves. Only Ctrl/Cmd or leaving the window floats. +#[test] +fn a_release_over_no_target_cancels_the_drag() { + let mut app = FilmcraftApp::new(Session::default()); + app.ui.dock = layout(); + let mut h = Harness::builder().with_size(vec2(1200.0, 760.0)).build_eframe(move |_| app); + for _ in 0..4 { + step(&mut h); + } + let before = h.state().ui.dock.clone(); + let info = rect(&h, "panel.Info"); + for to in [pos2(600.0, 10.0), pos2(info.right() + 2.0, info.center().y)] { + let from = rect(&h, "panel.tab.Info").center(); + drag(&mut h, from, to, Modifiers::NONE); + assert_eq!(h.state().ui.dock, before, "release at {to:?}"); + } +} + #[test] fn dock_panel_without_anchor_docks_along_a_workspace_edge() { let mut app = FilmcraftApp::new(Session::default()); @@ -709,13 +728,12 @@ fn the_last_docked_group_cannot_float_away() { assert!(app.ui.dock.docked(PanelKind::Project) && !app.ui.dock.docked(PanelKind::Info)); } -/// Tools and the audio meters have no tab strip; the grip above each one moves it. +/// Tools and the audio meters are ordinary groups with a tab strip: the meters show their tab, +/// narrow Tools at least its drag bar, and either one joins another group. #[test] -fn tools_and_meters_move_by_their_grips() { +fn tools_and_meters_move_like_any_group() { let mut h = demo_harness(); - for panel in [PanelKind::Tools, PanelKind::AudioMeters] { - assert!(h.state().auto.find(&format!("panel.drag.{}", panel.id())).is_some(), "{panel:?} has no grip"); - } + assert!(h.state().auto.find("panel.tab.AudioMeters").is_some(), "the meters have no tab"); let from = rect(&h, "panel.drag.Tools").center(); let to = rect(&h, "panel.Program").center(); drag(&mut h, from, to, Modifiers::NONE); diff --git a/crates/ui-egui/tests/ui_control_input.rs b/crates/ui-egui/tests/ui_control_input.rs index 15550468f..847882638 100644 --- a/crates/ui-egui/tests/ui_control_input.rs +++ b/crates/ui-egui/tests/ui_control_input.rs @@ -115,10 +115,9 @@ fn background_divider(isolated: bool) { d.frames(2, true); d.h.input_mut().events.push(Event::WindowFocused(false)); d.ok("ui.key", json!({"key":"ArrowRight"}), false); - assert!( - matches!(d.h.state().ui.dock.root, Some(craft_ui::docking::Node::Split { size: craft_ui::layout::SplitSize::FixedFirst(408.0), .. })), - "control input must work without native activation" - ); + d.frames(2, false); + let width = d.h.state().auto.find("panel.Project").unwrap().rect[2]; + assert!((width - 408.0).abs() < 0.5, "control input must work without native activation: Project is {width} wide"); assert_eq!(d.h.state().session.state.playheads, before_playheads); assert!(Arc::ptr_eq(&d.h.state().session.project, &before_project)); assert!(!d.h.state().playback.playing); diff --git a/crates/ui-egui/tests/ui_dock_resize.rs b/crates/ui-egui/tests/ui_dock_resize.rs index d08657e99..ce4a7bc32 100644 --- a/crates/ui-egui/tests/ui_dock_resize.rs +++ b/crates/ui-egui/tests/ui_dock_resize.rs @@ -33,11 +33,15 @@ fn snapshots(h: &mut Harness<'_, FilmcraftApp>, directory: Option<&std::path::Pa frames(h, 3); } -fn first_size(h: &Harness<'_, FilmcraftApp>) -> f32 { - match h.state().ui.dock.root { - Some(craft_ui::docking::Node::Split { size: craft_ui::layout::SplitSize::FixedFirst(value), .. }) => value, - ref other => panic!("fixed-size mode changed: {other:?}"), - } +/// The drawn width of the Project panel, the first child of the tests' split, after the layout +/// change has been drawn. +fn first_size(h: &mut Harness<'_, FilmcraftApp>) -> f32 { + frames(h, 2); + h.state().auto.find("panel.Project").unwrap().rect[2] +} + +fn assert_near(value: f32, expected: f32) { + assert!((value - expected).abs() < 0.5, "{value} is not {expected}"); } #[test] @@ -79,7 +83,7 @@ fn focused_dock_resize_owns_arrows_and_keeps_pointer_dragging() { step(&mut h); h.event(Event::Key { key: Key::ArrowRight, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); step(&mut h); - assert_eq!(first_size(&h), 408.0); + assert_near(first_size(&mut h), 408.0); assert_eq!(h.state().session.state.playheads, before_playheads, "the Timeline shortcut must not seek"); assert!(std::sync::Arc::ptr_eq(&h.state().session.project, &before_project)); @@ -96,7 +100,7 @@ fn focused_dock_resize_owns_arrows_and_keeps_pointer_dragging() { } h.event(Event::PointerButton { pos: from + vec2(20.0, 0.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); step(&mut h); - assert_eq!(first_size(&h), 428.0); + assert_near(first_size(&mut h), 428.0); assert_eq!(h.state().session.state.playheads, before_playheads); } @@ -138,7 +142,7 @@ fn space_plays_after_a_pane_is_resized_with_the_pointer() { } h.event(Event::PointerButton { pos: from + vec2(20.0, 0.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); frames(&mut h, 3); - assert_eq!(first_size(&h), 420.0); + assert_near(first_size(&mut h), 420.0); // A pointer drag leaves no keyboard focus on the divider, so the editor keeps its shortcuts. assert_ne!( h.ctx.memory(|memory| memory.focused()), @@ -178,7 +182,7 @@ fn space_plays_while_any_floating_resize_edge_has_focus() { } } -/// The divider between groups stacked in a floating window has an automation id, and Space plays +/// The divider between groups split in a floating window has an automation id, and Space plays /// while it has keyboard focus (#1482). #[test] fn space_plays_while_a_floating_group_divider_has_focus() { @@ -193,10 +197,10 @@ fn space_plays_while_a_floating_group_divider_has_focus() { frames(&mut h, 3); use craft_ui::docking::{Divider, DockArea, Hit}; let area = filmcraft_ui_egui::dock_area_id(); - let hit = Hit::Divider(Divider::Group(PanelKind::Info)); - assert!(DockArea::::hit_rects(&h.ctx, area).iter().any(|(drawn, _)| *drawn == hit), "no group divider drawn"); + let hit = Hit::Divider(Divider::WindowSplit(PanelKind::Info, vec![])); + assert!(DockArea::::hit_rects(&h.ctx, area).iter().any(|(drawn, _)| *drawn == hit), "no window divider drawn"); let divider = DockArea::::hit_id(area, &hit); - let element = h.state().auto.find("dock.gutter.group.Info").expect("the group divider has no automation id"); + let element = h.state().auto.find("panel.float.0.gutter.").expect("the window divider has no automation id"); assert_eq!(element.widget_id, Some(format!("{:016x}", divider.value()))); h.ctx.memory_mut(|memory| memory.request_focus(divider)); frames(&mut h, 2); @@ -207,35 +211,42 @@ fn space_plays_while_a_floating_group_divider_has_focus() { assert!(!h.state().playback.playing); } -/// Tools keeps its factory width: dragging the divider beside it does not widen it. +/// Tools is an ordinary group: the divider beside it widens and narrows it. #[test] -fn the_divider_beside_tools_cannot_widen_it() { +fn the_divider_beside_tools_moves_both_ways() { let app = FilmcraftApp::new(Session::default()); let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(move |_| app); frames(&mut h, 4); h.state_mut().ui.dock = craft_ui::docking::Layout { root: Some(craft_ui::docking::Node::Split { axis: craft_ui::layout::SplitAxis::Horizontal, - size: craft_ui::layout::SplitSize::FixedFirst(filmcraft_ui_egui::dock::TOOLS_WIDTH), + size: craft_ui::layout::SplitSize::Ratio(0.06), first: Box::new(craft_ui::docking::Node::Tabs { panels: vec![PanelKind::Tools], active: 0 }), second: Box::new(craft_ui::docking::Node::Tabs { panels: vec![PanelKind::Timeline], active: 0 }), }), floating: vec![], }; frames(&mut h, 3); - let element = h.state().auto.find("dock.gutter.").unwrap(); - let from = Rect::from_min_size(pos2(element.rect[0], element.rect[1]), vec2(element.rect[2], element.rect[3])).center(); - h.event(Event::PointerMoved(from)); - step(&mut h); - h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); - step(&mut h); - for offset in [10.0, 20.0, 30.0, 40.0] { - h.event(Event::PointerMoved(from + vec2(offset, 0.0))); - step(&mut h); - } - h.event(Event::PointerButton { pos: from + vec2(40.0, 0.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); - frames(&mut h, 3); - assert!(first_size(&h) <= filmcraft_ui_egui::dock::TOOLS_WIDTH, "Tools widened to {}", first_size(&h)); - let tools = h.state().auto.find("panel.Tools").unwrap(); - assert!(tools.rect[2] <= filmcraft_ui_egui::dock::TOOLS_WIDTH, "Tools drawn {} wide", tools.rect[2]); + let width = |h: &Harness<'_, FilmcraftApp>| h.state().auto.find("panel.Tools").unwrap().rect[2]; + let before = width(&h); + let drag = |h: &mut Harness<'_, FilmcraftApp>, dx: f32| { + let element = h.state().auto.find("dock.gutter.").unwrap(); + let from = Rect::from_min_size(pos2(element.rect[0], element.rect[1]), vec2(element.rect[2], element.rect[3])).center(); + h.event(Event::PointerMoved(from)); + step(h); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + step(h); + for step_index in 1..=4 { + h.event(Event::PointerMoved(from + vec2(dx * step_index as f32 / 4.0, 0.0))); + step(h); + } + h.event(Event::PointerButton { pos: from + vec2(dx, 0.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + frames(h, 3); + }; + drag(&mut h, 200.0); + let wider = width(&h); + assert!(wider >= before + 150.0, "Tools widened only from {before} to {wider}"); + drag(&mut h, -150.0); + let narrower = width(&h); + assert!(narrower <= wider - 100.0, "Tools narrowed only from {wider} to {narrower}"); } From 1673d42dd2944e767405cd7327cce89de62a4780 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 20:56:14 +0000 Subject: [PATCH 043/113] docking: saved workspaces load fixed split sizes as ratios A fixed size in a saved layout (older FilmCraft versions wrote them, the legacy frame tree included) loads as the share it had in a 1600 x 1000 pt window, docked and in floating windows, so every group scales with the window. (cherry picked from commit 69c8f7863da4ebca052d40300c65c011754ddc3d) --- crates/ui-egui/src/dock.rs | 82 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 034fe602f..d904456a6 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -227,19 +227,68 @@ pub fn find(prefs: &WorkspacePrefs, query: &str) -> Option { } /// A workspace as saved: the user's saved version, else the built-in default. An entry that -/// does not read or validate falls back to the default and stays in the file untouched. +/// does not read or validate falls back to the default and stays in the file untouched. Fixed +/// split sizes load as ratios. pub fn saved_workspace(prefs: &WorkspacePrefs, name: &str) -> Workspace { let saved = prefs.saved.iter().find(|saved| saved.name == name).and_then(|saved| { - let workspace = if saved.workspace.is_null() { + let mut workspace = if saved.workspace.is_null() { Workspace::new(legacy::layout(&saved.layout, &saved.floating)?) } else { serde_json::from_value::(saved.workspace.clone()).ok()? }; - workspace.validate().ok().map(|()| workspace) + workspace.validate().ok()?; + ratio_layout(&mut workspace.layout); + if let Some(layout) = workspace.saved.as_mut() { + ratio_layout(layout); + } + Some(workspace) }); saved.unwrap_or_else(|| Workspace::new(workspace(name))) } +/// The docked area of a 1600 x 1000 pt window, which a fixed split size from a saved layout is +/// measured against when it loads as a ratio. +const NOMINAL_DOCK: egui::Vec2 = vec2(1600.0, 939.0); +/// The gap between groups (`Tokens::gap`). +const GAP: f32 = 4.0; + +/// Saved layouts may hold fixed split sizes (older FilmCraft versions wrote them); every split +/// loads as the ratio it had at the nominal window size, so a window resize scales all groups. +fn ratio_layout(layout: &mut Layout) { + if let Some(root) = layout.root.as_mut() { + ratio_splits(root, NOMINAL_DOCK, 0); + } + for window in &mut layout.floating { + let [_, _, width, height] = window.rect; + ratio_splits(&mut window.root, vec2(width, height), 0); + } +} + +fn ratio_splits(node: &mut Node, size: egui::Vec2, depth: usize) { + if depth > craft_ui::docking::MAX_DEPTH { + return; + } + let Node::Split { axis, size: split, first, second } = node else { return }; + let extent = match axis { + SplitAxis::Horizontal => size.x, + SplitAxis::Vertical => size.y, + }; + let available = (extent - GAP).max(1.0); + let ratio = match *split { + SplitSize::Ratio(ratio) => ratio, + SplitSize::FixedFirst(points) => points / available, + SplitSize::FixedSecond(points) => 1.0 - points / available, + }; + let ratio = if ratio.is_finite() { ratio.clamp(0.0, 1.0) } else { 0.5 }; + *split = SplitSize::Ratio(ratio); + let (a, b) = match axis { + SplitAxis::Horizontal => (vec2(available * ratio, size.y), vec2(available * (1.0 - ratio), size.y)), + SplitAxis::Vertical => (vec2(size.x, available * ratio), vec2(size.x, available * (1.0 - ratio))), + }; + ratio_splits(first, a, depth + 1); + ratio_splits(second, b, depth + 1); +} + /// The saved layout of a workspace (see [`saved_workspace`]). pub fn saved_docking(prefs: &WorkspacePrefs, name: &str) -> Layout { saved_workspace(prefs, name).layout @@ -766,6 +815,29 @@ mod tests { } } + /// A saved v1 workspace with fixed split sizes, docked and floating, loads with ratios that + /// keep its proportions at the nominal window size. + #[test] + fn fixed_splits_in_saved_workspaces_load_as_ratios() { + let workspace = serde_json::json!({"layout": {"root": {"Split": {"axis": "Horizontal", "size": {"FixedSecond": 450.0}, + "first": {"Split": {"axis": "Horizontal", "size": {"FixedFirst": 64.0}, + "first": {"Tabs": {"panels": ["Tools"], "active": 0}}, "second": {"Tabs": {"panels": ["Timeline"], "active": 0}}}}, + "second": {"Tabs": {"panels": ["AudioMeters"], "active": 0}}}}, + "floating": [{"groups": [{"panels": ["Info"], "active": 0, "open": true, "height": 100.0}, + {"panels": ["Effects"], "active": 0, "open": true, "height": 200.0}], "rect": [10.0, 20.0, 300.0, 304.0]}]}, "hidden": []}); + let prefs = WorkspacePrefs { saved: vec![SavedWorkspace { name: "Mine".into(), workspace, ..Default::default() }], current: "Mine".into() }; + let loaded = saved_docking(&prefs, "Mine"); + assert!(loaded.is_docked(PanelKind::Tools) && loaded.is_visible(PanelKind::Info) && loaded.is_visible(PanelKind::Effects), "{loaded:?}"); + let root = loaded.root.as_ref().unwrap(); + assert_eq!(fixed_splits(root), 0, "{root:?}"); + assert!(loaded.floating.iter().all(|window| fixed_splits(&window.root) == 0), "{:?}", loaded.floating); + let Node::Split { size: SplitSize::Ratio(outer), first, .. } = root else { panic!("{root:?}") }; + let available = NOMINAL_DOCK.x - GAP; + assert!((outer - (available - 450.0) / available).abs() < 1e-4, "{outer}"); + let Node::Split { size: SplitSize::Ratio(inner), .. } = &**first else { panic!("{first:?}") }; + assert!((inner - 64.0 / (available * outer - GAP)).abs() < 1e-4, "{inner}"); + } + /// #284: with Project and Program closed (or every panel closed) a panel still opens. #[test] fn open_near_falls_back_to_the_first_group() { @@ -796,7 +868,9 @@ mod tests { let prefs: WorkspacePrefs = serde_json::from_value(legacy).unwrap(); let mine = saved_docking(&prefs, "Mine"); assert!(mine.is_visible(PanelKind::Info) && mine.is_visible(PanelKind::Timeline) && mine.is_visible(PanelKind::Effects)); - assert!(matches!(mine.root, Some(Node::Split { size: SplitSize::FixedFirst(300.0), .. }))); + // the fixed 300 pt Project column loads as its share of the nominal 1600 pt window + let Some(Node::Split { size: SplitSize::Ratio(ratio), .. }) = mine.root else { panic!("{:?}", mine.root) }; + assert!((ratio - 300.0 / (NOMINAL_DOCK.x - GAP)).abs() < 1e-4, "{ratio}"); assert_eq!(saved_docking(&prefs, "Broken"), workspace("Broken")); let saved = SavedWorkspace { name: "Mine".into(), workspace: serde_json::to_value(Workspace::new(mine.clone())).unwrap(), ..Default::default() }; let prefs = WorkspacePrefs { saved: vec![saved], current: "Mine".into() }; From 5787e7e5ca4301dd2c98d9127644f0d1e8a07c4d Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 20:57:33 +0000 Subject: [PATCH 044/113] docking: rustfmt (cherry picked from commit e98ea9221f505cefcfff3cafdebc6f007214f5a3) --- crates/ui-egui/src/dock.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index d904456a6..d84b183bd 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -416,7 +416,8 @@ fn workspace_root(name: &str) -> Node { use SplitSize::Ratio; // Tools | Timeline | Audio Meters. The ratios start Tools about 64 pt and the meters about // 116 pt wide in a 1600 pt window; all three resize like any other group. - let bottom_editing = |tools: f32, timeline: f32| hsplit(Ratio(tools), tabs(&[Tools], 0), hsplit(Ratio(timeline), tabs(&[Timeline], 0), tabs(&[AudioMeters], 0))); + let bottom_editing = + |tools: f32, timeline: f32| hsplit(Ratio(tools), tabs(&[Tools], 0), hsplit(Ratio(timeline), tabs(&[Timeline], 0), tabs(&[AudioMeters], 0))); match name { "Assembly" => vsplit( Ratio(0.52), @@ -481,11 +482,7 @@ fn workspace_root(name: &str) -> Node { hsplit(Ratio(0.5), tabs(&[Source, EffectControls, AudioClipMixer, Metadata], 0), tabs(&[Program], 0)), tabs(&[Properties, EssentialGraphics, Text], 0), ), - hsplit( - Ratio(0.29), - tabs(&[Project, MediaBrowser, Libraries, Info, Effects, Markers, History], 0), - bottom_editing(0.06, 0.89), - ), + hsplit(Ratio(0.29), tabs(&[Project, MediaBrowser, Libraries, Info, Effects, Markers, History], 0), bottom_editing(0.06, 0.89)), ), } } From 85d1c820a1cc20a6048b9e53f1b1d2aec061720f Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 20:58:07 +0000 Subject: [PATCH 045/113] docking: docs for the split-tree settings and the new divider ids (cherry picked from commit c6450ee1afcda0f222e2eb36719bbcd6e1e06b53) --- crates/ui-egui/README.md | 6 ++++-- docs/control-protocol.md | 9 ++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/README.md b/crates/ui-egui/README.md index c02b32ebf..ea7ccb2df 100644 --- a/crates/ui-egui/README.md +++ b/crates/ui-egui/README.md @@ -65,8 +65,10 @@ keeps Source navigation, markers and marked-range playback separate from Program Dock transactions and pointer targets use `craft-ui::docking`; bounded split geometry and keyboard-accessible resize handles use `craft-ui::layout`. FilmCraft adapts its existing saved dock tree and panel identities to the shared model. Panel tabs or their header grip can move -between tab groups, split at an edge, reorder, or float when released with Cmd/Ctrl. Floating -panels move by their empty header and resize from the bottom-right corner. Timeline sequence +between tab groups, split at an edge, reorder, or float when released with Cmd/Ctrl; a release +over no target cancels. Floating windows move by their title bar, resize from every edge and +split like the main window. Every group, Tools and the Audio Meters included, resizes down to +the same minimum and has no maximum. Timeline sequence tabs retain their sequence-opening, closing and horizontal reorder behavior; the header grip moves the Timeline panel. Workspace saves retain floating groups alongside the legacy layout JSON, and workspaces without floating data still load. Automation IDs and workspace commands diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 8dd2510e2..cba92f79e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -201,18 +201,21 @@ Invalid source/target identities and non-finite or out-of-range geometry return the layout unchanged. `ui.inspect.ui.dock.floating` reports floating windows and their logical-point rectangles. `panel.drag.` locates the drag bar of each panel's group; `panel.float..move` and `.resize` locate floating geometry handles; `dock.gutter.` -locates a split divider (`a`/`b` per split level), and `dock.gutter.group.`, +locates a split divider (`a`/`b` per split level), `dock.corner..` the +four-way handle where two dividers meet, `panel.float..gutter.` and +`panel.float..corner..` the same inside a floating window, and +`dock.gutter.group.`, `.stack.` and `.column.` the dividers below a group or accordion entry and beside a column, named by the panel they hold first; `ui.docking` lists every docking gesture source. Named workspace saves include floating windows. -The panels use craft-ui's shared docking in its Frame profile. Its control commands +The panels use craft-ui's shared docking with its split-tree settings. Its control commands (`window.panel.move`, `.float`, `.dock`, `.activate`, `.close`, `.closeGroup`, `.closeOthers`, `.minimize`, `.layout`; see craft-libs `docs/ui-docking.md`) route here too; the three commands above are FilmCraft's names for `window.panel.float`, `.dock` and `.close`. `window.dockAction` accepts `{action}` using the shared docking action's JSON form. It covers tab moves and ordering, split and floating geometry, activation, and closing. -For example, `{"action":{"ResizeSplit":{"path":[],"size":{"FixedFirst":360}}}}` +For example, `{"action":{"ResizeSplit":{"path":[],"size":{"Ratio":0.3}}}}` resizes the root split; path entries use `false` for the first child and `true` for the second. `{"action":{"MoveFloating":{"panel":"Info","rect":[45,65,420,320]}}}` repositions and resizes the floating group containing Info. Invalid actions leave the From 172ccf34560c851faef1ab59aa23eb371d33abaa Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 17:53:47 +0000 Subject: [PATCH 046/113] docking: floating panels open in their own OS windows craft-ui 92f5083 with `native-windows`: every desktop float gets its own OS window. Keys, text and clipboard events a floating window leaves over (`DockOutput::forwarded_events`) run the main window's bindings in the same frame, with the modifiers held in that window, so shortcuts work while a floating window has focus. (cherry picked from commit 477eedbf4aac9d83934824b273c44275f6f6efaf) --- crates/ui-egui/src/dock_ui.rs | 7 ++ crates/ui-egui/src/lib.rs | 142 ++++++++++++++++++++++++---------- 2 files changed, 106 insertions(+), 43 deletions(-) diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index d397499f2..eab1379f9 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -218,6 +218,7 @@ impl FilmcraftApp { active: self.session.state.active_sequence.map(|id| id.0), }; let style = dock_style(ui, &t); + // No `monitors`: the monitor list comes with the egui fork's `ViewportInfo::monitors`. let mut area = DockArea::new(area_id()).float_bounds(self.float_bounds(&ctx)).maximized(self.ui.keys.maximized.filter(|panel| self.ui.dock.is_docked(*panel))); if let Some(panel) = self.pending_panel_raise.take() { @@ -235,6 +236,7 @@ impl FilmcraftApp { if let Some(panel) = output.focused { self.ui.focused = panel; } + self.handle_forwarded_keys(&ctx, output.forwarded_events); if let Some(error) = output.error { self.ui.status = error.to_string(); } @@ -437,6 +439,11 @@ impl DockContent for FilmContent<'_> { crate::i18n::t(panel.title()).to_string() } + /// Every floating panel gets its own OS window on desktop. + fn allow_native(&self, _panel: &PanelKind) -> bool { + true + } + /// With no sequence open, the Timeline's tab says so. fn label(&mut self, _ui: &egui::Ui, panel: &PanelKind, logical: &str, _font: &egui::FontId, _color: egui::Color32) -> egui::WidgetText { if *panel == PanelKind::Timeline && self.seqs.open.is_empty() { self.seqs.label().into() } else { logical.into() } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 830244b34..d1fc971ca 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1566,60 +1566,100 @@ impl FilmcraftApp { let _ = self.session.execute("trim.cancelDynamic", json!({})); } panels::masks::pen_take_back(self, ctx, text_focused); + let fire = ctx.input_mut(|i| self.match_shortcuts(i, text_focused, divider_focused)); + self.fire_shortcuts(ctx, fire); + } + + /// Key, text and clipboard events that a floating panel in its own OS window left over + /// (`DockOutput::forwarded_events`): they run the main window's bindings in the frame they + /// arrive, so shortcuts work while a floating window has focus. craft-ui forwards nothing + /// while that window's text field has focus. + pub(crate) fn handle_forwarded_keys(&mut self, ctx: &egui::Context, events: Vec) { + if events.is_empty() || self.dialog == Some(Dialog::Shortcuts) { + return; + } + let mut input = egui::InputState::default(); + input.modifiers = self.floating_window_modifiers(ctx); + input.events = events; + if self.session.trim_play.dynamic.is_some() && input.consume_key(egui::Modifiers::NONE, egui::Key::Escape) { + let _ = self.session.execute("trim.cancelDynamic", json!({})); + } + let fire = self.match_shortcuts(&mut input, false, false); + self.fire_shortcuts(ctx, fire); + } + + /// The modifiers held in the focused native floating window. Copy, Cut and Paste events + /// carry none, and egui-winit drops the key press they stand for. + fn floating_window_modifiers(&self, ctx: &egui::Context) -> egui::Modifiers { + self.ui + .dock + .floating + .iter() + .filter(|window| window.native.is_some()) + .map(|window| craft_ui::docking::DockArea::::window_viewport(dock_ui::area_id(), window.id)) + .find_map(|viewport| ctx.input_for(viewport, |i| (i.viewport().focused == Some(true)).then_some(i.modifiers))) + .unwrap_or_default() + } + + /// The binding ids the key presses in `i` fire, consuming those presses. + fn match_shortcuts(&mut self, i: &mut egui::InputState, text_focused: bool, divider_focused: bool) -> Vec { // Panel shortcuts of the focused panel override application shortcuts on the same chord. // egui ignores extra Shift and Alt when matching, so the most specific chords go first // across both scopes (the sort is stable, so the panel still wins a tie): otherwise the // Timeline's `Delete` (Clear) swallows the app-wide `Shift+Delete` (Ripple Delete) (#680). let focused = self.ui.focused.title(); let mut fire = Vec::new(); - ctx.input_mut(|i| { - let modifiers = i.modifiers; - // With no text on the system clipboard, egui-winit drops the Ctrl+V press but still - // passes its release: put the press back, so Paste can paste files or an image (#611). - let mut swallowed = None; - for e in &i.events { - match e { - egui::Event::Key { key: egui::Key::V, pressed, modifiers: m, .. } => { - if !pressed && !self.v_down { - swallowed = Some(*m); - } - self.v_down = *pressed; + let modifiers = i.modifiers; + // With no text on the system clipboard, egui-winit drops the Ctrl+V press but still + // passes its release: put the press back, so Paste can paste files or an image (#611). + let mut swallowed = None; + for e in &i.events { + match e { + egui::Event::Key { key: egui::Key::V, pressed, modifiers: m, .. } => { + if !pressed && !self.v_down { + swallowed = Some(*m); } - egui::Event::Paste(_) if modifiers.command => self.v_down = true, - _ => {} + self.v_down = *pressed; } + egui::Event::Paste(_) if modifiers.command => self.v_down = true, + _ => {} } - if let Some(m) = swallowed { - i.events.push(egui::Event::Key { - key: egui::Key::V, - physical_key: Some(egui::Key::V), - pressed: true, - repeat: false, - modifiers: m | egui::Modifiers::COMMAND, - }); + } + if let Some(m) = swallowed { + i.events.push(egui::Event::Key { + key: egui::Key::V, + physical_key: Some(egui::Key::V), + pressed: true, + repeat: false, + modifiers: m | egui::Modifiers::COMMAND, + }); + } + clipboard_events_as_keys(&mut i.events, modifiers); + let panel = self.bindings.iter().filter(|b| b.3.as_deref() == Some(focused)); + let app_wide = self.bindings.iter().filter(|b| b.3.is_none()); + let mut candidates: Vec<&menus::KeyBinding> = panel.chain(app_wide).collect(); + candidates.sort_by_key(|b| std::cmp::Reverse(menus::specificity(b.0))); + for (m, k, id, _) in candidates { + // With a text field focused, an unmodified key belongs to the field (and to the input + // method composing in it), not to a tool shortcut: `J`, `Z`, `R`, `A`… are the keys + // Vietnamese Telex combines into diacritics (#243). Chords with a modifier stay: + // Ctrl+S and friends are not something a text field or an IME consumes. + if lets_text_field_keep(*m, text_focused) { + continue; } - clipboard_events_as_keys(&mut i.events, modifiers); - let panel = self.bindings.iter().filter(|b| b.3.as_deref() == Some(focused)); - let app_wide = self.bindings.iter().filter(|b| b.3.is_none()); - let mut candidates: Vec<&menus::KeyBinding> = panel.chain(app_wide).collect(); - candidates.sort_by_key(|b| std::cmp::Reverse(menus::specificity(b.0))); - for (m, k, id, _) in candidates { - // With a text field focused, an unmodified key belongs to the field (and to the input - // method composing in it), not to a tool shortcut: `J`, `Z`, `R`, `A`… are the keys - // Vietnamese Telex combines into diacritics (#243). Chords with a modifier stay: - // Ctrl+S and friends are not something a text field or an IME consumes. - if lets_text_field_keep(*m, text_focused) { - continue; - } - // The focused divider consumes the arrows itself (egui matches them with any Shift/Alt). - if divider_focused && matches!(k, egui::Key::ArrowLeft | egui::Key::ArrowRight | egui::Key::ArrowUp | egui::Key::ArrowDown) { - continue; - } - if i.consume_key(*m, *k) { - fire.push(id.clone()); - } + // The focused divider consumes the arrows itself (egui matches them with any Shift/Alt). + if divider_focused && matches!(k, egui::Key::ArrowLeft | egui::Key::ArrowRight | egui::Key::ArrowUp | egui::Key::ArrowDown) { + continue; } - }); + if i.consume_key(*m, *k) { + fire.push(id.clone()); + } + } + fire + } + + /// Run the commands of the bindings `match_shortcuts` fired, as the focused panel scopes them. + fn fire_shortcuts(&mut self, ctx: &egui::Context, fire: Vec) { for mut id in fire { // Select All / Deselect All act on the Project panel's items when it has focus (#168). // Select All takes only what the shown bin lists, not the whole project (#456). @@ -2365,6 +2405,22 @@ mod clipboard_key_tests { } } +#[cfg(test)] +mod forwarded_key_tests { + use egui::{Event, Key, Modifiers}; + + /// A key a floating native window left over runs the main window's bindings. + #[test] + fn forwarded_keys_fire_shortcuts() { + let mut app = super::FilmcraftApp::new(filmcraft_engine::Session::default()); + app.bindings = super::menus::bindings(&app); + app.ui.focused = crate::dock::PanelKind::Timeline; + let shift_backtick = Event::Key { key: Key::Backtick, physical_key: Some(Key::Backtick), pressed: true, repeat: false, modifiers: Modifiers::SHIFT }; + app.handle_forwarded_keys(&egui::Context::default(), vec![Event::Text("~".into()), shift_backtick]); + assert_eq!(app.ui.keys.maximized, Some(crate::dock::PanelKind::Timeline), "Shift+` maximizes the focused frame"); + } +} + #[cfg(test)] mod mac_ctrl_click_tests { use egui::{Event, Modifiers, PointerButton, RawInput}; From 7f77bd73774591c4cba6588c68d0cf604726c3cb Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 18:48:09 +0000 Subject: [PATCH 047/113] docking: craft-libs 16fb3d4 with shared docking and native windows craft-docpath, craft-media and craft-ui pin storytold/craft-libs main at 16fb3d4, which has craft-libs#264 and the undock-hint fix craft-libs#285. craft-ui builds with `native-windows`. --- Cargo.lock | 10 +++++++--- Cargo.toml | 6 +++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3a7851c0c..13dd2c54d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1014,12 +1014,12 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" [[package]] name = "craft-media" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4#f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4" +source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" dependencies = [ "thiserror 2.0.21", ] @@ -1027,9 +1027,13 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=958c5ada58269ceb84a763021ad31524c0ee7a76#958c5ada58269ceb84a763021ad31524c0ee7a76" +source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" dependencies = [ "egui", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "serde", ] diff --git a/Cargo.toml b/Cargo.toml index 409a6be30..f14bd2447 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,12 +72,12 @@ chrono = { version = "0.4.45", default-features = false, features = ["std"] } rayon = "1" proptest = "1" roxmltree = "0.21" -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } -craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4", default-features = false } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" } +craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3", default-features = false } # ui # Shared frontend components; keep this dependency above the engine boundary. -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "958c5ada58269ceb84a763021ad31524c0ee7a76", features = ["serde"] } +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3", features = ["serde", "native-windows"] } egui = "0.36" eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" From 7e08dcafc3c6ac9dd608f141f49049d81b008973 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 20:09:24 +0000 Subject: [PATCH 048/113] Skip Tools and Audio Meters when cycling panels --- crates/ui-egui/src/panels/keyboard.rs | 7 +++++-- crates/ui-egui/tests/it/keyboard_ui.rs | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/keyboard.rs b/crates/ui-egui/src/panels/keyboard.rs index bfbea5306..33ada49f6 100644 --- a/crates/ui-egui/src/panels/keyboard.rs +++ b/crates/ui-egui/src/panels/keyboard.rs @@ -255,10 +255,13 @@ fn panel_under_pointer(app: &FilmcraftApp, ctx: &egui::Context) -> Option Vec { use crate::dock::LayoutExt as _; - app.ui.dock.visible_panels() + let mut v = app.ui.dock.visible_panels(); + v.retain(|p| !matches!(p, PanelKind::Tools | PanelKind::AudioMeters)); + v } fn cycle_panel(app: &mut FilmcraftApp, next: bool) -> Value { diff --git a/crates/ui-egui/tests/it/keyboard_ui.rs b/crates/ui-egui/tests/it/keyboard_ui.rs index e0147f1d1..7d42995e6 100644 --- a/crates/ui-egui/tests/it/keyboard_ui.rs +++ b/crates/ui-egui/tests/it/keyboard_ui.rs @@ -206,6 +206,24 @@ fn new_keys_work_in_the_app() { assert_eq!(d.app().ui.program.zoom, None); } +/// Select Next / Previous Panel skip the narrow Tools and Audio Meters strips. +#[test] +fn panel_cycling_skips_tools_and_audio_meters() { + use filmcraft_ui_egui::dock::{LayoutExt as _, PanelKind}; + let mut d = Driver::new(); + let shown = d.app().ui.dock.visible_panels(); + assert!(shown.contains(&PanelKind::Tools) && shown.contains(&PanelKind::AudioMeters), "{shown:?}"); + for key in ["Ctrl+Shift+.", "Ctrl+Shift+,"] { + let mut seen = Vec::new(); + for _ in 0..shown.len() * 2 { + d.key(key); + seen.push(d.app().ui.focused); + } + assert!(!seen.contains(&PanelKind::Tools) && !seen.contains(&PanelKind::AudioMeters), "{key}: {seen:?}"); + assert!(seen.len() > 1 && seen.iter().any(|p| *p != seen[0]), "{key} cycles: {seen:?}"); + } +} + /// Off macOS the menus show `Ctrl+…`, so that is what agents send: a `Ctrl` chord through /// `ui.key` (and `ctrl` in `ui.click` modifiers) must act like the physical key, which egui-winit /// reports as `ctrl` + `command`. Bindings are written `Cmd+…`, so this failed to match (#245). From b8f7819477c80fc7221dade1625e00d127fac73d Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 20:16:57 +0000 Subject: [PATCH 049/113] Match Maximize Frame Under Cursor within the pointer's window and tag native-window elements --- crates/ui-egui/src/automation.rs | 21 ++++++++++++- crates/ui-egui/src/dock_ui.rs | 34 ++++++++++++++++++--- crates/ui-egui/src/panels/keyboard.rs | 11 +++++-- crates/ui-egui/tests/it/keyboard_ui.rs | 41 ++++++++++++++++++++++++++ docs/control-protocol.md | 2 +- 5 files changed, 100 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/automation.rs b/crates/ui-egui/src/automation.rs index 850fcb8dd..474a86c96 100644 --- a/crates/ui-egui/src/automation.rs +++ b/crates/ui-egui/src/automation.rs @@ -2,6 +2,8 @@ //! frame, so agents can click/drag by id (`ui.click {id:"timeline.track.V1.lock"}`) and //! `ui.inspect` can report the full on-screen widget tree. +use std::collections::HashMap; + use egui::Rect; use serde::Serialize; @@ -17,8 +19,14 @@ pub struct Element { pub enabled: Option, #[serde(skip_serializing_if = "Option::is_none")] pub focusable: Option, + /// The floating window (`n` in `panel.float.`) when the element is drawn in that window's + /// own OS window; `rect` is then in that window's coordinates, not the main window's. + #[serde(skip_serializing_if = "Option::is_none")] + pub window: Option, #[serde(skip)] pub(crate) response_id: Option, + #[serde(skip)] + pub(crate) viewport: egui::ViewportId, } #[derive(Default)] @@ -26,10 +34,19 @@ pub struct Registry { pub elements: Vec, /// Last frame's elements (for lookups from control handlers between frames). pub previous: Vec, + /// The viewport `add` registers into: a native floating window's while its panel draws. + pub(crate) viewport: egui::ViewportId, + /// The floating window index drawn in each native viewport. + pub(crate) windows: HashMap, } impl Registry { pub fn add(&mut self, id: &str, rect: Rect, label: &str) { + self.add_in(self.viewport, id, rect, label); + } + /// Register an element drawn in `viewport`. + pub(crate) fn add_in(&mut self, viewport: egui::ViewportId, id: &str, rect: Rect, label: &str) { + let window = if viewport == egui::ViewportId::ROOT { None } else { self.windows.get(&viewport).copied() }; self.elements.push(Element { id: id.to_string(), label: label.to_string(), @@ -37,12 +54,14 @@ impl Registry { widget_id: None, enabled: None, focusable: None, + window, response_id: None, + viewport, }); } /// Observe the widget without changing its accessibility role or input behavior. pub fn add_response(&mut self, id: &str, response: &egui::Response, label: &str) { - self.add(id, response.rect, label); + self.add_in(response.ctx.viewport_id(), id, response.rect, label); if let Some(element) = self.elements.last_mut() { element.widget_id = Some(format!("{:016x}", response.id.value())); element.enabled = Some(response.enabled()); diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index eab1379f9..8f0d20343 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -4,6 +4,8 @@ //! sequence tabs, and maps its control commands onto //! craft-ui's `DockCommand`. +use std::collections::HashMap; + use craft_ui::docking::{Action, Divider, DockArea, DockCommand, DockContent, DockEvent, DockStyle, FloatSpace, Hit, Layout, ResizeEdge, Workspace}; use egui::{Rect, Sense, Stroke, StrokeKind, vec2}; use serde_json::{Value, json}; @@ -228,6 +230,7 @@ impl FilmcraftApp { let mut child = ui.new_child(egui::UiBuilder::new().max_rect(body).id_salt("filmcraft-dock-area")); child.set_clip_rect(body.intersect(ui.clip_rect())); let seq_width = dock::sequence_tabs_width(ui, &seqs); + self.auto.windows.clear(); let mut content = FilmContent { app: self, seqs, seq_width, tokens: t, seq_actions: Vec::new(), menu: None, bodies: Vec::new(), restore_workspace: false }; let output = area.show_content(&mut child, &layout, &style, &mut content); @@ -320,11 +323,24 @@ impl FilmcraftApp { /// where two dividers meet, the same inside floating window `n` as `panel.float..gutter.…` /// and `panel.float..corner.…`, and `dock.gutter.group.`, `.stack.` and /// `.column.` for the dividers below a group or accordion entry and beside a column, - /// named by the panel they hold first. + /// named by the panel they hold first. Controls of a floating window in its own OS window + /// are registered in that window's viewport. fn register_dock_hits(&mut self, hits: &[(Hit, Rect)]) { let window = |panel: &PanelKind| self.ui.dock.floating.iter().position(|window| window.contains(panel)); + let native: HashMap = self.auto.windows.iter().map(|(viewport, index)| (*index, *viewport)).collect(); let mut found = Vec::new(); for (hit, rect) in hits { + let viewport = match hit { + Hit::Divider(Divider::Split(_) | Divider::Intersection(..)) => None, + Hit::Divider(Divider::WindowSplit(panel, _) | Divider::WindowIntersection(panel, ..)) + | Hit::Tab(panel) + | Hit::DragBar(panel) + | Hit::Overflow(panel) + | Hit::Title(panel) + | Hit::Resize(panel, _) => window(panel).and_then(|index| native.get(&index).copied()), + _ => None, + } + .unwrap_or(egui::ViewportId::ROOT); let entry = match hit { Hit::Tab(panel) => (format!("panel.tab.{}", panel.id()), panel.title()), Hit::DragBar(panel) => (format!("panel.drag.{}", panel.id()), "Move panel"), @@ -353,12 +369,12 @@ impl FilmcraftApp { }, _ => continue, }; - found.push((entry, *rect, DockArea::hit_id(area_id(), hit))); + found.push((entry, *rect, DockArea::hit_id(area_id(), hit), viewport)); } // The widget id lets keyboard focus on a divider or grip map to its element (shortcuts // stay live while one is focused). - for ((id, label), rect, widget) in found { - self.auto.add(&id, rect, label); + for ((id, label), rect, widget, viewport) in found { + self.auto.add_in(viewport, &id, rect, label); if let Some(element) = self.auto.elements.last_mut() { element.widget_id = Some(format!("{:016x}", widget.value())); element.response_id = Some(widget); @@ -423,10 +439,20 @@ impl DockContent for FilmContent<'_> { let frame = ui.max_rect(); let rect = frame; self.bodies.push((*panel, rect, ui.layer_id())); + // A panel in its own OS window draws in that window's coordinates: its automation + // elements carry the window so they are not matched against main-window points. + let viewport = ui.ctx().viewport_id(); + if viewport != egui::ViewportId::ROOT + && let Some(index) = self.app.ui.dock.floating.iter().position(|window| window.contains(panel)) + { + self.app.auto.windows.insert(viewport, index); + } + self.app.auto.viewport = viewport; self.app.auto.add(&format!("panel.{}", panel.id()), rect, panel.title()); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).id_salt(("panel", panel.id()))); child.set_clip_rect(rect.intersect(ui.clip_rect())); panels::show(self.app, &mut child, *panel, rect); + self.app.auto.viewport = egui::ViewportId::ROOT; // The body fills its frame; allocating it lets a floated panel take its full size. ui.allocate_rect(ui.max_rect(), Sense::hover()); // Painted after the body so child backgrounds cannot hide the focus outline (#1075). diff --git a/crates/ui-egui/src/panels/keyboard.rs b/crates/ui-egui/src/panels/keyboard.rs index 33ada49f6..79e88f225 100644 --- a/crates/ui-egui/src/panels/keyboard.rs +++ b/crates/ui-egui/src/panels/keyboard.rs @@ -246,10 +246,15 @@ pub(crate) fn toggle_maximize(app: &mut FilmcraftApp, p: PanelKind) -> Value { json!({"maximized": app.ui.keys.maximized.map(|p| p.title())}) } -/// The visible panel under the pointer (from last frame's automation rects). +/// The visible panel under the pointer (from last frame's automation rects). Each native +/// floating window has its own coordinates, so only rects of the window holding the pointer +/// count. fn panel_under_pointer(app: &FilmcraftApp, ctx: &egui::Context) -> Option { - let pos = ctx.input(|i| i.pointer.latest_pos())?; - app.auto.query("panel.").into_iter().rev().find_map(|e| { + let panels = app.auto.query("panel."); + let pointer = |viewport| ctx.input_for(viewport, |i| i.pointer.latest_pos()).map(|pos| (viewport, pos)); + let (viewport, pos) = + pointer(egui::ViewportId::ROOT).or_else(|| panels.iter().map(|e| e.viewport).filter(|v| *v != egui::ViewportId::ROOT).find_map(pointer))?; + panels.into_iter().rev().filter(|e| e.viewport == viewport).find_map(|e| { let r = egui::Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])); r.contains(pos).then(|| PanelKind::from_name(&e.id["panel.".len()..])).flatten() }) diff --git a/crates/ui-egui/tests/it/keyboard_ui.rs b/crates/ui-egui/tests/it/keyboard_ui.rs index 7d42995e6..87094087f 100644 --- a/crates/ui-egui/tests/it/keyboard_ui.rs +++ b/crates/ui-egui/tests/it/keyboard_ui.rs @@ -206,6 +206,47 @@ fn new_keys_work_in_the_app() { assert_eq!(d.app().ui.program.zoom, None); } +/// Maximize Frame Under Cursor matches the pointer only against the panels of the window +/// holding it: a panel floated into its own OS window has that window's coordinates, which +/// overlap the main window's. +#[test] +fn maximize_under_cursor_ignores_native_window_rects() { + use filmcraft_ui_egui::dock::PanelKind; + let mut d = Driver::new(); + // A stand-in for OS windows: each native viewport runs its own frame, with no input. + egui::Context::set_immediate_viewport_renderer(|ctx, mut viewport| { + let size = viewport.builder.inner_size.unwrap_or(egui::vec2(400.0, 300.0)); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size); + let info = egui::ViewportInfo { inner_rect: Some(screen), ..Default::default() }; + let id = viewport.ids.this; + let input = egui::RawInput { viewport_id: id, viewports: std::iter::once((id, info)).collect(), screen_rect: Some(screen), ..Default::default() }; + ctx.run_ui(input, |ui| (viewport.viewport_ui_cb)(ui)).textures_delta.clear(); + }); + d.harness.ctx.set_embed_viewports(false); + d.ok("ui.menu.invoke", json!({"id": "window.floatPanel", "params": {"panel": "Info", "rect": [0, 0, 700, 500]}})); + d.frames(4); + let elements = d.ok("ui.elements", json!({"prefix": "panel."})); + let elements = elements.as_array().unwrap(); + let rect = |e: &Value| { + let r: Vec = e["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap() as f32).collect(); + egui::Rect::from_min_size(egui::pos2(r[0], r[1]), egui::vec2(r[2], r[3])) + }; + let floated = elements.iter().find(|e| e["id"] == "panel.Info").unwrap(); + assert!(floated["window"].is_u64(), "the native window's body carries its window: {floated}"); + let (under, pos) = elements + .iter() + .filter(|e| e["window"].is_null()) + .find_map(|e| { + let panel = PanelKind::from_name(e["id"].as_str()?.strip_prefix("panel.")?)?; + let overlap = rect(e).intersect(rect(floated)); + overlap.is_positive().then(|| (panel, overlap.center())) + }) + .expect("a main-window panel overlaps the native window's body rect"); + d.harness.event(egui::Event::PointerMoved(pos)); + d.key("`"); + assert_eq!(d.app().ui.keys.maximized, Some(under)); +} + /// Select Next / Previous Panel skip the narrow Tools and Audio Meters strips. #[test] fn panel_cycling_skips_tools_and_audio_meters() { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d6d9286ca..e939f59d3 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -50,7 +50,7 @@ Methods (handlers in `crates/ui-egui/src/control.rs`): | `engine.commands` / `ui.menu.list` | – | command registry / menu tree | | `ui.inspect` | – | UI state (tool, workspace, dock, timeline view, playback, fps…) | | `ui.docking` | – | the panel layout (craft-ui `Layout`), the focused and maximized panel, and `hitRects`: each docking gesture source drawn last frame (`{hit, rect}`) | -| `ui.elements` | `{prefix?}` | every on-screen interactive element: id, label, rect | +| `ui.elements` | `{prefix?}` | every on-screen interactive element: id, label, rect; `window` (the `n` of `panel.float.`) marks an element drawn in a floating panel's own OS window, whose rect is in that window's coordinates | | `ui.set` | `{tool, workspace, mode, theme, focused, playbackRes, timeline:{pps,scroll,fit}, program:{…}, source:{…}, clipDialog:{param: value}, menuDialog:{param: value}, panels:{scopes, timecode, reference, events, progress}, export:{…}, keyframeSelection:[…]}` | `keyframeSelection` replaces the Effect Controls keyframe selection (`[{clip, effect, param, mask?, time}]`, `time` in media ticks; read back as `ui.inspect` → `ui.keyframe_selection`; a click on a lane keyframe selects just that one); `program` / `source` merge fields into the monitor view state (`res`, `paused_res`, `high_quality`, `display`, `zoom`, `pan`, `show_rulers`, `show_guides`, `lock_guides`, `snap`, `guides`, `compare_ref`, …; see [monitors.md](monitors.md)); `clipDialog` sets fields of the open Edit / Clip / File dialog (Paste Attributes, Make Subclip, Frame Hold Options, …); `menuDialog` those of the M3.11 dialogs (Find, Create Search Bin, Project Settings, Scene Edit Detection, Simplify Sequence, Automate to Sequence…; see `panels::menu_dialogs`); `panels` merges into the panel settings (`ui.inspect` → `ui.panels`; see `panels::panel_state`): Lumetri Scopes `{shown: ["vectorscopeYuv"|"vectorscopeHls"|"histogram"|"parade"|"waveform"], waveformType, paradeType, colorSpace, brightness, scale, clamp, targets}`, Timecode `{rows: [{source, mode, display}], showName}`, Reference Monitor `{ganged, time, display: "composite"|"scopes", scopes}`, Events `{level, selected}`, Progress `{showFinished}` `export` deep-merges into Export mode state (`preset` — applied first —, `settings` (ExportSettings JSON), `fileName`, `location`, `range`, `customStart`, `customEnd`, `openSections`, `manager` (Preset Manager: `query`, `favoritesOnly`, `selected`, `saveName`), `quickOpen`, `quickPath`, `quickPreset`); `program` / `source` merge fields into the monitor view state (`res`, `paused_res`, `high_quality`, `display`, `zoom`, `pan`, `show_rulers`, `show_guides`, `lock_guides`, `snap`, `guides`, `compare_ref`, …; see [monitors.md](monitors.md)); `clipDialog` sets fields of the open Edit / Clip / File dialog (Paste Attributes, Make Subclip, Frame Hold Options, …); `menuDialog` those of the M3.11 dialogs (Find, Create Search Bin, Project Settings, Scene Edit Detection, Simplify Sequence, Automate to Sequence…; see `panels::menu_dialogs`); `projectPanel` merges into the Project panel's view state (`bin` shown in place, `tabs` (bins in a tab or window: `{bin, floating, view, iconSize, nav}`), `activeTab`, `selectedBin`, `rename: {item|bin, text}`, `dialog` (`{"metadataDisplay": {columns, filter}}`, `{"savePresetAs": {name}}`, `{"managePresets": {selected, name}}`, `{"freeformOptions": {options}}`, `{"saveArrangement": {name, bin}}`)); `mediaBrowser` into the Media Browser's (`expanded`, `pathEdit`, `editColumns`, `treeWidth`). View settings, columns and presets are engine commands (`project.view.set`, `project.columns.set`, `project.viewPreset.*`, `mediaBrowser.settings`) | | `ui.panel.show` / `ui.panel.close` | `{panel}` | | | `ui.click` / `ui.move` | `{id}` or `{x,y}`, `button`, `count`, `modifiers` | synthetic pointer input; click `count` is an integer 0–10 (default 1); `ui.move` ignores it | From 7a8a2da2df7c8e23d2892a37b41781c5020027ce Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 20:18:10 +0000 Subject: [PATCH 050/113] Update docking docs and drop leftovers --- crates/ui-egui/README.md | 28 +++++++++++++-------- crates/ui-egui/src/dock_ui.rs | 9 ++----- crates/ui-egui/tests/it/monitor_geometry.rs | 6 ++--- docs/control-protocol.md | 9 +++---- 4 files changed, 25 insertions(+), 27 deletions(-) diff --git a/crates/ui-egui/README.md b/crates/ui-egui/README.md index 2911aa3ed..98799841f 100644 --- a/crates/ui-egui/README.md +++ b/crates/ui-egui/README.md @@ -78,17 +78,23 @@ keeps Source navigation, markers and marked-range playback separate from Program ## Pane layout controls -Dock transactions and pointer targets use `craft-ui::docking`; bounded split geometry and -keyboard-accessible resize handles use `craft-ui::layout`. FilmCraft adapts its existing saved -dock tree and panel identities to the shared model. Panel tabs or their header grip can move -between tab groups, split at an edge, reorder, or float when released with Cmd/Ctrl; a release -over no target cancels. Floating windows move by their title bar, resize from every edge and -split like the main window. Every group, Tools and the Audio Meters included, resizes down to -the same minimum and has no maximum. Timeline sequence -tabs retain their sequence-opening, closing and horizontal reorder behavior; the header grip -moves the Timeline panel. Workspace saves retain floating groups alongside the legacy layout -JSON, and workspaces without floating data still load. Automation IDs and workspace commands -remain the integration boundary. +The panels use `craft-ui::docking` with its split-tree settings: craft-ui draws the frames, tab +strips, floating windows and drag previews and owns the gestures; `dock_ui.rs` draws the panel +bodies, the ≡ panel menu and the Timeline's sequence tabs. Bounded split geometry and +keyboard-accessible resize handles use `craft-ui::layout`. Panel tabs or their group's drag bar +move between tab groups, split at a group's or the window's edge, reorder, or float when released +with Cmd/Ctrl; a release over no target cancels. Every group, Tools and the Audio Meters included, +resizes down to the same minimum and has no maximum. Timeline sequence tabs retain their +sequence-opening, closing and horizontal reorder behavior. + +On desktop each floating panel opens in its own OS window. These windows stay above other +windows and hide while another application is active. A floating window moves by its title bar, +resizes from every edge and splits like the main window. On Wayland, drops between windows can't +be resolved, so a floating window docks back only through a command such as `window.dockPanel`. +Where egui embeds viewports (tests), floating windows stay inside the main window. + +Named workspaces save the layout, floating windows included; workspaces saved in the earlier +layout format still load. Automation IDs and workspace commands remain the integration boundary. Tools and dock tabs expose their actual egui widget IDs, paint keyboard focus, and support Tab/Shift-Tab traversal and Space/Enter activation. Control-injected keys can reach focused diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 8f0d20343..73f0fb0c7 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -321,10 +321,8 @@ impl FilmcraftApp { /// (the group's drag bar), `panel.tabs.more.`, `panel.float..move` and `.resize`, /// `dock.gutter.` (`a`/`b` per split level), `dock.corner..` /// where two dividers meet, the same inside floating window `n` as `panel.float..gutter.…` - /// and `panel.float..corner.…`, and `dock.gutter.group.`, `.stack.` and - /// `.column.` for the dividers below a group or accordion entry and beside a column, - /// named by the panel they hold first. Controls of a floating window in its own OS window - /// are registered in that window's viewport. + /// and `panel.float..corner.…`. Controls of a floating window in its own OS window are + /// registered in that window's viewport. fn register_dock_hits(&mut self, hits: &[(Hit, Rect)]) { let window = |panel: &PanelKind| self.ui.dock.floating.iter().position(|window| window.contains(panel)); let native: HashMap = self.auto.windows.iter().map(|(viewport, index)| (*index, *viewport)).collect(); @@ -355,9 +353,6 @@ impl FilmcraftApp { None => continue, }, Hit::Divider(Divider::Split(path)) => (format!("dock.gutter.{}", path_id(path)), "gutter"), - Hit::Divider(Divider::Group(panel)) => (format!("dock.gutter.group.{}", panel.id()), "gutter"), - Hit::Divider(Divider::Stack(panel)) => (format!("dock.gutter.stack.{}", panel.id()), "gutter"), - Hit::Divider(Divider::Column(panel)) => (format!("dock.gutter.column.{}", panel.id()), "gutter"), Hit::Divider(Divider::Intersection(outer, inner)) => (format!("dock.corner.{}.{}", path_id(outer), path_id(inner)), "gutter corner"), Hit::Divider(Divider::WindowSplit(panel, path)) => match window(panel) { Some(index) => (format!("panel.float.{index}.gutter.{}", path_id(path)), "gutter"), diff --git a/crates/ui-egui/tests/it/monitor_geometry.rs b/crates/ui-egui/tests/it/monitor_geometry.rs index 6ecaa81d4..0b6b355cf 100644 --- a/crates/ui-egui/tests/it/monitor_geometry.rs +++ b/crates/ui-egui/tests/it/monitor_geometry.rs @@ -6,7 +6,6 @@ use egui_kittest::kittest::{NodeT, Queryable}; use filmcraft_engine::Session; use filmcraft_ui_egui::{ FilmcraftApp, - dock::PanelKind, panels::monitor::{self, Which}, theme::{self, ThemeKind}, }; @@ -24,12 +23,11 @@ fn harness_at_scale(width: f32, kind: ThemeKind, which: Which, scale: f32) -> Ha move |ui, app: &mut FilmcraftApp| { app.set_theme(ui.ctx(), kind); app.auto.begin_frame(); - let panel = if which == Which::Source { PanelKind::Source } else { PanelKind::Program }; - // The panel frame as docking draws it: a tab strip over the themed panel background. + // A stand-in for the docked frame: the themed panel background, with the body below + // a tab strip's height. let frame = ui.max_rect(); ui.painter().rect_filled(frame, 0.0, app.tokens.panel_bg); let content = egui::Rect::from_min_max(egui::pos2(frame.min.x, frame.min.y + app.tokens.tab_h), frame.max); - let _ = panel; monitor::show(app, ui, content, which); let source = filmcraft_ui_egui::panels::dragged_source(ui); ui.ctx().data_mut(|data| data.insert_temp(egui::Id::new("observed-source-drag"), source)); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e939f59d3..8e8cf301a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -209,13 +209,12 @@ Time is in ticks (254 016 000 000 per second); commands also accept `seconds`, ` Invalid source/target identities and non-finite or out-of-range geometry return an error with the layout unchanged. `ui.inspect.ui.dock.floating` reports floating windows and their logical-point rectangles. `panel.drag.` locates the drag bar of each panel's group; -`panel.float..move` and `.resize` locate floating geometry handles; `dock.gutter.` +`panel.float..move`, `.resize` (bottom-right corner) and `.resize.` locate floating +geometry handles; `dock.gutter.` locates a split divider (`a`/`b` per split level), `dock.corner..` the four-way handle where two dividers meet, `panel.float..gutter.` and -`panel.float..corner..` the same inside a floating window, and -`dock.gutter.group.`, -`.stack.` and `.column.` the dividers below a group or accordion entry and beside a -column, named by the panel they hold first; `ui.docking` lists every docking gesture source. Named workspace saves include floating windows. +`panel.float..corner..` the same inside a floating window; +`ui.docking` lists every docking gesture source. Named workspace saves include floating windows. The panels use craft-ui's shared docking with its split-tree settings. Its control commands (`window.panel.move`, `.float`, `.dock`, `.activate`, `.close`, `.closeGroup`, `.closeOthers`, From 3c8bb57ebe116761c5edbeb1d925f2c46a50f410 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 20:20:51 +0000 Subject: [PATCH 051/113] Write the earlier workspace layout format alongside saved workspaces --- crates/ui-egui/README.md | 3 +- crates/ui-egui/src/dock.rs | 139 ++++++++++++++++++- crates/ui-egui/src/panels/workspaces.rs | 8 +- crates/ui-egui/tests/it/shared_docking_ui.rs | 4 +- 4 files changed, 142 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/README.md b/crates/ui-egui/README.md index 98799841f..ccc6a03e9 100644 --- a/crates/ui-egui/README.md +++ b/crates/ui-egui/README.md @@ -94,7 +94,8 @@ be resolved, so a floating window docks back only through a command such as `win Where egui embeds viewports (tests), floating windows stay inside the main window. Named workspaces save the layout, floating windows included; workspaces saved in the earlier -layout format still load. Automation IDs and workspace commands remain the integration boundary. +layout format still load. While every floating window is one tab group, a save also writes the +earlier format, so earlier versions still open the workspace. Automation IDs and workspace commands remain the integration boundary. Tools and dock tabs expose their actual egui widget IDs, paint keyboard focus, and support Tab/Shift-Tab traversal and Space/Enter activation. Control-injected keys can reach focused diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index d84b183bd..11cbecb3d 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -162,7 +162,9 @@ pub struct WorkspacePrefs { /// A named workspace. `workspace` is a craft-ui [`Workspace`] kept as JSON, so one unreadable /// entry (a panel a newer version added) doesn't lose the others. Files written before /// FilmCraft used craft-ui workspaces hold a frame tree in `layout` and floating groups in -/// `floating` instead; [`saved_workspace`] reads them, and saving writes `workspace`. +/// `floating` instead; [`saved_workspace`] reads them. [`SavedWorkspace::new`] writes +/// `workspace`, and also `layout` and `floating` while every floating window is one tab +/// group, so that earlier versions still open the workspace. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct SavedWorkspace { @@ -175,6 +177,16 @@ pub struct SavedWorkspace { pub floating: serde_json::Value, } +impl SavedWorkspace { + pub fn new(name: &str, layout: &Layout) -> Result { + let workspace = Workspace::new(layout.clone()); + workspace.validate().map_err(|e| e.to_string())?; + let workspace = serde_json::to_value(&workspace).map_err(|e| e.to_string())?; + let (layout, floating) = legacy::write(layout).unwrap_or_default(); + Ok(SavedWorkspace { name: name.to_string(), workspace, layout, floating }) + } +} + impl WorkspacePrefs { pub fn load(path: &std::path::Path) -> Self { use std::io::Read as _; @@ -298,11 +310,11 @@ pub fn saved_docking(prefs: &WorkspacePrefs, name: &str) -> Layout { /// [`Workspace`]s. mod legacy { use super::{Floating, Layout, Node, PanelKind, SplitAxis, SplitSize}; - use serde::Deserialize; + use serde::{Deserialize, Serialize}; const MAX_DEPTH: usize = 32; - #[derive(Deserialize)] + #[derive(Deserialize, Serialize)] enum Tree { /// `vertical`: children stacked top/bottom; else side by side. Split { @@ -317,14 +329,14 @@ mod legacy { }, } - #[derive(Deserialize)] + #[derive(Deserialize, Serialize)] enum Size { Ratio(f32), FixedA(f32), FixedB(f32), } - #[derive(Deserialize)] + #[derive(Deserialize, Serialize)] struct Group { panels: Vec, active: usize, @@ -350,6 +362,45 @@ mod legacy { }) } + fn tree(node: &Node, depth: usize) -> Option { + if depth > MAX_DEPTH { + return None; + } + Some(match node { + Node::Tabs { panels, active } => Tree::Tabs { panels: panels.clone(), active: *active }, + Node::Split { axis, size, first, second } => Tree::Split { + vertical: *axis == SplitAxis::Vertical, + size: match *size { + SplitSize::Ratio(value) => Size::Ratio(value), + SplitSize::FixedFirst(value) => Size::FixedA(value), + SplitSize::FixedSecond(value) => Size::FixedB(value), + }, + a: Box::new(tree(first, depth + 1)?), + b: Box::new(tree(second, depth + 1)?), + }, + _ => return None, + }) + } + + /// `layout` as the `layout` and `floating` fields earlier versions read; `None` when a + /// floating window holds more than one tab group, which they can't represent. + pub(super) fn write(layout: &Layout) -> Option<(serde_json::Value, serde_json::Value)> { + let root = match &layout.root { + Some(root) => tree(root, 0)?, + None => Tree::Tabs { panels: Vec::new(), active: 0 }, + }; + let floating = layout + .floating + .iter() + .map(|window| match &window.root { + Node::Tabs { panels, active } => Some(Group { panels: panels.clone(), active: *active, rect: window.rect }), + _ => None, + }) + .collect::>>()?; + let floating = if floating.is_empty() { serde_json::Value::Null } else { serde_json::to_value(floating).ok()? }; + Some((serde_json::to_value(root).ok()?, floating)) + } + pub(super) fn layout(root: &serde_json::Value, floating: &serde_json::Value) -> Option { let tree: Tree = serde_json::from_value(root.clone()).ok()?; let floating: Vec = if floating.is_null() { vec![] } else { serde_json::from_value(floating.clone()).ok()? }; @@ -876,4 +927,82 @@ mod tests { let back: WorkspacePrefs = serde_json::from_str(&text).unwrap(); assert_eq!(saved_docking(&back, "Mine"), mine); } + + /// A saved workspace still opens in versions that read only the earlier `layout` and + /// `floating` fields, as long as every floating window is one tab group. + #[test] + fn saved_workspaces_open_in_earlier_versions() { + // The shapes earlier versions read the file into. + #[allow(dead_code)] + #[derive(Debug, PartialEq, Deserialize)] + enum OldSize { + Ratio(f32), + FixedA(f32), + FixedB(f32), + } + #[allow(dead_code)] + #[derive(Debug, PartialEq, Deserialize)] + enum OldNode { + Split { vertical: bool, size: OldSize, a: Box, b: Box }, + Tabs { panels: Vec, active: usize }, + } + #[derive(Debug, PartialEq, Deserialize)] + struct OldFloating { + panels: Vec, + active: usize, + rect: [f32; 4], + } + #[derive(Default, Deserialize)] + #[serde(rename_all = "camelCase", default)] + struct OldSaved { + layout: serde_json::Value, + floating: serde_json::Value, + } + #[derive(Default, Deserialize)] + #[serde(rename_all = "camelCase", default)] + struct OldPrefs { + saved: Vec, + } + let read = |layout: &Layout| { + let prefs = WorkspacePrefs { saved: vec![SavedWorkspace::new("Mine", layout).unwrap()], current: "Mine".into() }; + let text = serde_json::to_string(&prefs).unwrap(); + assert_eq!(saved_docking(&serde_json::from_str(&text).unwrap(), "Mine"), *layout, "{text}"); + let old: OldPrefs = serde_json::from_str(&text).unwrap(); + let entry = old.saved.into_iter().next().unwrap(); + let floating: Vec = if entry.floating.is_null() { vec![] } else { serde_json::from_value(entry.floating).unwrap() }; + (serde_json::from_value::(entry.layout).ok(), floating) + }; + + fn panels(node: &OldNode, out: &mut Vec) { + match node { + OldNode::Split { a, b, .. } => { + panels(a, out); + panels(b, out); + } + OldNode::Tabs { panels, .. } => out.extend(panels), + } + } + + let mut layout = workspace("Editing"); + layout.apply(Action::Float { panel: PanelKind::Info, rect: [10.0, 20.0, 300.0, 200.0] }).unwrap(); + let (root, floating) = read(&layout); + let mut old = Vec::new(); + panels(&root.unwrap(), &mut old); + let docked = Layout { root: layout.root.clone(), floating: vec![] }; + assert_eq!(old, docked.panels().into_iter().copied().collect::>()); + assert_eq!(floating, vec![OldFloating { panels: vec![PanelKind::Info], active: 0, rect: [10.0, 20.0, 300.0, 200.0] }]); + + // A floating window split in two has no earlier form: those versions use the default. + let mut split = layout.clone(); + split.apply(Action::Float { panel: PanelKind::Effects, rect: [400.0, 20.0, 300.0, 200.0] }).unwrap(); + let window = split.floating.iter().position(|w| w.contains(&PanelKind::Effects)).unwrap(); + let effects = split.floating.remove(window); + split.floating[0].root = Node::Split { + axis: SplitAxis::Vertical, + size: SplitSize::Ratio(0.5), + first: Box::new(split.floating[0].root.clone()), + second: Box::new(effects.root), + }; + assert_eq!(read(&split), (None, vec![])); + } } diff --git a/crates/ui-egui/src/panels/workspaces.rs b/crates/ui-egui/src/panels/workspaces.rs index 89ac924a1..aed12891b 100644 --- a/crates/ui-egui/src/panels/workspaces.rs +++ b/crates/ui-egui/src/panels/workspaces.rs @@ -56,14 +56,12 @@ pub fn route(app: &mut FilmcraftApp, id: &str, params: &Value) -> Option Result<(), String> { - let workspace = crate::dock::Workspace::new(app.ui.dock.clone()); - workspace.validate().map_err(|e| e.to_string())?; - let workspace = serde_json::to_value(&workspace).map_err(|e| e.to_string())?; + let entry = dock::SavedWorkspace::new(name, &app.ui.dock)?; let mut next = app.workspaces.clone(); let saved = &mut next.saved; match saved.iter_mut().find(|s| s.name == name) { - Some(s) => *s = dock::SavedWorkspace { name: name.to_string(), workspace, ..Default::default() }, - None => saved.push(dock::SavedWorkspace { name: name.to_string(), workspace, ..Default::default() }), + Some(s) => *s = entry, + None => saved.push(entry), } next.current = name.to_string(); app.set_workspaces(next) diff --git a/crates/ui-egui/tests/it/shared_docking_ui.rs b/crates/ui-egui/tests/it/shared_docking_ui.rs index 5a7359266..1d2fa0a28 100644 --- a/crates/ui-egui/tests/it/shared_docking_ui.rs +++ b/crates/ui-egui/tests/it/shared_docking_ui.rs @@ -91,7 +91,9 @@ fn builtin_and_legacy_workspace_shapes_roundtrip_and_save_floating() { let prefs = dock::WorkspacePrefs::load(&dir.join(dock::WORKSPACES_FILE)); let restored = dock::saved_docking(&prefs, "Floating test"); assert_eq!(restored, app.ui.dock); - assert!(prefs.saved[0].layout.is_null() && !prefs.saved[0].workspace.is_null()); + // Earlier versions read `layout` and `floating`. + assert!(!prefs.saved[0].layout.is_null() && !prefs.saved[0].workspace.is_null()); + assert_eq!(prefs.saved[0].floating[0]["panels"], json!(["Info"])); let mut restarted_session = Session::default(); restarted_session.prefs_path = Some(dir.join("preferences.json")); let restarted = FilmcraftApp::new(restarted_session); From 2ff95d03d61c2dd1ea2bc387a9b7c4299ce125f7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:05:59 +0000 Subject: [PATCH 052/113] docking: craft-libs 6e7f285 Repins craft-docpath, craft-media and craft-ui to storytold/craft-libs 6e7f285. No API changes are needed. --- Cargo.lock | 6 +++--- Cargo.toml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 13dd2c54d..d8c1aaa11 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1014,12 +1014,12 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" [[package]] name = "craft-media" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" dependencies = [ "thiserror 2.0.21", ] @@ -1027,7 +1027,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" dependencies = [ "egui", "log", diff --git a/Cargo.toml b/Cargo.toml index f14bd2447..d5d85d982 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,12 +72,12 @@ chrono = { version = "0.4.45", default-features = false, features = ["std"] } rayon = "1" proptest = "1" roxmltree = "0.21" -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" } -craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3", default-features = false } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } +craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae", default-features = false } # ui # Shared frontend components; keep this dependency above the engine boundary. -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3", features = ["serde", "native-windows"] } +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae", features = ["serde", "native-windows"] } egui = "0.36" eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" From ccb5bd4289a3975279ad060e6718c96d079dfe31 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:05:59 +0000 Subject: [PATCH 053/113] docking: Tools and the Audio Meters keep fixed widths The built-in workspaces give Tools 64 pt and the Audio Meters 116 pt as fixed split sizes, so they keep their widths as the window resizes; craft-ui keeps a fixed size fixed through divider drags. Saved workspaces load their fixed and ratio sizes as written. --- crates/ui-egui/src/dock.rs | 135 ++++++++++++++----------------------- 1 file changed, 52 insertions(+), 83 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 11cbecb3d..9a245dda3 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -239,68 +239,20 @@ pub fn find(prefs: &WorkspacePrefs, query: &str) -> Option { } /// A workspace as saved: the user's saved version, else the built-in default. An entry that -/// does not read or validate falls back to the default and stays in the file untouched. Fixed -/// split sizes load as ratios. +/// does not read or validate falls back to the default and stays in the file untouched. pub fn saved_workspace(prefs: &WorkspacePrefs, name: &str) -> Workspace { let saved = prefs.saved.iter().find(|saved| saved.name == name).and_then(|saved| { - let mut workspace = if saved.workspace.is_null() { + let workspace = if saved.workspace.is_null() { Workspace::new(legacy::layout(&saved.layout, &saved.floating)?) } else { serde_json::from_value::(saved.workspace.clone()).ok()? }; workspace.validate().ok()?; - ratio_layout(&mut workspace.layout); - if let Some(layout) = workspace.saved.as_mut() { - ratio_layout(layout); - } Some(workspace) }); saved.unwrap_or_else(|| Workspace::new(workspace(name))) } -/// The docked area of a 1600 x 1000 pt window, which a fixed split size from a saved layout is -/// measured against when it loads as a ratio. -const NOMINAL_DOCK: egui::Vec2 = vec2(1600.0, 939.0); -/// The gap between groups (`Tokens::gap`). -const GAP: f32 = 4.0; - -/// Saved layouts may hold fixed split sizes (older FilmCraft versions wrote them); every split -/// loads as the ratio it had at the nominal window size, so a window resize scales all groups. -fn ratio_layout(layout: &mut Layout) { - if let Some(root) = layout.root.as_mut() { - ratio_splits(root, NOMINAL_DOCK, 0); - } - for window in &mut layout.floating { - let [_, _, width, height] = window.rect; - ratio_splits(&mut window.root, vec2(width, height), 0); - } -} - -fn ratio_splits(node: &mut Node, size: egui::Vec2, depth: usize) { - if depth > craft_ui::docking::MAX_DEPTH { - return; - } - let Node::Split { axis, size: split, first, second } = node else { return }; - let extent = match axis { - SplitAxis::Horizontal => size.x, - SplitAxis::Vertical => size.y, - }; - let available = (extent - GAP).max(1.0); - let ratio = match *split { - SplitSize::Ratio(ratio) => ratio, - SplitSize::FixedFirst(points) => points / available, - SplitSize::FixedSecond(points) => 1.0 - points / available, - }; - let ratio = if ratio.is_finite() { ratio.clamp(0.0, 1.0) } else { 0.5 }; - *split = SplitSize::Ratio(ratio); - let (a, b) = match axis { - SplitAxis::Horizontal => (vec2(available * ratio, size.y), vec2(available * (1.0 - ratio), size.y)), - SplitAxis::Vertical => (vec2(size.x, available * ratio), vec2(size.x, available * (1.0 - ratio))), - }; - ratio_splits(first, a, depth + 1); - ratio_splits(second, b, depth + 1); -} - /// The saved layout of a workspace (see [`saved_workspace`]). pub fn saved_docking(prefs: &WorkspacePrefs, name: &str) -> Layout { saved_workspace(prefs, name).layout @@ -457,6 +409,11 @@ mod legacy { } } +/// The width of the Tools panel in the built-in workspaces, in points. +const TOOLS_WIDTH: f32 = 64.0; +/// The width of the Audio Meters in the built-in workspaces, in points. +const METERS_WIDTH: f32 = 116.0; + /// The default layout of a named workspace. pub fn workspace(name: &str) -> Layout { Layout { root: Some(workspace_root(name)), floating: vec![] } @@ -465,10 +422,15 @@ pub fn workspace(name: &str) -> Layout { fn workspace_root(name: &str) -> Node { use PanelKind::*; use SplitSize::Ratio; - // Tools | Timeline | Audio Meters. The ratios start Tools about 64 pt and the meters about - // 116 pt wide in a 1600 pt window; all three resize like any other group. - let bottom_editing = - |tools: f32, timeline: f32| hsplit(Ratio(tools), tabs(&[Tools], 0), hsplit(Ratio(timeline), tabs(&[Timeline], 0), tabs(&[AudioMeters], 0))); + // Tools | Timeline | Audio Meters. Tools and the meters keep their widths as the window + // resizes; the Timeline takes the rest. + let bottom_editing = || { + hsplit( + SplitSize::FixedFirst(TOOLS_WIDTH), + tabs(&[Tools], 0), + hsplit(SplitSize::FixedSecond(METERS_WIDTH), tabs(&[Timeline], 0), tabs(&[AudioMeters], 0)), + ) + }; match name { "Assembly" => vsplit( Ratio(0.52), @@ -477,14 +439,14 @@ fn workspace_root(name: &str) -> Node { tabs(&[Project, MediaBrowser, Libraries, Info, Effects, Markers, History], 0), hsplit(Ratio(0.5), tabs(&[Source, EffectControls, AudioClipMixer, Metadata], 0), tabs(&[Program], 0)), ), - bottom_editing(0.04, 0.92), + bottom_editing(), ), "Color" => hsplit( Ratio(0.79), vsplit( Ratio(0.58), hsplit(Ratio(0.42), tabs(&[LumetriScopes, Source, EffectControls], 0), tabs(&[Program], 0)), - hsplit(Ratio(0.3), tabs(&[Project, Info, Markers, History], 0), bottom_editing(0.07, 0.86)), + hsplit(Ratio(0.3), tabs(&[Project, Info, Markers, History], 0), bottom_editing()), ), tabs(&[LumetriColor, Effects], 0), ), @@ -493,7 +455,7 @@ fn workspace_root(name: &str) -> Node { vsplit( Ratio(0.5), hsplit(Ratio(0.5), tabs(&[Source, EffectControls, AudioClipMixer], 1), tabs(&[Program], 0)), - hsplit(Ratio(0.3), tabs(&[Project, MediaBrowser, Info, Markers, History], 0), bottom_editing(0.07, 0.86)), + hsplit(Ratio(0.3), tabs(&[Project, MediaBrowser, Info, Markers, History], 0), bottom_editing()), ), vsplit(Ratio(0.5), tabs(&[Effects], 0), tabs(&[EssentialGraphics], 0)), ), @@ -502,7 +464,7 @@ fn workspace_root(name: &str) -> Node { vsplit( Ratio(0.5), hsplit(Ratio(0.5), tabs(&[AudioTrackMixer, Source, AudioClipMixer, EffectControls], 0), tabs(&[Program], 0)), - hsplit(Ratio(0.3), tabs(&[Project, MediaBrowser, Effects, Markers, History], 0), bottom_editing(0.07, 0.86)), + hsplit(Ratio(0.3), tabs(&[Project, MediaBrowser, Effects, Markers, History], 0), bottom_editing()), ), tabs(&[EssentialSound, TextToSpeech], 0), ), @@ -511,7 +473,7 @@ fn workspace_root(name: &str) -> Node { vsplit( Ratio(0.5), hsplit(Ratio(0.4), tabs(&[Text, Source, EffectControls], 0), tabs(&[Program], 0)), - hsplit(Ratio(0.3), tabs(&[Project, MediaBrowser, Libraries], 0), bottom_editing(0.07, 0.86)), + hsplit(Ratio(0.3), tabs(&[Project, MediaBrowser, Libraries], 0), bottom_editing()), ), tabs(&[EssentialGraphics, Properties], 0), ), @@ -522,7 +484,7 @@ fn workspace_root(name: &str) -> Node { tabs(&[Source, EffectControls, AudioClipMixer, Metadata, LumetriScopes, Text], 0), hsplit(Ratio(0.6), tabs(&[Program], 0), tabs(&[LumetriColor, EssentialGraphics, EssentialSound, TextToSpeech, Properties, AudioTrackMixer], 0)), ), - hsplit(Ratio(0.26), tabs(&[Project, MediaBrowser, Libraries, Info, Effects, Markers, History], 0), bottom_editing(0.055, 0.9)), + hsplit(Ratio(0.26), tabs(&[Project, MediaBrowser, Libraries, Info, Effects, Markers, History], 0), bottom_editing()), ), // Editing (default), Learning, Review — Premiere 26 factory layout: // top: Source group | Program | Properties; bottom: Project group | Tools | Timeline | Meters. @@ -533,7 +495,7 @@ fn workspace_root(name: &str) -> Node { hsplit(Ratio(0.5), tabs(&[Source, EffectControls, AudioClipMixer, Metadata], 0), tabs(&[Program], 0)), tabs(&[Properties, EssentialGraphics, Text], 0), ), - hsplit(Ratio(0.29), tabs(&[Project, MediaBrowser, Libraries, Info, Effects, Markers, History], 0), bottom_editing(0.06, 0.89)), + hsplit(Ratio(0.29), tabs(&[Project, MediaBrowser, Libraries, Info, Effects, Markers, History], 0), bottom_editing()), ), } } @@ -591,7 +553,7 @@ impl LayoutExt for Layout { match n { Node::Split { first, second, .. } => beside_meters(first) || beside_meters(second), Node::Tabs { panels, .. } if panels.as_slice() == [PanelKind::AudioMeters] => { - *n = hsplit(SplitSize::Ratio(0.85), tabs(&[PanelKind::Timeline], 0), tabs(&[PanelKind::AudioMeters], 0)); + *n = hsplit(SplitSize::FixedSecond(METERS_WIDTH), tabs(&[PanelKind::Timeline], 0), tabs(&[PanelKind::AudioMeters], 0)); true } _ => false, @@ -847,43 +809,50 @@ mod tests { } } - fn fixed_splits(node: &Node) -> usize { + /// The fixed sizes of a tree's splits, depth first. + fn fixed_splits(node: &Node) -> Vec { match node { - Node::Split { size, first, second, .. } => usize::from(!matches!(size, SplitSize::Ratio(_))) + fixed_splits(first) + fixed_splits(second), - _ => 0, + Node::Split { size, first, second, .. } => { + let mut out = if matches!(size, SplitSize::Ratio(_)) { vec![] } else { vec![*size] }; + out.extend(fixed_splits(first)); + out.extend(fixed_splits(second)); + out + } + _ => vec![], } } - /// Every group of a built-in workspace resizes with the window: no split keeps a fixed size. + /// In every built-in workspace Tools and the Audio Meters keep their widths as the window + /// resizes; every other group resizes with it. #[test] - fn workspaces_use_ratio_splits() { + fn workspaces_fix_only_tools_and_meters() { for w in WORKSPACES { let layout = workspace(w); - assert_eq!(layout.root.as_ref().map_or(0, fixed_splits), 0, "{w}"); + let fixed = layout.root.as_ref().map(fixed_splits).unwrap_or_default(); + assert_eq!(fixed, vec![SplitSize::FixedFirst(TOOLS_WIDTH), SplitSize::FixedSecond(METERS_WIDTH)], "{w}"); } } - /// A saved v1 workspace with fixed split sizes, docked and floating, loads with ratios that - /// keep its proportions at the nominal window size. + /// A saved workspace keeps its fixed split sizes, docked and floating, and its ratios. #[test] - fn fixed_splits_in_saved_workspaces_load_as_ratios() { + fn saved_workspaces_keep_fixed_and_ratio_splits() { let workspace = serde_json::json!({"layout": {"root": {"Split": {"axis": "Horizontal", "size": {"FixedSecond": 450.0}, - "first": {"Split": {"axis": "Horizontal", "size": {"FixedFirst": 64.0}, + "first": {"Split": {"axis": "Horizontal", "size": {"Ratio": 0.25}, "first": {"Tabs": {"panels": ["Tools"], "active": 0}}, "second": {"Tabs": {"panels": ["Timeline"], "active": 0}}}}, "second": {"Tabs": {"panels": ["AudioMeters"], "active": 0}}}}, - "floating": [{"groups": [{"panels": ["Info"], "active": 0, "open": true, "height": 100.0}, - {"panels": ["Effects"], "active": 0, "open": true, "height": 200.0}], "rect": [10.0, 20.0, 300.0, 304.0]}]}, "hidden": []}); + "floating": [{"root": {"Split": {"axis": "Vertical", "size": {"FixedFirst": 100.0}, + "first": {"Tabs": {"panels": ["Info"], "active": 0}}, "second": {"Tabs": {"panels": ["Effects"], "active": 0}}}}, + "rect": [10.0, 20.0, 300.0, 304.0]}]}, "hidden": []}); let prefs = WorkspacePrefs { saved: vec![SavedWorkspace { name: "Mine".into(), workspace, ..Default::default() }], current: "Mine".into() }; let loaded = saved_docking(&prefs, "Mine"); assert!(loaded.is_docked(PanelKind::Tools) && loaded.is_visible(PanelKind::Info) && loaded.is_visible(PanelKind::Effects), "{loaded:?}"); let root = loaded.root.as_ref().unwrap(); - assert_eq!(fixed_splits(root), 0, "{root:?}"); - assert!(loaded.floating.iter().all(|window| fixed_splits(&window.root) == 0), "{:?}", loaded.floating); - let Node::Split { size: SplitSize::Ratio(outer), first, .. } = root else { panic!("{root:?}") }; - let available = NOMINAL_DOCK.x - GAP; - assert!((outer - (available - 450.0) / available).abs() < 1e-4, "{outer}"); + let Node::Split { size: SplitSize::FixedSecond(outer), first, .. } = root else { panic!("{root:?}") }; + assert_eq!(*outer, 450.0); let Node::Split { size: SplitSize::Ratio(inner), .. } = &**first else { panic!("{first:?}") }; - assert!((inner - 64.0 / (available * outer - GAP)).abs() < 1e-4, "{inner}"); + assert_eq!(*inner, 0.25); + let floating: Vec<_> = loaded.floating.iter().flat_map(|window| fixed_splits(&window.root)).collect(); + assert_eq!(floating, vec![SplitSize::FixedFirst(100.0)], "{:?}", loaded.floating); } /// #284: with Project and Program closed (or every panel closed) a panel still opens. @@ -916,9 +885,9 @@ mod tests { let prefs: WorkspacePrefs = serde_json::from_value(legacy).unwrap(); let mine = saved_docking(&prefs, "Mine"); assert!(mine.is_visible(PanelKind::Info) && mine.is_visible(PanelKind::Timeline) && mine.is_visible(PanelKind::Effects)); - // the fixed 300 pt Project column loads as its share of the nominal 1600 pt window - let Some(Node::Split { size: SplitSize::Ratio(ratio), .. }) = mine.root else { panic!("{:?}", mine.root) }; - assert!((ratio - 300.0 / (NOMINAL_DOCK.x - GAP)).abs() < 1e-4, "{ratio}"); + // the Project column keeps its fixed 300 pt + let Some(Node::Split { size, .. }) = &mine.root else { panic!("{:?}", mine.root) }; + assert_eq!(*size, SplitSize::FixedFirst(300.0)); assert_eq!(saved_docking(&prefs, "Broken"), workspace("Broken")); let saved = SavedWorkspace { name: "Mine".into(), workspace: serde_json::to_value(Workspace::new(mine.clone())).unwrap(), ..Default::default() }; let prefs = WorkspacePrefs { saved: vec![saved], current: "Mine".into() }; From c8de47df060607948e49ec667ab5f60400c54019 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:06:24 +0000 Subject: [PATCH 054/113] docking: video viewers float as normal windows Program, Source, Reference Monitor and Lumetri Scopes return WindowKind::Normal, so in their own OS windows they stack with the main window and stay shown while the app is in the background. The other panels stay palettes. --- crates/ui-egui/src/dock_ui.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 73f0fb0c7..3f1c39642 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -6,7 +6,9 @@ use std::collections::HashMap; -use craft_ui::docking::{Action, Divider, DockArea, DockCommand, DockContent, DockEvent, DockStyle, FloatSpace, Hit, Layout, ResizeEdge, Workspace}; +use craft_ui::docking::{ + Action, Divider, DockArea, DockCommand, DockContent, DockEvent, DockStyle, FloatSpace, Hit, Layout, ResizeEdge, WindowKind, Workspace, +}; use egui::{Rect, Sense, Stroke, StrokeKind, vec2}; use serde_json::{Value, json}; @@ -465,6 +467,15 @@ impl DockContent for FilmContent<'_> { true } + /// The video viewers float as normal windows, say on a second display; the other panels + /// are palettes. + fn window_kind(&self, panel: &PanelKind) -> WindowKind { + match panel { + PanelKind::Program | PanelKind::Source | PanelKind::ReferenceMonitor | PanelKind::LumetriScopes => WindowKind::Normal, + _ => WindowKind::Palette, + } + } + /// With no sequence open, the Timeline's tab says so. fn label(&mut self, _ui: &egui::Ui, panel: &PanelKind, logical: &str, _font: &egui::FontId, _color: egui::Color32) -> egui::WidgetText { if *panel == PanelKind::Timeline && self.seqs.open.is_empty() { self.seqs.label().into() } else { logical.into() } From 9a31d342492126a199e82567a566fb6987700bc7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:06:37 +0000 Subject: [PATCH 055/113] docking: native windows take no input while a modal dialog is open egui's Modal blocks input only in the main window. While Settings, Recovery or Revert is open, another egui Modal was shown the frame before, or a native file dialog is pending, the dock area gets input_blocked(true). --- crates/ui-egui/src/dock_ui.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 3f1c39642..d0fa2a8c4 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -202,6 +202,15 @@ impl FilmcraftApp { }) } + /// Whether a modal dialog is open: Settings, Recovery or Revert (egui `Modal`s), any other + /// egui `Modal` shown last frame, or a native file dialog. egui's `Modal` blocks input only + /// in the main window, so the dock area blocks its native windows meanwhile. + fn modal_open(&self, ctx: &egui::Context) -> bool { + matches!(self.dialog, Some(crate::Dialog::Preferences | crate::Dialog::Recovery | crate::Dialog::RevertConfirm)) + || self.native_dialog_open() + || ctx.memory(|memory| memory.top_modal_layer().is_some()) + } + pub(crate) fn dock_area(&mut self, ui: &mut egui::Ui, body: Rect) { let t = self.tokens; let ctx = ui.ctx().clone(); @@ -223,8 +232,10 @@ impl FilmcraftApp { }; let style = dock_style(ui, &t); // No `monitors`: the monitor list comes with the egui fork's `ViewportInfo::monitors`. - let mut area = - DockArea::new(area_id()).float_bounds(self.float_bounds(&ctx)).maximized(self.ui.keys.maximized.filter(|panel| self.ui.dock.is_docked(*panel))); + let mut area = DockArea::new(area_id()) + .float_bounds(self.float_bounds(&ctx)) + .maximized(self.ui.keys.maximized.filter(|panel| self.ui.dock.is_docked(*panel))) + .input_blocked(self.modal_open(&ctx)); if let Some(panel) = self.pending_panel_raise.take() { area = area.raise_panel(panel); } From bfb181da741092eaaa704d66a41539855421600a Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:06:53 +0000 Subject: [PATCH 056/113] docking: input rewrite and file drops in native windows eframe runs raw_input_hook for the main window only. The dock area's native_input_hook runs the same rewrite in each native window: macOS Control+Click becomes a secondary click, paired per window, and --control-input-only drops the window system's input. Files dropped on a native window (DockOutput::window_files) are imported next frame like files dropped on the main window. On a Timeline, with drop imports to the Timeline on, they are placed at the drop point. The queued Timeline drop and the drag-over preview carry their viewport, so only the Timeline drawn in that window takes them. --- crates/ui-egui/src/dock_ui.rs | 6 +- crates/ui-egui/src/lib.rs | 219 +++++++++++++++++++++----- crates/ui-egui/src/panels/timeline.rs | 9 +- 3 files changed, 190 insertions(+), 44 deletions(-) diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index d0fa2a8c4..24e66ba9f 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -232,10 +232,13 @@ impl FilmcraftApp { }; let style = dock_style(ui, &t); // No `monitors`: the monitor list comes with the egui fork's `ViewportInfo::monitors`. + let control_input_only = self.control_input_only; + let armed = std::sync::Arc::clone(&self.native_ctrl_click_armed); let mut area = DockArea::new(area_id()) .float_bounds(self.float_bounds(&ctx)) .maximized(self.ui.keys.maximized.filter(|panel| self.ui.dock.is_docked(*panel))) - .input_blocked(self.modal_open(&ctx)); + .input_blocked(self.modal_open(&ctx)) + .native_input_hook(move |_ctx, raw_input| crate::rewrite_native_window_input(control_input_only, &armed, raw_input)); if let Some(panel) = self.pending_panel_raise.take() { area = area.raise_panel(panel); } @@ -253,6 +256,7 @@ impl FilmcraftApp { self.ui.focused = panel; } self.handle_forwarded_keys(&ctx, output.forwarded_events); + self.window_files = output.window_files; if let Some(error) = output.error { self.ui.status = error.to_string(); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d1fc971ca..be6c507b5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -64,8 +64,8 @@ mod theme_favorites; pub mod widgets; use std::collections::HashMap; -use std::sync::Arc; use std::sync::mpsc::{Receiver, Sender}; +use std::sync::{Arc, Mutex}; use egui::{TextureHandle, TextureOptions}; use filmcraft_engine::{Services, Session}; @@ -323,6 +323,9 @@ pub struct FilmcraftApp { /// right button or two-finger click), so the app does the platform's translation itself. #[cfg_attr(not(target_os = "macos"), allow(dead_code))] mac_ctrl_click_armed: bool, + /// [`Self::mac_ctrl_click_armed`] for each dock panel's own OS window, shared with the dock + /// area's input hook. + pub(crate) native_ctrl_click_armed: Arc>>, /// The mouse press in progress (its start time) and the undo history when it began, for Escape /// to take back what the press changed (#580). value_press: Option<(f64, filmcraft_engine::UndoMark)>, @@ -364,10 +367,14 @@ pub struct FilmcraftApp { /// The sequence whose view `ui.timeline` holds, and that view as it was last exchanged with /// `session.state.timeline_views` (see `sync_timeline_view`). timeline_view_of: Option, - /// Items imported by an OS file drop, to place on the Timeline at the drop point this frame. - pending_timeline_drop: Option<(Vec, egui::Pos2)>, - /// Files being dragged over the window from the OS (count, pointer): the Timeline previews where they would land. - file_drag_hover: Option<(usize, egui::Pos2)>, + /// Items imported by an OS file drop, to place on the Timeline at the drop point this frame + /// (in the coordinates of the viewport the files were dropped on). + pending_timeline_drop: Option<(Vec, egui::Pos2, egui::ViewportId)>, + /// Files being dragged over a window from the OS (count, pointer, viewport): the Timeline previews where they would land. + file_drag_hover: Option<(usize, egui::Pos2, egui::ViewportId)>, + /// Files dragged over or dropped on dock panels in their own OS windows last frame; the + /// dock area reports them after the panels have drawn, so they are imported next frame. + window_files: Vec>, timeline_view_last: Option, pub fps: f32, last_time: f64, @@ -578,6 +585,7 @@ impl FilmcraftApp { last_ui_time: 0.0, synthetic: Vec::new(), mac_ctrl_click_armed: false, + native_ctrl_click_armed: Arc::default(), value_press: None, loudness: None, status_seen: (String::new(), 0.0), @@ -599,6 +607,7 @@ impl FilmcraftApp { timeline_view_of: None, pending_timeline_drop: None, file_drag_hover: None, + window_files: Vec::new(), timeline_view_last: None, fps: 60.0, last_time: 0.0, @@ -1466,51 +1475,69 @@ impl FilmcraftApp { }) } - /// Import dropped files. With Timeline ▸ drop imports to the timeline, the new items are also - /// queued to be placed at the pointer (see `timeline::interact`). + /// Import dropped files, on the main window and on dock panels in their own OS windows. + /// With Timeline ▸ drop imports to the timeline, the new items are also queued to be placed + /// at the pointer (see `timeline::interact`). fn handle_drops(&mut self, ctx: &egui::Context) { + let to_timeline = self.session.prefs.timeline.drop_import_to_timeline; // While files hover, the OS sends no cursor events: poll the cursor each frame to draw the Timeline preview. let hovering = ctx.input(|i| i.raw.hovered_files.len()); - self.file_drag_hover = if hovering > 0 && self.session.prefs.timeline.drop_import_to_timeline { + self.file_drag_hover = None; + if hovering > 0 && to_timeline { ctx.request_repaint(); - self.drop_pointer(ctx).map(|p| (hovering, p)) - } else { - None - }; - let dropped = ctx.input(|i| i.raw.dropped_files.clone()); - let mut paths = Vec::new(); - for f in dropped { - let p = f.path(); - if p.exists() { - paths.push(p.to_string_lossy().to_string()); - } + self.file_drag_hover = self.drop_pointer(ctx, egui::ViewportId::ROOT, None).map(|p| (hovering, p, egui::ViewportId::ROOT)); } - if !paths.is_empty() { - let pointer = self.drop_pointer(ctx); - let imported = self.session.execute("file.import", json!({"paths": paths, "bin": self.import_bin().0})); - if let Ok(v) = imported - && let Some(pos) = pointer - && self.session.prefs.timeline.drop_import_to_timeline - { - let items: Vec = - v["items"].as_array().map(|a| a.iter().filter_map(|i| i.as_u64()).map(filmcraft_engine::project::ItemId).collect()).unwrap_or_default(); - if !items.is_empty() { - self.pending_timeline_drop = Some((items, pos)); - } + let dropped = ctx.input(|i| i.raw.dropped_files.clone()); + if !dropped.is_empty() { + let pointer = self.drop_pointer(ctx, egui::ViewportId::ROOT, None); + self.import_dropped(&dropped, pointer.map(|p| (p, egui::ViewportId::ROOT))); + } + for files in std::mem::take(&mut self.window_files) { + // Only the Timeline places files at the pointer; elsewhere they go to the bin. + let at_timeline = to_timeline && files.panel == PanelKind::Timeline; + let pointer = if at_timeline { self.drop_pointer(ctx, files.viewport, files.pos) } else { None }; + if !files.hovered.is_empty() && self.file_drag_hover.is_none() && at_timeline { + ctx.request_repaint(); + self.file_drag_hover = pointer.map(|p| (files.hovered.len(), p, files.viewport)); + } + if !files.dropped.is_empty() { + self.import_dropped(&files.dropped, pointer.map(|p| (p, files.viewport))); } } } - /// Where files dragged in from the OS are over the window. The OS reports no cursor motion during such a drag - /// (winit, Windows), so ask it through the host hook, else use the last pointer position egui saw. - fn drop_pointer(&self, ctx: &egui::Context) -> Option { + /// Import the dropped files that exist; with a pointer and Timeline ▸ drop imports to the + /// timeline, queue the new items for the Timeline drawn in that viewport. + fn import_dropped(&mut self, dropped: &[egui::DroppedFileHandle], pointer: Option<(egui::Pos2, egui::ViewportId)>) { + let paths: Vec = dropped.iter().map(|f| f.path()).filter(|p| p.exists()).map(|p| p.to_string_lossy().to_string()).collect(); + if paths.is_empty() { + return; + } + let imported = self.session.execute("file.import", json!({"paths": paths, "bin": self.import_bin().0})); + if let Ok(v) = imported + && let Some((pos, viewport)) = pointer + && self.session.prefs.timeline.drop_import_to_timeline + { + let items: Vec = + v["items"].as_array().map(|a| a.iter().filter_map(|i| i.as_u64()).map(filmcraft_engine::project::ItemId).collect()).unwrap_or_default(); + if !items.is_empty() { + self.pending_timeline_drop = Some((items, pos, viewport)); + } + } + } + + /// Where files dragged in from the OS are over `viewport`. The OS reports no cursor motion during such a drag + /// (winit, Windows), so ask it through the host hook, else use `last` (a native window's last + /// pointer position), else the last pointer position egui saw in the main window. + fn drop_pointer(&self, ctx: &egui::Context, viewport: egui::ViewportId, last: Option) -> Option { let os_pointer = self .hooks .cursor_screen_position .as_ref() .and_then(|f| f()) - .and_then(|px| ctx.input(|i| i.viewport().inner_rect.map(|r| screen_px_to_ui(px, i.pixels_per_point, r.min)))); - os_pointer.or_else(|| ctx.input(|i| i.pointer.hover_pos().or(i.pointer.latest_pos()))) + .and_then(|px| ctx.input_for(viewport, |i| i.viewport().inner_rect.map(|r| screen_px_to_ui(px, i.pixels_per_point, r.min)))); + let fallback = || if viewport == egui::ViewportId::ROOT { ctx.input(|i| i.pointer.hover_pos().or(i.pointer.latest_pos())) } else { None }; + os_pointer.or(last).or_else(fallback) } // ---------------------------------------------------------------- input @@ -2094,6 +2121,34 @@ impl FilmcraftApp { } } +/// `--control-input-only`: drop the window system's input. Screenshot responses and all +/// viewport lifecycle and geometry data stay. +fn drop_native_input(raw_input: &mut egui::RawInput) { + raw_input.events.retain(|event| matches!(event, egui::Event::Screenshot { .. })); + raw_input.hovered_files.clear(); + raw_input.dropped_files.clear(); +} + +/// The main window's input rewrite ([`eframe::App::raw_input_hook`]) for a dock panel in its +/// own OS window, which eframe does not run the hook for: macOS Control+Click becomes a +/// secondary click, and `--control-input-only` drops the window system's input. Control +/// gestures go to the main window only, so nothing is added. +pub(crate) fn rewrite_native_window_input( + control_input_only: bool, + #[cfg_attr(not(target_os = "macos"), allow(unused_variables))] armed: &Mutex>, + raw_input: &mut egui::RawInput, +) { + if control_input_only { + drop_native_input(raw_input); + return; + } + #[cfg(target_os = "macos")] + { + let mut armed = armed.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + synthesize_mac_ctrl_click(armed.entry(raw_input.viewport_id).or_default(), raw_input); + } +} + /// On macOS a Control+Click means "right click": AppKit delivers it to the view as a *primary* /// press carrying the Control modifier (`rightMouseDown:` only fires for a real right button or /// two-finger click), so the app translates it to a secondary click before egui computes pointer @@ -2133,10 +2188,7 @@ impl eframe::App for FilmcraftApp { synthesize_mac_ctrl_click(&mut self.mac_ctrl_click_armed, raw_input); } if self.control_input_only { - // Preserve screenshot responses and all viewport lifecycle/geometry metadata. - raw_input.events.retain(|event| matches!(event, egui::Event::Screenshot { .. })); - raw_input.hovered_files.clear(); - raw_input.dropped_files.clear(); + drop_native_input(raw_input); } if self.control_input_only || !self.synthetic.is_empty() { // Native modifiers cannot replace those requested by a control gesture. A queued @@ -3025,3 +3077,90 @@ mod native_menu_tests { assert_eq!(last(&sent, "edit.redo"), Some(true), "the undone edit can be redone"); } } + +#[cfg(test)] +mod native_window_input_tests { + use super::*; + use std::path::{Path, PathBuf}; + + use craft_ui::docking::WindowFiles; + use egui_kittest::Harness; + use filmcraft_engine::Session; + + #[derive(Debug)] + struct OsFile(PathBuf); + + impl egui::DroppedFile for OsFile { + fn path(&self) -> &Path { + &self.0 + } + + fn bytes(&self) -> Result, String> { + std::fs::read(&self.0).map_err(|e| e.to_string()) + } + } + + fn step(h: &mut Harness<'static, FilmcraftApp>) { + let ctx = h.ctx.clone(); + let mut raw = std::mem::take(h.input_mut()); + eframe::App::raw_input_hook(h.state_mut(), &ctx, &mut raw); + *h.input_mut() = raw; + ctx.request_repaint(); + h.step(); + } + + fn clips(h: &Harness<'static, FilmcraftApp>) -> usize { + h.state().session.active_sequence().map_or(0, |q| q.video_tracks.iter().map(|t| t.items.len()).sum()) + } + + /// Files dropped on a dock panel in its own OS window are imported; on the Timeline they are + /// placed at the pointer, by the Timeline drawn in that window only. + #[test] + fn files_dropped_on_native_windows_import_and_reach_their_timeline() { + let dir = tempfile::tempdir().unwrap(); + let still = dir.path().join("native_window_drop.png"); + image::RgbaImage::from_pixel(64, 36, image::Rgba([40, 140, 220, 255])).save(&still).unwrap(); + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + s.execute("file.newSequence", json!({"name": "Drop test", "width": 1280, "height": 720, "fps": 24})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).build_eframe(move |_cc| FilmcraftApp::new(s)); + for _ in 0..4 { + step(&mut h); + } + let row = h.state().auto.find("timeline.track.V1.target").unwrap().rect; + let panel = h.state().auto.find("panel.Timeline").unwrap().rect; + let at = egui::pos2(panel[0] + h.state().ui.timeline.header_w + 60.0, row[1] + row[3] / 2.0); + let drop_files = |h: &mut Harness<'static, FilmcraftApp>, panel: PanelKind, viewport: egui::ViewportId| { + let files = WindowFiles { window: 1, viewport, panel, pos: Some(at), hovered: Vec::new(), dropped: vec![Arc::new(OsFile(still.clone()))] }; + h.state_mut().window_files = vec![files]; + step(h); + step(h); + }; + let items = h.state().session.project.items.len(); + // another window's Timeline: imported, and the main window's Timeline leaves it + drop_files(&mut h, PanelKind::Timeline, egui::ViewportId::from_hash_of("another window")); + assert_eq!((h.state().session.project.items.len(), clips(&h)), (items + 1, 0), "status: {}", h.state().ui.status); + // a panel other than the Timeline: imported to the bin only + drop_files(&mut h, PanelKind::Project, egui::ViewportId::ROOT); + assert_eq!((h.state().session.project.items.len(), clips(&h)), (items + 2, 0), "status: {}", h.state().ui.status); + // the Timeline drawn in the window the files were dropped on places them + drop_files(&mut h, PanelKind::Timeline, egui::ViewportId::ROOT); + assert_eq!((h.state().session.project.items.len(), clips(&h)), (items + 3, 1), "status: {}", h.state().ui.status); + } + + /// `--control-input-only` drops a native window's own input as the main window's hook does. + #[test] + fn control_input_only_drops_native_window_input() { + let armed = Mutex::new(HashMap::new()); + let mut raw = egui::RawInput { viewport_id: egui::ViewportId::from_hash_of("palette"), ..Default::default() }; + raw.events = vec![egui::Event::Text("typing".into()), egui::Event::PointerMoved(egui::pos2(4.0, 4.0))]; + raw.hovered_files.push(Default::default()); + raw.dropped_files.push(Arc::new(OsFile(PathBuf::from("/unrelated-input.txt")))); + let mut kept = raw.clone(); + rewrite_native_window_input(false, &armed, &mut kept); + assert_eq!(kept.events.len(), 2); + assert_eq!(kept.dropped_files.len(), 1); + rewrite_native_window_input(true, &armed, &mut raw); + assert!(raw.events.is_empty() && raw.hovered_files.is_empty() && raw.dropped_files.is_empty()); + } +} diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 3d59a0c6c..c2d8cd399 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -767,7 +767,8 @@ fn empty_state(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } } // OS files dropped on an empty Timeline: import already happened; create a sequence from the first item - if let Some((items, p)) = app.pending_timeline_drop.clone() + if let Some((items, p, viewport)) = app.pending_timeline_drop.clone() + && viewport == ui.ctx().viewport_id() && rect.contains(p) && let Some((first, rest)) = items.split_first() { @@ -3157,7 +3158,8 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } } // ---- OS file drops imported with "drop imports to the timeline": placed at the drop point, one after another - if let Some((items, p)) = app.pending_timeline_drop.clone() + if let Some((items, p, viewport)) = app.pending_timeline_drop.clone() + && viewport == ui.ctx().viewport_id() && layout.content.contains(p) && app.timeline_drop_target(ui.ctx(), p) { @@ -3178,7 +3180,8 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } } // preview of OS files being dragged over the Timeline: a placeholder where the drop would start - if let Some((count, p)) = app.file_drag_hover + if let Some((count, p, viewport)) = app.file_drag_hover + && viewport == ui.ctx().viewport_id() && layout.content.contains(p) && app.timeline_drop_target(ui.ctx(), p) { From 34961f28d7dfbbe25fd381fc7cbcf91f4af17169 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:06:53 +0000 Subject: [PATCH 057/113] docking: describe native window kinds, modal blocking and drops The ui-egui README covers the fixed Tools and Audio Meters widths, palettes and normal windows, input blocking under a modal, the input rewrite and file drops in native windows, and floating windows staying in-window on Wayland. --- crates/ui-egui/README.md | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/README.md b/crates/ui-egui/README.md index ccc6a03e9..44081703e 100644 --- a/crates/ui-egui/README.md +++ b/crates/ui-egui/README.md @@ -83,15 +83,22 @@ strips, floating windows and drag previews and owns the gestures; `dock_ui.rs` d bodies, the ≡ panel menu and the Timeline's sequence tabs. Bounded split geometry and keyboard-accessible resize handles use `craft-ui::layout`. Panel tabs or their group's drag bar move between tab groups, split at a group's or the window's edge, reorder, or float when released -with Cmd/Ctrl; a release over no target cancels. Every group, Tools and the Audio Meters included, -resizes down to the same minimum and has no maximum. Timeline sequence tabs retain their -sequence-opening, closing and horizontal reorder behavior. +with Cmd/Ctrl; a release over no target cancels. Tools and the Audio Meters keep their widths +as the window resizes (fixed split sizes, which a divider drag keeps fixed); the other groups +resize with the window. Every group resizes down to the same minimum and has no maximum. +Timeline sequence tabs retain their sequence-opening, closing and horizontal reorder behavior. -On desktop each floating panel opens in its own OS window. These windows stay above other -windows and hide while another application is active. A floating window moves by its title bar, -resizes from every edge and splits like the main window. On Wayland, drops between windows can't -be resolved, so a floating window docks back only through a command such as `window.dockPanel`. -Where egui embeds viewports (tests), floating windows stay inside the main window. +On desktop each floating panel opens in its own OS window. Tool panels are palettes: they stay +above other windows and hide while another application is active. The video viewers (Program, +Source, Reference Monitor, Lumetri Scopes) are normal windows, say on a second display: they +stack with the main window and stay shown in the background. A floating window moves by its +title bar, resizes from every edge and splits like the main window. While a modal dialog is open +(Settings, Recovery, Revert, a file dialog), the native windows are dimmed and take no input. +The main window's input rewrite (macOS Control+Click as a secondary click, +`--control-input-only`) also runs in each native window, and files dropped on one are imported +like files dropped on the main window; on the Timeline they land at the drop point. On Wayland +floating windows stay inside the main window. Where egui embeds viewports (tests), floating +windows stay inside the main window too. Named workspaces save the layout, floating windows included; workspaces saved in the earlier layout format still load. While every floating window is one tab group, a save also writes the From a540c8dba0e3f6ff3a85610c8f33b7cdd6f1ae5c Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:10:10 +0000 Subject: [PATCH 058/113] docking: no needless return in the native window input rewrite off macOS --- crates/ui-egui/src/lib.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index be6c507b5..c00c349ab 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2140,12 +2140,9 @@ pub(crate) fn rewrite_native_window_input( ) { if control_input_only { drop_native_input(raw_input); - return; - } - #[cfg(target_os = "macos")] - { - let mut armed = armed.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - synthesize_mac_ctrl_click(armed.entry(raw_input.viewport_id).or_default(), raw_input); + } else { + #[cfg(target_os = "macos")] + synthesize_mac_ctrl_click(armed.lock().unwrap_or_else(std::sync::PoisonError::into_inner).entry(raw_input.viewport_id).or_default(), raw_input); } } From a31735dad2007dec2414d93d86e8e3eba28036c7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:19:21 +0000 Subject: [PATCH 059/113] docking: the native-window maximize test reports the main window's outer rect craft-ui takes a display-less session whose main window reports no outer rect for Wayland, where floating windows stay in-window. The test's main window reports one, as under X11. --- crates/ui-egui/tests/it/keyboard_ui.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/ui-egui/tests/it/keyboard_ui.rs b/crates/ui-egui/tests/it/keyboard_ui.rs index 87094087f..5b1f00611 100644 --- a/crates/ui-egui/tests/it/keyboard_ui.rs +++ b/crates/ui-egui/tests/it/keyboard_ui.rs @@ -223,6 +223,10 @@ fn maximize_under_cursor_ignores_native_window_rects() { ctx.run_ui(input, |ui| (viewport.viewport_ui_cb)(ui)).textures_delta.clear(); }); d.harness.ctx.set_embed_viewports(false); + // The main window reports its outer rect, as under X11: without a display variable craft-ui + // otherwise takes the session for Wayland and keeps floating windows in-window. + d.harness.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().outer_rect = + Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))); d.ok("ui.menu.invoke", json!({"id": "window.floatPanel", "params": {"panel": "Info", "rect": [0, 0, 700, 500]}})); d.frames(4); let elements = d.ok("ui.elements", json!({"prefix": "panel."})); From 190f5def2fdf3eb2c2881f7b3fcb04b22286cafc Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 02:54:58 +0000 Subject: [PATCH 060/113] docking: craft-libs 865c9af --- Cargo.lock | 6 +++--- Cargo.toml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d8c1aaa11..c47153c5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1014,12 +1014,12 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +source = "git+https://github.com/storytold/craft-libs?rev=865c9af7fdf455cd3892402d18e526a9f019c6bc#865c9af7fdf455cd3892402d18e526a9f019c6bc" [[package]] name = "craft-media" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +source = "git+https://github.com/storytold/craft-libs?rev=865c9af7fdf455cd3892402d18e526a9f019c6bc#865c9af7fdf455cd3892402d18e526a9f019c6bc" dependencies = [ "thiserror 2.0.21", ] @@ -1027,7 +1027,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +source = "git+https://github.com/storytold/craft-libs?rev=865c9af7fdf455cd3892402d18e526a9f019c6bc#865c9af7fdf455cd3892402d18e526a9f019c6bc" dependencies = [ "egui", "log", diff --git a/Cargo.toml b/Cargo.toml index d5d85d982..09333ba2a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,12 +72,12 @@ chrono = { version = "0.4.45", default-features = false, features = ["std"] } rayon = "1" proptest = "1" roxmltree = "0.21" -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } -craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae", default-features = false } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "865c9af7fdf455cd3892402d18e526a9f019c6bc" } +craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "865c9af7fdf455cd3892402d18e526a9f019c6bc", default-features = false } # ui # Shared frontend components; keep this dependency above the engine boundary. -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae", features = ["serde", "native-windows"] } +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "865c9af7fdf455cd3892402d18e526a9f019c6bc", features = ["serde", "native-windows"] } egui = "0.36" eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" From e43097a2e2d00f97dfcaaf5fc11588159d933f69 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 03:42:39 +0000 Subject: [PATCH 061/113] docking: native windows tag headers, block for the quit prompt, take panel drags - Automation windows are mapped from each floating window's viewport before the dock area draws, so a native window's header elements (panel.menu, timeline.tab) carry its window in the frame they are drawn. - The Save changes? prompt of Quit and Close Project counts as modal, so native windows are blocked and the prompt gets the keyboard. - While a FilmCraft drag payload is held, panel bodies call craft_ui::docking::route_app_drag, so a drag from a panel drops on a panel in another OS window. --- crates/ui-egui/README.md | 4 +- crates/ui-egui/src/dock_ui.rs | 21 ++- crates/ui-egui/src/panels/mod.rs | 5 + crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/native_windows_ui.rs | 179 +++++++++++++++++++ 5 files changed, 201 insertions(+), 9 deletions(-) create mode 100644 crates/ui-egui/tests/it/native_windows_ui.rs diff --git a/crates/ui-egui/README.md b/crates/ui-egui/README.md index 44081703e..6708cf54b 100644 --- a/crates/ui-egui/README.md +++ b/crates/ui-egui/README.md @@ -96,7 +96,9 @@ title bar, resizes from every edge and splits like the main window. While a moda (Settings, Recovery, Revert, a file dialog), the native windows are dimmed and take no input. The main window's input rewrite (macOS Control+Click as a secondary click, `--control-input-only`) also runs in each native window, and files dropped on one are imported -like files dropped on the main window; on the Timeline they land at the drop point. On Wayland +like files dropped on the main window; on the Timeline they land at the drop point. A drag from +a panel (a clip from the Source Monitor or the Project panel, an effect, a template) drops on a +panel in any of the app's windows. On Wayland floating windows stay inside the main window. Where egui embeds viewports (tests), floating windows stay inside the main window too. diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 24e66ba9f..55cef1dbf 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -203,10 +203,12 @@ impl FilmcraftApp { } /// Whether a modal dialog is open: Settings, Recovery or Revert (egui `Modal`s), any other - /// egui `Modal` shown last frame, or a native file dialog. egui's `Modal` blocks input only - /// in the main window, so the dock area blocks its native windows meanwhile. + /// egui `Modal` shown last frame, the Save changes? prompt of Quit or Close Project, or a + /// native file dialog. egui's `Modal` blocks input only in the main window, so the dock area + /// blocks its native windows meanwhile. fn modal_open(&self, ctx: &egui::Context) -> bool { matches!(self.dialog, Some(crate::Dialog::Preferences | crate::Dialog::Recovery | crate::Dialog::RevertConfirm)) + || self.ui.clip_dialog.as_ref().is_some_and(|dialog| matches!(dialog.command.as_str(), "app.quit" | "file.closeProject")) || self.native_dialog_open() || ctx.memory(|memory| memory.top_modal_layer().is_some()) } @@ -246,7 +248,10 @@ impl FilmcraftApp { let mut child = ui.new_child(egui::UiBuilder::new().max_rect(body).id_salt("filmcraft-dock-area")); child.set_clip_rect(body.intersect(ui.clip_rect())); let seq_width = dock::sequence_tabs_width(ui, &seqs); - self.auto.windows.clear(); + // Known before anything draws, so header elements of a native window (the ≡ menu, the + // Timeline's sequence tabs), drawn before its bodies, carry that window too. + self.auto.windows = + self.ui.dock.floating.iter().enumerate().map(|(index, window)| (DockArea::::window_viewport(area_id(), window.id), index)).collect(); let mut content = FilmContent { app: self, seqs, seq_width, tokens: t, seq_actions: Vec::new(), menu: None, bodies: Vec::new(), restore_workspace: false }; let output = area.show_content(&mut child, &layout, &style, &mut content); @@ -454,16 +459,16 @@ impl DockContent for FilmContent<'_> { // A panel in its own OS window draws in that window's coordinates: its automation // elements carry the window so they are not matched against main-window points. let viewport = ui.ctx().viewport_id(); - if viewport != egui::ViewportId::ROOT - && let Some(index) = self.app.ui.dock.floating.iter().position(|window| window.contains(panel)) - { - self.app.auto.windows.insert(viewport, index); - } self.app.auto.viewport = viewport; self.app.auto.add(&format!("panel.{}", panel.id()), rect, panel.title()); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).id_salt(("panel", panel.id()))); child.set_clip_rect(rect.intersect(ui.clip_rect())); panels::show(self.app, &mut child, *panel, rect); + // FilmCraft's drag payload lives in context data, not egui's drag-and-drop, so craft-ui + // routes it to the native window under the pointer only when asked, each frame. + if panels::dragging(ui.ctx()) { + craft_ui::docking::route_app_drag(ui.ctx(), area_id()); + } self.app.auto.viewport = egui::ViewportId::ROOT; // The body fills its frame; allocating it lets a floated panel take its full size. ui.allocate_rect(ui.max_rect(), Sense::hover()); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 5f548f92f..f796bf10f 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -170,6 +170,11 @@ pub fn start_drag_lumetri_preset(ui: &egui::Ui, name: &str) { #[derive(Clone, Debug)] struct DragPayloadBox(DragPayload); +/// Whether one of FilmCraft's own drags (an item, bin, source range, effect or template) is under way. +pub(crate) fn dragging(ctx: &egui::Context) -> bool { + ctx.data(|d| d.get_temp::>(payload_id())).flatten().is_some() +} + fn payload(ui: &egui::Ui) -> Option { ui.ctx().data(|d| d.get_temp::>(payload_id())).flatten().map(|b| b.0) } diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index b8e5b4409..010a5098a 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -39,6 +39,7 @@ mod mixer_ui; mod monitor_geometry; mod multicam_ui; mod native_dialogs_ui; +mod native_windows_ui; mod nest_editing_ui; mod nest_rendering_ui; mod nested_ui; diff --git a/crates/ui-egui/tests/it/native_windows_ui.rs b/crates/ui-egui/tests/it/native_windows_ui.rs new file mode 100644 index 000000000..ab96e22fa --- /dev/null +++ b/crates/ui-egui/tests/it/native_windows_ui.rs @@ -0,0 +1,179 @@ +//! Panels floated into their own OS windows, with a stand-in for the OS: each native viewport +//! runs its own frame at the desktop place craft-ui asks for, with the main window's content at +//! the desktop origin. + +use std::sync::mpsc::{Sender, channel}; + +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::control::ControlRequest; +use serde_json::{Value, json}; + +/// Native windows at their builders' places, reporting that place as their desktop rect. +fn native_host() { + egui::Context::set_immediate_viewport_renderer(|ctx, mut viewport| { + let size = viewport.builder.inner_size.unwrap_or(egui::vec2(400.0, 300.0)); + let place = egui::Rect::from_min_size(viewport.builder.position.unwrap_or_default(), size); + let id = viewport.ids.this; + let info = egui::ViewportInfo { inner_rect: Some(place), outer_rect: Some(place), focused: Some(false), ..Default::default() }; + let input = egui::RawInput { + viewport_id: id, + viewports: std::iter::once((id, info)).collect(), + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), + time: ctx.input(|i| Some(i.time)), + ..Default::default() + }; + ctx.run_ui(input, |ui| (viewport.viewport_ui_cb)(ui)).textures_delta.clear(); + }); +} + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, +} + +impl Driver { + /// The demo project with a new sequence open and `source` in the Source Monitor. + fn new(source: u64) -> Self { + native_host(); + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + s.execute("file.newSequence", json!({"name": "Native", "width": 1280, "height": 720, "fps": 24})).unwrap(); + s.execute("source.open", json!({"item": source})).unwrap(); + let (tx, rx) = channel(); + let app = FilmcraftApp::new(s).with_control(rx); + let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app); + let mut d = Driver { harness, tx }; + d.harness.ctx.set_embed_viewports(false); + // A main window that reports its desktop place, as under X11: craft-ui otherwise keeps + // floating windows in-window. + let main = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 980.0)); + let info = d.harness.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default(); + (info.inner_rect, info.outer_rect, info.focused) = (Some(main), Some(main), Some(true)); + d.frames(4); + d + } + + fn frames(&mut self, n: usize) { + for _ in 0..n { + let ctx = self.harness.ctx.clone(); + let mut raw = std::mem::take(self.harness.input_mut()); + eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw); + *self.harness.input_mut() = raw; + ctx.request_repaint(); + self.harness.step(); + } + } + + fn ok(&mut self, method: &str, params: Value) -> Value { + let (req, reply) = ControlRequest::new(method, params.clone()); + self.tx.send(req).unwrap(); + for _ in 0..600 { + self.frames(1); + if let Ok(v) = reply.try_recv() { + assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}"); + return v["result"].clone(); + } + } + panic!("no reply to {method} {params}"); + } + + /// Float `panel` into its own OS window at `rect` on the desktop. + fn float(&mut self, panel: &str, rect: [f32; 4]) { + self.ok("ui.menu.invoke", json!({"id": "window.floatPanel", "params": {"panel": panel, "rect": rect}})); + self.frames(4); + } + + /// Every element whose id starts with `prefix`, as `ui.elements` reports it. + fn elements(&mut self, prefix: &str) -> Vec { + self.ok("ui.elements", json!({"prefix": prefix})).as_array().cloned().unwrap_or_default() + } + + fn element(&mut self, id: &str) -> Value { + self.elements(id).into_iter().find(|e| e["id"] == id).unwrap_or_else(|| panic!("no element {id}")) + } + + fn app(&self) -> &FilmcraftApp { + self.harness.state() + } + + fn pointer(&mut self, pos: egui::Pos2, pressed: Option) { + self.harness.input_mut().events.push(egui::Event::PointerMoved(pos)); + if pressed == Some(true) { + self.frames(1); + } + if let Some(pressed) = pressed { + self.harness.input_mut().events.push(egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }); + } + self.frames(1); + } +} + +fn rect(element: &Value) -> egui::Rect { + let r: Vec = element["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap() as f32).collect(); + egui::Rect::from_min_size(egui::pos2(r[0], r[1]), egui::vec2(r[2], r[3])) +} + +/// The header of a panel in its own OS window (the ≡ menu, the Timeline's sequence tabs) is +/// drawn before its body, and carries the window from the frame it is drawn in. +#[test] +fn a_native_windows_header_elements_carry_its_window() { + let mut d = Driver::new(5); + d.float("Timeline", [900.0, 120.0, 680.0, 420.0]); + let body = d.element("panel.Timeline"); + assert!(body["window"].is_u64(), "the floated Timeline is in its own OS window: {body}"); + let menu = d.element("panel.menu.Timeline"); + assert_eq!(menu["window"], body["window"], "{menu}"); + let tabs = d.elements("timeline.tab."); + assert!(!tabs.is_empty(), "the open sequence has a tab"); + for tab in tabs { + assert_eq!(tab["window"], body["window"], "{tab}"); + } +} + +/// The Save changes? prompt of Quit blocks the panels in native windows as Settings does, so +/// they don't take the keyboard from it. +#[test] +fn the_quit_prompt_blocks_native_windows() { + let mut d = Driver::new(5); + d.float("Timeline", [900.0, 120.0, 680.0, 420.0]); + assert_eq!(d.element("panel.menu.Timeline")["enabled"], json!(true)); + d.ok("engine.execute", json!({"command": "sequence.addTracks", "params": {}})); + assert!(d.app().session.is_dirty()); + d.harness.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close); + d.frames(3); + assert!(d.elements("quit.").iter().any(|e| e["id"] == "quit.cancel"), "Quit asks first"); + assert_eq!(d.element("panel.menu.Timeline")["enabled"], json!(false), "the native window is blocked"); +} + +/// A Source Monitor drag in the main window reaches the Timeline in its own OS window, and drops +/// the clip there. +#[test] +fn a_source_drag_drops_on_a_timeline_in_its_own_window() { + let mut d = Driver::new(5); + let place = egui::pos2(900.0, 120.0); + d.float("Timeline", [place.x, place.y, 680.0, 420.0]); + let clips = |d: &Driver| d.app().session.active_sequence().map_or(0, |q| q.video_tracks.iter().map(|t| t.items.len()).sum::()); + assert_eq!(clips(&d), 0); + let row = d.element("timeline.track.V1.target"); + assert!(row["window"].is_u64(), "{row}"); + let panel = rect(&d.element("panel.Timeline")); + // In the native window's coordinates, then on the desktop, where the main window's + // content starts at the origin. + let local = egui::pos2(panel.min.x + d.app().ui.timeline.header_w + 60.0, rect(&row).center().y); + let to = place + local.to_vec2(); + let from = rect(&d.element("source.drag.video")).center(); + d.pointer(from, Some(true)); + d.pointer(from + egui::vec2(0.0, 9.0), None); + d.pointer(to, None); + d.pointer(to, None); + d.pointer(to, Some(false)); + d.frames(3); + assert_eq!(clips(&d), 1, "status: {}", d.app().ui.status); +} From 364d0ba18fc1a033518db26b9bed7337f04a4adf Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 03:55:40 +0000 Subject: [PATCH 062/113] docking: craft-libs 8a503cf --- Cargo.lock | 6 +++--- Cargo.toml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c47153c5c..1b73d29d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1014,12 +1014,12 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=865c9af7fdf455cd3892402d18e526a9f019c6bc#865c9af7fdf455cd3892402d18e526a9f019c6bc" +source = "git+https://github.com/storytold/craft-libs?rev=8a503cfe79f0a4e7da2e167e01e71155668e0cee#8a503cfe79f0a4e7da2e167e01e71155668e0cee" [[package]] name = "craft-media" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=865c9af7fdf455cd3892402d18e526a9f019c6bc#865c9af7fdf455cd3892402d18e526a9f019c6bc" +source = "git+https://github.com/storytold/craft-libs?rev=8a503cfe79f0a4e7da2e167e01e71155668e0cee#8a503cfe79f0a4e7da2e167e01e71155668e0cee" dependencies = [ "thiserror 2.0.21", ] @@ -1027,7 +1027,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=865c9af7fdf455cd3892402d18e526a9f019c6bc#865c9af7fdf455cd3892402d18e526a9f019c6bc" +source = "git+https://github.com/storytold/craft-libs?rev=8a503cfe79f0a4e7da2e167e01e71155668e0cee#8a503cfe79f0a4e7da2e167e01e71155668e0cee" dependencies = [ "egui", "log", diff --git a/Cargo.toml b/Cargo.toml index 09333ba2a..03c217c27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,12 +72,12 @@ chrono = { version = "0.4.45", default-features = false, features = ["std"] } rayon = "1" proptest = "1" roxmltree = "0.21" -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "865c9af7fdf455cd3892402d18e526a9f019c6bc" } -craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "865c9af7fdf455cd3892402d18e526a9f019c6bc", default-features = false } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "8a503cfe79f0a4e7da2e167e01e71155668e0cee" } +craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "8a503cfe79f0a4e7da2e167e01e71155668e0cee", default-features = false } # ui # Shared frontend components; keep this dependency above the engine boundary. -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "865c9af7fdf455cd3892402d18e526a9f019c6bc", features = ["serde", "native-windows"] } +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "8a503cfe79f0a4e7da2e167e01e71155668e0cee", features = ["serde", "native-windows"] } egui = "0.36" eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" From a46b7fa0d62e081e8f46204dbbfb9fb7aca76115 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 25 Oct 2026 09:06:55 +0100 Subject: [PATCH 063/113] egui-037: fix capture failures, UTF-8 input and hidden timing --- crates/ui-egui/src/capture.rs | 46 ++++++++++++++++++- crates/ui-egui/src/lib.rs | 39 +++++++++++++++- crates/ui-egui/src/menus.rs | 18 ++++++++ crates/ui-egui/src/panels/graphics.rs | 5 +- .../ui-egui/src/panels/transition_controls.rs | 11 +++-- .../ui-egui/tests/it/graphics_handles_ui.rs | 19 ++++++++ 6 files changed, 129 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/capture.rs b/crates/ui-egui/src/capture.rs index 94234f500..d089969b2 100644 --- a/crates/ui-egui/src/capture.rs +++ b/crates/ui-egui/src/capture.rs @@ -469,7 +469,14 @@ pub(crate) fn issue(app: &mut FilmcraftApp, ctx: &egui::Context) { failures.push((i, message)); } } - for (i, message) in failures.into_iter().rev() { + // Failures arrive from the ascending admission scan and the reverse geometry-retry scan, so + // sort them by index and remove from the highest index down: an earlier removal must not shift + // a later index (which could remove the wrong shot or panic). + failures.sort_unstable_by_key(|(i, _)| std::cmp::Reverse(*i)); + for (i, message) in failures { + if i >= app.shots.requests.len() { + continue; + } let shot = app.shots.requests.remove(i); if let Some(slot) = shot.slot { slot.cancel(); @@ -615,7 +622,7 @@ mod tests { app.capture_geometry = Some(geometry(app)); let (tx, rx) = channel(); admit(app, ctx, None, None, tx); - let token = app.shots.requests.first().map(|shot| shot.token).unwrap_or_default(); + let token = app.shots.requests.last().map(|shot| shot.token).unwrap_or_default(); (rx, token) } @@ -792,4 +799,39 @@ mod tests { assert_eq!(app.shots.requests[0].expected, [1024, 768]); assert!(!app.shots.requests[0].issued, "the retry waits for its own settling passes"); } + + #[test] + fn mixed_order_geometry_failures_reject_every_shot_without_shifting_indices() { + let ctx = egui::Context::default(); + let mut app = app(); + let (rx0, token0) = admit_one(&mut app, &ctx); + let (rx1, token1) = admit_one(&mut app, &ctx); + for shot in &mut app.shots.requests { + shot.ready_at = Instant::now() - Duration::from_millis(1); + shot.frames = SETTLE_PASSES; + } + app.timeline_still = 2; + // A resize makes both queued shots need more crop/encode buffers than the aggregate + // budget, so the geometry retry fails for the last shot first. + app.capture_geometry = Some(CaptureGeometry { + surface: SurfaceGeometry { + viewport: Some(vec2(8000.0, 4000.0)), + pixels_per_point: 1.0, + max_texture_side: 16384, + minimized: false, + occluded: false, + }, + document: active_document(&app), + focus: None, + }); + app.capture_epoch += 1; + issue(&mut app, &ctx); + assert!(app.shots.requests.is_empty(), "both failed shots were removed"); + for (rx, token) in [(rx0, token0), (rx1, token1)] { + let reply = rx.try_recv().expect("each failed shot gets its own reply"); + assert_eq!(reply["request"], token); + assert_eq!(reply["ok"], false, "{reply}"); + assert!(reply["error"].as_str().unwrap_or_default().contains("aggregate"), "{reply}"); + } + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ea5a56f62..37247080b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -369,6 +369,11 @@ pub struct FilmcraftApp { /// later of this and the host clock keeps simulated tests on their clock while a hidden /// logic tick (whose `InputState::time` is stale) still advances by host time. ui_clock: f64, + /// Non-negative `ui_clock - host_elapsed`, captured on the first real UI pass. Native eframe + /// starts its `RawInput::time` at its own integration beginning, which can precede + /// `clock_start`; without this offset, `max` would hold `now()` at the stale UI time during + /// hidden logic and then jump by the startup offset when the next UI pass arrives. + ui_clock_offset: Option, #[cfg(test)] test_now: Option, /// macOS: a Control+Click already translated to a secondary (context) click, so its release is @@ -654,6 +659,7 @@ impl FilmcraftApp { pasted_image: None, clock_start: web_time::Instant::now(), ui_clock: 0.0, + ui_clock_offset: None, #[cfg(test)] test_now: None, mac_ctrl_click_armed: false, @@ -877,7 +883,9 @@ impl FilmcraftApp { if let Some(now) = self.test_now { return now; } - self.clock_start.elapsed().as_secs_f64().max(self.ui_clock) + let host = self.clock_start.elapsed().as_secs_f64(); + let host = self.ui_clock_offset.map_or(host, |offset| host + offset); + host.max(self.ui_clock) } #[cfg(test)] @@ -2429,6 +2437,15 @@ impl FilmcraftApp { } fn ui_inner(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { + if self.ui_clock_offset.is_none() { + let egui_time = ui.ctx().input(|input| input.time); + if egui_time.is_finite() { + let host = self.clock_start.elapsed().as_secs_f64(); + // Never move host time backwards when a harness clock starts behind it; the host + // clock stays authoritative in that case. + self.ui_clock_offset = Some((egui_time - host).max(0.0)); + } + } if !self.fonts_ready { // This pass did not consume the pending synthetic input; the next hook must offer it // again (the UI path does not preserve raw input across this early return). @@ -2881,6 +2898,26 @@ mod gpu_fallback_tests { } } +#[cfg(test)] +mod clock_tests { + use super::*; + + /// The egui clock starts at the integration's beginning, which can precede `clock_start`. + /// Applying the captured offset to host time keeps hidden logic moving instead of freezing at + /// the last UI time and then jumping when the next UI pass arrives. + #[test] + fn now_applies_the_ui_clock_offset_and_keeps_the_test_override_first() { + let mut app = FilmcraftApp::new(Session::default()); + app.ui_clock = 123.0; + app.ui_clock_offset = Some(1_000.0); + assert!(app.now() >= 1_000.0, "the captured offset moves the host clock to egui time"); + app.ui_clock = 2_000.0; + assert_eq!(app.now(), 2_000.0, "an egui clock ahead of host+offset still wins"); + app.set_now_for_test(7.0); + assert_eq!(app.now(), 7.0, "the deterministic test override keeps precedence"); + } +} + #[cfg(test)] mod audio_recovery_tests { use super::*; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 0ead242e2..e0b26c104 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -678,11 +678,21 @@ fn paste_clipboard_media(app: &mut FilmcraftApp, insert: bool) -> Option Result { let [width, height] = image.size; let len = width.checked_mul(height).and_then(|pixels| pixels.checked_mul(4)).ok_or_else(|| "the pasted image size overflows the clipboard buffer".to_string())?; + if len > MAX_CLIPBOARD_IMAGE_BYTES { + return Err(format!( + "the pasted image is too large: {width}×{height} needs {len} RGBA bytes, over the {MAX_CLIPBOARD_IMAGE_BYTES}-byte clipboard image limit" + )); + } let mut rgba = Vec::with_capacity(len); for c in &image.pixels { // `Color32` is premultiplied internally; PNG/RgbaImage expect straight alpha. Round-trip @@ -1137,6 +1147,14 @@ mod tests { assert_eq!(decoded.get_pixel(0, 0).0, [255, 0, 0, 128]); } + #[test] + fn oversized_clipboard_images_are_rejected_before_allocating_rgba() { + let side = 100_000; + let image = egui::ColorImage { size: [side, side], source_size: egui::vec2(100_000.0, 100_000.0), pixels: Vec::new() }; + let Err(error) = paste_image_media(&image) else { panic!("an oversized clipboard image was accepted") }; + assert!(error.contains("too large"), "{error}"); + } + /// What `cargo xtask parity` measures: every item sits under a top-level menu, and Window ▸ /// Workspaces lists the built-in workspaces before the layout commands, none checked. #[test] diff --git a/crates/ui-egui/src/panels/graphics.rs b/crates/ui-egui/src/panels/graphics.rs index 4cce6f970..8ff14fd4f 100644 --- a/crates/ui-egui/src/panels/graphics.rs +++ b/crates/ui-egui/src/panels/graphics.rs @@ -1886,7 +1886,10 @@ fn text_edit( } if let Some(m) = moved { let previous_selection = (ed.caret, ed.anchor); - ed.caret = m.min(text.len()); + // `m` comes from the layout of the text at the start of this batch, but an + // earlier Text/Paste/IME event may already have changed `text`; clamp to a + // UTF-8 boundary in the current text before a later event slices or inserts. + ed.caret = char_boundary_floor(&text, m); if !shift { ed.anchor = ed.caret; } diff --git a/crates/ui-egui/src/panels/transition_controls.rs b/crates/ui-egui/src/panels/transition_controls.rs index a6f05ad50..1b12fd3ad 100644 --- a/crates/ui-egui/src/panels/transition_controls.rs +++ b/crates/ui-egui/src/panels/transition_controls.rs @@ -168,8 +168,9 @@ pub fn effect_controls(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, id for pd in def.map(|d| d.params.as_slice()).unwrap_or(&[]) { let value = x.effect.params.get(pd.id).map_or(&pd.default, |p| &p.value); - ui.label(crate::i18n::t(pd.label)); - if let Some(v) = param_widget(ui, &mut elems, pd.id, &pd.kind, value, (fw, fh)) { + let label = crate::i18n::t(pd.label); + ui.label(label); + if let Some(v) = param_widget(ui, &mut elems, pd.id, label, &pd.kind, value, (fw, fh)) { changes.push(v); } ui.end_row(); @@ -470,7 +471,7 @@ fn paint_ruler(p: &egui::Painter, ruler: Rect, view: (Tick, Tick), rate: FrameRa } /// One of a transition's own settings. Returns the change, as `sequence.setTransition` params. -fn param_widget(ui: &mut egui::Ui, elems: &mut Elems, pid: &str, kind: &ParamKind, value: &ParamValue, frame: (f64, f64)) -> Option { +fn param_widget(ui: &mut egui::Ui, elems: &mut Elems, pid: &str, label: &str, kind: &ParamKind, value: &ParamValue, frame: (f64, f64)) -> Option { let eid = format!("effectControls.transition.param.{pid}"); let drag = |r: &egui::Response| json!({"merge": true, "begin": r.drag_started() || !r.dragged()}); match (kind, value) { @@ -497,7 +498,7 @@ fn param_widget(ui: &mut egui::Ui, elems: &mut Elems, pid: &str, kind: &ParamKin } (ParamKind::Bool, ParamValue::Bool(b)) => { let mut b = *b; - let r = crate::widgets::accessible_name(ui.checkbox(&mut b, ""), eid.clone()); + let r = crate::widgets::accessible_name(ui.checkbox(&mut b, ""), label); elems.push((eid, r.rect, b.to_string())); r.changed().then(|| json!({"params": { pid: b }})) } @@ -515,7 +516,7 @@ fn param_widget(ui: &mut egui::Ui, elems: &mut Elems, pid: &str, kind: &ParamKin } }); elems.extend(options); - let response = crate::widgets::accessible_name(r.response, eid.clone()); + let response = crate::widgets::accessible_name(r.response, label); elems.push((eid, response.rect, shown.to_string())); picked.map(|i| json!({"params": { pid: i }})) } diff --git a/crates/ui-egui/tests/it/graphics_handles_ui.rs b/crates/ui-egui/tests/it/graphics_handles_ui.rs index 716576a77..384ec0d7c 100644 --- a/crates/ui-egui/tests/it/graphics_handles_ui.rs +++ b/crates/ui-egui/tests/it/graphics_handles_ui.rs @@ -562,6 +562,25 @@ fn ime_mixed_application_and_ime_mutation_is_other() { assert_eq!(toolbar.change_cause, egui::output::ImeChangeCause::Other); } +/// An Arrow key later in a raw batch uses the layout of the text from the start of that batch. +/// Text typed earlier in the same batch can move the caret onto a byte inside a multi-byte +/// character; the following Text/Paste/Cut must clamp it to a boundary instead of panicking. +#[test] +fn arrow_from_the_old_layout_does_not_split_text_inserted_in_the_same_batch() { + let mut d = Driver::new(); + let clip = d.start_point_text(); + d.exec("graphics.setText", json!({"clip": clip, "layer": 0, "text": "ab\ncd"})); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 0, "anchor": 0}})); + d.frames(2); + + // After "éé" the caret is at byte 4, on the old layout's second line. ArrowUp maps its x to + // byte 1 of that old line, which is inside the first "é" in the current text; "x" then inserts + // there. Before the fix this panicked on the non-boundary insert. + d.harness.input_mut().events.extend([egui::Event::Text("éé".into()), key_press(egui::Key::ArrowUp), egui::Event::Text("x".into())]); + d.frames(2); + assert_eq!(d.exec("graphics.list", json!({"clip": clip}))["layers"][0]["text"], json!("xééab\ncd")); +} + fn ime_preedit(text: &str) -> egui::Event { egui::Event::Ime(egui::ImeEvent::Preedit { text: text.into(), active_range_chars: None }) } From ff1d94b6889b5a07a39b30a18dbab89a860005c4 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 25 Oct 2026 09:43:02 +0100 Subject: [PATCH 064/113] Fix screenshot crop scale for fitted native surfaces --- crates/ui-egui/src/capture.rs | 149 +++++++++++++++++++++++++++++++++- 1 file changed, 147 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/capture.rs b/crates/ui-egui/src/capture.rs index d089969b2..9259d1110 100644 --- a/crates/ui-egui/src/capture.rs +++ b/crates/ui-egui/src/capture.rs @@ -124,6 +124,39 @@ impl SurfaceGeometry { Some([fit(width), fit(height)]) } + /// Pixels per point of the delivered screenshot texture. + /// + /// egui-wgpu's `surface_fit` shrinks an oversized window's surface by one factor and draws it + /// at `pixels_per_point * render_scale`; a crop expressed in points must use that rendered + /// scale, not the native one. A window within the limit keeps the native factor exactly, so a + /// one-pixel rounding difference between `viewport * ppp` and the integer window size never + /// becomes a bogus sub-1 scale. + fn captured_pixels_per_point(self) -> f32 { + let native = self.pixels_per_point; + if !native.is_finite() || native <= 0.0 { + return 1.0; + } + let Some(viewport) = self.viewport else { return native }; + if !viewport.x.is_finite() || !viewport.y.is_finite() || viewport.x <= 0.0 || viewport.y <= 0.0 || self.max_texture_side == 0 { + return native; + } + let axis = |points: f32| (f64::from(points) * f64::from(native)).round(); + let (width, height) = (axis(viewport.x), axis(viewport.y)); + if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 { + return native; + } + let longest = width.max(height); + let limit = self.max_texture_side as f64; + if longest <= limit { + return native; + } + // Match egui-wgpu exactly: it casts `surface_fit`'s scale to f32 and then multiplies the + // f32 pixels-per-point by it. + let render_scale = (limit / longest) as f32; + let effective = native * render_scale; + if effective.is_finite() && effective > 0.0 { effective } else { native } + } + fn expected_bytes(self) -> Option { let [width, height] = self.expected_size()?; width.checked_mul(height)?.checked_mul(std::mem::size_of::()) @@ -548,7 +581,8 @@ pub(crate) fn drain(app: &mut FilmcraftApp, ctx: &egui::Context) { ), ) } else { - let mut reply = crate::control::save_screenshot_with_ppp(&image, shot.path.as_deref(), shot.crop, shot.geometry.surface.pixels_per_point); + let captured_ppp = shot.geometry.surface.captured_pixels_per_point(); + let mut reply = crate::control::save_screenshot_with_ppp(&image, shot.path.as_deref(), shot.crop, captured_ppp); if let Some(object) = reply.as_object_mut() { object.insert("request".into(), json!(shot.token)); } @@ -619,7 +653,11 @@ mod tests { } fn admit_one(app: &mut FilmcraftApp, ctx: &egui::Context) -> (Receiver, u64) { - app.capture_geometry = Some(geometry(app)); + admit_with_surface(app, ctx, geometry(app).surface) + } + + fn admit_with_surface(app: &mut FilmcraftApp, ctx: &egui::Context, surface: SurfaceGeometry) -> (Receiver, u64) { + app.capture_geometry = Some(CaptureGeometry { surface, document: active_document(app), focus: None }); let (tx, rx) = channel(); admit(app, ctx, None, None, tx); let token = app.shots.requests.last().map(|shot| shot.token).unwrap_or_default(); @@ -645,10 +683,117 @@ mod tests { surface.viewport = Some(vec2(16_384.0, 4_000.0)); surface.max_texture_side = 8192; assert_eq!(surface.expected_size(), Some([8192, 2000]), "both axes scale by the same factor, like egui-wgpu's surface_fit"); + assert_eq!(surface.captured_pixels_per_point(), 0.5, "the fitted texture is drawn at the same factor"); surface.viewport = Some(vec2(4_000.0, 16_384.0)); assert_eq!(surface.expected_size(), Some([2000, 8192])); + assert_eq!(surface.captured_pixels_per_point(), 0.5); surface.viewport = Some(vec2(1920.0, 1080.0)); assert_eq!(surface.expected_size(), Some([1920, 1080]), "within the limit nothing shrinks"); + assert_eq!(surface.captured_pixels_per_point(), 1.0, "an uncapped surface keeps the native scale exactly"); + } + + #[test] + fn captured_scale_falls_back_when_geometry_is_unusable() { + let app = app(); + let mut surface = geometry(&app).surface; + surface.pixels_per_point = 2.0; + surface.viewport = None; + assert_eq!(surface.captured_pixels_per_point(), 2.0, "no viewport preserves the native scale"); + surface.viewport = Some(vec2(0.0, 600.0)); + assert_eq!(surface.captured_pixels_per_point(), 2.0); + surface.viewport = Some(vec2(f32::NAN, 600.0)); + assert_eq!(surface.captured_pixels_per_point(), 2.0); + surface.viewport = Some(vec2(800.0, 600.0)); + surface.pixels_per_point = f32::INFINITY; + assert_eq!(surface.captured_pixels_per_point(), 1.0, "an invalid native scale falls back to 1, like save_screenshot_with_ppp"); + surface.pixels_per_point = 0.0; + assert_eq!(surface.captured_pixels_per_point(), 1.0); + } + + #[test] + fn downscaled_capture_crops_with_the_rendered_scale() { + let ctx = egui::Context::default(); + let mut app = app(); + let surface = SurfaceGeometry { + viewport: Some(vec2(16_384.0, 2_000.0)), + // A 2x native surface fits at 0.25 render scale: crop at 0.5 pixels per point. + // This also catches accidentally returning render_scale without the native factor. + pixels_per_point: 2.0, + max_texture_side: 8192, + minimized: false, + occluded: false, + }; + assert_eq!(surface.expected_size(), Some([8192, 1_000])); + assert_eq!(surface.captured_pixels_per_point(), 0.5); + let (rx, token) = admit_with_surface(&mut app, &ctx, surface); + let dir = tempfile::tempdir().expect("a temporary screenshot directory"); + let path = dir.path().join("downscaled-crop.png"); + // The crop is 400x200 points, which the fitted surface draws as 200x100 pixels. Paint a + // red rectangle at exactly that rendered position so a wrong (native) scale would crop + // black pixels or the wrong dimensions instead of this region. + let mut image = ColorImage::filled([8192, 1_000], Color32::BLACK); + for y in 25..125 { + for x in 50..250 { + image.pixels[y * 8192 + x] = Color32::RED; + } + } + let shot = &mut app.shots.requests[0]; + shot.path = Some(path.to_string_lossy().into_owned()); + shot.crop = Some([100.0, 50.0, 400.0, 200.0]); + shot.issued = true; + let callback = shot.callback.take().expect("a callback"); + callback.complete(Arc::new(image)); + drain(&mut app, &ctx); + let reply = rx.try_recv().expect("a screenshot reply"); + assert_eq!(reply["request"], token); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!([reply["result"]["width"].as_u64(), reply["result"]["height"].as_u64()], [Some(200), Some(100)]); + let decoded = image::load_from_memory(&std::fs::read(&path).expect("the saved crop")).expect("decode the saved crop").to_rgba8(); + assert_eq!(decoded.dimensions(), (200, 100)); + assert!( + decoded.pixels().all(|pixel| *pixel == image::Rgba([255, 0, 0, 255])), + "the crop must select the red region where the downscaled surface drew it" + ); + } + + #[test] + fn uncapped_hidpi_capture_keeps_the_native_scale() { + let ctx = egui::Context::default(); + let mut app = app(); + let surface = SurfaceGeometry { + // 801 points at 1.5x is 1201.5 window pixels, which rounds to 1202. The fitted scale + // must still be exactly 1.0 rather than 1202/1201.5 from that one-pixel difference. + viewport: Some(vec2(801.0, 600.0)), + pixels_per_point: 1.5, + max_texture_side: 8192, + minimized: false, + occluded: false, + }; + assert_eq!(surface.expected_size(), Some([1202, 900])); + assert_eq!(surface.captured_pixels_per_point(), 1.5); + let (rx, token) = admit_with_surface(&mut app, &ctx, surface); + let dir = tempfile::tempdir().expect("a temporary screenshot directory"); + let path = dir.path().join("hidpi-crop.png"); + let mut image = ColorImage::filled([1202, 900], Color32::BLACK); + for y in 75..375 { + for x in 150..750 { + image.pixels[y * 1202 + x] = Color32::GREEN; + } + } + let shot = &mut app.shots.requests[0]; + shot.path = Some(path.to_string_lossy().into_owned()); + shot.crop = Some([100.0, 50.0, 400.0, 200.0]); + shot.issued = true; + let callback = shot.callback.take().expect("a callback"); + callback.complete(Arc::new(image)); + drain(&mut app, &ctx); + let reply = rx.try_recv().expect("a screenshot reply"); + assert_eq!(reply["request"], token); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!([reply["result"]["width"].as_u64(), reply["result"]["height"].as_u64()], [Some(600), Some(300)]); + let decoded = image::load_from_memory(&std::fs::read(&path).expect("the saved crop")).expect("decode the saved crop").to_rgba8(); + assert_eq!(decoded.dimensions(), (600, 300)); + assert!(decoded.pixels().all(|pixel| *pixel == image::Rgba([0, 255, 0, 255])), "the HiDPI crop must select the green region at the native scale"); } #[test] From ec55f2bd8eace85b4299c3b034834a7abbdfc175 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 25 Oct 2026 11:07:44 +0100 Subject: [PATCH 065/113] egui037: simplify UI helpers and preserve pointer IME cancellation --- crates/ui-egui/src/capture.rs | 286 ++++--- crates/ui-egui/src/lib.rs | 45 +- crates/ui-egui/src/panels/effect_controls.rs | 85 +- crates/ui-egui/src/panels/graphics.rs | 776 ++++++++++++------- crates/ui-egui/src/panels/lumetri.rs | 7 +- crates/ui-egui/src/panels/mixer.rs | 44 +- crates/ui-egui/src/widgets.rs | 133 ++-- 7 files changed, 871 insertions(+), 505 deletions(-) diff --git a/crates/ui-egui/src/capture.rs b/crates/ui-egui/src/capture.rs index 9259d1110..c8d69a3a2 100644 --- a/crates/ui-egui/src/capture.rs +++ b/crates/ui-egui/src/capture.rs @@ -11,10 +11,11 @@ //! is still settling therefore reaches its timeout. That fork gap is recorded as a prerequisite; //! this module does not claim a hidden web capture works. +use std::sync::Arc; use std::sync::mpsc::Sender; -use craft_ui::capture::CaptureSlot; -use egui::ViewportCommand; +use craft_ui::capture::{CaptureError, CaptureSlot}; +use egui::{ColorImage, ViewportCommand}; use serde_json::{Value, json}; use web_time::{Duration, Instant}; @@ -434,6 +435,77 @@ fn retry_geometry(app: &mut FilmcraftApp, ctx: &egui::Context, index: usize) -> Ok(()) } +/// Whether an unissued request has waited for enough accepted passes, including the bounded +/// maximum when the monitor is still showing an inexact frame or the timeline is animating. +fn shot_is_settled(now: Instant, monitor_inexact: bool, timeline_still: u32, shot: &Shot) -> bool { + if now < shot.ready_at || shot.frames < SETTLE_PASSES { + return false; + } + !((monitor_inexact || timeline_still < 2) && now < shot.ready_at + SETTLE_MAX) +} + +/// The result of trying to issue one settled request. Every variant keeps the original admission +/// order: identity failures reject, geometry changes retry, a missing crop target waits a bounded +/// number of passes, and everything else issues in the current accepted UI pass. +enum IssueOutcome { + Identity(String), + GeometryRetry, + CropDeferred, + Failed(String), + Issued, +} + +fn try_issue_shot(app: &mut FilmcraftApp, ctx: &egui::Context, index: usize) -> IssueOutcome { + let shot = &app.shots.requests[index]; + if let Some(reason) = identity_reason(app, ctx, shot) { + return IssueOutcome::Identity(reason.to_string()); + } + if geometry_reason(app, shot).is_some() { + return IssueOutcome::GeometryRetry; + } + let crop_result = match shot.crop_target.as_ref() { + Some(target) => target.resolve(&app.auto).map(Some), + None => Ok(None), + }; + let resolved_crop = match crop_result { + Ok(crop) => crop, + Err(message) => { + let shot = &mut app.shots.requests[index]; + if shot.crop_misses < CROP_SETTLE_PASSES { + // The panel may be shown by a request processed just before this admission; + // let the next accepted pass draw it before giving up. + shot.crop_misses = shot.crop_misses.saturating_add(1); + return IssueOutcome::CropDeferred; + } + return IssueOutcome::Failed(message); + } + }; + let shot = &mut app.shots.requests[index]; + let Some(callback) = shot.callback.take() else { + return IssueOutcome::Failed("screenshot failed: the capture callback was already consumed".into()); + }; + shot.crop = resolved_crop; + ctx.send_viewport_cmd_to(ctx.viewport_id(), ViewportCommand::Screenshot(callback)); + shot.issued = true; + IssueOutcome::Issued +} + +/// Reject failed requests from the highest index down, so an earlier removal cannot shift a later +/// index and remove the wrong shot. +fn reject_failures(app: &mut FilmcraftApp, mut failures: Vec<(usize, String)>) { + failures.sort_unstable_by_key(|(i, _)| std::cmp::Reverse(*i)); + for (i, message) in failures { + if i >= app.shots.requests.len() { + continue; + } + let shot = app.shots.requests.remove(i); + if let Some(slot) = shot.slot { + slot.cancel(); + } + reject(shot.reply, shot.token, message); + } +} + /// Issue settled requests. Called from every real `App::ui` pass (never from a hidden /// `run_logic` tick), including the accepted final pass of a multipass layout. `monitor_inexact` /// describes the pass that just drew the monitor, so consume it here: it must not stay sticky and @@ -452,146 +524,116 @@ pub(crate) fn issue(app: &mut FilmcraftApp, ctx: &egui::Context) { } app.shots.requests[i].frames = app.shots.requests[i].frames.saturating_add(1); let shot = &app.shots.requests[i]; - if now < shot.ready_at || shot.frames < SETTLE_PASSES { + if !shot_is_settled(now, monitor_inexact, app.timeline_still, shot) { continue; } - // After a seek the monitor can show the nearest cached picture until the exact frame is - // decoded, and the Timeline can still be animating. Capture once those have settled, or - // after the same bounded maximum the old `Ui` settle path used. - if (monitor_inexact || app.timeline_still < 2) && now < shot.ready_at + SETTLE_MAX { - continue; + match try_issue_shot(app, ctx, i) { + IssueOutcome::Identity(reason) => failures.push((i, reason)), + IssueOutcome::GeometryRetry => retries.push(i), + IssueOutcome::CropDeferred => ctx.request_repaint(), + IssueOutcome::Failed(message) => failures.push((i, message)), + IssueOutcome::Issued => {} } - if let Some(reason) = identity_reason(app, ctx, shot) { - failures.push((i, reason.to_string())); - continue; - } - if geometry_reason(app, shot).is_some() { - retries.push(i); - continue; - } - let crop_result = match app.shots.requests[i].crop_target.as_ref() { - Some(target) => target.resolve(&app.auto).map(Some), - None => Ok(None), - }; - let resolved_crop = match crop_result { - Ok(crop) => crop, - Err(message) => { - let shot = &mut app.shots.requests[i]; - if shot.crop_misses < CROP_SETTLE_PASSES { - // The panel may be shown by a request processed just before this admission; - // let the next accepted pass draw it before giving up. - shot.crop_misses = shot.crop_misses.saturating_add(1); - ctx.request_repaint(); - continue; - } - failures.push((i, message)); - continue; - } - }; - let shot = &mut app.shots.requests[i]; - let Some(callback) = shot.callback.take() else { - failures.push((i, "screenshot failed: the capture callback was already consumed".into())); - continue; - }; - shot.crop = resolved_crop; - ctx.send_viewport_cmd_to(ctx.viewport_id(), ViewportCommand::Screenshot(callback)); - shot.issued = true; } for i in retries.into_iter().rev() { if let Err(message) = retry_geometry(app, ctx, i) { failures.push((i, message)); } } - // Failures arrive from the ascending admission scan and the reverse geometry-retry scan, so - // sort them by index and remove from the highest index down: an earlier removal must not shift - // a later index (which could remove the wrong shot or panic). - failures.sort_unstable_by_key(|(i, _)| std::cmp::Reverse(*i)); - for (i, message) in failures { - if i >= app.shots.requests.len() { - continue; - } - let shot = app.shots.requests.remove(i); - if let Some(slot) = shot.slot { - slot.cancel(); - } - reject(shot.reply, shot.token, message); - } + reject_failures(app, failures); if !app.shots.is_empty() { ctx.request_repaint_after(REPAINT_INTERVAL); } } +/// The next step for one drain scan position. `Keep` advances to the next request; every removal +/// variant leaves the index unchanged so it sees the shot shifted into place. +enum DrainStep { + Keep, + Timeout, + Identity(String), + Geometry, + Complete(Result, CaptureError>), +} + +fn drain_step(app: &FilmcraftApp, ctx: &egui::Context, index: usize, now: Instant) -> DrainStep { + let shot = &app.shots.requests[index]; + if now >= shot.deadline { + return DrainStep::Timeout; + } + if let Some(reason) = identity_reason(app, ctx, shot) { + return DrainStep::Identity(reason.to_string()); + } + if geometry_reason(app, shot).is_some() { + if !shot.issued { + // The next accepted UI pass re-creates the slot against the fresh geometry. + return DrainStep::Keep; + } + // Geometry changed while the callback was already issued: reject stale pixels rather + // than pairing them with the new surface. + return DrainStep::Geometry; + } + if !shot.issued { + return DrainStep::Keep; + } + let Some(result) = shot.slot.as_ref().and_then(CaptureSlot::take) else { + return DrainStep::Keep; + }; + DrainStep::Complete(result) +} + +fn remove_and_reject(app: &mut FilmcraftApp, index: usize, message: impl std::fmt::Display) { + let shot = app.shots.requests.remove(index); + if let Some(slot) = shot.slot { + slot.cancel(); + } + reject(shot.reply, shot.token, message); +} + +/// Build the saved-image reply, using the rendered scale of the delivered texture for crops. +fn image_reply(shot: &Shot, image: &ColorImage) -> Value { + if image.size != shot.expected { + return error_reply( + shot.token, + format!( + "screenshot is {}×{} pixels, expected {}×{}: the window changed size while it was captured", + image.size[0], image.size[1], shot.expected[0], shot.expected[1] + ), + ); + } + let captured_ppp = shot.geometry.surface.captured_pixels_per_point(); + let mut reply = crate::control::save_screenshot_with_ppp(image, shot.path.as_deref(), shot.crop, captured_ppp); + if let Some(object) = reply.as_object_mut() { + object.insert("request".into(), json!(shot.token)); + } + reply +} + +fn complete_shot(mut shot: Shot, result: Result, CaptureError>) { + if let Some(slot) = shot.slot.take() { + slot.cancel(); + } + let reply = match result { + Ok(image) => image_reply(&shot, &image), + Err(error) => error_reply(shot.token, error), + }; + let _ = shot.reply.send(reply); +} + /// Drain completed slots and expire timed-out requests. Call from every logic callback (including /// hidden ones). The deadline is checked before taking or writing an image. pub(crate) fn drain(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut i = 0; while i < app.shots.requests.len() { let now = Instant::now(); - if now >= app.shots.requests[i].deadline { - let shot = app.shots.requests.remove(i); - if let Some(slot) = shot.slot { - slot.cancel(); - } - reject(shot.reply, shot.token, "no frame was presented before the screenshot timeout"); - continue; - } - if let Some(reason) = identity_reason(app, ctx, &app.shots.requests[i]) { - let shot = app.shots.requests.remove(i); - if let Some(slot) = shot.slot { - slot.cancel(); - } - reject(shot.reply, shot.token, format!("screenshot failed: {reason}")); - continue; - } - if geometry_reason(app, &app.shots.requests[i]).is_some() { - if !app.shots.requests[i].issued { - // The next accepted UI pass re-creates the slot against the fresh geometry. - i += 1; - continue; - } - // Geometry changed while the callback was already issued: reject stale pixels rather - // than pairing them with the new surface. - let shot = app.shots.requests.remove(i); - if let Some(slot) = shot.slot { - slot.cancel(); - } - reject(shot.reply, shot.token, "screenshot failed: the window geometry changed while the capture was in flight"); - continue; - } - if !app.shots.requests[i].issued { - i += 1; - continue; - } - let Some(result) = app.shots.requests[i].slot.as_ref().and_then(CaptureSlot::take) else { - i += 1; - continue; - }; - let shot = app.shots.requests.remove(i); - if let Some(slot) = shot.slot { - slot.cancel(); - } - match result { - Ok(image) => { - let reply = if image.size != shot.expected { - error_reply( - shot.token, - format!( - "screenshot is {}×{} pixels, expected {}×{}: the window changed size while it was captured", - image.size[0], image.size[1], shot.expected[0], shot.expected[1] - ), - ) - } else { - let captured_ppp = shot.geometry.surface.captured_pixels_per_point(); - let mut reply = crate::control::save_screenshot_with_ppp(&image, shot.path.as_deref(), shot.crop, captured_ppp); - if let Some(object) = reply.as_object_mut() { - object.insert("request".into(), json!(shot.token)); - } - reply - }; - let _ = shot.reply.send(reply); - } - Err(error) => { - let _ = shot.reply.send(error_reply(shot.token, error)); + match drain_step(app, ctx, i, now) { + DrainStep::Keep => i += 1, + DrainStep::Timeout => remove_and_reject(app, i, "no frame was presented before the screenshot timeout"), + DrainStep::Identity(reason) => remove_and_reject(app, i, format!("screenshot failed: {reason}")), + DrainStep::Geometry => remove_and_reject(app, i, "screenshot failed: the window geometry changed while the capture was in flight"), + DrainStep::Complete(result) => { + let shot = app.shots.requests.remove(i); + complete_shot(shot, result); } } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 37247080b..8c085703f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2328,12 +2328,9 @@ pub fn install_ime_reset_callbacks(ctx: &egui::Context) { } impl FilmcraftApp { - pub fn logic_inner(&mut self, ctx: &egui::Context) { - // Remember the pass's clock before anything reads `now()`. A hidden logic-only tick keeps - // the last UI time (which is stale there), so `now()` never runs behind the host clock. - self.ui_clock = self.ui_clock.max(ctx.input(|i| i.time)); - // a native file dialog's answer first, so the panels draw what it chose (#260) - self.poll_native_dialog(ctx); + /// Install the theme once, then keep fonts ready; a saved language without its system face + /// falls back to English as before. + fn logic_theme(&mut self, ctx: &egui::Context) { if !self.styled { theme::install(ctx, &self.tokens); // theme::install replaces the fonts: add the system Japanese font back (or fall back to @@ -2351,6 +2348,11 @@ impl FilmcraftApp { } else { self.fonts_ready = true; } + } + + /// Advance the monotonic host clock and the smoothed frame rate, returning the pass time. + /// Hidden playback is marked so a hidden window's skipped frames are not counted as drops. + fn logic_clock(&mut self, ctx: &egui::Context) -> f64 { // Monotonic host time. Native and web both call logic once per layout pass and through // `run_logic` while hidden; `ctx.input(i.time)` is stale in that second case. let now = self.now(); @@ -2363,6 +2365,11 @@ impl FilmcraftApp { // Nothing is shown while the window is hidden: not a dropped frame. self.playback.hidden = true; } + now + } + + /// Close-request handling and control-driven synthetic input, in the original order. + fn logic_control(&mut self, ctx: &egui::Context) { // Quitting with unsaved changes asks to save them first. if ctx.input(|i| i.viewport().close_requested()) && panels::clip_dialogs::intercept_quit(self) { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); @@ -2380,8 +2387,10 @@ impl FilmcraftApp { // honours this; the pinned web runner logs it as unhandled (a recorded fork gap). ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); } + } - // Background work: consumed queues and idempotent deadlines, safe on every logic call. + /// Background queues and idempotent deadlines that are safe on every logic call. + fn logic_background(&mut self, ctx: &egui::Context, now: f64) { self.frames.set_context(ctx); self.frames.pump(std::time::Duration::from_millis(if self.playback.playing { 24 } else { 40 })); self.session.poll_persistence(); @@ -2396,6 +2405,10 @@ impl FilmcraftApp { self.sync_pool(); #[cfg(not(target_arch = "wasm32"))] self.tick_media_refresh(ctx, now); + } + + /// Apply engine events that change the active sequence/view, IME target, toast or project. + fn logic_session_events(&mut self, now: f64) { for ev in self.session.drain_events() { match ev { filmcraft_engine::Event::OpenSequence(_) => { @@ -2417,6 +2430,10 @@ impl FilmcraftApp { filmcraft_engine::Event::ProjectChanged { .. } => {} } } + } + + /// Drain native menu/control command ids, preserving the control-only Quit exception. + fn logic_command_inbox(&mut self, ctx: &egui::Context) { if let Some(rx) = self.command_inbox.take() { while let Ok(id) = rx.try_recv() { // control-only mode ignores native menu commands, but Quit stays available (#1943) @@ -2429,6 +2446,20 @@ impl FilmcraftApp { } self.command_inbox = Some(rx); } + } + + pub fn logic_inner(&mut self, ctx: &egui::Context) { + // Remember the pass's clock before anything reads `now()`. A hidden logic-only tick keeps + // the last UI time (which is stale there), so `now()` never runs behind the host clock. + self.ui_clock = self.ui_clock.max(ctx.input(|i| i.time)); + // a native file dialog's answer first, so the panels draw what it chose (#260) + self.poll_native_dialog(ctx); + self.logic_theme(ctx); + let now = self.logic_clock(ctx); + self.logic_control(ctx); + self.logic_background(ctx, now); + self.logic_session_events(now); + self.logic_command_inbox(ctx); self.advance_playback(ctx); self.advance_source_playback(ctx); self.poll_jobs(ctx); diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index eef9bcac1..6a9b98f0d 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -11,6 +11,7 @@ use crate::FilmcraftApp; use crate::icons::{self, Icon}; use crate::state::KeyframeRef; use crate::theme::Tokens; +use crate::widgets::HotNumberOptions; #[path = "effect_controls_selection.rs"] mod selection; @@ -602,22 +603,38 @@ pub(crate) fn param_row( match (&pd.kind, &value) { (ParamKind::Float { min, max, soft_min, soft_max, unit, decimals }, ParamValue::Float(v)) => { let speed = ((soft_max - soft_min) / 400.0).max(0.01); - let (_, nv) = crate::widgets::hot_number(&mut vui, crate::i18n::t(pd.label), id, *v, speed, (*min, *max), *decimals as usize, unit, &t); + let (_, nv) = crate::widgets::hot_number( + &mut vui, + crate::i18n::t(pd.label), + id, + *v, + HotNumberOptions::new(speed, (*min, *max), *decimals as usize, unit, &t), + ); if let Some(nv) = nv { set = Some(json!(nv)); } } (ParamKind::Angle, ParamValue::Float(v)) => { - let (_, nv) = crate::widgets::hot_number(&mut vui, crate::i18n::t(pd.label), id, *v, 0.5, (-36000.0, 36000.0), 1, "°", &t); + let (_, nv) = crate::widgets::hot_number(&mut vui, crate::i18n::t(pd.label), id, *v, HotNumberOptions::new(0.5, (-36000.0, 36000.0), 1, "°", &t)); if let Some(nv) = nv { set = Some(json!(nv)); } } (ParamKind::Point, ParamValue::Vec2(p)) => { - let (_, nx) = - crate::widgets::hot_number(&mut vui, &format!("{} X", crate::i18n::t(pd.label)), id.with("x"), p.x, 1.0, (-100_000.0, 100_000.0), 1, "", &t); - let (_, ny) = - crate::widgets::hot_number(&mut vui, &format!("{} Y", crate::i18n::t(pd.label)), id.with("y"), p.y, 1.0, (-100_000.0, 100_000.0), 1, "", &t); + let (_, nx) = crate::widgets::hot_number( + &mut vui, + &format!("{} X", crate::i18n::t(pd.label)), + id.with("x"), + p.x, + HotNumberOptions::new(1.0, (-100_000.0, 100_000.0), 1, "", &t), + ); + let (_, ny) = crate::widgets::hot_number( + &mut vui, + &format!("{} Y", crate::i18n::t(pd.label)), + id.with("y"), + p.y, + HotNumberOptions::new(1.0, (-100_000.0, 100_000.0), 1, "", &t), + ); if nx.is_some() || ny.is_some() { set = Some(json!([nx.unwrap_or(p.x), ny.unwrap_or(p.y)])); } @@ -824,11 +841,7 @@ pub fn lumetri_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { crate::i18n::t(pd.label), egui::Id::unique(("lum", pd.id)), val, - (soft_max - soft_min) / 300.0, - (*min, *max), - 1, - "", - &t, + HotNumberOptions::new((soft_max - soft_min) / 300.0, (*min, *max), 1, "", &t), ); if s.changed() { actions.push(json!({"clip": clip.0, "effect": idx, "param": pd.id, "value": val})); @@ -930,11 +943,7 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { &format!("{label} X"), egui::Id::unique(("pp", p, "x", clip.0)), v.x, - speed, - range, - 0, - " X", - &t, + HotNumberOptions::new(speed, range, 0, " X", &t), ); vui.add_space(10.0); let (_, ny) = crate::widgets::hot_number( @@ -942,11 +951,7 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { &format!("{label} Y"), egui::Id::unique(("pp", p, "y", clip.0)), v.y, - speed, - range, - 0, - " Y", - &t, + HotNumberOptions::new(speed, range, 0, " Y", &t), ); if nx.is_some() || ny.is_some() { actions.push(( @@ -961,11 +966,7 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { label, egui::Id::unique(("pp", p, clip.0)), v, - speed, - range, - if p == "scale" { 0 } else { 1 }, - unit, - &t, + HotNumberOptions::new(speed, range, if p == "scale" { 0 } else { 1 }, unit, &t), ); if let Some(nv) = nv { actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "motion", "param": p, "value": nv}))); @@ -982,7 +983,13 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { .layout(egui::Layout::left_to_right(egui::Align::Center)), ); if let Some(ParamValue::Float(v)) = val("opacity", "opacity") { - let (_, nv) = crate::widgets::hot_number(&mut vui, tl!("Opacity"), egui::Id::unique(("pp-op", clip.0)), v, 0.5, (0.0, 100.0), 0, " %", &t); + let (_, nv) = crate::widgets::hot_number( + &mut vui, + tl!("Opacity"), + egui::Id::unique(("pp-op", clip.0)), + v, + HotNumberOptions::new(0.5, (0.0, 100.0), 0, " %", &t), + ); if let Some(nv) = nv { actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "opacity", "param": "opacity", "value": nv}))); } @@ -999,7 +1006,13 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { .layout(egui::Layout::left_to_right(egui::Align::Center)), ); let v = val("crop", p).and_then(|v| v.as_f64()).unwrap_or(0.0); - let (_, nv) = crate::widgets::hot_number(&mut vui, label, egui::Id::unique(("pp-crop", p, clip.0)), v, 0.2, (0.0, 100.0), 1, " %", &t); + let (_, nv) = crate::widgets::hot_number( + &mut vui, + label, + egui::Id::unique(("pp-crop", p, clip.0)), + v, + HotNumberOptions::new(0.2, (0.0, 100.0), 1, " %", &t), + ); if let Some(nv) = nv { if crop.is_none() { actions.push(("effects.apply".into(), json!({"clips": [clip.0], "effect": "crop"}))); @@ -1023,7 +1036,13 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { .layout(egui::Layout::left_to_right(egui::Align::Center)), ); let v = val("volume", "level").and_then(|v| v.as_f64()).unwrap_or(0.0); - let (_, nv) = crate::widgets::hot_number(&mut vui, tl!("Level"), egui::Id::unique(("pp-vol", clip.0)), v, 0.1, (-96.0, 15.0), 1, " dB", &t); + let (_, nv) = crate::widgets::hot_number( + &mut vui, + tl!("Level"), + egui::Id::unique(("pp-vol", clip.0)), + v, + HotNumberOptions::new(0.1, (-96.0, 15.0), 1, " dB", &t), + ); if let Some(nv) = nv { actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "volume", "param": "level", "value": nv}))); } @@ -1035,7 +1054,13 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { .layout(egui::Layout::left_to_right(egui::Align::Center)), ); let v = val("panner", "balance").and_then(|v| v.as_f64()).unwrap_or(0.0); - let (_, nv) = crate::widgets::hot_number(&mut vui, tl!("Pan"), egui::Id::unique(("pp-pan", clip.0)), v, 0.5, (-100.0, 100.0), 0, "", &t); + let (_, nv) = crate::widgets::hot_number( + &mut vui, + tl!("Pan"), + egui::Id::unique(("pp-pan", clip.0)), + v, + HotNumberOptions::new(0.5, (-100.0, 100.0), 0, "", &t), + ); if let Some(nv) = nv { actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "panner", "param": "balance", "value": nv}))); } diff --git a/crates/ui-egui/src/panels/graphics.rs b/crates/ui-egui/src/panels/graphics.rs index 8ff14fd4f..a41a7b7fd 100644 --- a/crates/ui-egui/src/panels/graphics.rs +++ b/crates/ui-egui/src/panels/graphics.rs @@ -36,6 +36,7 @@ use crate::FilmcraftApp; use crate::icons::{self, Icon}; use crate::state::{GfxEdit, TextPropsDialog, Tool}; use crate::theme::Tokens; +use crate::widgets::HotNumberOptions; /// A graphic layer visible at the playhead, with its layer→screen transform. pub struct LayerView { @@ -1611,12 +1612,364 @@ fn ime_event_is_stale(app: &FilmcraftApp, target: ImeTarget) -> bool { } } +/// Whether an event was fully handled or must skip the per-event IME destination update, which +/// mirrors the original loop's `continue` paths. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum EventFlow { + Applied, + Skip, +} + +/// Mutable state for one batch of text-editor events: the committed text, caret/selection, the IME +/// receipt chain, and whether the batch contains ordinary application-origin edits. +struct TextEditState { + ed: GfxEdit, + text: String, + changed: bool, + stop: bool, + application_mutation: bool, + custom_mutations: bool, + ime_before: Option<(usize, usize)>, + ime_after: Vec, +} + +impl TextEditState { + fn selection(&self) -> (usize, usize) { + (self.ed.caret.min(self.ed.anchor), self.ed.caret.max(self.ed.anchor)) + } + + fn delete_selection(&mut self) -> bool { + let (a, b) = self.selection(); + if a == b { + return false; + } + self.text.replace_range(a..b, ""); + self.ed.caret = a; + self.ed.anchor = a; + true + } + + /// Record the first pre-mutation caret/anchor and the post-mutation stamp. The caller records + /// only after an accepted IME mutation, exactly as the original inline arms did. + fn record_ime_change(&mut self, before: (usize, usize)) { + self.ime_before.get_or_insert(before); + self.ime_after.push(ime_stamp(&self.text, self.ed.caret, self.ed.anchor)); + self.changed = true; + } +} + +/// The per-event prologue: sync the live destination, consume IME-reset completions, and reject +/// events while a reset blocks IME input. `None` is the original loop's `continue`. +fn begin_text_event(app: &mut FilmcraftApp, ui: &egui::Ui, state: &TextEditState, target: ImeTarget, event: &egui::Event) -> Option<(bool, bool)> { + let destination = local_reset_destination(app, &state.ed, &state.text, target); + if state.custom_mutations { + app.sync_live_ime_destination(ui.ctx(), destination); + } + if let egui::Event::ImeResetCompleted { request_id } = event { + if state.custom_mutations && app.ime_reset.complete(ui.ctx(), *request_id, destination) && app.ime_composition_viewport == Some(ui.ctx().viewport_id()) + { + app.ime_composition = None; + app.ime_composition_viewport = None; + } + return None; + } + if !state.custom_mutations || (app.ime_reset.blocked(ui.ctx().viewport_id()) && matches!(event, egui::Event::Ime(_))) { + return None; + } + let from_ime = matches!(event, egui::Event::Ime(_)); + let terminal = matches!(event, egui::Event::Ime(egui::ImeEvent::Commit(_))); + Some((from_ime, terminal)) +} + +/// The per-event epilogue: remember or terminal-acknowledge an IME event's destination, or keep +/// the live destination in sync for ordinary application edits. +fn finish_text_event(app: &mut FilmcraftApp, ui: &egui::Ui, state: &TextEditState, target: ImeTarget, from_ime: bool, terminal: bool) { + let current = local_reset_destination(app, &state.ed, &state.text, target); + if from_ime { + if terminal { + app.ime_reset.terminal(ui.ctx().viewport_id()); + } else { + app.ime_reset.remember(ui.ctx().viewport_id(), current); + } + } else if state.custom_mutations { + app.sync_live_ime_destination(ui.ctx(), current); + } +} + +fn apply_preedit(app: &mut FilmcraftApp, ui: &egui::Ui, target: ImeTarget, text: &str) { + app.ime_composition_viewport = Some(ui.ctx().viewport_id()); + if text.is_empty() { + app.ime_composition = Some(crate::ImeComposition::TerminalPending(target)); + } else { + app.ime_composition = Some(crate::ImeComposition::Active(target)); + } +} + +fn apply_delete_surrounding_bytes(app: &FilmcraftApp, state: &mut TextEditState, target: ImeTarget, before_bytes: usize, after_bytes: usize) -> EventFlow { + if ime_event_is_stale(app, target) { + return EventFlow::Skip; + } + let (a, b) = state.selection(); + let (before, after) = surrounding_byte_deletion_ranges(&state.text, a, b, before_bytes, after_bytes); + if !before.is_empty() || !after.is_empty() { + let previous = (state.ed.caret, state.ed.anchor); + apply_surrounding_deletion(&mut state.text, &mut state.ed, before, after); + state.record_ime_change(previous); + } + EventFlow::Applied +} + +fn apply_delete_surrounding_chars(app: &FilmcraftApp, state: &mut TextEditState, target: ImeTarget, before_chars: usize, after_chars: usize) -> EventFlow { + if ime_event_is_stale(app, target) { + return EventFlow::Skip; + } + let (a, b) = state.selection(); + let before = prev_n_chars(&state.text, a, before_chars)..a; + let after = b..next_n_chars(&state.text, b, after_chars); + if !before.is_empty() || !after.is_empty() { + let previous = (state.ed.caret, state.ed.anchor); + apply_surrounding_deletion(&mut state.text, &mut state.ed, before, after); + state.record_ime_change(previous); + } + EventFlow::Applied +} + +fn apply_text_input(state: &mut TextEditState, input: &str) -> EventFlow { + let s: String = input.chars().filter(|c| !c.is_control() || *c == '\n').collect(); + if s.is_empty() { + return EventFlow::Skip; + } + state.delete_selection(); + let at = state.ed.caret; + state.text.insert_str(at, &s); + state.ed.caret += s.len(); + state.ed.anchor = state.ed.caret; + state.application_mutation = true; + state.changed = true; + EventFlow::Applied +} + +fn apply_ime_commit(app: &mut FilmcraftApp, state: &mut TextEditState, target: ImeTarget, input: &str) -> EventFlow { + if ime_event_is_stale(app, target) { + return EventFlow::Skip; + } + app.ime_composition = None; + app.ime_composition_viewport = None; + let s: String = input.chars().filter(|c| !c.is_control() || *c == '\n').collect(); + let previous = (state.ed.caret, state.ed.anchor); + if s.is_empty() { + // A commit replaces the selection even when the input method produced no text + // (for example DeleteSurroundingBytes followed by an empty commit). An empty + // commit with no selection stays a no-op. + if state.delete_selection() { + state.record_ime_change(previous); + } + } else { + state.delete_selection(); + let at = state.ed.caret; + state.text.insert_str(at, &s); + state.ed.caret += s.len(); + state.ed.anchor = state.ed.caret; + state.record_ime_change(previous); + } + EventFlow::Applied +} + +fn apply_copy_cut(ui: &egui::Ui, state: &mut TextEditState, cut: bool) -> EventFlow { + let (a, b) = state.selection(); + if a < b { + ui.ctx().copy_text(state.text[a..b].to_string()); + if cut && state.delete_selection() { + state.application_mutation = true; + state.changed = true; + } + } + EventFlow::Applied +} + +fn left_target(state: &TextEditState, layout: &filmcraft_text::Layout, shift: bool, command: bool, alt: bool) -> usize { + let (a, _) = state.selection(); + if !shift && state.ed.caret != state.ed.anchor { + a + } else if command { + layout.lines[layout.caret(state.ed.caret).2].range.start + } else if alt { + word_left(&state.text, state.ed.caret) + } else { + prev_boundary(&state.text, state.ed.caret) + } +} + +fn right_target(state: &TextEditState, layout: &filmcraft_text::Layout, shift: bool, command: bool, alt: bool) -> usize { + let (_, b) = state.selection(); + if !shift && state.ed.caret != state.ed.anchor { + b + } else if command { + layout.lines[layout.caret(state.ed.caret).2].range.end + } else if alt { + word_right(&state.text, state.ed.caret) + } else { + next_boundary(&state.text, state.ed.caret) + } +} + +fn vertical_target(state: &TextEditState, layout: &filmcraft_text::Layout, up: bool) -> usize { + let (x, _, line) = layout.caret(state.ed.caret); + let target = if up { line.checked_sub(1) } else { Some(line + 1).filter(|next| *next < layout.lines.len()) }; + match target { + Some(next) => layout.hit(x, layout.lines[next].baseline), + None if up => 0, + None => state.text.len(), + } +} + +fn apply_moved_caret(app: &mut FilmcraftApp, state: &mut TextEditState, moved: Option, shift: bool) { + if let Some(m) = moved { + let previous_selection = (state.ed.caret, state.ed.anchor); + // `m` comes from the layout of the text at the start of this batch, but an + // earlier Text/Paste/IME event may already have changed `text`; clamp to a + // UTF-8 boundary in the current text before a later event slices or inserts. + state.ed.caret = char_boundary_floor(&state.text, m); + if !shift { + state.ed.anchor = state.ed.caret; + } + if previous_selection != (state.ed.caret, state.ed.anchor) { + app.invalidate_ime_composition(); + } + } +} + +fn apply_key_event( + app: &mut FilmcraftApp, + ui: &egui::Ui, + state: &mut TextEditState, + layout: &filmcraft_text::Layout, + key: egui::Key, + modifiers: egui::Modifiers, +) -> EventFlow { + let shift = modifiers.shift; + let mut moved = None; + match key { + egui::Key::Escape => { + // Escape cancels the edit now: a commit later in the same raw batch must + // not still be inserted after the user cancelled. + state.stop = true; + state.custom_mutations = false; + app.suspend_ime_input(ui.ctx()); + return EventFlow::Skip; + } + egui::Key::Enter => { + state.delete_selection(); + let at = state.ed.caret; + state.text.insert(at, '\n'); + state.ed.caret += 1; + state.ed.anchor = state.ed.caret; + state.application_mutation = true; + state.changed = true; + } + egui::Key::Backspace => { + let mut did = state.delete_selection(); + if !did && state.ed.caret > 0 { + let p = if modifiers.alt || modifiers.command { word_left(&state.text, state.ed.caret) } else { prev_boundary(&state.text, state.ed.caret) }; + let end = state.ed.caret; + state.text.replace_range(p..end, ""); + state.ed.caret = p; + state.ed.anchor = p; + did = true; + } + if did { + state.application_mutation = true; + state.changed = true; + } + } + egui::Key::Delete => { + let mut did = state.delete_selection(); + if !did && state.ed.caret < state.text.len() { + let n = next_boundary(&state.text, state.ed.caret); + let at = state.ed.caret; + state.text.replace_range(at..n, ""); + did = true; + } + if did { + state.application_mutation = true; + state.changed = true; + } + } + egui::Key::A if modifiers.command => { + let moved = (state.ed.anchor, state.ed.caret) != (0, state.text.len()); + state.ed.anchor = 0; + state.ed.caret = state.text.len(); + if moved { + app.invalidate_ime_composition(); + } + } + egui::Key::ArrowLeft => { + moved = Some(left_target(state, layout, shift, modifiers.command, modifiers.alt)); + } + egui::Key::ArrowRight => { + moved = Some(right_target(state, layout, shift, modifiers.command, modifiers.alt)); + } + egui::Key::ArrowUp | egui::Key::ArrowDown => { + moved = Some(vertical_target(state, layout, key == egui::Key::ArrowUp)); + } + egui::Key::Home => moved = Some(layout.lines[layout.caret(state.ed.caret).2].range.start), + egui::Key::End => moved = Some(layout.lines[layout.caret(state.ed.caret).2].range.end), + _ => {} + } + apply_moved_caret(app, state, moved, shift); + EventFlow::Applied +} + +/// Apply one already-admitted event. The match arms keep the original order and short-circuits; +/// `Skip` reproduces the inline `continue` for stale or empty IME/text events. +fn apply_text_event( + app: &mut FilmcraftApp, + ui: &egui::Ui, + state: &mut TextEditState, + target: ImeTarget, + layout: &filmcraft_text::Layout, + event: egui::Event, +) -> EventFlow { + let is_cut = matches!(&event, egui::Event::Cut); + match event { + egui::Event::Ime(egui::ImeEvent::Preedit { text, .. }) => { + apply_preedit(app, ui, target, &text); + EventFlow::Applied + } + // text-input-v3/Wayland delete-surrounding arrives in UTF-8 bytes. Only whole + // characters fully inside the reported range are deleted: a byte count that ends + // inside a character leaves that character (the fork's contract), so a combining + // accent or the letter next to an emoji is preserved. + egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes, after_bytes }) => { + apply_delete_surrounding_bytes(app, state, target, before_bytes, after_bytes) + } + egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars, after_chars }) => { + apply_delete_surrounding_chars(app, state, target, before_chars, after_chars) + } + // ordinary typing and paste are application-origin, even in the same pass as an IME event + egui::Event::Text(s) | egui::Event::Paste(s) => apply_text_input(state, &s), + // an input method (Chinese, Japanese, Korean) hands over its finished text as a commit (#1083) + egui::Event::Ime(egui::ImeEvent::Commit(s)) => apply_ime_commit(app, state, target, &s), + egui::Event::Copy | egui::Event::Cut => apply_copy_cut(ui, state, is_cut), + egui::Event::Key { key, pressed: true, modifiers, .. } => apply_key_event(app, ui, state, layout, key, modifiers), + // A pointer press is handled later in this pass (caret placement, another layer, or + // ending the edit). Events after it in the same raw batch belong to the new target and + // must not be applied to the old one. + egui::Event::PointerButton { pressed: true, .. } => { + state.custom_mutations = false; + app.suspend_ime_input(ui.ctx()); + EventFlow::Applied + } + _ => EventFlow::Applied, + } +} + /// Keyboard handling while a text layer is being edited. fn text_edit( app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &LayerView, - mut ed: GfxEdit, + ed: GfxEdit, resp: &egui::Response, actions: &mut Vec<(String, Value)>, pending_ime: &mut Option, @@ -1657,292 +2010,59 @@ fn text_edit( app.suspended_ime_controls(ui.ctx()); return; }; - let mut text = tp.text.clone(); + let mut state = TextEditState { + ed, + text: tp.text.clone(), + changed: false, + stop: false, + application_mutation: false, + custom_mutations: true, + ime_before: None, + ime_after: Vec::new(), + }; // `ui.set.gfxEdit` accepts arbitrary byte offsets. Clamp both ends to UTF-8 boundaries here, // before any slice/replace_range/insert_str, so hostile offsets can never panic the editor. - clamp_edit_offsets(&text, &mut ed); + clamp_edit_offsets(&state.text, &mut state.ed); let l = text_layout(tp); let events = ui.input(|i| i.events.clone()); let sequence = app.session.state.active_sequence.map_or(0, |sequence| sequence.0); - let composition_target = graphics_ime_target(sequence, ed.clip, ed.layer, v.spec.uid, app.ime_replacement_epoch); - let mut changed = false; - let mut stop = false; - let mut application_mutation = false; - // A break becomes an ordered controls-only suffix, never a marker pre-scan. - let mut custom_mutations = true; - let mut ime_before: Option<(usize, usize)> = None; - let mut ime_after: Vec = Vec::new(); - let sel = |ed: &GfxEdit| (ed.caret.min(ed.anchor), ed.caret.max(ed.anchor)); - let delete_sel = |text: &mut String, ed: &mut GfxEdit| -> bool { - let (a, b) = sel(ed); - if a == b { - return false; - } - text.replace_range(a..b, ""); - ed.caret = a; - ed.anchor = a; - true - }; + let composition_target = graphics_ime_target(sequence, state.ed.clip, state.ed.layer, v.spec.uid, app.ime_replacement_epoch); for ev in events { - let destination = local_reset_destination(app, &ed, &text, composition_target); - if custom_mutations { - app.sync_live_ime_destination(ui.ctx(), destination); - } - if let egui::Event::ImeResetCompleted { request_id } = ev { - if custom_mutations && app.ime_reset.complete(ui.ctx(), request_id, destination) && app.ime_composition_viewport == Some(ui.ctx().viewport_id()) { - app.ime_composition = None; - app.ime_composition_viewport = None; - } + let Some((from_ime, terminal)) = begin_text_event(app, ui, &state, composition_target, &ev) else { + continue; + }; + if apply_text_event(app, ui, &mut state, composition_target, &l, ev) == EventFlow::Skip { continue; } - if !custom_mutations || (app.ime_reset.blocked(ui.ctx().viewport_id()) && matches!(&ev, egui::Event::Ime(_))) { - continue; - } - let from_ime = matches!(&ev, egui::Event::Ime(_)); - let terminal = matches!(&ev, egui::Event::Ime(egui::ImeEvent::Commit(_))); - match ev { - egui::Event::Ime(egui::ImeEvent::Preedit { text, .. }) => { - app.ime_composition_viewport = Some(ui.ctx().viewport_id()); - if text.is_empty() { - app.ime_composition = Some(crate::ImeComposition::TerminalPending(composition_target)); - } else { - app.ime_composition = Some(crate::ImeComposition::Active(composition_target)); - } - } - // text-input-v3/Wayland delete-surrounding arrives in UTF-8 bytes. Only whole - // characters fully inside the reported range are deleted: a byte count that ends - // inside a character leaves that character (the fork's contract), so a combining - // accent or the letter next to an emoji is preserved. - egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes, after_bytes }) => { - if ime_event_is_stale(app, composition_target) { - continue; - } - let (a, b) = sel(&ed); - let (before, after) = surrounding_byte_deletion_ranges(&text, a, b, before_bytes, after_bytes); - if !before.is_empty() || !after.is_empty() { - let (before_caret, before_anchor) = (ed.caret, ed.anchor); - apply_surrounding_deletion(&mut text, &mut ed, before, after); - ime_before.get_or_insert((before_caret, before_anchor)); - ime_after.push(ime_stamp(&text, ed.caret, ed.anchor)); - changed = true; - } - } - egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars, after_chars }) => { - if ime_event_is_stale(app, composition_target) { - continue; - } - let (a, b) = sel(&ed); - let before = prev_n_chars(&text, a, before_chars)..a; - let after = b..next_n_chars(&text, b, after_chars); - if !before.is_empty() || !after.is_empty() { - let (before_caret, before_anchor) = (ed.caret, ed.anchor); - apply_surrounding_deletion(&mut text, &mut ed, before, after); - ime_before.get_or_insert((before_caret, before_anchor)); - ime_after.push(ime_stamp(&text, ed.caret, ed.anchor)); - changed = true; - } - } - // ordinary typing and paste are application-origin, even in the same pass as an IME event - egui::Event::Text(s) | egui::Event::Paste(s) => { - let s: String = s.chars().filter(|c| !c.is_control() || *c == '\n').collect(); - if s.is_empty() { - continue; - } - delete_sel(&mut text, &mut ed); - text.insert_str(ed.caret, &s); - ed.caret += s.len(); - ed.anchor = ed.caret; - application_mutation = true; - changed = true; - } - // an input method (Chinese, Japanese, Korean) hands over its finished text as a commit (#1083) - egui::Event::Ime(egui::ImeEvent::Commit(s)) => { - if ime_event_is_stale(app, composition_target) { - continue; - } - app.ime_composition = None; - app.ime_composition_viewport = None; - let s: String = s.chars().filter(|c| !c.is_control() || *c == '\n').collect(); - let (before_caret, before_anchor) = (ed.caret, ed.anchor); - if s.is_empty() { - // A commit replaces the selection even when the input method produced no text - // (for example DeleteSurroundingBytes followed by an empty commit). An empty - // commit with no selection stays a no-op. - if delete_sel(&mut text, &mut ed) { - ime_before.get_or_insert((before_caret, before_anchor)); - ime_after.push(ime_stamp(&text, ed.caret, ed.anchor)); - changed = true; - } - } else { - delete_sel(&mut text, &mut ed); - text.insert_str(ed.caret, &s); - ed.caret += s.len(); - ed.anchor = ed.caret; - ime_before.get_or_insert((before_caret, before_anchor)); - ime_after.push(ime_stamp(&text, ed.caret, ed.anchor)); - changed = true; - } - } - egui::Event::Copy | egui::Event::Cut => { - let (a, b) = sel(&ed); - if a < b { - ui.ctx().copy_text(text[a..b].to_string()); - if matches!(ev, egui::Event::Cut) && delete_sel(&mut text, &mut ed) { - application_mutation = true; - changed = true; - } - } - } - egui::Event::Key { key, pressed: true, modifiers, .. } => { - let shift = modifiers.shift; - let mut moved = None; - match key { - egui::Key::Escape => { - // Escape cancels the edit now: a commit later in the same raw batch must - // not still be inserted after the user cancelled. - stop = true; - custom_mutations = false; - app.suspend_ime_input(ui.ctx()); - continue; - } - egui::Key::Enter => { - delete_sel(&mut text, &mut ed); - text.insert(ed.caret, '\n'); - ed.caret += 1; - ed.anchor = ed.caret; - application_mutation = true; - changed = true; - } - egui::Key::Backspace => { - let mut did = delete_sel(&mut text, &mut ed); - if !did && ed.caret > 0 { - let p = if modifiers.alt || modifiers.command { word_left(&text, ed.caret) } else { prev_boundary(&text, ed.caret) }; - text.replace_range(p..ed.caret, ""); - ed.caret = p; - ed.anchor = p; - did = true; - } - if did { - application_mutation = true; - changed = true; - } - } - egui::Key::Delete => { - let mut did = delete_sel(&mut text, &mut ed); - if !did && ed.caret < text.len() { - let n = next_boundary(&text, ed.caret); - text.replace_range(ed.caret..n, ""); - did = true; - } - if did { - application_mutation = true; - changed = true; - } - } - egui::Key::A if modifiers.command => { - let moved = (ed.anchor, ed.caret) != (0, text.len()); - ed.anchor = 0; - ed.caret = text.len(); - if moved { - app.invalidate_ime_composition(); - } - } - egui::Key::ArrowLeft => { - let (a, _) = sel(&ed); - moved = Some(if !shift && ed.caret != ed.anchor { - a - } else if modifiers.command { - l.lines[l.caret(ed.caret).2].range.start - } else if modifiers.alt { - word_left(&text, ed.caret) - } else { - prev_boundary(&text, ed.caret) - }); - } - egui::Key::ArrowRight => { - let (_, b) = sel(&ed); - moved = Some(if !shift && ed.caret != ed.anchor { - b - } else if modifiers.command { - l.lines[l.caret(ed.caret).2].range.end - } else if modifiers.alt { - word_right(&text, ed.caret) - } else { - next_boundary(&text, ed.caret) - }); - } - egui::Key::ArrowUp | egui::Key::ArrowDown => { - let (x, _, li) = l.caret(ed.caret); - let target = if key == egui::Key::ArrowUp { li.checked_sub(1) } else { Some(li + 1).filter(|n| *n < l.lines.len()) }; - moved = Some(match target { - Some(n) => l.hit(x, l.lines[n].baseline), - None if key == egui::Key::ArrowUp => 0, - None => text.len(), - }); - } - egui::Key::Home => moved = Some(l.lines[l.caret(ed.caret).2].range.start), - egui::Key::End => moved = Some(l.lines[l.caret(ed.caret).2].range.end), - _ => {} - } - if let Some(m) = moved { - let previous_selection = (ed.caret, ed.anchor); - // `m` comes from the layout of the text at the start of this batch, but an - // earlier Text/Paste/IME event may already have changed `text`; clamp to a - // UTF-8 boundary in the current text before a later event slices or inserts. - ed.caret = char_boundary_floor(&text, m); - if !shift { - ed.anchor = ed.caret; - } - if previous_selection != (ed.caret, ed.anchor) { - app.invalidate_ime_composition(); - } - } - } - // A pointer press is handled later in this pass (caret placement, another layer, or - // ending the edit). Events after it in the same raw batch belong to the new target - // and must not be applied to the old one. - egui::Event::PointerButton { pressed: true, .. } => { - custom_mutations = false; - app.suspend_ime_input(ui.ctx()); - } - _ => {} - } - let current = local_reset_destination(app, &ed, &text, composition_target); - if from_ime { - if terminal { - app.ime_reset.terminal(ui.ctx().viewport_id()); - } else { - app.ime_reset.remember(ui.ctx().viewport_id(), current); - } - } else if custom_mutations { - app.sync_live_ime_destination(ui.ctx(), current); - } + finish_text_event(app, ui, &state, composition_target, from_ime, terminal); } - if changed && text != tp.text { + if state.changed && state.text != tp.text { let action_index = actions.len(); + let ime_before = state.ime_before; let first_before = - ime_before.and_then(|(cursor, anchor)| (!ime_after.is_empty()).then(|| ImeMutationStamp::from_committed(&tp.text, cursor, anchor, None))); + ime_before.and_then(|(cursor, anchor)| (!state.ime_after.is_empty()).then(|| ImeMutationStamp::from_committed(&tp.text, cursor, anchor, None))); *pending_ime = Some(PendingImeEdit { action_index, - clip: ed.clip, - layer: ed.layer, + clip: state.ed.clip, + layer: state.ed.layer, first_before, - before_cursor: ime_before.map_or(ed.caret, |(cursor, _)| cursor), - before_anchor: ime_before.map_or(ed.anchor, |(_, anchor)| anchor), - after: ime_after, - final_cursor: ed.caret, - final_anchor: ed.anchor, - application_mutation, + before_cursor: ime_before.map_or(state.ed.caret, |(cursor, _)| cursor), + before_anchor: ime_before.map_or(state.ed.anchor, |(_, anchor)| anchor), + final_cursor: state.ed.caret, + final_anchor: state.ed.anchor, + application_mutation: state.application_mutation, + after: state.ime_after, }); - actions.push(("graphics.setText".into(), json!({"clip": ed.clip, "layer": ed.layer, "text": text, "merge": true}))); + let text = std::mem::take(&mut state.text); + actions.push(("graphics.setText".into(), json!({"clip": state.ed.clip, "layer": state.ed.layer, "text": text, "merge": true}))); } else { *pending_ime = None; } - if stop { + if state.stop { end_edit(app, ui); } else { - app.ui.gfx_edit = Some(ed); + app.ui.gfx_edit = Some(state.ed); } } @@ -2082,6 +2202,15 @@ fn color32(c: [f32; 4]) -> Color32 { Color32::from_rgba_unmultiplied(q(c[0]), q(c[1]), q(c[2]), q(c[3])) } +/// The formatting knobs of an inline graphics number field. +struct NumberField<'a> { + id: &'a str, + speed: f64, + range: (f64, f64), + decimals: usize, + unit: &'a str, +} + struct Ctx<'a> { clip: ClipId, layer: usize, @@ -2162,17 +2291,29 @@ impl Ctx<'_> { fn number(&mut self, ui: &mut egui::Ui, label: &str, id: &str, speed: f64, range: (f64, f64), dec: usize, unit: &str) { let v = pv(self.e, id, self.mt).as_f64().unwrap_or(0.0); let (r, mut vui) = self.row(ui, label); - let (resp, nv) = - crate::widgets::hot_number(&mut vui, label, egui::Id::unique(("gfxp", self.clip.0, self.layer, id)), v, speed, range, dec, unit, &self.t); + let (resp, nv) = crate::widgets::hot_number( + &mut vui, + label, + egui::Id::unique(("gfxp", self.clip.0, self.layer, id)), + v, + HotNumberOptions::new(speed, range, dec, unit, &self.t), + ); self.auto(id, resp.rect, label); self.stopwatch(ui, r, id); if let Some(nv) = nv { self.set(id, json!(nv)); } } - fn number_in(&mut self, vui: &mut egui::Ui, label: &str, id: &str, speed: f64, range: (f64, f64), dec: usize, unit: &str) { + fn number_in(&mut self, vui: &mut egui::Ui, label: &str, field: NumberField<'_>) { + let NumberField { id, speed, range, decimals, unit } = field; let v = pv(self.e, id, self.mt).as_f64().unwrap_or(0.0); - let (resp, nv) = crate::widgets::hot_number(vui, label, egui::Id::unique(("gfxp", self.clip.0, self.layer, id)), v, speed, range, dec, unit, &self.t); + let (resp, nv) = crate::widgets::hot_number( + vui, + label, + egui::Id::unique(("gfxp", self.clip.0, self.layer, id)), + v, + HotNumberOptions::new(speed, range, decimals, unit, &self.t), + ); self.auto(id, resp.rect, id); if let Some(nv) = nv { self.set(id, json!(nv)); @@ -2187,11 +2328,7 @@ impl Ctx<'_> { &format!("{label} X"), egui::Id::unique(("gfxp", self.clip.0, self.layer, id, "x")), v.x, - 1.0, - (-100_000.0, 100_000.0), - 1, - " X", - &self.t, + HotNumberOptions::new(1.0, (-100_000.0, 100_000.0), 1, " X", &self.t), ); vui.add_space(10.0); let (ry, ny) = crate::widgets::hot_number( @@ -2199,11 +2336,7 @@ impl Ctx<'_> { &format!("{label} Y"), egui::Id::unique(("gfxp", self.clip.0, self.layer, id, "y")), v.y, - 1.0, - (-100_000.0, 100_000.0), - 1, - " Y", - &self.t, + HotNumberOptions::new(1.0, (-100_000.0, 100_000.0), 1, " Y", &self.t), ); self.auto(&format!("{id}.x"), rx.rect, label); self.auto(&format!("{id}.y"), ry.rect, label); @@ -2698,7 +2831,7 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { cx.check(&mut vui, on, ""); cx.color(&mut vui, col); vui.add_space(6.0); - cx.number_in(&mut vui, &format!("{label} width"), w, 0.2, (0.0, 1000.0), 1, ""); + cx.number_in(&mut vui, &format!("{label} width"), NumberField { id: w, speed: 0.2, range: (0.0, 1000.0), decimals: 1, unit: "" }); vui.add_space(6.0); cx.choice(&mut vui, kind, graphic::STROKE_OPTS, 70.0); } @@ -2906,4 +3039,85 @@ mod ime_byte_delete_tests { let no_uid = graphics_ime_target(7, 11, 3, 0, 5); assert_ne!(no_uid, graphics_ime_target(7, 11, 4, 0, 5), "without a layer uid, the index is the identity"); } + + /// A pointer-down starts a new target, so later Text, Paste and IME events in the same raw + /// batch must not be applied to the layer that was being edited before the click. The old loop + /// cleared `custom_mutations` here; the extracted helper once fell through to `Applied` and + /// typed every later event into the old layer. + #[test] + fn pointer_press_rejects_text_paste_and_ime_later_in_the_same_raw_batch() { + use filmcraft_geom::{Affine, Vec2}; + use filmcraft_project::ClipId; + use filmcraft_project::graphic::{Appearance, LayerContent, LayerSpec, LayerTransform, TextProps}; + + let mut app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); + let ed = GfxEdit { clip: 1, layer: 0, caret: 3, anchor: 3, ..Default::default() }; + app.ui.gfx_edit = Some(ed.clone()); + let props = TextProps { + text: "old".into(), + font: String::new(), + style: String::new(), + size: 72.0, + align: 0, + tracking: 0.0, + kerning: true, + ligatures: true, + leading: 0.0, + baseline_shift: 0.0, + faux_bold: false, + faux_italic: false, + caps: 0, + underline: false, + box_width: 0.0, + box_height: 0.0, + vertical: false, + runs: Vec::new(), + }; + let view = super::LayerView { + clip: ClipId(1), + layer: 0, + spec: LayerSpec { + name: "Text".into(), + enabled: true, + uid: 0, + pin: None, + content: LayerContent::Text(props), + appearance: Appearance { fill: None, gradient: None, strokes: Vec::new(), background: None, shadow: None }, + transform: LayerTransform { position: Vec2::ZERO, anchor: Vec2::ZERO, scale: Vec2::new(1.0, 1.0), rotation: 0.0, opacity: 1.0 }, + }, + to_screen: Affine::IDENTITY, + canvas_to_screen: Affine::IDENTITY, + local: [-50.0, -25.0, 50.0, 25.0], + }; + + let ctx = egui::Context::default(); + let raw = egui::RawInput { + focused: true, + events: vec![ + egui::Event::PointerButton { + pos: egui::pos2(20.0, 20.0), + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }, + egui::Event::Text("typed".into()), + egui::Event::Paste("pasted".into()), + egui::Event::Ime(egui::ImeEvent::Commit("committed".into())), + ], + ..Default::default() + }; + let mut actions = Vec::new(); + let mut pending_ime = None; + let mut focused = false; + let mut out = ctx.run_ui(raw, |ui| { + let response = ui.allocate_response(egui::vec2(100.0, 100.0), egui::Sense::click()); + super::text_edit(&mut app, ui, &view, ed.clone(), &response, &mut actions, &mut pending_ime); + focused = ui.memory(|m| m.has_focus(egui::Id::unique(super::TEXT_EDIT_ID))); + }); + out.textures_delta.clear(); + + assert!(focused, "the text editor must own input for this batch"); + assert!(actions.is_empty(), "the pointer press must stop Text, Paste and IME editing the old layer"); + assert!(pending_ime.is_none(), "a rejected batch has no pending IME edit"); + } } diff --git a/crates/ui-egui/src/panels/lumetri.rs b/crates/ui-egui/src/panels/lumetri.rs index f7591e2ff..bd42b5d2c 100644 --- a/crates/ui-egui/src/panels/lumetri.rs +++ b/crates/ui-egui/src/panels/lumetri.rs @@ -17,6 +17,7 @@ use crate::FilmcraftApp; use crate::icons::{self, Icon}; use crate::native_dialogs::FileDialog; use crate::theme::Tokens; +use crate::widgets::HotNumberOptions; type Actions = Vec; @@ -448,11 +449,7 @@ fn slider( label, egui::Id::unique(("lval", clip.0, id)), v, - (soft_max - soft_min) / 300.0, - (min, max), - decimals as usize, - "", - t, + HotNumberOptions::new((soft_max - soft_min) / 300.0, (min, max), decimals as usize, "", t), ); if let Some(nv) = out.or(nv) { actions.push(json!({"clip": clip.0, "effect": idx, "param": id, "value": (nv * 10f64.powi(decimals as i32)).round() / 10f64.powi(decimals as i32)})); diff --git a/crates/ui-egui/src/panels/mixer.rs b/crates/ui-egui/src/panels/mixer.rs index 0949545a2..6cbc05170 100644 --- a/crates/ui-egui/src/panels/mixer.rs +++ b/crates/ui-egui/src/panels/mixer.rs @@ -34,6 +34,7 @@ use serde_json::{Value, json}; use crate::FilmcraftApp; use crate::icons::{self, Icon}; use crate::theme::Tokens; +use crate::widgets::HotNumberOptions; // ------------------------------------------------------------------------------------- fader law @@ -657,7 +658,13 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s } let vr = Rect::from_center_size(pos2(kc.x, y + 48.0), vec2(56.0, 16.0)); let mut vui = ui.new_child(egui::UiBuilder::new().max_rect(vr).layout(egui::Layout::top_down(egui::Align::Center))); - let (vresp, nv) = crate::widgets::hot_number(&mut vui, tl!("Pan"), egui::Id::unique((&ap, "panValue")), pan, 1.0, (-100.0, 100.0), 1, "", &t); + let (vresp, nv) = crate::widgets::hot_number( + &mut vui, + tl!("Pan"), + egui::Id::unique((&ap, "panValue")), + pan, + HotNumberOptions::new(1.0, (-100.0, 100.0), 1, "", &t), + ); app.auto.add(&format!("{ap}.panValue"), vresp.rect, &format!("{pan:.1}")); if let Some(v) = nv { cx.cmd("mixer.setValue", json!({"strip": sid, "lane": LANE_PAN, "value": v})); @@ -793,8 +800,13 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s // fader value (hot text) let vr = Rect::from_min_size(pos2(x + 4.0, sr.max.y - name_h - value_h), vec2(56.0, value_h)); let mut vui = ui.new_child(egui::UiBuilder::new().max_rect(vr)); - let (vresp, nv) = - crate::widgets::hot_number(&mut vui, tl!("Volume"), egui::Id::unique((&ap, "value")), vol.max(-96.0), 0.1, (FADER_MIN_DB, FADER_MAX_DB), 1, "", &t); + let (vresp, nv) = crate::widgets::hot_number( + &mut vui, + tl!("Volume"), + egui::Id::unique((&ap, "value")), + vol.max(-96.0), + HotNumberOptions::new(0.1, (FADER_MIN_DB, FADER_MAX_DB), 1, "", &t), + ); app.auto.add(&format!("{ap}.value"), vresp.rect, &db_text(vol)); if let Some(v) = nv { cx.cmd("mixer.setValue", json!({"strip": sid, "lane": LANE_VOLUME, "value": v})); @@ -862,7 +874,13 @@ fn pan51_panner(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, tr: &Tr small_text(ui, pos2(col_x, r.min.y + 6.0), Align2::LEFT_CENTER, tl!("Center"), 9.0, t.text_dim); let cr = Rect::from_min_size(pos2(col_x, r.min.y + 12.0), vec2(r.max.x - col_x, 14.0)); let mut cui = ui.new_child(egui::UiBuilder::new().max_rect(cr)); - let (cresp, nc) = crate::widgets::hot_number(&mut cui, tl!("Center"), egui::Id::unique((ap, "pan51.center")), center, 1.0, (0.0, 100.0), 0, "%", &t); + let (cresp, nc) = crate::widgets::hot_number( + &mut cui, + tl!("Center"), + egui::Id::unique((ap, "pan51.center")), + center, + HotNumberOptions::new(1.0, (0.0, 100.0), 0, "%", &t), + ); app.auto.add(&format!("{ap}.pan51.center"), cresp.rect, &format!("Center {center:.0} %")); if let Some(v) = nc { cx.cmd("mixer.setValue", json!({"strip": sid, "lane": LANE_PAN51_CENTER, "value": v})); @@ -870,8 +888,13 @@ fn pan51_panner(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, tr: &Tr small_text(ui, pos2(col_x, r.min.y + 32.0), Align2::LEFT_CENTER, "LFE", 9.0, t.text_dim); let lr = Rect::from_min_size(pos2(col_x, r.min.y + 38.0), vec2(r.max.x - col_x, 14.0)); let mut lui = ui.new_child(egui::UiBuilder::new().max_rect(lr)); - let (lresp, nl) = - crate::widgets::hot_number(&mut lui, "LFE", egui::Id::unique((ap, "pan51.lfe")), lfe.max(FADER_MIN_DB), 0.5, (FADER_MIN_DB, FADER_MAX_DB), 1, "", &t); + let (lresp, nl) = crate::widgets::hot_number( + &mut lui, + "LFE", + egui::Id::unique((ap, "pan51.lfe")), + lfe.max(FADER_MIN_DB), + HotNumberOptions::new(0.5, (FADER_MIN_DB, FADER_MAX_DB), 1, "", &t), + ); app.auto.add(&format!("{ap}.pan51.lfe"), lresp.rect, &format!("LFE {}", db_text(lfe))); if let Some(v) = nl { cx.cmd("mixer.setValue", json!({"strip": sid, "lane": LANE_PAN51_LFE, "value": v})); @@ -1086,8 +1109,13 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if !active { vui.set_opacity(0.4); } - let (vresp, nv) = - crate::widgets::hot_number(&mut vui, tl!("Volume"), egui::Id::unique((&ap, "value")), vol.max(-96.0), 0.1, (FADER_MIN_DB, FADER_MAX_DB), 1, "", &t); + let (vresp, nv) = crate::widgets::hot_number( + &mut vui, + tl!("Volume"), + egui::Id::unique((&ap, "value")), + vol.max(-96.0), + HotNumberOptions::new(0.1, (FADER_MIN_DB, FADER_MAX_DB), 1, "", &t), + ); app.auto.add(&format!("{ap}.value"), vresp.rect, &db_text(vol)); if let (Some(v), true) = (nv, active) { set(&mut acts, "volume", "level", v, !vresp.dragged() || vresp.drag_started()); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index f9caa667b..c7df56159 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -23,55 +23,80 @@ pub fn accessible_name(response: Response, name: impl Into) -> Response response } +/// Numeric formatting and theme context for a [`hot_number`] field. +/// +/// Grouping the related knobs keeps the widget's call sites readable and below the argument-count +/// limit; the fields are exactly the values the hot-number editor and drag handling need. +pub struct HotNumberOptions<'a> { + /// Value change per dragged point at the normal modifier setting. + pub speed: f64, + /// Inclusive value bounds used when committing a drag or a typed calculation. + pub range: (f64, f64), + /// Digits after the decimal point in the displayed value. + pub decimals: usize, + /// Unit text drawn after the value and stripped from typed expressions. + pub suffix: &'a str, + /// Theme tokens for the hot-text colours. + pub tokens: &'a Tokens, +} + +impl<'a> HotNumberOptions<'a> { + pub const fn new(speed: f64, range: (f64, f64), decimals: usize, suffix: &'a str, tokens: &'a Tokens) -> Self { + Self { speed, range, decimals, suffix, tokens } + } +} + /// Premiere "hot text": a blue number you drag horizontally to scrub, click to type a number or a /// calculation ([`crate::calc`]). `label` is the property's accessible name (for example /// "Position X"), used when the field switches to a text editor so screen readers can tell the /// value fields apart. /// Returns (response, Some(new value) when changed). -pub fn hot_number( - ui: &mut Ui, - label: &str, - id: egui::Id, - value: f64, - speed: f64, - range: (f64, f64), - decimals: usize, - suffix: &str, - t: &Tokens, -) -> (Response, Option) { +pub fn hot_number(ui: &mut Ui, label: &str, id: egui::Id, value: f64, options: HotNumberOptions<'_>) -> (Response, Option) { + let editing_id = id.with("editing"); + let mut editing: Option = ui.data(|d| d.get_temp(editing_id)); + if let Some(buf) = editing.as_mut() { + return hot_number_editor(ui, label, id, value, options.range, options.suffix, buf); + } + hot_number_display(ui, id, value, options) +} + +/// The single-line text editor shown after a click on a hot number. +fn hot_number_editor(ui: &mut Ui, label: &str, id: egui::Id, value: f64, range: (f64, f64), suffix: &str, buf: &mut String) -> (Response, Option) { let editing_id = id.with("editing"); let edit_id = id.with("edit"); let select_all_id = id.with("select-all"); - let mut editing: Option = ui.data(|d| d.get_temp(editing_id)); - if let Some(buf) = editing.as_mut() { - let r = accessible_name(ui.add(egui::TextEdit::singleline(buf).id(edit_id).desired_width(56.0).font(Tokens::ui(12.0))), label); - let mut out = None; - if r.lost_focus() { - // the field's own unit can be typed anywhere; calc ignores the common ones - let typed = if suffix.trim().is_empty() { buf.clone() } else { buf.replace(suffix.trim(), "") }; - if let Some(v) = crate::calc::eval(&typed, value) { - out = Some(v.clamp(range.0, range.1)); - } - ui.data_mut(|d| d.remove::(editing_id)); - } else { - let b = buf.clone(); - ui.data_mut(|d| d.insert_temp(editing_id, b)); - // select the whole value once the field has focus (egui collapses a selection to a caret - // in a field that does not have it yet), so typing replaces it as in Premiere - let selected_now = r.has_focus() && ui.data_mut(|d| d.remove_temp::(select_all_id)).is_some(); - if selected_now { - let mut st = egui::text_edit::TextEditState::load(ui.ctx(), edit_id).unwrap_or_default(); - st.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(buf.chars().count())))); - st.store(ui.ctx(), edit_id); - } - // the focus and the selection show on the next frame, which nothing else would trigger - if !r.has_focus() || selected_now { - ui.ctx().request_repaint(); - } - r.request_focus(); + let r = accessible_name(ui.add(egui::TextEdit::singleline(buf).id(edit_id).desired_width(56.0).font(Tokens::ui(12.0))), label); + let mut out = None; + if r.lost_focus() { + // the field's own unit can be typed anywhere; calc ignores the common ones + let typed = if suffix.trim().is_empty() { buf.clone() } else { buf.replace(suffix.trim(), "") }; + if let Some(v) = crate::calc::eval(&typed, value) { + out = Some(v.clamp(range.0, range.1)); } - return (r, out); + ui.data_mut(|d| d.remove::(editing_id)); + } else { + let b = buf.clone(); + ui.data_mut(|d| d.insert_temp(editing_id, b)); + // select the whole value once the field has focus (egui collapses a selection to a caret + // in a field that does not have it yet), so typing replaces it as in Premiere + let selected_now = r.has_focus() && ui.data_mut(|d| d.remove_temp::(select_all_id)).is_some(); + if selected_now { + let mut st = egui::text_edit::TextEditState::load(ui.ctx(), edit_id).unwrap_or_default(); + st.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(buf.chars().count())))); + st.store(ui.ctx(), edit_id); + } + // the focus and the selection show on the next frame, which nothing else would trigger + if !r.has_focus() || selected_now { + ui.ctx().request_repaint(); + } + r.request_focus(); } + (r, out) +} + +/// The painted, draggable value shown while the field is not in text-edit mode. +fn hot_number_display(ui: &mut Ui, id: egui::Id, value: f64, options: HotNumberOptions<'_>) -> (Response, Option) { + let HotNumberOptions { speed, range, decimals, suffix, tokens: t } = options; let text = format!("{value:.decimals$}{suffix}"); let galley = ui.painter().layout_no_wrap(text, Tokens::ui(12.0), t.hot_text); let (rect, resp) = ui.allocate_exact_size(galley.size() + vec2(4.0, 4.0), Sense::click_and_drag()); @@ -85,15 +110,7 @@ pub fn hot_number( let mut out = None; if resp.dragged() { let dx = resp.drag_delta().x as f64; - let mult = ui.input(|i| { - if i.modifiers.shift { - 10.0 - } else if i.modifiers.command { - 0.1 - } else { - 1.0 - } - }); + let mult = hot_number_drag_multiplier(ui); let nv = (value + dx * speed * mult).clamp(range.0, range.1); if (nv - value).abs() > f64::EPSILON { out = Some(nv); @@ -101,14 +118,26 @@ pub fn hot_number( } if resp.clicked() { ui.data_mut(|d| { - d.insert_temp(editing_id, format!("{value:.decimals$}")); - d.insert_temp(select_all_id, true); + d.insert_temp(id.with("editing"), format!("{value:.decimals$}")); + d.insert_temp(id.with("select-all"), true); }); ui.ctx().request_repaint(); } (resp, out) } +fn hot_number_drag_multiplier(ui: &Ui) -> f64 { + ui.input(|i| { + if i.modifiers.shift { + 10.0 + } else if i.modifiers.command { + 0.1 + } else { + 1.0 + } + }) +} + /// A large blue timecode readout (monitors/timeline) that moves its playhead: drag horizontally to /// scrub (1 frame per point, Shift ×10), click to type a time (Enter or clicking away commits, /// Escape cancels). Returns the time to go to (never before `min`), or why a typed time is invalid. @@ -397,7 +426,7 @@ mod tests { let input = egui::RawInput { events, ..Default::default() }; // no renderer takes the font atlas upload here; drop it explicitly (epaint panics otherwise) ctx.run_ui(input, |ui| { - hot_number(ui, "Value", id, 123.5, 1.0, (0.0, 1000.0), 1, "", &t); + hot_number(ui, "Value", id, 123.5, HotNumberOptions::new(1.0, (0.0, 1000.0), 1, "", &t)); }) .textures_delta .clear(); @@ -417,7 +446,7 @@ mod tests { d.insert_temp(id.with("editing"), "12.5".to_string()); d.insert_temp(id.with("select-all"), false); }); - hot_number(ui, "Position X", id, 12.5, 1.0, (-1000.0, 1000.0), 1, "", &t); + hot_number(ui, "Position X", id, 12.5, HotNumberOptions::new(1.0, (-1000.0, 1000.0), 1, "", &t)); }, (), ); From 403b823ce43ee702aca0fb5e9649dae110a76900 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 25 Oct 2026 11:46:39 +0100 Subject: [PATCH 066/113] Translate graphics and effect control accessibility labels --- crates/ui-egui/src/panels/effect_controls.rs | 2 +- crates/ui-egui/src/panels/graphics.rs | 18 ++-- crates/ui-egui/tests/it/graphics_ui.rs | 89 ++++++++++++++++++++ 3 files changed, 99 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 6a9b98f0d..aa1ab03c9 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -680,7 +680,7 @@ pub(crate) fn param_row( (ParamKind::Text, ParamValue::Text(s)) if !pd.id.ends_with("_lut") => { // edited in a buffer; committed (one undo step) when the field loses focus let mut buf = vui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| s.clone()); - let r = crate::widgets::accessible_name(vui.add(egui::TextEdit::singleline(&mut buf).desired_width(160.0)), pd.label); + let r = crate::widgets::accessible_name(vui.add(egui::TextEdit::singleline(&mut buf).desired_width(160.0)), crate::i18n::t(pd.label)); app.auto.add(&format!("effectControls.text.{}.{}", idx, pd.id), r.rect, pd.label); if r.lost_focus() { if buf != *s { diff --git a/crates/ui-egui/src/panels/graphics.rs b/crates/ui-egui/src/panels/graphics.rs index a41a7b7fd..9bce6b83a 100644 --- a/crates/ui-egui/src/panels/graphics.rs +++ b/crates/ui-egui/src/panels/graphics.rs @@ -2363,7 +2363,7 @@ impl Ctx<'_> { self.set(id, json!(format!("#{r8:02x}{g8:02x}{b8:02x}"))); } } - fn choice(&mut self, vui: &mut egui::Ui, id: &str, opts: &[&str], width: f32) { + fn choice(&mut self, vui: &mut egui::Ui, id: &str, label: &str, opts: &[&str], width: f32) { let cur = match pv(self.e, id, self.mt) { ParamValue::Choice(c) => c as usize, _ => 0, @@ -2377,7 +2377,7 @@ impl Ctx<'_> { ui.selectable_value(&mut sel, i, crate::i18n::t(o)); } }); - let response = crate::widgets::accessible_name(r.response, id); + let response = crate::widgets::accessible_name(r.response, label); self.auto(id, response.rect, id); if sel != cur { self.set(id, json!(sel)); @@ -2799,7 +2799,7 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } if e.effect == graphic::SHAPE_LAYER && section(ui, app, "Shape", &t) { let (_, mut vui) = cx.row(ui, tl!("Shape")); - cx.choice(&mut vui, "shape", graphic::SHAPE_OPTS, 120.0); + cx.choice(&mut vui, "shape", tl!("Shape"), graphic::SHAPE_OPTS, 120.0); cx.point(ui, tl!("Size"), "size"); cx.number(ui, tl!("Corner Radius"), "corner_radius", 0.5, (0.0, 10_000.0), 0, ""); cx.number(ui, tl!("Polygon Sides"), "sides", 0.05, (3.0, 64.0), 0, ""); @@ -2811,7 +2811,7 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { cx.color(&mut vui, "fill_color"); if fill_on { vui.add_space(6.0); - cx.choice(&mut vui, "fill_kind", graphic::FILL_KIND_OPTS, 130.0); + cx.choice(&mut vui, "fill_kind", tl!("Fill Type"), graphic::FILL_KIND_OPTS, 130.0); } let fill_kind = match pv(e, "fill_kind", mt) { ParamValue::Choice(c) => c, @@ -2823,17 +2823,17 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { cx.color(&mut vui, "gradient_end"); cx.number(ui, tl!(" Angle"), "gradient_angle", 0.5, (-3600.0, 3600.0), 0, " °"); } - for (on, col, w, kind, label) in [ - ("stroke", "stroke_color", "stroke_width", "stroke_type", tl!("Stroke")), - ("stroke2", "stroke2_color", "stroke2_width", "stroke2_type", tl!("Stroke 2")), + for (on, col, w, kind, label, width_label, type_label) in [ + ("stroke", "stroke_color", "stroke_width", "stroke_type", tl!("Stroke"), tl!("Stroke Width"), tl!("Stroke Type")), + ("stroke2", "stroke2_color", "stroke2_width", "stroke2_type", tl!("Stroke 2"), tl!("Stroke 2 Width"), tl!("Stroke 2 Type")), ] { let (_, mut vui) = cx.row(ui, label); cx.check(&mut vui, on, ""); cx.color(&mut vui, col); vui.add_space(6.0); - cx.number_in(&mut vui, &format!("{label} width"), NumberField { id: w, speed: 0.2, range: (0.0, 1000.0), decimals: 1, unit: "" }); + cx.number_in(&mut vui, width_label, NumberField { id: w, speed: 0.2, range: (0.0, 1000.0), decimals: 1, unit: "" }); vui.add_space(6.0); - cx.choice(&mut vui, kind, graphic::STROKE_OPTS, 70.0); + cx.choice(&mut vui, kind, type_label, graphic::STROKE_OPTS, 70.0); } let (_, mut vui) = cx.row(ui, tl!("Background")); let bg = cx.check(&mut vui, "background", ""); diff --git a/crates/ui-egui/tests/it/graphics_ui.rs b/crates/ui-egui/tests/it/graphics_ui.rs index fffa7a1d9..fe3c3e2d0 100644 --- a/crates/ui-egui/tests/it/graphics_ui.rs +++ b/crates/ui-egui/tests/it/graphics_ui.rs @@ -7,7 +7,9 @@ use std::sync::mpsc::{Sender, channel}; +use egui::accesskit::Role; use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; use filmcraft_engine::Session; use filmcraft_ui_egui::FilmcraftApp; use filmcraft_ui_egui::control::ControlRequest; @@ -153,6 +155,93 @@ fn type_tool_edit_style_and_move() { d.shot("shape-layer"); } +/// Properties-panel choice and stroke-width accessible names follow the interface language, while +/// the automation ids and the values shown stay those of the property. +#[test] +fn choice_and_stroke_width_accessible_names_follow_language() { + let mut d = Driver::demo(); + d.ok("ui.set", json!({"workspace": "Captions and Graphics"})); + let clip = d.exec("graphics.newShape", json!({"shape": "rectangle", "position": [800, 500], "size": [500, 300]}))["clip"].as_u64().unwrap(); + d.exec("graphics.selectLayer", json!({"clip": clip, "layers": [0]})); + d.frames(4); + + assert_eq!(d.harness.get_by_role_and_label(Role::ComboBox, "Shape").value().as_deref(), Some("Rectangle")); + + d.exec("prefs.set", json!({"key": "general.interfaceLanguage", "value": "es"})); + d.frames(4); + let es = filmcraft_ui_egui::i18n::Language::Es; + for (label, value) in [("Shape", "Rectangle"), ("Fill Type", "Solid"), ("Stroke Type", "Outer"), ("Stroke 2 Type", "Outer")] { + assert_eq!( + d.harness.get_by_role_and_label(Role::ComboBox, es.tr(label)).value().as_deref(), + Some(es.tr(value)), + "{label} keeps its translated name and selected value" + ); + } + + // The widget still writes the property it names, and its automation id does not change. + d.exec("graphics.set", json!({"clip": clip, "layer": 0, "props": {"shape": "ellipse", "stroke2_type": "center"}})); + d.frames(4); + assert_eq!(d.harness.get_by_role_and_label(Role::ComboBox, es.tr("Shape")).value().as_deref(), Some(es.tr("Ellipse"))); + assert_eq!(d.harness.get_by_role_and_label(Role::ComboBox, es.tr("Stroke 2 Type")).value().as_deref(), Some(es.tr("Center"))); + let ids: Vec = + d.ok("ui.elements", json!({"prefix": "graphics.prop."})).as_array().unwrap().iter().filter_map(|e| e["id"].as_str().map(str::to_string)).collect(); + for id in ["graphics.prop.shape", "graphics.prop.fill_kind", "graphics.prop.stroke_type", "graphics.prop.stroke2_type"] { + assert!(ids.iter().any(|i| i == id), "{id} is still registered: {ids:?}"); + } + + // Each stroke width opens its editor under the complete translated catalog entry, not an + // English " width" suffix appended to a translated label. + for _ in 0..20 { + if d.rect("graphics.prop.stroke_width")[1] < 900.0 { + break; + } + d.ok("ui.scroll", json!({"id": "graphics.prop.shape", "dy": -200.0})); + d.frames(2); + } + d.ok("ui.click", json!({"id": "graphics.prop.stroke_width"})); + d.frames(3); + assert_eq!( + d.harness.get_by_role_and_label(Role::TextInput, es.tr("Stroke Width")).value().as_deref(), + Some("4.0"), + "the first stroke width editor has the translated label" + ); + d.ok("ui.click", json!({"id": "graphics.prop.stroke2_width"})); + d.frames(3); + assert_eq!( + d.harness.get_by_role_and_label(Role::TextInput, es.tr("Stroke 2 Width")).value().as_deref(), + Some("10.0"), + "the second stroke width editor has its own translated label" + ); + filmcraft_ui_egui::i18n::set_current(filmcraft_ui_egui::i18n::Language::En); +} + +/// Effect Controls' Text parameter (graphic layers' Source Text / name / font fields) names its +/// text editor with the translated parameter label, like the boolean and choice arms. +#[test] +fn effect_controls_text_accessible_name_follows_language() { + let mut d = Driver::demo(); + d.ok("ui.set", json!({"workspace": "Captions and Graphics"})); + let clip = d.exec("graphics.newText", json!({"text": "Night Drive"}))["clip"].as_u64().unwrap(); + d.exec("timeline.select", json!({"clips": [clip]})); + d.ok("ui.panel.show", json!({"panel": "Effect Controls"})); + d.frames(4); + + d.exec("prefs.set", json!({"key": "general.interfaceLanguage", "value": "es"})); + d.frames(4); + let es = filmcraft_ui_egui::i18n::Language::Es; + assert_eq!( + d.harness.get_by_role_and_label(Role::TextInput, es.tr("Source Text")).value().as_deref(), + Some("Night Drive"), + "the translated accessible name and the same value" + ); + let elements = d.ok("ui.elements", json!({"prefix": "effectControls.text."})); + assert!( + elements.as_array().unwrap().iter().any(|e| e["id"].as_str().is_some_and(|id| id.ends_with(".text"))), + "the Source Text automation id is unchanged: {elements}" + ); + filmcraft_ui_egui::i18n::set_current(filmcraft_ui_egui::i18n::Language::En); +} + /// The value of `param` on the text layer of graphic clip `clip`, at its first frame. fn text_param(d: &mut Driver, clip: u64, param: &str) -> Option { let s = &d.harness.state().session; From e447a189c92c66eda68c346a0c33bce78e2f82c3 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 25 Oct 2026 12:39:23 +0100 Subject: [PATCH 067/113] egui037: Document capture and IME lifecycle contracts --- crates/ui-egui/src/capture.rs | 89 +++++++++++++++++++++++++++ crates/ui-egui/src/i18n/mod.rs | 4 +- crates/ui-egui/src/ime_reset.rs | 49 +++++++++++++++ crates/ui-egui/src/lib.rs | 35 ++++++++++- crates/ui-egui/src/native_dialogs.rs | 2 +- crates/ui-egui/src/panels/graphics.rs | 86 +++++++++++++++++++++++++- crates/ui-egui/src/panels/settings.rs | 2 +- crates/ui-egui/src/scrub.rs | 2 +- crates/ui-egui/src/theme.rs | 2 +- 9 files changed, 261 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/capture.rs b/crates/ui-egui/src/capture.rs index c8d69a3a2..617845643 100644 --- a/crates/ui-egui/src/capture.rs +++ b/crates/ui-egui/src/capture.rs @@ -69,12 +69,20 @@ pub(crate) struct SurfaceGeometry { } impl PartialEq for SurfaceGeometry { + /// Compares only the fields that change captured pixels: viewport size, pixels per point and + /// the GPU texture limit. The presentation-only minimized/occluded flags are deliberately + /// ignored, so minimizing, occluding or losing window focus does not invalidate an issued + /// capture. fn eq(&self, other: &Self) -> bool { self.viewport == other.viewport && self.pixels_per_point == other.pixels_per_point && self.max_texture_side == other.max_texture_side } } impl SurfaceGeometry { + /// Snapshot the surface from a raw-input callback, reading the fresh `viewports`/`screen_rect` + /// geometry rather than the stale `InputState`. Missing or hostile fields fall back to + /// `previous` where possible; `None` means no usable zoom and pixels-per-point pair could be + /// established. fn from_raw(raw: &egui::RawInput, ctx: &egui::Context, previous: Option) -> Option { let info = raw.viewports.get(&raw.viewport_id).cloned().unwrap_or_default(); let rect = info.inner_rect.or(raw.screen_rect); @@ -99,6 +107,11 @@ impl SurfaceGeometry { Some(Self { viewport, pixels_per_point, max_texture_side, minimized, occluded }) } + /// Pixel size the backend will present: viewport points scaled by pixels per point and + /// rounded. If either axis exceeds the texture limit, both axes shrink by the same factor to + /// a positive limit, matching wgpu's `surface_fit`; per-axis clamping would predict the wrong + /// image. A zero/unknown texture limit disables shrinking. + /// `None` when the viewport is missing, non-finite, empty or the size overflows `usize`. fn expected_size(self) -> Option<[usize; 2]> { let viewport = self.viewport?; if !viewport.x.is_finite() || !viewport.y.is_finite() || viewport.x <= 0.0 || viewport.y <= 0.0 { @@ -158,6 +171,8 @@ impl SurfaceGeometry { if effective.is_finite() && effective > 0.0 { effective } else { native } } + /// Raw RGBA bytes of [`Self::expected_size`], checked for multiplication overflow; `None` + /// when the surface has no capturable size. fn expected_bytes(self) -> Option { let [width, height] = self.expected_size()?; width.checked_mul(height)?.checked_mul(std::mem::size_of::()) @@ -180,10 +195,13 @@ pub(crate) struct CaptureGeometry { } impl CaptureGeometry { + /// Pixel size implied by the bound surface geometry; `None` when it is not capturable. fn expected_size(self) -> Option<[usize; 2]> { self.surface.expected_size() } + /// Raw RGBA byte count implied by the bound surface, with the same overflow rule as + /// [`SurfaceGeometry::expected_bytes`]. fn expected_bytes(self) -> Option { self.surface.expected_bytes() } @@ -198,6 +216,9 @@ pub enum CropTarget { } impl CropTarget { + /// Resolve the named element against the accepted pass's automation registry. Only an element + /// drawn in the current pass with a finite, non-empty rectangle is accepted; a stale + /// previous-pass rectangle or an unknown id is an error, never a fallback to the whole window. fn resolve(&self, registry: &crate::automation::Registry) -> Result<[f32; 4], String> { let Self::Element(id) = self; let element = registry @@ -243,19 +264,25 @@ pub(crate) struct Shots { } impl Shots { + /// True when no request is queued, in flight or completed-but-not-yet-drained. pub(crate) fn is_empty(&self) -> bool { self.requests.is_empty() } + /// True while at least one admitted request has not been handed to a `Screenshot` callback. fn has_unissued(&self) -> bool { self.requests.iter().any(|shot| !shot.issued) } + /// Sum of every request's byte reservation, including completed-but-undrained captures; + /// saturates instead of overflowing. fn reserved(&self) -> usize { self.requests.iter().fold(0usize, |sum, shot| sum.saturating_add(shot.reserved)) } } +/// Allocate the next request token, wrapping after `u64::MAX` and skipping zero so a token is +/// always usable as a presence marker. fn next_token(shots: &mut Shots) -> u64 { let mut token = shots.token.wrapping_add(1); if token == 0 { @@ -265,18 +292,24 @@ fn next_token(shots: &mut Shots) -> u64 { token } +/// Build the JSON error reply carrying `token`, so the client can match a rejection to the +/// request it sent. fn error_reply(token: u64, message: impl std::fmt::Display) -> Value { json!({"ok": false, "error": message.to_string(), "request": token}) } +/// Send an error reply for `token`, ignoring a client that has already disconnected. fn reject(reply: Sender, token: u64, message: impl std::fmt::Display) { let _ = reply.send(error_reply(token, message)); } +/// The active sequence id (none when no sequence is open) and project revision that a capture +/// is bound to. fn active_document(app: &FilmcraftApp) -> DocumentId { DocumentId { sequence: app.session.state.active_sequence.map(|id| id.0), revision: app.session.revision } } +/// The widget currently holding keyboard focus, part of the capture identity. fn current_focus(ctx: &egui::Context) -> Option { ctx.memory(|memory| memory.focused()) } @@ -385,6 +418,9 @@ pub(crate) fn admit(app: &mut FilmcraftApp, ctx: &egui::Context, path: Option Option<&'static str> { if active_document(app) != shot.document { Some("the document or project revision changed while the capture was queued") @@ -395,6 +431,8 @@ fn identity_reason(app: &FilmcraftApp, ctx: &egui::Context, shot: &Shot) -> Opti } } +/// Why the live surface snapshot or capture epoch differs from the one this request was +/// admitted or issued against. `None` means the geometry is still the one it reserved for. fn geometry_reason(app: &FilmcraftApp, shot: &Shot) -> Option<&'static str> { if app.capture_geometry != Some(shot.geometry) || app.capture_epoch != shot.geometry_epoch { Some("the window geometry changed while the capture was queued") @@ -403,6 +441,10 @@ fn geometry_reason(app: &FilmcraftApp, shot: &Shot) -> Option<&'static str> { } } +/// Re-admit an unissued request against the current geometry: cancel its old slot, check the +/// per-image and aggregate budgets including every other request, and reset its settled +/// frames, crop misses and ready time. On failure nothing is changed and the caller rejects +/// it; on success the request is unissued again and must collect fresh settling passes. fn retry_geometry(app: &mut FilmcraftApp, ctx: &egui::Context, index: usize) -> Result<(), String> { let now = Instant::now(); let current = app.capture_geometry.ok_or_else(|| "the window geometry is no longer known".to_string())?; @@ -455,6 +497,10 @@ enum IssueOutcome { Issued, } +/// Decide one settled request: reject on identity change, ask for a geometry retry, defer a +/// not-yet-drawn crop target for a bounded number of accepted passes, or issue the screenshot +/// with the crop resolved from this pass. The callback is taken at most once and `issued` is +/// set only when the viewport command was sent. fn try_issue_shot(app: &mut FilmcraftApp, ctx: &egui::Context, index: usize) -> IssueOutcome { let shot = &app.shots.requests[index]; if let Some(reason) = identity_reason(app, ctx, shot) { @@ -556,6 +602,10 @@ enum DrainStep { Complete(Result, CaptureError>), } +/// Classify one drain position before removing anything: check the deadline first, then +/// identity, then geometry (an unissued request keeps waiting for a retry, while in-flight +/// stale pixels are rejected), and finally take a completed slot. `Keep` leaves the request +/// in place and advances the scan. fn drain_step(app: &FilmcraftApp, ctx: &egui::Context, index: usize, now: Instant) -> DrainStep { let shot = &app.shots.requests[index]; if now >= shot.deadline { @@ -582,6 +632,8 @@ fn drain_step(app: &FilmcraftApp, ctx: &egui::Context, index: usize, now: Instan DrainStep::Complete(result) } +/// Remove one request, cancel its slot and send its error reply; later requests shift down by +/// one index. fn remove_and_reject(app: &mut FilmcraftApp, index: usize, message: impl std::fmt::Display) { let shot = app.shots.requests.remove(index); if let Some(slot) = shot.slot { @@ -609,6 +661,9 @@ fn image_reply(shot: &Shot, image: &ColorImage) -> Value { reply } +/// Cancel any remaining callback, turn the captured image or error into the reply and send it +/// exactly once. A pixel size that differs from the reserved size becomes an error reply +/// instead of writing an image for the wrong surface. fn complete_shot(mut shot: Shot, result: Result, CaptureError>) { if let Some(slot) = shot.slot.take() { slot.cancel(); @@ -672,10 +727,12 @@ mod tests { use crate::automation::Registry; use filmcraft_engine::Session; + /// App with a default session, for capture-state unit tests. fn app() -> FilmcraftApp { FilmcraftApp::new(Session::default()) } + /// A fixed 800×600, 1 px/pt capture identity for the app's current document with no focus. fn geometry(app: &FilmcraftApp) -> CaptureGeometry { CaptureGeometry { surface: SurfaceGeometry { @@ -690,14 +747,19 @@ mod tests { } } + /// Raw input reporting an 800×600 root viewport. fn raw() -> egui::RawInput { egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), ..Default::default() } } + /// Admit one uncropped in-memory capture at the default test surface; return its reply + /// receiver and token. fn admit_one(app: &mut FilmcraftApp, ctx: &egui::Context) -> (Receiver, u64) { admit_with_surface(app, ctx, geometry(app).surface) } + /// Bind `surface` as the current capture geometry, admit one uncropped in-memory capture and + /// return its reply receiver and token. fn admit_with_surface(app: &mut FilmcraftApp, ctx: &egui::Context, surface: SurfaceGeometry) -> (Receiver, u64) { app.capture_geometry = Some(CaptureGeometry { surface, document: active_document(app), focus: None }); let (tx, rx) = channel(); @@ -706,6 +768,8 @@ mod tests { (rx, token) } + /// `note_raw_geometry` uses the fresh raw 2x viewport (1600×1200) rather than the previous + /// pass's input and bumps the capture epoch. #[test] fn raw_geometry_uses_the_fresh_surface_not_the_previous_pass() { let ctx = egui::Context::default(); @@ -718,6 +782,8 @@ mod tests { assert!(app.capture_epoch > 0); } + /// Oversized surfaces shrink both axes by one factor to the texture limit and crop at the + /// matching rendered scale; a surface within the limit keeps the exact native scale. #[test] fn oversized_windows_predict_the_backends_uniformly_scaled_surface() { let app = app(); @@ -734,6 +800,8 @@ mod tests { assert_eq!(surface.captured_pixels_per_point(), 1.0, "an uncapped surface keeps the native scale exactly"); } + /// Unusable viewport data preserves a valid native scale, while an invalid native scale falls + /// back to 1.0 so a crop can never divide by zero. #[test] fn captured_scale_falls_back_when_geometry_is_unusable() { let app = app(); @@ -752,6 +820,8 @@ mod tests { assert_eq!(surface.captured_pixels_per_point(), 1.0); } + /// A crop in points selects the region where a uniformly downscaled surface actually drew it, + /// using the rendered rather than the native scale. #[test] fn downscaled_capture_crops_with_the_rendered_scale() { let ctx = egui::Context::default(); @@ -798,6 +868,8 @@ mod tests { ); } + /// A 1.5x surface whose point size rounds up by one window pixel is still uncapped, so its + /// crop keeps the exact 1.5 scale instead of a slightly shrunk one. #[test] fn uncapped_hidpi_capture_keeps_the_native_scale() { let ctx = egui::Context::default(); @@ -838,6 +910,8 @@ mod tests { assert!(decoded.pixels().all(|pixel| *pixel == image::Rgba([0, 255, 0, 255])), "the HiDPI crop must select the green region at the native scale"); } + /// Minimized, occluded and window-focus flags do not bump the epoch or reject an already + /// issued capture; its pixels are still delivered once. #[test] fn presentation_flags_do_not_invalidate_an_issued_capture() { let ctx = egui::Context::default(); @@ -867,6 +941,8 @@ mod tests { assert_eq!(reply["ok"], true, "{reply}"); } + /// Admission counts completed-but-undrained requests, so a new request over `MAX_REQUESTS` + /// is rejected instead of replacing a slot that still owes a reply. #[test] fn admission_counts_outstanding_and_completed_but_undrained_requests() { let ctx = egui::Context::default(); @@ -883,6 +959,7 @@ mod tests { assert!(reply["error"].as_str().unwrap_or_default().contains("outstanding"), "{reply}"); } + /// A timed-out request replies once; its late callback cannot produce a second reply. #[test] fn a_late_completion_after_timeout_does_not_write_or_reply_twice() { let ctx = egui::Context::default(); @@ -899,6 +976,8 @@ mod tests { assert!(rx.try_recv().is_err(), "a late image must not produce another reply"); } + /// A near-per-image-limit surface is rejected before any slot exists because its crop/encode + /// reservation alone exceeds the aggregate bound. #[test] fn near_limit_images_are_rejected_for_encode_buffer_memory() { let ctx = egui::Context::default(); @@ -925,6 +1004,8 @@ mod tests { assert!(admitted_bytes([8000, 4000]).is_some_and(|bytes| bytes > MAX_TOTAL_BYTES)); } + /// A crop resolves only from the current pass's registry; a stale rect and a missing id both + /// error rather than using old coordinates. #[test] fn a_crop_is_resolved_from_the_current_pass_not_the_previous_one() { let mut registry = Registry::default(); @@ -939,6 +1020,8 @@ mod tests { assert!(CropTarget::Element("panel.missing".into()).resolve(®istry).is_err()); } + /// Issuing waits for the accepted final pass of a multipass layout, not the discarded first + /// pass. #[test] fn issue_runs_on_the_accepted_final_pass_of_a_multipass_layout() { let ctx = egui::Context::default(); @@ -960,6 +1043,8 @@ mod tests { assert!(app.shots.requests[0].issued, "the accepted pass must issue the capture"); } + /// `issue` consumes `monitor_inexact`, so the signal cannot stay sticky and delay later + /// screenshots until the five-second settle maximum. #[test] fn monitor_inexact_is_consumed_by_each_issue_attempt() { let ctx = egui::Context::default(); @@ -969,6 +1054,8 @@ mod tests { assert!(!app.monitor_inexact, "a sticky flag would delay every later screenshot by up to five seconds"); } + /// An unissued request follows a geometry change: its expected size and budget are recomputed + /// and it waits for fresh settling passes before issuing. #[test] fn a_geometry_change_retries_an_unissued_capture_with_new_budget() { let ctx = egui::Context::default(); @@ -987,6 +1074,8 @@ mod tests { assert!(!app.shots.requests[0].issued, "the retry waits for its own settling passes"); } + /// When a resize pushes both queued shots over the aggregate budget, each is rejected with + /// its own token and removal of the first cannot shift the second's index. #[test] fn mixed_order_geometry_failures_reject_every_shot_without_shifting_indices() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 3fc984673..fb3314562 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -269,7 +269,7 @@ pub fn fmt(template: &str, args: &[(&str, &str)]) -> String { const JAPANESE_SAMPLE: &str = "日本語ファイル編集あア"; /// Installed families preferred for Japanese interface text, best first (Gothic / sans-serif faces -/// read best at menu sizes). Any other installed face that covers [`JAPANESE_SAMPLE`] is used if +/// read best at menu sizes). Any other installed face that covers `JAPANESE_SAMPLE` is used if /// none of these is present. const PREFERRED_JAPANESE: &[&str] = &[ "Hiragino Sans", @@ -314,7 +314,7 @@ pub fn system_japanese_font() -> Option> { } /// Whether the craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`) supplies a -/// Japanese interface font: some craft-fonts face covers [`JAPANESE_SAMPLE`]. `theme::install` adds +/// Japanese interface font: some craft-fonts face covers `JAPANESE_SAMPLE`. `theme::install` adds /// these faces to every font family, so nothing else needs installing (and no system scan runs). pub fn craft_japanese_font() -> bool { filmcraft_text::fonts::craft_japanese().next().is_some() diff --git a/crates/ui-egui/src/ime_reset.rs b/crates/ui-egui/src/ime_reset.rs index bc4641d33..86a01a1fc 100644 --- a/crates/ui-egui/src/ime_reset.rs +++ b/crates/ui-egui/src/ime_reset.rs @@ -24,6 +24,8 @@ pub(crate) struct OutputContext { } impl OutputContext { + /// Snapshot the owner/generation/purpose triple that identifies one published custom IME + /// output. pub(crate) fn of(ime: &egui::output::IMEOutput) -> Self { Self { owner: ime.owner, generation: ime.context_generation, purpose: ime.purpose } } @@ -56,27 +58,39 @@ pub(crate) struct State { } impl State { + /// Start a pass for `ctx`'s viewport and clear the per-pass published-output record, so + /// `finish` cannot mistake a previous pass's descriptor for this pass's. pub(crate) fn begin_pass(&mut self, ctx: &Context) { self.viewport = ctx.viewport_id(); self.custom_output = None; } + /// Whether a reset fence still guards `viewport`. While blocked, IME input for the custom + /// editor must not be admitted. pub(crate) fn blocked(&self, viewport: ViewportId) -> bool { self.pending.contains_key(&viewport) } + /// The composition target being retired for `viewport`: the pending origin, or the last + /// completed origin until `terminal` clears it. pub(crate) fn origin(&self, viewport: ViewportId) -> Option { self.pending.get(&viewport).map(|p| p.origin).or_else(|| self.completed_origin.get(&viewport).copied()) } + /// Whether `destination` differs from the last one remembered for `viewport`; false when + /// nothing has been remembered yet. pub(crate) fn incoming_changed(&self, viewport: ViewportId, destination: Destination) -> bool { self.incoming.get(&viewport).is_some_and(|old| *old != destination) } + /// Record the latest destination for `viewport`, so the next event can distinguish a real + /// target change from repeated observation of the same target. pub(crate) fn remember(&mut self, viewport: ViewportId, destination: Destination) { self.incoming.insert(viewport, destination); } + /// Acknowledge a terminal IME event for `viewport`: forget the incoming destination and + /// completed origin so stale targets cannot be reused. pub(crate) fn terminal(&mut self, viewport: ViewportId) { self.incoming.remove(&viewport); self.completed_origin.remove(&viewport); @@ -100,6 +114,8 @@ impl State { } } + /// Move a pending fence to `Suspended` and drop its old output binding. Only an actually + /// eligible final descriptor in `finish` may rearm it; `sync`/`interrupt` reconciliation cannot. pub(crate) fn suspend(&mut self, viewport: ViewportId) { if let Some(pending) = self.pending.get_mut(&viewport) { pending.phase = Phase::Suspended; @@ -107,10 +123,15 @@ impl State { } } + /// Whether the custom output published in this pass is the one recorded by `produced`. + /// False when no custom output was produced in this pass. pub(crate) fn owns_output(&self, ctx: &Context) -> bool { self.custom_output.is_some() && ctx.output(|out| out.ime.as_ref().map(OutputContext::of)) == self.custom_output } + /// Allocate a reset fence for the current viewport after clearing the previous fence's + /// output binding. Allocation failure leaves the phase `AllocationFailed`, which still + /// blocks input; a later real destination change can request again. fn request(ctx: &Context, pending: &mut Pending) { // A new fence invalidates any output context bound to the previous fence. Clearing it // also lets `finish` recognize this request as already made in this pass, even when an @@ -130,6 +151,11 @@ impl State { } } + /// Validate a completion token and release the viewport's guard. The token must belong to + /// this viewport and equal the current `Awaiting` id, and `destination` must equal the + /// pending destination; `ctx.output` is deliberately not consulted because raw events run + /// before this pass publishes its descriptor. Any mismatch returns false and keeps the + /// guard. Success records the origin and destination for the next observation. pub(crate) fn complete(&mut self, ctx: &Context, id: ImeResetId, destination: Destination) -> bool { let viewport = ctx.viewport_id(); let Some(pending) = self.pending.get(&viewport).copied() else { return false }; @@ -149,6 +175,8 @@ impl State { true } + /// Record the custom output descriptor published in this pass, for `finish` and + /// `owns_output` to compare against the actual output. pub(crate) fn produced(&mut self, key: OutputContext) { self.custom_output = Some(key); } @@ -180,6 +208,8 @@ impl State { pending.output = custom; } + /// Forget all reset state for a closed viewport: pending fence, incoming destination and + /// completed origin. pub(crate) fn end_viewport(&mut self, viewport: ViewportId) { self.pending.remove(&viewport); self.incoming.remove(&viewport); @@ -187,10 +217,14 @@ impl State { } } +/// Temp-data key marking whether `viewport` is inside a UI pass rather than a hidden logic +/// callback. fn mode_id(viewport: ViewportId) -> Id { Id::unique(("filmcraft-ime-reset-ui-pass", viewport)) } +/// Whether the current callback is a UI pass (between the installed begin/end-pass hooks) +/// for this viewport, rather than `run_logic`. Defaults to false when no hook ran. pub(crate) fn in_ui_pass(ctx: &Context) -> bool { ctx.data(|data| data.get_temp::(mode_id(ctx.viewport_id()))).unwrap_or(false) } @@ -222,15 +256,20 @@ pub(crate) fn install(ctx: &Context) { mod tests { use super::*; + /// A fixed destination for one editor owner with caret 3 and committed-text stamp 17. fn destination() -> Destination { Destination { document: 1, target: ImeTarget::new(Id::unique("film-model-editor"), 1), caret: 3, anchor: 3, text: 17 } } + /// Build the IME output a custom producer publishes for `destination` with the given + /// generation and purpose. fn output(destination: Destination, generation: u64, purpose: IMEPurpose) -> egui::output::IMEOutput { egui::output::IMEOutput::new(destination.target.owner, generation, purpose, egui::Rect::ZERO, egui::Rect::ZERO) } // These reducer tests use real Context requests/output, not native acknowledgements. + /// Run one real UI pass that publishes a custom `output` and calls `finish` as eligible; + /// return the reset request the Context attached, if any. fn custom_pass(ctx: &Context, state: &mut State, destination: Destination, generation: u64, purpose: IMEPurpose) -> Option { let mut result = ctx.run_ui(egui::RawInput::default(), |ui| { state.begin_pass(ui.ctx()); @@ -244,6 +283,8 @@ mod tests { id } + /// A generation or purpose change supersedes the old fence, old tokens cannot complete, an + /// allocation failure stays blocked, and a later real destination change retries. #[test] fn changed_output_context_supersedes_and_allocation_failure_stays_blocked() { let ctx = Context::default(); @@ -276,6 +317,8 @@ mod tests { assert!(!state.complete(&ctx, retry, changed)); } + /// `sync` followed by `finish` in the same pass allocates exactly one request and keeps it + /// as the awaiting boundary. #[test] fn same_pass_sync_and_finish_keep_one_request() { let ctx = Context::default(); @@ -303,6 +346,8 @@ mod tests { result.textures_delta.clear(); } + /// `finish` reuses the replacement request allocated by `sync` instead of allocating a + /// second one, and the stale token cannot release the guard. #[test] fn stale_output_binding_does_not_force_a_second_request_on_sync_then_finish() { let ctx = Context::default(); @@ -354,6 +399,8 @@ mod tests { assert!(!state.blocked(ViewportId::ROOT)); } + /// If custom eligibility is lost after `sync`, stock IME may echo the queued token but it + /// cannot release the suspended guard; only a later eligible descriptor can. #[test] fn queued_before_absence_token_may_reach_stock_but_cannot_release_suspended_guard() { let ctx = Context::default(); @@ -400,6 +447,8 @@ mod tests { assert!(state.complete(&ctx, replacement, destination)); } + /// UI-pass mode is scoped per viewport, `install` is idempotent, and a child viewport's + /// completion cannot release ROOT's guard. #[test] fn pass_mode_is_per_viewport_and_foreign_completion_cannot_release_a_real_guard() { let ctx = Context::default(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8c085703f..772999358 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -206,7 +206,7 @@ pub enum Dialog { NewSequence, /// Settings (Preferences window; page in `UiState::settings`). Preferences, - /// "Recover unsaved changes from