mirror of
https://github.com/storytold/filmcraft.git
synced 2026-10-24 10:10:05 -06:00
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:
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user