diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ba66aad8..0cb384c8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -263,6 +263,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { overlays(app, ui, &painter, xf, &t); ime_output(app, ui, xf); media_bar(app, ui, xf); + media_click(app, ui, &resp, xf); gif_buttons(app, ui, xf, &gifs); if resp.secondary_clicked() && let Some(p) = resp.interact_pointer_pos() @@ -409,6 +410,26 @@ fn media_bar(app: &mut SlideApp, ui: &mut Ui, xf: Xf) { } } +/// A click on a video plays or pauses it, as its bar's Play button does. The click selects it as +/// usual; dragging it moves it without playing, and a modifier key leaves it to the selection. +fn media_click(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { + if !resp.clicked() || resp.double_clicked() || !ui.input(|i| i.modifiers.is_none()) { + return; + } + let Some(p) = resp.interact_pointer_pos() else { return }; + let Some(d) = app.session.active() else { return }; + if d.selection.target != Target::Slides || d.selection.text.is_some() || d.selection.shapes.len() != 1 { + return; + } + let only = d.selection.shapes[0]; + let hit = crate::media::slide_media(&d.doc, d.selection.slide) + .into_iter() + .find(|(id, x, clip)| clip.video && *id == only && screen_rect(xf, x).contains(p)); + let Some((id, _, clip)) = hit else { return }; + let (uid, doc) = (d.uid, d.doc.clone()); + app.media.toggle(uid, &doc, id, &clip, crate::media::Owner::Editor); +} + fn master_tex( ctx: &egui::Context, doc: &deckcraft_model::Presentation, @@ -1603,3 +1624,7 @@ mod ruler_tests; #[cfg(test)] #[path = "guides_tests.rs"] mod guides_tests; + +#[cfg(test)] +#[path = "media_bar_tests.rs"] +mod media_bar_tests; diff --git a/crates/ui-egui/src/media.rs b/crates/ui-egui/src/media.rs index 7fd8fda7..fd389782 100644 --- a/crates/ui-egui/src/media.rs +++ b/crates/ui-egui/src/media.rs @@ -417,6 +417,9 @@ pub enum BarAction { pub fn control_bar(ui: &mut Ui, rect: Rect, status: Option<&VoiceStatus>, clip: &MediaClip) -> Option { let painter = ui.painter_at(rect.expand(2.0)); painter.rect_filled(rect, CornerRadius::same(4), Color32::from_rgba_unmultiplied(28, 28, 32, 230)); + // Sensing drags too keeps a press on the bar from reaching the editor canvas: there it would + // start a selection marquee, which hides the bar before the click lands. + ui.interact(rect, ui.id().with("media_bar_back"), Sense::click_and_drag()); let playing = status.is_some_and(|s| matches!(s.state, PlayState::Playing | PlayState::Loading)); let duration = status.map(|s| s.duration).filter(|d| *d > 0.0).unwrap_or(clip.duration_ms as f64 / 1000.0); let pos = status.map(|s| s.position).unwrap_or(clip.trim_start_ms as f64 / 1000.0); @@ -426,7 +429,7 @@ pub fn control_bar(ui: &mut Ui, rect: Rect, status: Option<&VoiceStatus>, clip: let button = |ui: &mut Ui, x: &mut f32, id: &str, paint: &dyn Fn(&egui::Painter, Rect)| -> bool { let r = Rect::from_min_size(pos2(*x, rect.min.y + 2.0), vec2(h - 4.0, h - 4.0)); *x += h - 2.0; - let resp = ui.interact(r, ui.id().with(("media_bar", id)), Sense::click()); + let resp = ui.interact(r, ui.id().with(("media_bar", id)), Sense::click_and_drag()); if resp.hovered() { painter.rect_filled(r, CornerRadius::same(3), Color32::from_white_alpha(28)); } diff --git a/crates/ui-egui/src/media_bar_tests.rs b/crates/ui-egui/src/media_bar_tests.rs new file mode 100644 index 00000000..7296fcb4 --- /dev/null +++ b/crates/ui-egui/src/media_bar_tests.rs @@ -0,0 +1,128 @@ +//! The play/pause and timeline bar under a selected audio or video in the editor, clicked with the +//! mouse: a press on it used to reach the canvas, start a selection marquee there and hide the bar +//! before the click landed, so Play only worked in the slide show. + +use std::sync::Arc; + +use deckcraft_engine::Session; +use deckcraft_model::{Shape, ShapeId, ShapeKind}; +use egui::{Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +use crate::{Services, SlideApp}; + +/// 16-bit mono WAV of silence, `secs` long at 8 kHz. +fn wav(secs: f64) -> Vec { + let data = vec![0u8; (8_000.0 * secs) as usize * 2]; + let mut b = Vec::new(); + b.extend_from_slice(b"RIFF"); + b.extend_from_slice(&(36 + data.len() as u32).to_le_bytes()); + b.extend_from_slice(b"WAVEfmt "); + for v in [16u32.to_le_bytes(), [1, 0, 1, 0], 8_000u32.to_le_bytes(), 16_000u32.to_le_bytes(), [2, 0, 16, 0]] { + b.extend_from_slice(&v); + } + b.extend_from_slice(b"data"); + b.extend_from_slice(&(data.len() as u32).to_le_bytes()); + b.extend_from_slice(&data); + b +} + +fn click(h: &mut Harness<'_, SlideApp>, p: Pos2) { + h.hover_at(p); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +#[test] +fn play_on_the_editor_media_bar_plays_the_selected_clip() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let r = s.execute("insert.audio", &json!({"name": "tone.wav", "data": deckcraft_engine::cmd::base64_encode(&wav(2.0))})).unwrap(); + let id = ShapeId(r["id"].as_u64().unwrap() as u32); + let app = SlideApp::new(s, Services::default()); + let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + let d = h.state().session.active().unwrap(); + assert_eq!(d.selection.shapes, vec![id], "the inserted clip is selected"); + let x = d.doc.slides[d.selection.slide].shape(id).unwrap().xfrm.unwrap(); + let (slide, k) = (h.state().slide_rect.unwrap(), h.state().canvas_scale); + // The bar sits 6 points under the clip, centred, at least 230 wide; Play is its first button. + let (left, right, bottom) = (slide.min.x + x.x as f32 * k, slide.min.x + (x.x + x.w) as f32 * k, slide.min.y + (x.y + x.h) as f32 * k); + let w = (right - left).max(230.0); + let bar = pos2((left + right) / 2.0 - w / 2.0, bottom + 6.0); + assert!(h.state().media.status(id).is_none()); + + click(&mut h, bar + vec2(15.0, 13.0)); + assert!(h.state().media.status(id).is_some(), "Play starts the clip"); + let d = h.state().session.active().unwrap(); + assert_eq!(d.selection.shapes, vec![id], "the click did not reach the slide behind the bar"); + assert!(!h.state().session.tool.dragging()); + + // Between the buttons and the timeline the bar still keeps the press from the slide. + click(&mut h, bar + vec2(w - 30.0, 13.0)); + assert_eq!(h.state().session.active().unwrap().selection.shapes, vec![id]); +} + +/// A click on a video plays it and the next one pauses it; dragging it only moves it. +#[test] +fn a_click_on_a_video_plays_or_pauses_it() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let r = s.execute("insert.audio", &json!({"name": "tone.wav", "data": deckcraft_engine::cmd::base64_encode(&wav(2.0))})).unwrap(); + let id = ShapeId(r["id"].as_u64().unwrap() as u32); + // Only videos play on a click; the clip's sound stands in for a film. + { + let d = s.active_mut().unwrap(); + let slide = Arc::make_mut(&mut Arc::make_mut(&mut d.doc).slides[d.selection.slide]); + if let Some(Shape { kind: ShapeKind::Media(clip), .. }) = deckcraft_model::find_shape_mut(&mut slide.shapes, id) { + clip.video = true; + } + } + s.execute("edit.deselect", &json!({})).unwrap(); + let app = SlideApp::new(s, Services::default()); + let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + let d = h.state().session.active().unwrap(); + let x = d.doc.slides[d.selection.slide].shape(id).unwrap().xfrm.unwrap(); + let (slide, k) = (h.state().slide_rect.unwrap(), h.state().canvas_scale); + let centre = slide.min + vec2((x.x + x.w / 2.0) as f32 * k, (x.y + x.h / 2.0) as f32 * k); + + click(&mut h, centre); + assert_eq!(h.state().session.active().unwrap().selection.shapes, vec![id], "the click selects it"); + assert!(h.state().media.is_playing(id), "and plays it"); + // Far enough apart not to make a double click. + h.run_steps(40); + click(&mut h, centre); + assert!(!h.state().media.is_playing(id), "the next click pauses it"); + + // A drag moves it without playing. + h.run_steps(40); + h.event(egui::Event::PointerButton { pos: centre, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for i in 1..=6 { + h.event(egui::Event::PointerMoved(centre + vec2(8.0 * i as f32, 0.0))); + h.run_steps(1); + } + h.event(egui::Event::PointerButton { pos: centre + vec2(48.0, 0.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert!(!h.state().media.is_playing(id), "dragging does not play it"); + let d = h.state().session.active().unwrap(); + let moved = d.doc.slides[d.selection.slide].shape(id).unwrap().xfrm.unwrap(); + assert!(moved.x > x.x, "the drag moved it"); +}