From 561322b46df6d1cdaffc1ef3ba1a3ee06cc7779f Mon Sep 17 00:00:00 2001 From: Tex Jernigan Date: Sat, 24 Oct 2026 10:24:03 -0400 Subject: [PATCH] Text panel: the Captions list follows the playhead (#3042) * Text panel: the Captions list follows the playhead As the playhead reaches a caption, the Captions list scrolls it to the middle, so the list stays lined up with the Timeline during playback and scrubbing. Each caption is brought into view once (scrolling the list by hand still works), and never while a field in the panel has focus. Co-Authored-By: Claude Opus 5.5 * Move the Captions list follow test into the shared ui-egui test binary Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/panels/text.rs | 10 ++ .../tests/it/caption_list_follow_ui.rs | 91 +++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + docs/captions.md | 3 +- 4 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/it/caption_list_follow_ui.rs 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