diff --git a/crates/ui-egui/src/panels/text.rs b/crates/ui-egui/src/panels/text.rs index b6eaca3c9..9702bd8b7 100644 --- a/crates/ui-egui/src/panels/text.rs +++ b/crates/ui-egui/src/panels/text.rs @@ -178,6 +178,9 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let q = app.ui.caption_search.to_lowercase(); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(list.shrink(4.0)).id_salt("caption-list")); let current = track.caption_at(ph).map(|c| c.id); + if current.is_none() { + ui.data_mut(|d| d.remove::(egui::Id::new("caption-follow"))); + } egui::ScrollArea::vertical().auto_shrink([false, false]).id_salt("caption-scroll").show(&mut child, |ui| { ui.set_width(list.width() - 12.0); let mut shown = 0; @@ -266,6 +269,13 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let row = fr.response.rect; if current == Some(c.id) { ui.painter().rect_stroke(row, 3.0, Stroke::new(1.0, t.accent), StrokeKind::Inside); + // the list follows the playhead: each caption it reaches is brought to the middle + // once, but never while a field is being edited + let follow = egui::Id::new("caption-follow"); + if ui.data(|d| d.get_temp::(follow)) != Some(c.id.0) && ui.memory(|m| m.focused().is_none()) { + ui.scroll_to_rect(row, Some(egui::Align::Center)); + ui.data_mut(|d| d.insert_temp(follow, c.id.0)); + } } app.auto.add(&format!("text.captions.{}.row", c.id.0), row, &c.text); ui.add_space(2.0); diff --git a/crates/ui-egui/tests/it/caption_list_follow_ui.rs b/crates/ui-egui/tests/it/caption_list_follow_ui.rs new file mode 100644 index 000000000..310a4182a --- /dev/null +++ b/crates/ui-egui/tests/it/caption_list_follow_ui.rs @@ -0,0 +1,91 @@ +//! The Text panel's Captions list keeps the caption under the playhead in view as the playhead +//! moves, so it stays lined up with the Timeline. The real `FilmcraftApp` under `egui_kittest` +//! with the demo project. + +use std::sync::mpsc::{Sender, channel}; + +use egui::{Rect, pos2}; +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::control::ControlRequest; +use serde_json::{Value, json}; + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, +} + +impl Driver { + /// The demo project with snapping off, so drags land where the pointer goes; frames 1/60 s + /// apart, as on a display. + fn demo() -> Self { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).expect("demo project"); + session.execute("sequence.snap", json!({"on": false})).expect("snapping off"); + let (tx, rx) = channel(); + let app = FilmcraftApp::new(session).with_control(rx); + let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_step_dt(1.0 / 60.0).with_max_steps(10_000).build_eframe(move |_cc| app); + let mut d = Driver { harness, tx }; + d.frames(4); + d.ok("ui.elements", json!({"prefix": "timeline.clip."})); + 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; + self.harness.step(); + } + } + + fn ok(&mut self, method: &str, params: Value) -> Value { + let (req, reply) = ControlRequest::new(method, params.clone()); + self.tx.send(req).expect("control channel"); + 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}"); + } + + fn exec(&mut self, command: &str, params: Value) -> Value { + self.ok("engine.execute", json!({"command": command, "params": params})) + } + + fn rect(&mut self, id: &str) -> Option { + self.frames(2); + let els = self.ok("ui.elements", json!({"prefix": id})); + let el = els.as_array()?.iter().find(|e| e["id"] == id)?.clone(); + let r: Vec = el["rect"].as_array()?.iter().filter_map(|v| v.as_f64().map(|v| v as f32)).collect(); + Some(Rect::from_min_size(pos2(*r.first()?, *r.get(1)?), egui::vec2(*r.get(2)?, *r.get(3)?))) + } +} + +/// The Captions list keeps the caption under the playhead in view as the playhead moves on. +#[test] +fn captions_list_follows_the_playhead() { + let mut d = Driver::demo(); + let mut last = 0; + for i in 0..30 { + last = d.exec("captions.add", json!({"seconds": i as f64, "durationSeconds": 0.5, "text": format!("word {i}")}))["caption"].as_u64().expect("id"); + } + d.ok("ui.panel.show", json!({"panel": "Text"})); + d.ok("ui.click", json!({"id": "text.tab.Captions"})); + d.exec("playhead.set", json!({"seconds": 0.0})); + let top = d.rect("text.captions.search").expect("search").max.y; + let bottom = d.rect("text.captions.style.font").expect("style strip").min.y; + let row = format!("text.captions.{last}.row"); + let before = d.rect(&row).expect("last row"); + assert!(before.center().y > bottom, "the last caption starts out of view: {before:?} below {bottom}"); + d.exec("playhead.set", json!({"seconds": 29.2})); + d.frames(30); + let after = d.rect(&row).expect("last row"); + assert!(after.center().y > top && after.center().y < bottom, "the playhead's caption is in view: {after:?} in {top}..{bottom}"); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 88587dde3..57bfabdf0 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -1,6 +1,7 @@ mod audio51_ui; mod audio_fx_editor_ui; mod caption_copy_paste_ui; +mod caption_list_follow_ui; mod caption_option_drag_ui; mod caption_properties_ui; mod caption_razor_ui; diff --git a/docs/captions.md b/docs/captions.md index b1000588e..e4212fe7d 100644 --- a/docs/captions.md +++ b/docs/captions.md @@ -72,7 +72,8 @@ also splits captions. split / merge / delete / export buttons, the list of segments with editable in/out timecodes and text (committed when the field loses focus; Arabic and Hebrew show in reading order and are edited in logical order while the field has focus), and the track style strip (size, colour, box, - alignment, position). Automation ids: `text.tab.Captions`, `text.captions.add|split|merge|delete|export|search|track`, + alignment, position). The list keeps the caption under the playhead in view (centred as the + playhead reaches it, not while a field is being edited). Automation ids: `text.tab.Captions`, `text.captions.add|split|merge|delete|export|search|track`, `text.captions..in|out|text|goto|row`, `text.captions.style.*`. ## Burn-in