From 85b994e7cdd0551f33dd28b4ed72ad4b0c039b71 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:48:58 +0200 Subject: [PATCH] Hide the slide show media controls when the pointer is idle (#967) --- crates/ui-egui/src/show.rs | 130 ++++++++++++++++++++++++++++++++++--- 1 file changed, 122 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 51f859af..1f7d39c9 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -211,6 +211,11 @@ impl Show { } } + /// True while the pointer moved in the last 2.5 s; the media controls and GIF button hide after that. + fn pointer_awake(&self, now: f64) -> bool { + now - self.last_move < 2.5 + } + /// Pen and laser are different pointers: turning one on puts the other away. fn set_pen(&mut self, on: bool) { self.pen = on; @@ -559,7 +564,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { && show.zoom.is_none() && !show.zoom_arm && !show.reading_bar_hit(&ctx) - && !pointer_on_media_bar(&ctx, ui, &doc, &show); + && !pointer_on_media_bar(&ctx, ui, &doc, &show, now); let gif_click = if clicked && on_slide && doc.show.show_media_controls { gif_hit(&ctx, ui, &show, doc_uid, &doc, app) } else { None }; let media_click = if clicked && on_slide && gif_click.is_none() { media_hit(&ctx, ui, &show, &doc) } else { None }; let under = if on_slide && gif_click.is_none() && media_click.is_none() { shapes_under_pointer(&ctx, ui, &mut show, &doc, now) } else { None }; @@ -597,7 +602,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { } } else if clicked && show.zoom.is_some() && !show.pen && !show.reading_bar_hit(&ctx) { show.dismiss_zoom(); - } else if clicked && !show.pen && !show.reading_bar_hit(&ctx) && !pointer_on_media_bar(&ctx, ui, &doc, &show) { + } else if clicked && !show.pen && !show.reading_bar_hit(&ctx) && !pointer_on_media_bar(&ctx, ui, &doc, &show, now) { if show.ended { keep = false; } else if show.blank.is_some() { @@ -858,7 +863,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { // Not while zoomed: the controls sit where the whole slide would be. if doc.show.show_media_controls && !show.zoom_arm && show.zoom.is_none() { let k = srect.width() / sw.max(1.0); - if let Some(p) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| !show.pen && !show.laser && now - show.last_move < 2.5) { + if let Some(p) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| !show.pen && !show.laser && show.pointer_awake(now)) { for g in &gifs { let r = on_screen(srect, k, &g.rect); if r.contains(p) { @@ -867,7 +872,8 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { } } } - if let Some((id, clip, bar)) = media_bar_target(&ctx, &doc, &show, srect) { + if let Some((id, clip, bar)) = media_bar_target(&ctx, &doc, &show, srect).filter(|_| show.pointer_awake(now)) { + ctx.request_repaint_after(std::time::Duration::from_millis(500)); let action = { let status = app.media.status(id); crate::media::control_bar(ui, bar, status.as_ref(), &clip) @@ -918,7 +924,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { ctx.set_cursor_icon(egui::CursorIcon::None); } else if show.zoom_arm || show.grid { ctx.set_cursor_icon(egui::CursorIcon::Crosshair); - } else if now - show.last_move > 2.5 && !show.reading { + } else if !show.pointer_awake(now) && !show.reading { // Idle first: a still pointer hides also over a clickable shape, such as a playing clip. ctx.set_cursor_icon(egui::CursorIcon::None); ctx.request_repaint_after(std::time::Duration::from_millis(500)); @@ -978,7 +984,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { let a = show.state.prev(&doc); show.apply(a, &doc, now); } - } else if !show.grid && (now - show.last_move < 2.5 || show.zoom_arm) { + } else if !show.grid && (show.pointer_awake(now) || show.zoom_arm) { let bar = tool_bar_rect(full); painter.rect_filled(bar, CornerRadius::same(17), Color32::from_black_alpha(140)); let mut child = @@ -1166,8 +1172,8 @@ fn media_bar_target(ctx: &egui::Context, doc: &Presentation, show: &Show, srect: } /// True when the pointer is on a visible playback bar, so that click must not advance the show. -fn pointer_on_media_bar(ctx: &egui::Context, ui: &Ui, doc: &Presentation, show: &Show) -> bool { - if !doc.show.show_media_controls || show.pen || show.ended || show.blank.is_some() { +fn pointer_on_media_bar(ctx: &egui::Context, ui: &Ui, doc: &Presentation, show: &Show, now: f64) -> bool { + if !doc.show.show_media_controls || !show.pointer_awake(now) || show.pen || show.ended || show.blank.is_some() { return false; } let Some(p) = ctx.input(|i| i.pointer.interact_pos().or(i.pointer.hover_pos())) else { return false }; @@ -1909,6 +1915,114 @@ mod tests { assert_eq!(harness.output().platform_output.cursor_icon, egui::CursorIcon::None, "still for 3 s: hidden"); } + /// A silent mono 8 kHz WAV, so the clip has a real duration to seek in. + fn silent_wav(secs: u32) -> Vec { + let rate = 8_000u32; + let data = vec![0u8; (rate * secs * 2) as usize]; + 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 "); + b.extend_from_slice(&16u32.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&rate.to_le_bytes()); + b.extend_from_slice(&(rate * 2).to_le_bytes()); + b.extend_from_slice(&2u16.to_le_bytes()); + b.extend_from_slice(&16u16.to_le_bytes()); + b.extend_from_slice(b"data"); + b.extend_from_slice(&(data.len() as u32).to_le_bytes()); + b.extend_from_slice(&data); + b + } + + /// A running show on a slide with one 10 s clip. Returns the harness, the clip, a point on the + /// bar's seek track, and what `pointer_on_media_bar` said on the last frame. + #[allow(clippy::type_complexity)] + fn show_with_clip() -> (egui_kittest::Harness<'static, SlideApp>, ShapeId, Pos2, std::rc::Rc>) { + let size = vec2(1000.0, 700.0); + let mut session = deckcraft_engine::Session::with_new(); + let id = ShapeId(4000); + session + .edit(|doc, _| { + let media = doc.add_media("clip.wav", "audio/wav", silent_wav(10)).unwrap(); + let (w, h) = (doc.slide_size.width, doc.slide_size.height); + let clip = deckcraft_model::MediaClip { media, video: true, volume: 1.0, duration_ms: 10_000, ..Default::default() }; + let xfrm = deckcraft_geom::Xfrm::new(w * 0.2, h * 0.2, w * 0.6, h * 0.6); + let slide = std::sync::Arc::make_mut(&mut doc.slides[0]); + slide.shapes.push(deckcraft_model::Shape { + id, + xfrm: Some(xfrm), + kind: deckcraft_model::ShapeKind::Media(clip), + ..Default::default() + }); + Ok(()) + }) + .unwrap(); + let on_bar = std::rc::Rc::new(std::cell::Cell::new(false)); + let seen = on_bar.clone(); + let slide = std::rc::Rc::new(std::cell::Cell::new(Rect::NOTHING)); + let slide_seen = slide.clone(); + let mut harness = egui_kittest::Harness::builder().with_size(size).with_step_dt(0.25).build_ui_state( + move |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + if let (Some(show), Some(d)) = (app.show.as_ref(), app.session.active()) { + let now = ui.input(|i| i.time); + seen.set(pointer_on_media_bar(ui.ctx(), ui, &d.doc, show, now)); + slide_seen.set(shown_slide_rect(ui, show, &d.doc)); + } + app.ui(ui); + }, + SlideApp::new(session, crate::Services::default()), + ); + harness.run_steps(3); + harness.state_mut().start_show(0, false); + harness.run_steps(2); + let doc = harness.state().session.active().unwrap().doc.clone(); + let bar = slide_media_bars(slide.get(), &doc, 0)[0].3; + let track = pos2(bar.min.x + bar.width() * 0.8, bar.center().y); + (harness, id, track, on_bar) + } + + /// Press and release the primary button at `p`. + fn click_at(harness: &mut egui_kittest::Harness<'static, SlideApp>, p: Pos2) { + for pressed in [true, false] { + harness.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + harness.step(); + } + } + + /// #967: hovering a clip shows its bar and a click on the bar seeks. After 3 s without moving + /// the bar is gone, and a click where it was acts on the clip itself (play), not on the bar. + #[test] + fn media_controls_hide_when_pointer_is_idle() { + // Awake: the bar is there and a click on its timeline seeks, leaving the clip paused. + let (mut harness, id, track, on_bar) = show_with_clip(); + harness.hover_at(track); + harness.run_steps(2); + assert!(harness.state().show.as_ref().unwrap().pointer_awake(harness.ctx.input(|i| i.time))); + assert!(on_bar.get(), "the bar is under a pointer that just moved"); + click_at(&mut harness, track); + harness.run_steps(2); + let status = harness.state().media.status(id).expect("the click on the bar cued the clip"); + assert!(!harness.state().media.is_playing(id), "a bar click seeks, it does not play"); + assert!(status.position > 1.0, "seeked along the timeline, at {}", status.position); + + // Idle: same spot, no movement for 3 s. + let (mut harness, id, track, on_bar) = show_with_clip(); + harness.hover_at(track); + harness.run_steps(2); + assert!(on_bar.get()); + harness.run_steps(12); + assert!(!harness.state().show.as_ref().unwrap().pointer_awake(harness.ctx.input(|i| i.time))); + assert!(!on_bar.get(), "the bar is hidden once the pointer is idle"); + click_at(&mut harness, track); + harness.run_steps(2); + assert!(harness.state().media.is_playing(id), "the click reached the clip"); + let at = harness.state().media.status(id).map_or(0.0, |s| s.position); + assert!(at < 5.0, "the click did not seek along the timeline, position {at}"); + } + /// Send real egui key events to the presenter UI, without the audience window's handler. #[test] fn presenter_navigation_uses_its_own_keyboard_events() {