Merge PR #3223: fix(ui): wire video menu pickers/dialogs and fix Timeline Stop playback

This commit is contained in:
aiongg
2026-10-20 22:07:10 +00:00
7 changed files with 139 additions and 1 deletions
+1
View File
@@ -2777,6 +2777,7 @@ curves Smooth Glätten
Light Red Hellrot
Medium Blue Mittelblau
Light Gray Hellgrau
Stop Stopp
# Status messages, system info and tool names
Join the ArtCraft Discord ({url}) Dem ArtCraft-Discord beitreten ({url})
Nothing to paste: the clipboard holds no image Nichts einzufügen: Die Zwischenablage enthält kein Bild
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -3308,6 +3308,7 @@ curves Smooth 매끄럽게
Light Red 연한 빨강
Medium Blue 중간 파랑
Light Gray 연한 회색
Stop 정지
Object Finder 개체 찾기
No object found there — drag a rectangle around it 그곳에서 개체를 찾지 못했습니다 — 주위에 사각형을 드래그하세요
Remove after each stroke 획마다 제거
Can't render this file because it contains an unexpected character in line 3172 and column 53.
+1
View File
@@ -2748,6 +2748,7 @@ curves Smooth 平滑
Light Red 浅红色
Medium Blue 中蓝色
Light Gray 浅灰色
Stop 停止
Object Finder 对象查找程序
No object found there — drag a rectangle around it 那里未找到对象 — 请在其周围拖动矩形
Remove after each stroke 每次描边后移除
Can't render this file because it contains an unexpected character in line 2606 and column 53.
+1
View File
@@ -2733,6 +2733,7 @@ curves Smooth 平滑
Light Red 淺紅色
Medium Blue 中藍色
Light Gray 淺灰色
Stop 停止
Object Finder 物件尋找器
No object found there — drag a rectangle around it 那裡找不到物件 — 請在其周圍拖曳矩形
Remove after each stroke 每次描邊後移除
Can't render this file because it contains an unexpected character in line 6 and column 47.
+78 -1
View File
@@ -49,6 +49,7 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) {
let mut act: Option<(&str, Value)> = None;
let mut close = false;
let mut toggle_play = false;
let mut stop_play = false;
let playing = app.ui.timeline.playing;
let t = Tokens::get(ctx);
@@ -84,7 +85,14 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) {
if crate::icons::button(ui, play_icon, 22.0, playing, if playing { tl!("Pause") } else { tl!("Play") }).clicked() {
toggle_play = true;
}
if crate::icons::button(ui, "square", 22.0, false, tl!("Stop")).clicked() {
if !playing {
act = Some(("timeline.setFrame", json!({ "frame": 0 })));
}
stop_play = true;
}
if crate::icons::button(ui, "chevron-left", 22.0, false, tl!("Previous frame")).clicked() {
stop_play = true;
act = Some(("timeline.previousFrame", json!({})));
}
ui.label(
@@ -96,12 +104,14 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) {
.monospace(),
);
if crate::icons::button(ui, "chevron-right", 22.0, false, tl!("Next frame")).clicked() {
stop_play = true;
act = Some(("timeline.nextFrame", json!({})));
}
ui.separator();
ui.label(RichText::new(format!("{:.2}s", tl.time())).color(t.text_dim).monospace());
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if crate::icons::button(ui, "trash", 20.0, false, tl!("Delete Timeline")).clicked() {
stop_play = true;
act = Some(("timeline.delete", json!({})));
}
ui.label(RichText::new(format!("{:.0} fps", tl.fps)).color(t.text_dim));
@@ -113,6 +123,7 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) {
let mut frame = tl.current as f64;
let resp = ui.add(egui::Slider::new(&mut frame, 0.0..=(tl.duration - 1) as f64).integer().show_value(false).text("playhead"));
if resp.changed() {
stop_play = true;
act = Some(("timeline.setFrame", json!({ "frame": frame as u64 })));
}
}
@@ -124,7 +135,9 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) {
app.ui.timeline.open = false;
app.ui.timeline.playing = false;
}
if toggle_play {
if stop_play {
app.ui.timeline.playing = false;
} else if toggle_play {
app.ui.timeline.playing = !app.ui.timeline.playing;
app.ui.timeline.last_time = ctx.input(|i| i.time);
}
@@ -147,3 +160,67 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use egui_kittest::{Harness, kittest::Queryable};
#[test]
fn stop_step_and_scrub_pause_playback_and_second_stop_rewinds() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
app.run("timeline.create", json!({"duration": 10, "fps": 30.0})).unwrap();
app.ui.timeline.open = true;
app.last_canvas_rect = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1000.0, 700.0));
let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).with_step_dt(0.001).build_ui_state(|ui, app| windows(app, ui.ctx()), app);
h.run_steps(2);
// Start playback via Play button.
h.get_by_label("Play").click();
h.run_steps(2);
assert!(h.state().ui.timeline.playing);
// Move to frame 5 while playing; first Stop pauses at the current frame without rewinding.
h.state_mut().run("timeline.setFrame", json!({"frame": 5})).unwrap();
h.get_by_label("Stop").click();
h.run_steps(2);
assert!(!h.state().ui.timeline.playing, "first Stop pauses playback");
assert_eq!(h.state().session.active().unwrap().doc.timeline.as_ref().unwrap().current, 5);
// Second Stop while already paused rewinds to frame 0.
h.get_by_label("Stop").click();
h.run_steps(2);
assert!(!h.state().ui.timeline.playing);
assert_eq!(h.state().session.active().unwrap().doc.timeline.as_ref().unwrap().current, 0);
// Next frame and Previous frame pause playback.
h.state_mut().ui.timeline.playing = true;
h.run_steps(1);
h.get_by_label("Next frame").click();
h.run_steps(2);
assert!(!h.state().ui.timeline.playing, "Next frame pauses playback");
assert_eq!(h.state().session.active().unwrap().doc.timeline.as_ref().unwrap().current, 1);
h.state_mut().ui.timeline.playing = true;
h.run_steps(1);
h.get_by_label("Previous frame").click();
h.run_steps(2);
assert!(!h.state().ui.timeline.playing, "Previous frame pauses playback");
assert_eq!(h.state().session.active().unwrap().doc.timeline.as_ref().unwrap().current, 0);
// Scrubbing the playhead slider pauses playback and updates the current frame.
h.state_mut().ui.timeline.playing = true;
h.run_steps(1);
let slider = h.get_by_role(egui::accesskit::Role::Slider).rect();
let target = egui::pos2(slider.right() - 8.0, slider.center().y);
h.event(egui::Event::PointerMoved(target));
h.event(egui::Event::PointerButton { pos: target, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
h.step();
h.event(egui::Event::PointerButton { pos: target, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
h.run_steps(2);
assert!(!h.state().ui.timeline.playing, "scrubbing pauses playback");
assert!(h.state().session.active().unwrap().doc.timeline.as_ref().unwrap().current > 0);
}
}
+32
View File
@@ -612,6 +612,11 @@ fn wraps(id: &str) -> bool {
| "image.calculations"
| "select.saveSelection"
| "select.loadSelection"
| "layer.videoLayers.newVideoLayerFromFile"
| "layer.videoLayers.replaceFootage"
| "layer.videoLayers.interpretFootage"
| "file.import.videoFramesToLayers"
| "file.export.renderVideo"
)
}
@@ -1251,6 +1256,33 @@ fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Value)
app.with_document(doc, |app| app.run("layer.smartObjects.convertToLinked", json!({"layer": layer.0, "path": path})))
}))
}
"layer.videoLayers.newVideoLayerFromFile" | "file.import.videoFramesToLayers" | "layer.videoLayers.replaceFootage" => {
let doc = match app.active_doc_id() {
Ok(doc) => doc,
Err(e) => return Some(Err(e)),
};
let cmd = id.to_string();
Some(app.pick_file_bytes(move |app, path, _bytes| {
app.with_document(doc, |app| {
let r = app.run(&cmd, json!({"path": path}));
if r.is_ok() && app.session.active().is_some_and(|d| d.doc.timeline.is_some()) {
app.ui.timeline.open = true;
}
app.sync_views();
r
})
}))
}
"layer.videoLayers.interpretFootage" => {
let st = app.session.active()?;
let id_layer = st.active_layer?;
let fps = st.doc.layer(id_layer).and_then(|l| l.video.as_ref()).map_or(30.0, |v| v.fps);
dialog(app, json!({"fps": fps}), json!({}))
}
"file.export.renderVideo" => {
let _ = doc?;
dialog(app, json!({"dir": dir, "format": "gif"}), json!({"format": ["gif", "png", "jpg", "webp", "bmp", "tiff"]}))
}
"file.fileInfo" => {
let info = app.session.execute("file.fileInfo", json!({})).ok()?;
let kw = info["keywords"].as_array().map(|a| a.iter().filter_map(Value::as_str).collect::<Vec<_>>().join("; ")).unwrap_or_default();
+25
View File
@@ -503,6 +503,31 @@ fn load_selection_without_alpha_channels_offers_the_composite() {
crate::dialogs::confirm(&mut app, dialog).unwrap();
}
#[test]
fn video_menu_commands_open_pickers_and_dialogs() {
let (mut app, ctx) = app_with(1);
app.services.file_dialog = Some(Box::new(|_, _, _| {}));
for id in ["layer.videoLayers.newVideoLayerFromFile", "file.import.videoFramesToLayers", "layer.videoLayers.replaceFootage"] {
assert!(is_live(id), "{id}");
assert_eq!(menu(&mut app, &ctx, id, json!({})).unwrap(), json!({"fileDialog": "open"}), "{id}");
app.file_dialog = None;
}
let interpret = menu(&mut app, &ctx, "layer.videoLayers.interpretFootage", json!({})).unwrap()["dialog"].as_u64().unwrap();
let fields = &app.ui.dialog_mut(interpret).unwrap().fields;
assert_eq!(fields["__command"], "layer.videoLayers.interpretFootage");
assert_eq!(fields["fps"], 30.0);
app.ui.close_dialog(interpret);
let render = menu(&mut app, &ctx, "file.export.renderVideo", json!({})).unwrap()["dialog"].as_u64().unwrap();
let fields = &app.ui.dialog_mut(render).unwrap().fields;
assert_eq!(fields["__command"], "file.export.renderVideo");
assert_eq!(fields["format"], "gif");
assert_eq!(fields["__choices"]["format"], json!(["gif", "png", "jpg", "webp", "bmp", "tiff"]));
app.ui.close_dialog(render);
}
/// One frame of the shortcut handler with a plain Esc press.
fn press_esc(app: &mut PhotocraftApp, ctx: &egui::Context) {
let key = egui::Key::Escape;