diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index ef72bfa7b6..eb3ca77944 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 5cd5b8ff74..dc4bf58c95 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -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 획마다 제거 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 4461426e10..a030a8885f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -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 每次描边后移除 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 612e294a92..25a05a1826 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -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 每次描邊後移除 diff --git a/crates/ui-egui/src/timeline_ui.rs b/crates/ui-egui/src/timeline_ui.rs index 22d8408abe..282f88e5de 100644 --- a/crates/ui-egui/src/timeline_ui.rs +++ b/crates/ui-egui/src/timeline_ui.rs @@ -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); + } +} diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 64239add86..189c2ea9b0 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -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::>().join("; ")).unwrap_or_default(); diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index ec7a0ebfa6..f8e2f4116a 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -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;