From 7dce06dca415485a250d1f4b21d782383d6718c7 Mon Sep 17 00:00:00 2001 From: kinower Date: Fri, 16 Oct 2026 09:29:36 +0200 Subject: [PATCH 1/4] UI: keep rapid mouse-wheel slide navigation steps --- ROADMAP.md | 2 ++ crates/ui-egui/src/canvas.rs | 59 +++++++++++++++++++++++-------- crates/ui-egui/src/lib.rs | 68 ++++++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 14 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f5daa2d3..f8029e47 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -140,6 +140,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-15 | PPTX trigger sequences keep separate On Click steps, including paragraph builds; synthetic import/edit/save/reopen and headless show regressions | | 2026-10-16 | Export: PNG with a transparent background — the slide background is left out, only the slide's content is drawn (Export… dialog, File › Export, web download, `file.export`/`file.render` `transparentBackground`, `deckcraft-cli render --transparent`) (#784) | | 2026-10-16 | Arrange ▸ Align: Align to Slide and Align Selected Objects, as in PowerPoint. One object aligns to the slide and several to each other until the other choice is ticked; the choice holds for that selection and also drives Distribute | +| 2026-10-16 | Fitted canvas: discrete mouse-wheel steps navigate immediately, including rapid and batched input, while trackpad gestures retain their cooldown and zoomed slides still pan. | | 2026-10-15 | CLI: .txt and .md export includes table cell text, which it used to skip silently (#814) | | 2026-10-15 | A `.pptx` slide that can't be read is reported, not dropped in silence: `lostSlides` from `file.open`, a message and a repaired copy in the app, a warning from `deckcraft-cli` (#203) | | 2026-10-16 | AutoFormat as you type: ordinals (1st, 22nd, 103rd) get superscript suffixes after a space or punctuation; Tools ▸ AutoCorrect and `text.autocorrect {ordinals}` turn it off | @@ -245,6 +246,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged | | 2026-10-16 | trivial | Progress log: PNG export with a transparent background (#784) | | 2026-10-16 | trivial | Progress log: a `.ppsx` from the system starts the slide show (#862) | +| 2026-10-16 | trivial | Progress log: responsive mouse-wheel navigation on the fitted canvas | | 2026-10-14 | trivial | remote MCP checks reply IDs, envelopes and bounded newline framing (#718) | | 2026-10-14 | minor | Gray list markers on empty paragraphs while editing; caret keeps the list indent before typing | | 2026-10-14 | minor | Community PR wave folded: headline breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) and the feature-area breadths from `cargo xtask parity`; one row per area; Upcoming item 2 merged; per-PR count notes dropped from the progress log. Ready stays ~55%, stage alpha | diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ba66aad8..f2efc09d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -95,6 +95,37 @@ pub(crate) fn page_flick(wheel: egui::Vec2) -> Option { (along.abs() > MIN_FLICK).then_some(along < 0.0) } +/// Discrete wheel input must not share the trackpad's gesture cooldown. Keep all the steps, +/// including several notches delivered in one frame, and clamp at each end of the deck. +fn wheel_slide_target(events: &[egui::Event], current: usize, slides: usize) -> Option { + let last = slides.checked_sub(1)?; + let mut target = current.min(last); + let mut handled = false; + for event in events { + if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = event + && matches!(unit, egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page) + && !modifiers.ctrl + && !modifiers.command + && !modifiers.shift + && delta.y.is_finite() + && delta.y.abs() > 0.0 + && delta.y.abs() >= delta.x.abs() + { + let steps = delta.y.abs().ceil().min(slides as f32) as usize; + target = if delta.y < 0.0 { target.saturating_add(steps).min(last) } else { target.saturating_sub(steps) }; + handled = true; + } + } + handled.then_some(target) +} + +fn trackpad_scroll(events: &[egui::Event]) -> egui::Vec2 { + events.iter().fold(egui::Vec2::ZERO, |sum, event| match event { + egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta, modifiers, .. } if !modifiers.ctrl && !modifiers.command => sum + *delta, + _ => sum, + }) +} + pub fn show(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let avail = ui.available_rect_before_wrap(); @@ -134,20 +165,20 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { if resp.hovered() && !zooming { if overflow != egui::Vec2::ZERO { offset -= wheel; - } else if !app.anim_preview_active() - && app.session.tool.marquee.is_none() - && !app.session.tool.dragging() - && let Some(next) = page_flick(wheel) - { - // A fitted slide: the mouse wheel and a trackpad swipe turn the page. Pinch zoom is - // the branch above and is left alone. - let gate = ui.id().with("wheel_gate"); - // No previous flick yet: the first one must turn the page, not wait out the gap. - let last: f64 = ui.data_mut(|d| d.get_temp(gate).unwrap_or(f64::NEG_INFINITY)); - let now = ui.input(|i| i.time); - if now - last > 0.35 { - ui.data_mut(|d| d.insert_temp(gate, now)); - let _ = app.run(if next { "slide.next" } else { "slide.previous" }, json!({})); + } else if !app.anim_preview_active() && app.session.tool.marquee.is_none() && !app.session.tool.dragging() { + let target = app.session.active().and_then(|d| ui.input(|i| wheel_slide_target(&i.events, d.selection.slide, d.doc.slides.len()))); + if let Some(index) = target { + let _ = app.run("slide.go", json!({"index": index})); + } else if let Some(next) = page_flick(ui.input(|i| trackpad_scroll(&i.events))) { + // Point-based trackpad gestures keep their cooldown. Raw input avoids reading + // the smoothed tail of a discrete mouse notch as another page turn. + let gate = ui.id().with("wheel_gate"); + let last: f64 = ui.data_mut(|d| d.get_temp(gate).unwrap_or(f64::NEG_INFINITY)); + let now = ui.input(|i| i.time); + if now - last > 0.35 { + ui.data_mut(|d| d.insert_temp(gate, now)); + let _ = app.run(if next { "slide.next" } else { "slide.previous" }, json!({})); + } } } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index eef9b1b3..0ccb7a65 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -3315,6 +3315,74 @@ mod tests { assert_eq!(crate::canvas::page_flick(egui::vec2(4.0, 10.0)), None); } + fn mouse_wheel(harness: &mut egui_kittest::Harness<'static, SlideApp>, notches: &[f32]) { + let centre = harness.state().canvas_rect.unwrap().center(); + let input = harness.input_mut(); + input.events.push(egui::Event::PointerMoved(centre)); + for &y in notches { + input.events.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, y), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + } + harness.run_steps(1); + } + + #[test] + fn mouse_wheel_keeps_fast_notches_in_both_directions_without_a_smoothed_tail() { + let mut h = flick_harness(); + for _ in 0..8 { + h.state_mut().run("slide.new", json!({})).unwrap(); + } + h.state_mut().run("slide.go", json!({"index": 0})).unwrap(); + h.run_steps(1); + + mouse_wheel(&mut h, &[-1.0]); + assert_eq!(slide_at(h.state()), 1, "one notch responds immediately"); + mouse_wheel(&mut h, &[-1.0]); + assert_eq!(slide_at(h.state()), 2, "the next frame does not lose a notch to the cooldown"); + mouse_wheel(&mut h, &[-1.0, -1.0]); + assert_eq!(slide_at(h.state()), 4, "notches batched into a frame are retained"); + mouse_wheel(&mut h, &[-3.0]); + assert_eq!(slide_at(h.state()), 7, "an accelerated wheel event keeps its steps"); + h.run_steps(30); + assert_eq!(slide_at(h.state()), 7, "smoothing must not turn more pages"); + mouse_wheel(&mut h, &[2.0]); + assert_eq!(slide_at(h.state()), 5, "reversing direction responds immediately"); + mouse_wheel(&mut h, &[100.0]); + assert_eq!(slide_at(h.state()), 0, "stop at the first slide"); + mouse_wheel(&mut h, &[-100.0]); + assert_eq!(slide_at(h.state()), 8, "stop at the last slide"); + } + + #[test] + fn mouse_wheel_over_a_zoomed_canvas_pans_and_outside_the_canvas_does_not_navigate() { + let mut h = flick_harness(); + h.state_mut().run("slide.new", json!({})).unwrap(); + h.state_mut().run("slide.go", json!({"index": 0})).unwrap(); + h.state_mut().ui.zoom = Some(3.0); + h.run_steps(2); + let before = h.state().slide_rect.unwrap(); + mouse_wheel(&mut h, &[-3.0]); + h.run_steps(30); + assert_eq!(slide_at(h.state()), 0); + assert!(h.state().slide_rect.unwrap().min.y < before.min.y, "the enlarged canvas scrolls"); + + h.state_mut().ui.zoom = Some(0.4); + h.run_steps(2); + h.input_mut().events.push(egui::Event::PointerMoved(egui::pos2(-10.0, -10.0))); + h.input_mut().events.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, -3.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(30); + assert_eq!(slide_at(h.state()), 0, "wheel input outside the canvas is ignored"); + } + /// A sideways swipe on a fitted slide turns the page, like PowerPoint on a Mac trackpad. /// The frames of one gesture do not skip ahead. #[test] From 8ddb977733eb56e3230898b29ea3ec7b960b3448 Mon Sep 17 00:00:00 2001 From: kinower Date: Thu, 22 Oct 2026 01:01:48 +0200 Subject: [PATCH 2/4] UI: accumulate fractional mouse-wheel lines into whole slide steps High-resolution and free-spin wheels on Windows send fractions of a line (e.g. 0.125 per event); rounding each one up turned a single notch into several slides. The fractional line delta now accumulates in ui.data temp state, resets when the direction changes, and each whole line is one slide. slide.go is skipped when the target is the current slide. Test: eight 0.125-line events turn exactly one slide, and a reversal drops the leftover fraction. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 29 ++++++++++++++++++++++++----- crates/ui-egui/src/lib.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f2efc09d..e29046ff 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -97,7 +97,11 @@ pub(crate) fn page_flick(wheel: egui::Vec2) -> Option { /// Discrete wheel input must not share the trackpad's gesture cooldown. Keep all the steps, /// including several notches delivered in one frame, and clamp at each end of the deck. -fn wheel_slide_target(events: &[egui::Event], current: usize, slides: usize) -> Option { +/// +/// High-resolution and free-spin wheels send fractions of a line (0.125 per event on Windows), so +/// the delta is accumulated in `carry` and each whole line is one slide; a change of direction +/// drops what was left over. Returns the target, `None` when no wheel event was read. +fn wheel_slide_target(events: &[egui::Event], current: usize, slides: usize, carry: &mut f32) -> Option { let last = slides.checked_sub(1)?; let mut target = current.min(last); let mut handled = false; @@ -111,7 +115,14 @@ fn wheel_slide_target(events: &[egui::Event], current: usize, slides: usize) -> && delta.y.abs() > 0.0 && delta.y.abs() >= delta.x.abs() { - let steps = delta.y.abs().ceil().min(slides as f32) as usize; + if *carry * delta.y < 0.0 { + *carry = 0.0; + } + *carry += delta.y; + // The small slack keeps sums such as 10 × 0.1 from falling just short of a line. + let whole = (carry.abs() + 1e-4).floor(); + *carry -= whole.copysign(*carry); + let steps = whole.min(slides as f32) as usize; target = if delta.y < 0.0 { target.saturating_add(steps).min(last) } else { target.saturating_sub(steps) }; handled = true; } @@ -166,9 +177,17 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { if overflow != egui::Vec2::ZERO { offset -= wheel; } else if !app.anim_preview_active() && app.session.tool.marquee.is_none() && !app.session.tool.dragging() { - let target = app.session.active().and_then(|d| ui.input(|i| wheel_slide_target(&i.events, d.selection.slide, d.doc.slides.len()))); - if let Some(index) = target { - let _ = app.run("slide.go", json!({"index": index})); + let carry_id = ui.id().with("wheel_carry"); + let mut carry: f32 = ui.data_mut(|d| d.get_temp(carry_id).unwrap_or_default()); + let wheel_step = app.session.active().and_then(|d| { + let current = d.selection.slide; + ui.input(|i| wheel_slide_target(&i.events, current, d.doc.slides.len(), &mut carry)).map(|target| (current, target)) + }); + ui.data_mut(|d| d.insert_temp(carry_id, carry)); + if let Some((current, index)) = wheel_step { + if index != current { + let _ = app.run("slide.go", json!({"index": index})); + } } else if let Some(next) = page_flick(ui.input(|i| trackpad_scroll(&i.events))) { // Point-based trackpad gestures keep their cooldown. Raw input avoids reading // the smoothed tail of a discrete mouse notch as another page turn. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0ccb7a65..0c6e532c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -3357,6 +3357,32 @@ mod tests { assert_eq!(slide_at(h.state()), 8, "stop at the last slide"); } + /// A high-resolution wheel on Windows sends one notch as eight 0.125-line events. + #[test] + fn mouse_wheel_fractional_lines_accumulate_into_one_slide_per_line() { + let mut h = flick_harness(); + for _ in 0..4 { + h.state_mut().run("slide.new", json!({})).unwrap(); + } + h.state_mut().run("slide.go", json!({"index": 0})).unwrap(); + h.run_steps(1); + + for _ in 0..7 { + mouse_wheel(&mut h, &[-0.125]); + assert_eq!(slide_at(h.state()), 0, "a fraction of a line does not turn the page"); + } + mouse_wheel(&mut h, &[-0.125]); + assert_eq!(slide_at(h.state()), 1, "eight eighths make exactly one slide"); + h.run_steps(30); + assert_eq!(slide_at(h.state()), 1, "nothing is left over to turn another page"); + + mouse_wheel(&mut h, &[-0.5]); + mouse_wheel(&mut h, &[0.5, 0.25]); + assert_eq!(slide_at(h.state()), 1, "a change of direction drops the leftover fraction"); + mouse_wheel(&mut h, &[0.25]); + assert_eq!(slide_at(h.state()), 0, "a whole line back is one slide back"); + } + #[test] fn mouse_wheel_over_a_zoomed_canvas_pans_and_outside_the_canvas_does_not_navigate() { let mut h = flick_harness(); From e11c7e4d7eea4436de5e6650a5cf4642d4dda0ca Mon Sep 17 00:00:00 2001 From: kinower Date: Thu, 22 Oct 2026 08:09:23 +0200 Subject: [PATCH 3/4] UI: reset the fractional wheel carry when the presentation changes The leftover fraction of a wheel line is now stored with the active document's uid and dropped when another presentation becomes active, so a quarter line left in one deck can no longer turn a slide in another. Test: 0.75 of a line in one presentation followed by 0.25 in a new one does not turn the page; a whole line in the new one does. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 9 +++++++-- crates/ui-egui/src/lib.rs | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e29046ff..1d14b60c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -177,13 +177,18 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { if overflow != egui::Vec2::ZERO { offset -= wheel; } else if !app.anim_preview_active() && app.session.tool.marquee.is_none() && !app.session.tool.dragging() { + // The leftover fraction belongs to one presentation: another one starts from zero. let carry_id = ui.id().with("wheel_carry"); - let mut carry: f32 = ui.data_mut(|d| d.get_temp(carry_id).unwrap_or_default()); + let (carry_doc, mut carry): (Option, f32) = ui.data_mut(|d| d.get_temp(carry_id).unwrap_or_default()); + let doc_uid = app.session.active().map(|d| d.uid); + if carry_doc != doc_uid { + carry = 0.0; + } let wheel_step = app.session.active().and_then(|d| { let current = d.selection.slide; ui.input(|i| wheel_slide_target(&i.events, current, d.doc.slides.len(), &mut carry)).map(|target| (current, target)) }); - ui.data_mut(|d| d.insert_temp(carry_id, carry)); + ui.data_mut(|d| d.insert_temp(carry_id, (doc_uid, carry))); if let Some((current, index)) = wheel_step { if index != current { let _ = app.run("slide.go", json!({"index": index})); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0c6e532c..b7759cc8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -3383,6 +3383,27 @@ mod tests { assert_eq!(slide_at(h.state()), 0, "a whole line back is one slide back"); } + /// A fraction of a line left over in one presentation does not carry into another. + #[test] + fn mouse_wheel_fraction_does_not_carry_into_another_presentation() { + let mut h = flick_harness(); + h.state_mut().run("slide.new", json!({})).unwrap(); + h.state_mut().run("slide.go", json!({"index": 0})).unwrap(); + h.run_steps(1); + mouse_wheel(&mut h, &[-0.75]); + assert_eq!(slide_at(h.state()), 0); + + h.state_mut().run("file.new", json!({})).unwrap(); + h.state_mut().run("slide.new", json!({})).unwrap(); + h.state_mut().run("slide.go", json!({"index": 0})).unwrap(); + h.state_mut().ui.zoom = Some(0.4); + h.run_steps(2); + mouse_wheel(&mut h, &[-0.25]); + assert_eq!(slide_at(h.state()), 0, "a quarter line in the new presentation does not turn the page"); + mouse_wheel(&mut h, &[-0.75]); + assert_eq!(slide_at(h.state()), 1, "a whole line in the new presentation does"); + } + #[test] fn mouse_wheel_over_a_zoomed_canvas_pans_and_outside_the_canvas_does_not_navigate() { let mut h = flick_harness(); From dffef3396039bf8c05e60b5829a2d42e4f65a263 Mon Sep 17 00:00:00 2001 From: kinower Date: Thu, 22 Oct 2026 09:52:50 +0200 Subject: [PATCH 4/4] UI: drop the wheel carry whenever another presentation is drawn The presentation check on the leftover wheel fraction ran only while the canvas was hovered and fitted, so switching to another presentation from the tabs and back kept the old fraction. It now runs every frame the canvas draws a presentation, before the hover and overflow branches. Test: 0.75 of a line in one presentation, a switch away and back with the pointer off the canvas, then 0.25 does not turn the page. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 17 ++++++++++------- crates/ui-egui/src/lib.rs | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1d14b60c..818a9f5f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -142,6 +142,15 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { let avail = ui.available_rect_before_wrap(); app.canvas_rect = Some(avail); let Some(st) = app.session.active() else { return }; + // The leftover fraction of a wheel line belongs to one presentation. Drop it whenever another + // one is drawn here, hovered or not, so switching away and back starts from zero. + let carry_id = ui.id().with("wheel_carry"); + let doc_uid = st.uid; + ui.data_mut(|d| { + if d.get_temp::<(u64, f32)>(carry_id).is_none_or(|(uid, _)| uid != doc_uid) { + d.insert_temp(carry_id, (doc_uid, 0.0_f32)); + } + }); if st.doc.slides.is_empty() && st.selection.target == Target::Slides { let resp = ui.allocate_rect(avail, Sense::click()); ui.painter().text(avail.center(), Align2::CENTER_CENTER, "Click to add first slide", theme::font(22.0), t.text_dim); @@ -177,13 +186,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { if overflow != egui::Vec2::ZERO { offset -= wheel; } else if !app.anim_preview_active() && app.session.tool.marquee.is_none() && !app.session.tool.dragging() { - // The leftover fraction belongs to one presentation: another one starts from zero. - let carry_id = ui.id().with("wheel_carry"); - let (carry_doc, mut carry): (Option, f32) = ui.data_mut(|d| d.get_temp(carry_id).unwrap_or_default()); - let doc_uid = app.session.active().map(|d| d.uid); - if carry_doc != doc_uid { - carry = 0.0; - } + let (_, mut carry): (u64, f32) = ui.data_mut(|d| d.get_temp(carry_id).unwrap_or_default()); let wheel_step = app.session.active().and_then(|d| { let current = d.selection.slide; ui.input(|i| wheel_slide_target(&i.events, current, d.doc.slides.len(), &mut carry)).map(|target| (current, target)) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b7759cc8..c898465c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -3393,6 +3393,7 @@ mod tests { mouse_wheel(&mut h, &[-0.75]); assert_eq!(slide_at(h.state()), 0); + let a = h.state().session.active_index().unwrap(); h.state_mut().run("file.new", json!({})).unwrap(); h.state_mut().run("slide.new", json!({})).unwrap(); h.state_mut().run("slide.go", json!({"index": 0})).unwrap(); @@ -3402,6 +3403,23 @@ mod tests { assert_eq!(slide_at(h.state()), 0, "a quarter line in the new presentation does not turn the page"); mouse_wheel(&mut h, &[-0.75]); assert_eq!(slide_at(h.state()), 1, "a whole line in the new presentation does"); + + // Back in the first one, leave 0.75 of a line, then switch away and back with the pointer + // off the canvas (on the tabs): the canvas never sees a hover in the other presentation. + let b = h.state().session.active_index().unwrap(); + h.state_mut().session.set_active(a); + h.state_mut().ui.zoom = Some(0.4); + h.run_steps(2); + mouse_wheel(&mut h, &[-0.75]); + assert_eq!(slide_at(h.state()), 0); + h.input_mut().events.push(egui::Event::PointerMoved(egui::pos2(-10.0, -10.0))); + h.run_steps(1); + h.state_mut().session.set_active(b); + h.run_steps(2); + h.state_mut().session.set_active(a); + h.run_steps(2); + mouse_wheel(&mut h, &[-0.25]); + assert_eq!(slide_at(h.state()), 0, "switching away without a hover still drops the fraction"); } #[test]