From b75858b43a160521c78555a99cd20acc04866e2c Mon Sep 17 00:00:00 2001 From: Harald Date: Mon, 19 Oct 2026 18:12:51 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Schneller=20durch=20lange=20Notizb?= =?UTF-8?q?=C3=BCcher=20scrollen=20(Issue=20#28),=20neue=20Notizb=C3=BCche?= =?UTF-8?q?r=20durchgehend=20(Issue=20#30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nachgleiten: Nach schnellem Wischen mit dem Finger gleitet das Blatt weiter und bremst ab; eine neue Berührung, Klick oder Mausrad hält an - Bildlaufleiste in der durchgehenden Ansicht gegenüber der Werkzeugleiste: Griff ziehen oder auf die Leiste tippen springt durch das Notizbuch, beim Ziehen steht die Seitenzahl daneben - Mausrad scrollt doppelt so weit je Raste - Neue Notizbücher (auch PDF-Importe) sind durchgehend; beim Anlegen stehen nur noch Durchgehend und Endlos zur Wahl, feste Seiten gibt es weiter im ☰-Menü Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/canvas.rs | 12 +++ src/main.rs | 31 +++++- src/storage.rs | 12 +-- src/ui.rs | 247 +++++++++++++++++++++++++++++++++++++++++++++++- src/viewport.rs | 118 +++++++++++++++++++++++ 6 files changed, 408 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index dfe1bc5..2f9817d 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra ### Seiten und Ansicht - **Hintergründe:** blanko, kariert (5 mm), liniert (7,5 mm), Punktraster (5 mm). Die **Farbe von Karos, Linien und Punkten** ist wählbar (hell, mittel, kräftig, blau, grün) und gilt auch im Export. - **Papierfarbe** der Anzeige: weiß, creme, dunkelgrau, schwarz; auf dunklem Papier werden dunkle Stiftfarben auf Wunsch aufgehellt. Gespeichert und exportiert wird immer auf weißem Papier. -- **Feste Seiten, durchgehendes Scrollen oder endlose Fläche, je Notizbuch:** Beim Anlegen wählbar (Vorgabe: feste Seiten, Seite für Seite blättern mit Pfeilen in der Werkzeugleiste), später im ☰-Menü umschaltbar. Durchgehend hängen die Seiten lückenlos untereinander, eine dunkle Linie markiert die Grenze; wer im unteren Fünftel der letzten Seite schreibt, bekommt automatisch eine neue Seite. Endlos ist eine einzige Fläche ohne Rand, auf der man in alle Richtungen schreibt; der PDF-Export enthält nur den beschriebenen Bereich. +- **Durchgehendes Scrollen, endlose Fläche oder feste Seiten, je Notizbuch:** Beim Anlegen wählbar (Vorgabe: durchgehend; oder endlos), im ☰-Menü umschaltbar – dort auch auf feste Seiten (Seite für Seite blättern mit Pfeilen in der Werkzeugleiste). Durchgehend hängen die Seiten lückenlos untereinander, eine dunkle Linie markiert die Grenze; schnelles Wischen gleitet nach, und mit der Bildlaufleiste am Rand gegenüber der Werkzeugleiste springt man samt Seitenzahl durch lange PDFs; wer im unteren Fünftel der letzten Seite schreibt, bekommt automatisch eine neue Seite. Endlos ist eine einzige Fläche ohne Rand, auf der man in alle Richtungen schreibt; der PDF-Export enthält nur den beschriebenen Bereich. - **Seiten einfügen und löschen:** ➕ in der Werkzeugleiste fügt nach der aktuellen Seite eine leere Seite ein. Löschen im ☰-Menü (mit Rückfrage); die zuletzt gelöschte Seite lässt sich dort wiederherstellen, solange die App läuft. - **Zoom** von 25 % bis 1000 % (`Strg + Mausrad`, zwei Finger, `Strg + +/-/0`), Verschieben mit mittlerer Maustaste, `Leertaste + Ziehen` oder einem Finger. - **Seiten** anhängen oder direkt nach der aktuellen Seite einfügen, **Seitentitel** vergeben; die Seitenübersicht zeigt Vorschaubilder oder ein Inhaltsverzeichnis. Seitentitel werden im PDF zu Lesezeichen. diff --git a/src/canvas.rs b/src/canvas.rs index 3195300..097fe96 100644 --- a/src/canvas.rs +++ b/src/canvas.rs @@ -843,6 +843,18 @@ impl CanvasState { changed } + /// Durchgehende Ansicht: Scrollt senkrecht, bis die Fenstermitte auf Höhe `y` + /// (Koordinaten der aktuellen Seite) liegt. + pub fn scroll_center_to(&mut self, y: f32) { + let c = self.camera.screen_to_page_unclamped( + self.viewport.page_x + self.viewport.page_width / 2.0, + self.size.height as f32 / 2.0, + &self.viewport, + ); + self.camera.pan_offset[1] += c.y - y; + self.update_uniforms(); + } + /// Durchgehende Ansicht: Die Seite mit der Ecke `origin` (Koordinaten der aktuellen Seite) /// wird zur Bezugsseite, ohne dass sich der Bildausschnitt bewegt. Danach ist die neue /// Seite mit [`Self::sync_page`] zu setzen. diff --git a/src/main.rs b/src/main.rs index 80499d3..dd19fd1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1422,6 +1422,8 @@ fn main() -> Result<(), Box> { // Langes Drücken mit einem Finger öffnet das Kontextmenü let mut long_press = ui::LongPress::default(); let mut touch_pan_last_pos = (0.0_f32, 0.0_f32); + // Nachgleiten nach schnellem Wischen mit dem Finger (Issue #28) + let mut fling = ink_notes::viewport::Fling::default(); // Stylus / Digitizer Zustand (Octotablet / Wayland Tablet Protocol) let mut is_stylus_drawing = false; @@ -1961,6 +1963,12 @@ fn main() -> Result<(), Box> { window.request_redraw(); } + // Nachgleiten nach dem Wischen + if let Some(delta) = fling.step(Instant::now()) { + canvas.pan(delta.x, delta.y); + window.request_redraw(); + } + // Von egui angefordertes, verzögertes Neuzeichnen (Tooltips, Animationen) if let Some(at) = egui_repaint_at { if Instant::now() >= at { @@ -1976,6 +1984,7 @@ fn main() -> Result<(), Box> { || (is_drawing && !stroke_already_snapped) || is_stylus_drawing || is_stylus_in_proximity + || fling.is_active() { target.set_control_flow(ControlFlow::wait_duration(Duration::from_millis(8))); } else if let Some(at) = [egui_repaint_at, long_press.deadline()] @@ -2080,6 +2089,7 @@ fn main() -> Result<(), Box> { pinch_prev_center = None; is_touch_drawing = false; is_touch_panning = false; + fling.stop(); is_stylus_drawing = false; is_stylus_selecting = false; is_stylus_in_proximity = false; @@ -2536,6 +2546,7 @@ fn main() -> Result<(), Box> { return; } + fling.stop(); // 1. Pan via mittlere Maustaste oder Leertaste + Linksklick if button == MouseButton::Middle || (button == MouseButton::Left && is_space_pressed) { is_panning = true; @@ -2721,6 +2732,7 @@ fn main() -> Result<(), Box> { } WindowEvent::MouseWheel { delta, .. } => { + fling.stop(); if is_pointer_in_ui { return; } @@ -2729,7 +2741,7 @@ fn main() -> Result<(), Box> { if (ui_state.continuous_scroll || ui_state.endless) && !modifiers.control_key() { let (mut dx, mut dy) = match delta { MouseScrollDelta::LineDelta(x, y) => { - let line = 60.0 * window.scale_factor() as f32; + let line = 120.0 * window.scale_factor() as f32; (x * line, y * line) } MouseScrollDelta::PixelDelta(pos) => (pos.x as f32, pos.y as f32), @@ -2802,6 +2814,8 @@ fn main() -> Result<(), Box> { match touch.phase { TouchPhase::Started => { + // Jede neue Berührung hält ein gleitendes Blatt an + fling.stop(); // Event-Isolation für Touch-Eingaben let is_touch_in_ui = ui::is_screen_point_in_ui( &egui_ctx, @@ -2957,6 +2971,7 @@ fn main() -> Result<(), Box> { let dy = ty - touch_pan_last_pos.1; touch_pan_last_pos = (tx, ty); canvas.pan(dx, dy); + fling.track(Vec2::new(dx, dy), Instant::now()); window.request_redraw(); } else if is_touch_drawing { current_pressure = pressure; @@ -3003,6 +3018,10 @@ fn main() -> Result<(), Box> { is_pinch_gesturing = false; pinch_prev_distance = None; pinch_prev_center = None; + if is_touch_panning && touch.phase == TouchPhase::Ended { + fling.release(Instant::now()); + window.request_redraw(); + } is_touch_panning = false; if is_erasing { @@ -3067,7 +3086,10 @@ fn main() -> Result<(), Box> { canvas.set_fit_box(notebook.fit_box()); if ui_state.continuous_scroll { let stack = notebook.page_stack(); - canvas.clamp_scroll(&stack); + if canvas.clamp_scroll(&stack) { + // Am Anfang oder Ende des Notizbuchs angekommen + fling.stop(); + } // Unabhängig von der Bezugsseite: Lage bezogen auf die erste Seite let scroll = [ canvas.camera.zoom, @@ -3846,6 +3868,11 @@ fn main() -> Result<(), Box> { save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state); window.request_redraw(); } + UiAction::ScrollTo(y) => { + fling.stop(); + canvas.scroll_center_to(y); + window.request_redraw(); + } UiAction::SetNotebookLayout(layout) => { // Von und zur endlosen Fläche nur mit einer Seite, deren Inhalt passt let was_endless = notebook.is_endless(); diff --git a/src/storage.rs b/src/storage.rs index 6de2605..4445ecf 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -330,7 +330,7 @@ impl NotebookManifest { page_ids: vec![1], next_stroke_id: 1, next_page_id: 2, - layout: Some(NotebookLayout::Paged), + layout: Some(NotebookLayout::Continuous), } } @@ -1640,14 +1640,14 @@ mod tests { fn test_notebook_layout_is_saved_and_old_manifests_fall_back() { let temp_dir = tempfile::tempdir().unwrap(); let (_nb, mut manifest, dir) = create_new_notebook(temp_dir.path(), "Layout").unwrap(); - // Neue Notizbücher: feste Seiten, unabhängig von der früheren globalen Einstellung - assert_eq!(manifest.layout, Some(NotebookLayout::Paged)); - assert_eq!(manifest.layout_or(true), NotebookLayout::Paged); + // Neue Notizbücher: durchgehend, unabhängig von der früheren globalen Einstellung + assert_eq!(manifest.layout, Some(NotebookLayout::Continuous)); + assert_eq!(manifest.layout_or(false), NotebookLayout::Continuous); - manifest.layout = Some(NotebookLayout::Continuous); + manifest.layout = Some(NotebookLayout::Paged); save_manifest(&dir, &manifest).unwrap(); let (_nb, loaded) = load_notebook(&dir).unwrap(); - assert_eq!(loaded.layout, Some(NotebookLayout::Continuous)); + assert_eq!(loaded.layout, Some(NotebookLayout::Paged)); // Manifest von vor 0.28 ohne `layout`: es gilt die globale Einstellung let mut json: serde_json::Value = diff --git a/src/ui.rs b/src/ui.rs index f31da1d..fccf8fa 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -320,6 +320,9 @@ pub enum UiAction { SavePressureCurve(PressureCurve), /// Anordnung der Seiten des offenen Notizbuchs (wird im Manifest gespeichert) SetNotebookLayout(NotebookLayout), + /// Durchgehende Ansicht: Fenstermitte auf diese Höhe (Koordinaten der aktuellen Seite) + /// scrollen – Bildlaufleiste, Issue #28 + ScrollTo(f32), /// Farbe von Karos, Linien und Punkten (wird gespeichert, gilt auch für den Export) SetPatternColor(PatternColor), /// Bilddatei auf der aktuellen Seite einfügen @@ -455,6 +458,8 @@ pub struct UiState { /// Eingeblendetes Lineal pub ruler: Option, pub ruler_drag: Option, + /// Bildlaufleiste wird gezogen: Abstand des Fingers von der Oberkante des Griffs + pub scrollbar_grab: Option, /// Aktuelle Abbildung Seite ↔ Bildschirm (für Lineal und Treffertests) pub page_view: Option, /// Fenstergröße in logischen Punkten (vom letzten Bild) @@ -491,7 +496,7 @@ impl Default for UiState { document_title: t!("Standard-Notizbuch").to_string(), available_notebooks: Vec::new(), new_notebook_title_input: String::new(), - new_notebook_layout: NotebookLayout::Paged, + new_notebook_layout: NotebookLayout::Continuous, renaming_notebook_id: None, library: crate::library::Library::default(), notebook_folder: String::new(), @@ -543,6 +548,7 @@ impl Default for UiState { queued_actions: Vec::new(), ruler: None, ruler_drag: None, + scrollbar_grab: None, page_view: None, screen_size: egui::Vec2::ZERO, page_title_input: String::new(), @@ -1653,8 +1659,14 @@ fn page_title_list(ui: &mut egui::Ui, notebook: &Notebook) -> Option { } /// Auswahl der Seitenanordnung eines Notizbuchs. Ohne `can_cross` lässt sich nicht zwischen -/// Seiten und endloser Fläche wechseln. `true`, wenn sie geändert wurde. -fn layout_picker(ui: &mut egui::Ui, layout: &mut NotebookLayout, can_cross: bool) -> bool { +/// Seiten und endloser Fläche wechseln; ohne `offer_paged` fehlen feste Seiten (beim Anlegen, +/// Issue #30 – umstellen geht im ☰-Menü). `true`, wenn sie geändert wurde. +fn layout_picker( + ui: &mut egui::Ui, + layout: &mut NotebookLayout, + can_cross: bool, + offer_paged: bool, +) -> bool { let mut changed = false; let endless_now = *layout == NotebookLayout::Endless; ui.horizontal_wrapped(|ui| { @@ -1681,6 +1693,9 @@ fn layout_picker(ui: &mut egui::Ui, layout: &mut NotebookLayout, can_cross: bool ), ), ] { + if option == NotebookLayout::Paged && !offer_paged { + continue; + } let enabled = can_cross || (option == NotebookLayout::Endless) == endless_now; if ui .add_enabled( @@ -2344,6 +2359,147 @@ fn page_separators(ctx: &egui::Context, state: &UiState, notebook: &Notebook) { } } +/// Lage der Bildlaufleiste: Leiste (Trefferfläche) und Griff in logischen Punkten. Der Griff +/// zeigt, wo die Fenstermitte `center` zwischen Ober- und Unterkante des Notizbuchs liegt; +/// `visible` ist die sichtbare Höhe (Seiteneinheiten) und bestimmt seine Länge. +fn scrollbar_layout( + screen: egui::Rect, + side: SidebarSide, + (top, bottom): (f32, f32), + center: f32, + visible: f32, +) -> (egui::Rect, egui::Rect) { + const WIDTH: f32 = 28.0; + const MARGIN: f32 = 12.0; + // Gegenüber der Werkzeugleiste + let x = match side { + SidebarSide::Left => screen.right() - WIDTH, + SidebarSide::Right => screen.left(), + }; + let track = egui::Rect::from_min_size( + egui::pos2(x, screen.top() + MARGIN), + egui::vec2(WIDTH, (screen.height() - 2.0 * MARGIN).max(1.0)), + ); + let total = (bottom - top).max(1.0); + let thumb_h = (visible / (total + visible) * track.height()) + .clamp(48.0_f32.min(track.height()), track.height()); + let fraction = ((center - top) / total).clamp(0.0, 1.0); + let thumb = egui::Rect::from_min_size( + egui::pos2( + track.left(), + track.top() + fraction * (track.height() - thumb_h), + ), + egui::vec2(WIDTH, thumb_h), + ); + (track, thumb) +} + +/// Durchgehende Ansicht: Bildlaufleiste am Fensterrand gegenüber der Werkzeugleiste. Den +/// Griff ziehen (oder auf die Leiste tippen) springt schnell durch lange Notizbücher und +/// PDFs (Issue #28); beim Ziehen steht die Seitenzahl daneben. +fn page_scrollbar( + ctx: &egui::Context, + state: &mut UiState, + notebook: &Notebook, + actions: &mut Vec, +) { + let Some(view) = state.page_view.filter(|_| state.continuous_scroll) else { + state.scrollbar_grab = None; + return; + }; + if notebook.pages.len() < 2 { + state.scrollbar_grab = None; + return; + } + let stack = notebook.page_stack(); + let (min, max) = stack.bounds(); + let screen = ctx.screen_rect(); + let center = view.to_page(screen.center()).y; + let visible = screen.height() / view.scale.max(1e-6); + let (track, thumb) = + scrollbar_layout(screen, state.sidebar_side, (min.y, max.y), center, visible); + + let dark = state.paper_theme.is_dark(); + let response = egui::Area::new(egui::Id::new("page_scrollbar")) + .order(egui::Order::Middle) + .fixed_pos(track.min) + .show(ctx, |ui| { + let (_, response) = ui.allocate_exact_size(track.size(), egui::Sense::drag()); + let active = response.hovered() || response.dragged(); + let bar_w = if active { 10.0 } else { 6.0 }; + let bar = egui::Rect::from_center_size( + egui::pos2(thumb.center().x, thumb.center().y), + egui::vec2(bar_w, thumb.height()), + ); + let alpha = if active { 200 } else { 110 }; + let color = if dark { + egui::Color32::from_white_alpha(alpha) + } else { + egui::Color32::from_black_alpha(alpha) + }; + ui.painter() + .rect_filled(bar, egui::Rounding::same(bar_w / 2.0), color); + response + }) + .inner; + + if response.drag_started() { + let y = response + .interact_pointer_pos() + .map_or(thumb.center().y, |p| p.y); + // Auf den Griff: dort festhalten; daneben: Griff unter den Finger springen lassen + state.scrollbar_grab = Some(if thumb.y_range().contains(y) { + y - thumb.top() + } else { + thumb.height() / 2.0 + }); + } + if !response.dragged() { + state.scrollbar_grab = None; + return; + } + let (Some(grab), Some(pos)) = (state.scrollbar_grab, response.interact_pointer_pos()) else { + return; + }; + let free = (track.height() - thumb.height()).max(1.0); + let fraction = ((pos.y - grab - track.top()) / free).clamp(0.0, 1.0); + let target = min.y + fraction * (max.y - min.y); + if (target - center).abs() > 0.5 { + actions.push(UiAction::ScrollTo(target)); + } + if let Some(page) = stack.page_at(target) { + let label_x = match state.sidebar_side { + SidebarSide::Left => track.left() - 8.0, + SidebarSide::Right => track.right() + 8.0, + }; + let anchor = match state.sidebar_side { + SidebarSide::Left => egui::Align2::RIGHT_CENTER, + SidebarSide::Right => egui::Align2::LEFT_CENTER, + }; + let painter = ctx.layer_painter(egui::LayerId::new( + egui::Order::Foreground, + egui::Id::new("page_scrollbar_label"), + )); + let text = format!("{} / {}", page + 1, notebook.pages.len()); + let galley = + painter.layout_no_wrap(text, egui::FontId::proportional(18.0), egui::Color32::WHITE); + let rect = anchor + .anchor_size(egui::pos2(label_x, pos.y), galley.size()) + .expand2(egui::vec2(10.0, 6.0)); + painter.rect_filled( + rect, + egui::Rounding::same(8.0), + egui::Color32::from_black_alpha(190), + ); + painter.galley( + rect.min + egui::vec2(10.0, 6.0), + galley, + egui::Color32::WHITE, + ); + } + ctx.request_repaint(); +} + /// Zeichnet das Lineal über dem Blatt und bedient Verschieben und Drehen. fn ruler_overlay(ctx: &egui::Context, state: &mut UiState) { let (Some(mut ruler), Some(view)) = (state.ruler, state.page_view) else { @@ -2751,6 +2907,7 @@ pub fn render_ui( let mut actions = std::mem::take(&mut state.queued_actions); state.screen_size = ctx.screen_rect().size(); page_separators(ctx, state, notebook); + page_scrollbar(ctx, state, notebook, &mut actions); space_preview(ctx, state, notebook); ruler_overlay(ctx, state); text_edit_overlay(ctx, state); @@ -3823,7 +3980,7 @@ pub fn render_ui( } else { NotebookLayout::Paged }; - if layout_picker(ui, &mut layout, state.endless_switch_possible) { + if layout_picker(ui, &mut layout, state.endless_switch_possible, true) { actions.push(UiAction::SetNotebookLayout(layout)); } @@ -4173,7 +4330,7 @@ pub fn render_ui( }); ui.horizontal(|ui| { ui.label(t!("Seiten:")); - layout_picker(ui, &mut state.new_notebook_layout, true); + layout_picker(ui, &mut state.new_notebook_layout, true, false); }); ui.add_space(8.0); @@ -5027,6 +5184,86 @@ mod tests { ctx } + #[test] + fn test_page_scrollbar_jumps_through_the_notebook() { + // Layout: Griff oben am Anfang, unten am Ende, gegenüber der Werkzeugleiste + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 850.0)); + let (track, top) = + scrollbar_layout(screen, SidebarSide::Left, (0.0, 80_000.0), 0.0, 1000.0); + assert_eq!(track.right(), screen.right()); + assert_eq!(top.top(), track.top()); + assert!(top.height() >= 48.0); + let (_, end) = + scrollbar_layout(screen, SidebarSide::Left, (0.0, 80_000.0), 80_000.0, 1000.0); + assert_eq!(end.bottom(), track.bottom()); + let (left, _) = scrollbar_layout(screen, SidebarSide::Right, (0.0, 1.0), 0.0, 1.0); + assert_eq!(left.left(), screen.left()); + + // Notizbuch mit 80 Seiten, durchgehend, Ansicht auf Seite 1 + let mut notebook = Notebook::new(); + for _ in 1..80 { + notebook.add_page(PageBackground::Blank); + } + let mut state = UiState::new(); + state.continuous_scroll = true; + state.page_view = Some(PageView { + origin: egui::pos2(300.0, 0.0), + scale: 0.25, + }); + let ctx = render_frames(&mut state, ¬ebook, 2); + let updater = crate::updater::UpdaterController::new(); + let x = screen.right() - 14.0; + let mut actions = Vec::new(); + // Griff greifen und bis ans untere Ende ziehen + for (pos, pressed) in [ + (egui::pos2(x, 30.0), Some(true)), + (egui::pos2(x, 400.0), None), + (egui::pos2(x, 845.0), None), + (egui::pos2(x, 845.0), Some(false)), + ] { + let mut events = vec![egui::Event::PointerMoved(pos)]; + if let Some(pressed) = pressed { + events.push(egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::default(), + }); + } + ctx.begin_frame(egui::RawInput { + screen_rect: Some(screen), + events, + ..Default::default() + }); + actions.extend(render_ui( + &ctx, + &mut state, + ¬ebook, + AutosaveStatus::Saved, + &updater, + )); + let _ = ctx.end_frame(); + } + let targets: Vec = actions + .iter() + .filter_map(|a| match a { + UiAction::ScrollTo(y) => Some(*y), + _ => None, + }) + .collect(); + let (_, max) = notebook.page_stack().bounds(); + assert!(targets.len() >= 2, "{actions:?}"); + assert!(targets.windows(2).all(|w| w[1] >= w[0])); + assert!((targets.last().unwrap() - max.y).abs() < 1.0, "{targets:?}"); + assert!(state.scrollbar_grab.is_none()); + + // Feste Seiten: keine Leiste, Tippen an den Rand scrollt nicht + state.continuous_scroll = false; + let ctx = render_frames(&mut state, ¬ebook, 2); + let actions = click_at(&ctx, &mut state, ¬ebook, egui::pos2(x, 400.0)); + assert!(!actions.iter().any(|a| matches!(a, UiAction::ScrollTo(_)))); + } + #[test] fn test_text_edit_overlay_takes_typing_and_escape() { let notebook = Notebook::new(); diff --git a/src/viewport.rs b/src/viewport.rs index 58860f3..68bc7aa 100644 --- a/src/viewport.rs +++ b/src/viewport.rs @@ -305,6 +305,84 @@ impl ViewportState { } } +/// Schwung beim Scrollen mit dem Finger (Issue #28): Nach dem Abheben gleitet der +/// Ausschnitt mit der zuletzt gewischten Geschwindigkeit weiter und bremst langsam ab. +/// Geschwindigkeiten in Bildschirmpixeln je Sekunde. +#[derive(Debug, Clone, Copy, Default)] +pub struct Fling { + velocity: Vec2, + /// Zeit der letzten Fingerbewegung bzw. des letzten Gleitschritts + last: Option, + active: bool, +} + +impl Fling { + /// Abbremsen je Sekunde (Anteil der Geschwindigkeit, der nach 1 s bleibt: e^-FRICTION) + const FRICTION: f32 = 2.5; + /// Langsamer wischen gleitet nicht nach + const MIN_START_SPEED: f32 = 400.0; + /// Darunter hört das Gleiten auf + const MIN_SPEED: f32 = 40.0; + /// Liegt der Finger vor dem Abheben so lange still, gleitet nichts nach + const MAX_REST: std::time::Duration = std::time::Duration::from_millis(80); + + /// Finger hat sich um `delta` verschoben. + pub fn track(&mut self, delta: Vec2, now: std::time::Instant) { + self.active = false; + let dt = self + .last + .map_or(0.0, |last| now.duration_since(last).as_secs_f32()); + self.last = Some(now); + if dt <= 0.0 { + return; + } + // Geglättet: einzelne ungleichmäßige Touch-Ereignisse sollen nicht zählen + let sample = delta / dt.max(0.004); + self.velocity = self.velocity.lerp(sample, 0.4); + } + + /// Finger abgehoben: gleitet weiter, wenn zuletzt schnell genug gewischt wurde. + pub fn release(&mut self, now: std::time::Instant) { + let resting = self + .last + .is_none_or(|last| now.duration_since(last) > Self::MAX_REST); + self.active = !resting && self.velocity.length() >= Self::MIN_START_SPEED; + self.last = Some(now); + if !self.active { + self.stop(); + } + } + + /// Hält an (neue Berührung, Mausklick, Mausrad). + pub fn stop(&mut self) { + *self = Self::default(); + } + + pub fn is_active(&self) -> bool { + self.active + } + + /// Verschiebung (Bildschirmpixel) seit dem letzten Schritt; `None`, wenn nichts gleitet. + pub fn step(&mut self, now: std::time::Instant) -> Option { + if !self.active { + return None; + } + let dt = self + .last + .map_or(0.0, |last| now.duration_since(last).as_secs_f32()) + .min(0.1); + self.last = Some(now); + // Exakt integriert, damit die Strecke nicht von der Bildrate abhängt + let decay = (-Self::FRICTION * dt).exp(); + let delta = self.velocity * (1.0 - decay) / Self::FRICTION; + self.velocity *= decay; + if self.velocity.length() < Self::MIN_SPEED { + self.stop(); + } + Some(delta) + } +} + #[cfg(test)] mod tests { use super::*; @@ -316,6 +394,46 @@ mod tests { PageStack::new(vec![A4; count], current) } + #[test] + fn test_fling_glides_and_slows_down() { + use std::time::{Duration, Instant}; + let t0 = Instant::now(); + let ms = |n: u64| t0 + Duration::from_millis(n); + let mut fling = Fling::default(); + // Schnell nach oben wischen: 30 px alle 10 ms = 3000 px/s + for i in 0..=10 { + fling.track(Vec2::new(0.0, -30.0), ms(i * 10)); + } + fling.release(ms(105)); + assert!(fling.is_active()); + let first = fling.step(ms(121)).unwrap(); + assert!(first.y < -20.0, "{first:?}"); + let mut total = first; + let mut t = 121; + while let Some(d) = fling.step(ms(t)) { + total += d; + t += 16; + assert!(t < 10_000, "gleitet endlos"); + } + // Gleitet ungefähr v / FRICTION = 1200 px weiter, dann ist Schluss + assert!(total.y < -800.0 && total.y > -1300.0, "{total:?}"); + assert!(!fling.is_active()); + + // Langsam schieben oder vor dem Abheben stillhalten: kein Nachgleiten + let mut slow = Fling::default(); + for i in 0..=10 { + slow.track(Vec2::new(0.0, -2.0), ms(i * 10)); + } + slow.release(ms(105)); + assert!(!slow.is_active()); + let mut rested = Fling::default(); + for i in 0..=10 { + rested.track(Vec2::new(0.0, -30.0), ms(i * 10)); + } + rested.release(ms(400)); + assert!(!rested.is_active()); + } + #[test] fn test_continuous_layout() { let stack = a4_stack(5, 1);