Merge pull request #1849 from marcoarnulfo/fix/caption-properties

Properties panel edits the selected caption's text and in/out times
This commit is contained in:
Brandon Thomas
2026-10-18 03:21:14 -04:00
committed by GitHub
3 changed files with 152 additions and 0 deletions
+1
View File
@@ -86,6 +86,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rect)
PanelKind::AudioMeters => meters::show(app, ui, rect),
PanelKind::LumetriColor => lumetri::show(app, ui, rect),
PanelKind::Properties if graphics::graphic_selected(app) => graphics::properties(app, ui, rect),
PanelKind::Properties if text::caption_selected(app) => text::caption_properties(app, ui, rect),
PanelKind::Properties => effect_controls::properties_panel(app, ui, rect),
PanelKind::EssentialGraphics => graphics_templates::essential_graphics(app, ui, rect),
PanelKind::History => misc::history(app, ui, rect),
+60
View File
@@ -282,6 +282,66 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
run(app, ui, actions);
}
/// Whether a caption is selected, so the Properties panel shows [`caption_properties`].
pub fn caption_selected(app: &FilmcraftApp) -> bool {
app.session.active_sequence().is_some_and(|q| app.session.state.caption_selection.iter().any(|c| q.find_caption(*c).is_some()))
}
/// The Properties panel for the first selected caption (#1846): its text and in/out timecodes,
/// edited with the same `captions.*` commands as the Captions tab. The style belongs to the caption
/// track and stays in the Captions tab.
pub fn caption_properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let project = app.session.project.clone();
let Some(seq) = app.session.state.active_sequence.and_then(|id| project.sequence(id)) else { return };
let Some((track_id, c)) = app.session.state.caption_selection.iter().find_map(|id| seq.find_caption(*id)) else { return };
let track_idx = seq.caption_tracks.iter().position(|tr| tr.id == track_id).unwrap_or(0);
let track_name = seq.caption_tracks.get(track_idx).map(|tr| tr.name.as_str()).unwrap_or_default();
let (rate, df) = (seq.settings.frame_rate, seq.settings.drop_frame);
let tc = |x: filmcraft_time::Tick| format_time(x, rate, df, TimeDisplay::Timecode, 48_000);
let mut actions: Vec<(String, Value)> = Vec::new();
let mut bui = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(14.0, 8.0))).id_salt("props-caption"));
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 mut buf = bui.data(|d| d.get_temp::<String>(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)));
app.auto.add("properties.caption.text", resp.rect, &c.text);
if resp.has_focus() {
bui.data_mut(|d| d.insert_temp(key, buf.clone()));
} else {
bui.data_mut(|d| d.remove::<String>(key));
}
if resp.lost_focus() && buf != c.text {
actions.push(("captions.setText".into(), json!({"caption": c.id.0, "text": buf})));
}
bui.add_space(8.0);
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 mut buf = ui.data(|d| d.get_temp::<String>(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)));
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()));
} else {
ui.data_mut(|d| d.remove::<String>(key));
}
if resp.lost_focus() && buf.trim() != tc(val) {
let k = if edge == "in" { "startTimecode" } else { "endTimecode" };
actions.push(("captions.setTimes".into(), json!({"caption": c.id.0, k: buf.trim()})));
}
});
}
bui.horizontal(|ui| {
ui.add_sized(vec2(70.0, 22.0), egui::Label::new(egui::RichText::new(tl!("Duration")).size(12.0).color(t.text_dim)));
ui.label(egui::RichText::new(tc(c.duration)).font(Tokens::mono(11.5)).color(t.text));
});
run(app, ui, actions);
}
/// A caption's text in reading order ([`crate::rtl::Lines`]) in place of its `TextEdit`, which
/// shows right-to-left words in logical order. A click hands focus to the `TextEdit` (`id`), so the
/// text is edited in logical order and shown in reading order again when the field loses focus.
@@ -0,0 +1,91 @@
//! The Properties panel edits the selected caption (#1846): an imported SRT caption picked in the
//! timeline shows its text and in/out times there, and editing the text changes the caption.
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};
struct Driver {
harness: Harness<'static, FilmcraftApp>,
tx: Sender<ControlRequest>,
}
impl Driver {
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(r) = reply.try_recv() {
assert_eq!(r["ok"], true, "{method} {params}: {r}");
return r["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 ids(&mut self, prefix: &str) -> Vec<String> {
self.frames(2);
let v = self.ok("ui.elements", json!({"prefix": prefix}));
v.as_array().expect("elements").iter().filter_map(|e| e["id"].as_str().map(str::to_string)).collect()
}
fn captions(&mut self) -> Vec<Value> {
let r = self.exec("captions.list", json!({}));
r["tracks"][0]["captions"].as_array().expect("captions").clone()
}
}
#[test]
fn properties_panel_edits_the_selected_caption() {
let dir = std::env::temp_dir().join(format!("filmcraft-caption-props-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
let srt = dir.join("two.srt");
std::fs::write(&srt, "1\n00:00:01,000 --> 00:00:02,500\nFirst line\n\n2\n00:00:03,000 --> 00:00:04,000\nSecond line\n").expect("write srt");
let mut session = Session::default();
session.execute("file.openDemoProject", json!({})).expect("demo project");
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_max_steps(10_000).build_eframe(move |_cc| app);
let mut d = Driver { harness, tx };
d.frames(4);
d.exec("captions.import", json!({"path": srt.to_string_lossy()}));
let caps = d.captions();
assert_eq!(caps.len(), 2, "{caps:?}");
let first = caps[0]["id"].as_u64().expect("caption id");
d.ok("ui.panel.show", json!({"panel": "Properties"}));
d.ok("ui.click", json!({"id": format!("timeline.caption.{first}")}));
d.frames(3);
assert_eq!(d.harness.state().session.state.caption_selection.iter().map(|c| c.0).collect::<Vec<_>>(), vec![first]);
let shown = d.ids("properties.caption.");
for id in ["properties.caption.text", "properties.caption.in", "properties.caption.out"] {
assert!(shown.iter().any(|s| s == id), "{id} missing from Properties: {shown:?}");
}
// the field holds the caption's text: typing at its end extends it
d.ok("ui.click", json!({"id": "properties.caption.text"}));
d.ok("ui.key", json!({"key": "End"}));
d.ok("ui.type", json!({"text": " edited"}));
d.ok("ui.key", json!({"key": "Escape"}));
d.frames(3);
let caps = d.captions();
assert_eq!(caps[0]["text"], "First line edited", "{caps:?}");
assert_eq!(caps[1]["text"], "Second line", "{caps:?}");
let _ = std::fs::remove_dir_all(&dir);
}