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:
@@ -49,7 +49,7 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
|
|||||||
### Seiten und Ansicht
|
### 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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -843,6 +843,18 @@ impl CanvasState {
|
|||||||
changed
|
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)
|
/// 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
|
/// wird zur Bezugsseite, ohne dass sich der Bildausschnitt bewegt. Danach ist die neue
|
||||||
/// Seite mit [`Self::sync_page`] zu setzen.
|
/// Seite mit [`Self::sync_page`] zu setzen.
|
||||||
|
|||||||
+29
-2
@@ -1422,6 +1422,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
// Langes Drücken mit einem Finger öffnet das Kontextmenü
|
// Langes Drücken mit einem Finger öffnet das Kontextmenü
|
||||||
let mut long_press = ui::LongPress::default();
|
let mut long_press = ui::LongPress::default();
|
||||||
let mut touch_pan_last_pos = (0.0_f32, 0.0_f32);
|
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)
|
// Stylus / Digitizer Zustand (Octotablet / Wayland Tablet Protocol)
|
||||||
let mut is_stylus_drawing = false;
|
let mut is_stylus_drawing = false;
|
||||||
@@ -1961,6 +1963,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
window.request_redraw();
|
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)
|
// Von egui angefordertes, verzögertes Neuzeichnen (Tooltips, Animationen)
|
||||||
if let Some(at) = egui_repaint_at {
|
if let Some(at) = egui_repaint_at {
|
||||||
if Instant::now() >= at {
|
if Instant::now() >= at {
|
||||||
@@ -1976,6 +1984,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|| (is_drawing && !stroke_already_snapped)
|
|| (is_drawing && !stroke_already_snapped)
|
||||||
|| is_stylus_drawing
|
|| is_stylus_drawing
|
||||||
|| is_stylus_in_proximity
|
|| is_stylus_in_proximity
|
||||||
|
|| fling.is_active()
|
||||||
{
|
{
|
||||||
target.set_control_flow(ControlFlow::wait_duration(Duration::from_millis(8)));
|
target.set_control_flow(ControlFlow::wait_duration(Duration::from_millis(8)));
|
||||||
} else if let Some(at) = [egui_repaint_at, long_press.deadline()]
|
} 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;
|
pinch_prev_center = None;
|
||||||
is_touch_drawing = false;
|
is_touch_drawing = false;
|
||||||
is_touch_panning = false;
|
is_touch_panning = false;
|
||||||
|
fling.stop();
|
||||||
is_stylus_drawing = false;
|
is_stylus_drawing = false;
|
||||||
is_stylus_selecting = false;
|
is_stylus_selecting = false;
|
||||||
is_stylus_in_proximity = false;
|
is_stylus_in_proximity = false;
|
||||||
@@ -2536,6 +2546,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fling.stop();
|
||||||
// 1. Pan via mittlere Maustaste oder Leertaste + Linksklick
|
// 1. Pan via mittlere Maustaste oder Leertaste + Linksklick
|
||||||
if button == MouseButton::Middle || (button == MouseButton::Left && is_space_pressed) {
|
if button == MouseButton::Middle || (button == MouseButton::Left && is_space_pressed) {
|
||||||
is_panning = true;
|
is_panning = true;
|
||||||
@@ -2721,6 +2732,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
WindowEvent::MouseWheel { delta, .. } => {
|
WindowEvent::MouseWheel { delta, .. } => {
|
||||||
|
fling.stop();
|
||||||
if is_pointer_in_ui {
|
if is_pointer_in_ui {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2729,7 +2741,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
if (ui_state.continuous_scroll || ui_state.endless) && !modifiers.control_key() {
|
if (ui_state.continuous_scroll || ui_state.endless) && !modifiers.control_key() {
|
||||||
let (mut dx, mut dy) = match delta {
|
let (mut dx, mut dy) = match delta {
|
||||||
MouseScrollDelta::LineDelta(x, y) => {
|
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)
|
(x * line, y * line)
|
||||||
}
|
}
|
||||||
MouseScrollDelta::PixelDelta(pos) => (pos.x as f32, pos.y as f32),
|
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 {
|
match touch.phase {
|
||||||
TouchPhase::Started => {
|
TouchPhase::Started => {
|
||||||
|
// Jede neue Berührung hält ein gleitendes Blatt an
|
||||||
|
fling.stop();
|
||||||
// Event-Isolation für Touch-Eingaben
|
// Event-Isolation für Touch-Eingaben
|
||||||
let is_touch_in_ui = ui::is_screen_point_in_ui(
|
let is_touch_in_ui = ui::is_screen_point_in_ui(
|
||||||
&egui_ctx,
|
&egui_ctx,
|
||||||
@@ -2957,6 +2971,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let dy = ty - touch_pan_last_pos.1;
|
let dy = ty - touch_pan_last_pos.1;
|
||||||
touch_pan_last_pos = (tx, ty);
|
touch_pan_last_pos = (tx, ty);
|
||||||
canvas.pan(dx, dy);
|
canvas.pan(dx, dy);
|
||||||
|
fling.track(Vec2::new(dx, dy), Instant::now());
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
} else if is_touch_drawing {
|
} else if is_touch_drawing {
|
||||||
current_pressure = pressure;
|
current_pressure = pressure;
|
||||||
@@ -3003,6 +3018,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
is_pinch_gesturing = false;
|
is_pinch_gesturing = false;
|
||||||
pinch_prev_distance = None;
|
pinch_prev_distance = None;
|
||||||
pinch_prev_center = None;
|
pinch_prev_center = None;
|
||||||
|
if is_touch_panning && touch.phase == TouchPhase::Ended {
|
||||||
|
fling.release(Instant::now());
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
is_touch_panning = false;
|
is_touch_panning = false;
|
||||||
|
|
||||||
if is_erasing {
|
if is_erasing {
|
||||||
@@ -3067,7 +3086,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
canvas.set_fit_box(notebook.fit_box());
|
canvas.set_fit_box(notebook.fit_box());
|
||||||
if ui_state.continuous_scroll {
|
if ui_state.continuous_scroll {
|
||||||
let stack = notebook.page_stack();
|
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
|
// Unabhängig von der Bezugsseite: Lage bezogen auf die erste Seite
|
||||||
let scroll = [
|
let scroll = [
|
||||||
canvas.camera.zoom,
|
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);
|
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
|
UiAction::ScrollTo(y) => {
|
||||||
|
fling.stop();
|
||||||
|
canvas.scroll_center_to(y);
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
UiAction::SetNotebookLayout(layout) => {
|
UiAction::SetNotebookLayout(layout) => {
|
||||||
// Von und zur endlosen Fläche nur mit einer Seite, deren Inhalt passt
|
// Von und zur endlosen Fläche nur mit einer Seite, deren Inhalt passt
|
||||||
let was_endless = notebook.is_endless();
|
let was_endless = notebook.is_endless();
|
||||||
|
|||||||
+6
-6
@@ -330,7 +330,7 @@ impl NotebookManifest {
|
|||||||
page_ids: vec![1],
|
page_ids: vec![1],
|
||||||
next_stroke_id: 1,
|
next_stroke_id: 1,
|
||||||
next_page_id: 2,
|
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() {
|
fn test_notebook_layout_is_saved_and_old_manifests_fall_back() {
|
||||||
let temp_dir = tempfile::tempdir().unwrap();
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
let (_nb, mut manifest, dir) = create_new_notebook(temp_dir.path(), "Layout").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
|
// Neue Notizbücher: durchgehend, unabhängig von der früheren globalen Einstellung
|
||||||
assert_eq!(manifest.layout, Some(NotebookLayout::Paged));
|
assert_eq!(manifest.layout, Some(NotebookLayout::Continuous));
|
||||||
assert_eq!(manifest.layout_or(true), NotebookLayout::Paged);
|
assert_eq!(manifest.layout_or(false), NotebookLayout::Continuous);
|
||||||
|
|
||||||
manifest.layout = Some(NotebookLayout::Continuous);
|
manifest.layout = Some(NotebookLayout::Paged);
|
||||||
save_manifest(&dir, &manifest).unwrap();
|
save_manifest(&dir, &manifest).unwrap();
|
||||||
let (_nb, loaded) = load_notebook(&dir).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
|
// Manifest von vor 0.28 ohne `layout`: es gilt die globale Einstellung
|
||||||
let mut json: serde_json::Value =
|
let mut json: serde_json::Value =
|
||||||
|
|||||||
@@ -320,6 +320,9 @@ pub enum UiAction {
|
|||||||
SavePressureCurve(PressureCurve),
|
SavePressureCurve(PressureCurve),
|
||||||
/// Anordnung der Seiten des offenen Notizbuchs (wird im Manifest gespeichert)
|
/// Anordnung der Seiten des offenen Notizbuchs (wird im Manifest gespeichert)
|
||||||
SetNotebookLayout(NotebookLayout),
|
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)
|
/// Farbe von Karos, Linien und Punkten (wird gespeichert, gilt auch für den Export)
|
||||||
SetPatternColor(PatternColor),
|
SetPatternColor(PatternColor),
|
||||||
/// Bilddatei auf der aktuellen Seite einfügen
|
/// Bilddatei auf der aktuellen Seite einfügen
|
||||||
@@ -455,6 +458,8 @@ pub struct UiState {
|
|||||||
/// Eingeblendetes Lineal
|
/// Eingeblendetes Lineal
|
||||||
pub ruler: Option<Ruler>,
|
pub ruler: Option<Ruler>,
|
||||||
pub ruler_drag: Option<RulerDrag>,
|
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)
|
/// Aktuelle Abbildung Seite ↔ Bildschirm (für Lineal und Treffertests)
|
||||||
pub page_view: Option<PageView>,
|
pub page_view: Option<PageView>,
|
||||||
/// Fenstergröße in logischen Punkten (vom letzten Bild)
|
/// Fenstergröße in logischen Punkten (vom letzten Bild)
|
||||||
@@ -491,7 +496,7 @@ impl Default for UiState {
|
|||||||
document_title: t!("Standard-Notizbuch").to_string(),
|
document_title: t!("Standard-Notizbuch").to_string(),
|
||||||
available_notebooks: Vec::new(),
|
available_notebooks: Vec::new(),
|
||||||
new_notebook_title_input: String::new(),
|
new_notebook_title_input: String::new(),
|
||||||
new_notebook_layout: NotebookLayout::Paged,
|
new_notebook_layout: NotebookLayout::Continuous,
|
||||||
renaming_notebook_id: None,
|
renaming_notebook_id: None,
|
||||||
library: crate::library::Library::default(),
|
library: crate::library::Library::default(),
|
||||||
notebook_folder: String::new(),
|
notebook_folder: String::new(),
|
||||||
@@ -543,6 +548,7 @@ impl Default for UiState {
|
|||||||
queued_actions: Vec::new(),
|
queued_actions: Vec::new(),
|
||||||
ruler: None,
|
ruler: None,
|
||||||
ruler_drag: None,
|
ruler_drag: None,
|
||||||
|
scrollbar_grab: None,
|
||||||
page_view: None,
|
page_view: None,
|
||||||
screen_size: egui::Vec2::ZERO,
|
screen_size: egui::Vec2::ZERO,
|
||||||
page_title_input: String::new(),
|
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
|
/// 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.
|
/// Seiten und endloser Fläche wechseln; ohne `offer_paged` fehlen feste Seiten (beim Anlegen,
|
||||||
fn layout_picker(ui: &mut egui::Ui, layout: &mut NotebookLayout, can_cross: bool) -> bool {
|
/// 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 mut changed = false;
|
||||||
let endless_now = *layout == NotebookLayout::Endless;
|
let endless_now = *layout == NotebookLayout::Endless;
|
||||||
ui.horizontal_wrapped(|ui| {
|
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;
|
let enabled = can_cross || (option == NotebookLayout::Endless) == endless_now;
|
||||||
if ui
|
if ui
|
||||||
.add_enabled(
|
.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.
|
/// Zeichnet das Lineal über dem Blatt und bedient Verschieben und Drehen.
|
||||||
fn ruler_overlay(ctx: &egui::Context, state: &mut UiState) {
|
fn ruler_overlay(ctx: &egui::Context, state: &mut UiState) {
|
||||||
let (Some(mut ruler), Some(view)) = (state.ruler, state.page_view) else {
|
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);
|
let mut actions = std::mem::take(&mut state.queued_actions);
|
||||||
state.screen_size = ctx.screen_rect().size();
|
state.screen_size = ctx.screen_rect().size();
|
||||||
page_separators(ctx, state, notebook);
|
page_separators(ctx, state, notebook);
|
||||||
|
page_scrollbar(ctx, state, notebook, &mut actions);
|
||||||
space_preview(ctx, state, notebook);
|
space_preview(ctx, state, notebook);
|
||||||
ruler_overlay(ctx, state);
|
ruler_overlay(ctx, state);
|
||||||
text_edit_overlay(ctx, state);
|
text_edit_overlay(ctx, state);
|
||||||
@@ -3823,7 +3980,7 @@ pub fn render_ui(
|
|||||||
} else {
|
} else {
|
||||||
NotebookLayout::Paged
|
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));
|
actions.push(UiAction::SetNotebookLayout(layout));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4173,7 +4330,7 @@ pub fn render_ui(
|
|||||||
});
|
});
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label(t!("Seiten:"));
|
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);
|
ui.add_space(8.0);
|
||||||
@@ -5027,6 +5184,86 @@ mod tests {
|
|||||||
ctx
|
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<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, ¬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]
|
#[test]
|
||||||
fn test_text_edit_overlay_takes_typing_and_escape() {
|
fn test_text_edit_overlay_takes_typing_and_escape() {
|
||||||
let notebook = Notebook::new();
|
let notebook = Notebook::new();
|
||||||
|
|||||||
+118
@@ -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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -316,6 +394,46 @@ mod tests {
|
|||||||
PageStack::new(vec![A4; count], current)
|
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]
|
#[test]
|
||||||
fn test_continuous_layout() {
|
fn test_continuous_layout() {
|
||||||
let stack = a4_stack(5, 1);
|
let stack = a4_stack(5, 1);
|
||||||
|
|||||||
Reference in New Issue
Block a user