feat: Schneller durch lange Notizbücher scrollen (Issue #28), neue Notizbücher durchgehend (Issue #30)

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 18:12:51 +02:00
co-authored by Claude Opus 5.5
parent 5fafff5eaf
commit b75858b43a
6 changed files with 408 additions and 14 deletions
+1 -1
View File
@@ -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.
+12
View File
@@ -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.
+29 -2
View File
@@ -1422,6 +1422,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// 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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
|| (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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
}
WindowEvent::MouseWheel { delta, .. } => {
fling.stop();
if is_pointer_in_ui {
return;
}
@@ -2729,7 +2741,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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();
+6 -6
View File
@@ -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 =
+242 -5
View File
@@ -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<Ruler>,
pub ruler_drag: Option<RulerDrag>,
/// Bildlaufleiste wird gezogen: Abstand des Fingers von der Oberkante des Griffs
pub scrollbar_grab: Option<f32>,
/// Aktuelle Abbildung Seite ↔ Bildschirm (für Lineal und Treffertests)
pub page_view: Option<PageView>,
/// 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<usize> {
}
/// 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<UiAction>,
) {
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, &notebook, 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,
&notebook,
AutosaveStatus::Saved,
&updater,
));
let _ = ctx.end_frame();
}
let targets: Vec<f32> = 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, &notebook, 2);
let actions = click_at(&ctx, &mut state, &notebook, 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();
+118
View File
@@ -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<std::time::Instant>,
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<Vec2> {
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);