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 <noreply@anthropic.com>

* Move the Captions list follow test into the shared ui-egui test binary

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Tex Jernigan
2026-10-24 10:24:03 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent bd4139e430
commit 561322b46d
4 changed files with 104 additions and 1 deletions
+10
View File
@@ -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::<u64>(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::<u64>(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);
@@ -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<ControlRequest>,
}
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<Rect> {
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<f32> = 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}");
}
+1
View File
@@ -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;
+2 -1
View File
@@ -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.<id>.in|out|text|goto|row`, `text.captions.style.*`.
## Burn-in