mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-30 21:47:00 -06:00
UI: let the editor's media bar take its clicks; click a video to play it
A press on the play/pause bar under a selected audio or video reached the editor canvas, which started a selection marquee there; the bar hid while the marquee was dragging, so its buttons never got the click and media only played in the slide show. The bar's buttons and background now sense drags too, as the GIF play button does, so the press stays on the bar. A click on a video in the editor now also plays or pauses it (it selects it as before; dragging still only moves it, and a modifier key leaves the click to the selection). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b47195feec
commit
300872fb7d
@@ -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;
|
||||
|
||||
@@ -417,6 +417,9 @@ pub enum BarAction {
|
||||
pub fn control_bar(ui: &mut Ui, rect: Rect, status: Option<&VoiceStatus>, clip: &MediaClip) -> Option<BarAction> {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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<u8> {
|
||||
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");
|
||||
}
|
||||
Reference in New Issue
Block a user