diff --git a/README.md b/README.md index 38c4785..676f660 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,8 @@ Eine latenzoptimierte, hardwarebeschleunigte Inking- und Notizbuch-Anwendung fü ### 🔍 Stufenloser Zoom & Pan (Kamera) - **Viewport-Transformation:** 2D-Projektionsmatrix im WGSL-Vertex-Shader. -- **Cursor-zentrierter Zoom:** Stufenloses Zoomen von **25 % bis 500 %** zentriert auf die Cursor-Position via Mausrad, Trackpad-Pinch oder Tastenkürzel (`Strg +` / `Strg -` / `Strg 0`). +- **Durchgehendes Scrollen:** Seiten hängen untereinander und werden mit Mausrad, Finger oder Touchpad fließend gescrollt; wer im unteren Fünftel der letzten Seite schreibt, bekommt automatisch eine neue Seite angehängt. Abschaltbar im ☰-Menü unter „Ansicht“ (dann Seite für Seite blättern). +- **Cursor-zentrierter Zoom:** Stufenloses Zoomen von **25 % bis 500 %** zentriert auf die Cursor-Position via `Strg + Mausrad` (ohne durchgehende Ansicht: Mausrad), Trackpad-Pinch oder Tastenkürzel (`Strg +` / `Strg -` / `Strg 0`). - **Pan:** Verschieben des Canvas per mittlerer Maustaste, `Leertaste + Drag` oder Trackpad-Pan. - **Inverse Koordinatentransformation:** Präzise Umrechnung von Bildschirm- in DIN-A4-Dokumentkoordinaten (`screen_to_page`). @@ -219,7 +220,8 @@ cargo run --release | :--- | :--- | | **Zeichnen** | Linke Maustaste / Stiftspitze | | **Canvas verschieben (Pan)** | Mittlere Maustaste **oder** `Leertaste + Drag` | -| **Zoom zentriert auf Cursor** | Mausrad / Touchpad-Pinch | +| **Scrollen (durchgehende Ansicht)** | Mausrad (`Umschalt` = waagerecht) / ein Finger | +| **Zoom zentriert auf Cursor** | `Strg + Mausrad` / Touchpad-Pinch (Einzelseiten-Ansicht: Mausrad) | | **Zoom zurücksetzen (100 %)** | `Strg + 0` | | **Zoom In / Out** | `Strg + +` / `Strg + -` | | **Auswahlwerkzeug (Lasso)** | `V` | diff --git a/src/canvas.rs b/src/canvas.rs index f0e065b..a2de37d 100644 --- a/src/canvas.rs +++ b/src/canvas.rs @@ -112,6 +112,44 @@ pub fn choose_present_mode(available: &[wgpu::PresentMode]) -> Option, +} + +impl NeighborFingerprint { + fn of(page: &Page, paper_theme: PaperTheme) -> Self { + Self { + page_id: page.id, + background: page.background, + paper_theme, + vertex_count: page.vertex_cache.len(), + stroke_count: page.strokes.len(), + last_stroke_id: page.strokes.last().map(|s| s.id), + } + } +} + +/// GPU-Daten einer in der durchgehenden Ansicht sichtbaren Nachbarseite. +struct NeighborPage { + fingerprint: NeighborFingerprint, + /// Lage relativ zur aktuellen Seite (in Seiten, negativ = darüber) + offset: i64, + camera_buffer: wgpu::Buffer, + camera_bind_group: wgpu::BindGroup, + background_buffer: Option, + background_vertex_count: u32, + vertex_buffer: Option, + stroke_ranges: Vec>, +} + pub struct CanvasState { pub surface: wgpu::Surface<'static>, /// Verwendeter Grafikadapter (für erneute Abfrage der Surface-Fähigkeiten bei Größenänderung) @@ -168,6 +206,10 @@ pub struct CanvasState { // Eingefügte Bilder der aktuellen Seite image_layer: crate::image_layer::ImageLayer, + /// Durchgehende Ansicht: sichtbare Nachbarseiten der aktuellen Seite + neighbors: Vec, + camera_layout: wgpu::BindGroupLayout, + /// Eingeblendetes Lineal (Striche nahe einer Kante rasten ein) pub ruler: Option, /// Kante, an der der aktuelle Live-Strich eingerastet ist @@ -466,6 +508,8 @@ impl CanvasState { committed_vertex_count: 0, committed_strokes: Vec::with_capacity(128), image_layer, + neighbors: Vec::new(), + camera_layout: bind_group_layout, ruler: None, ruler_lock: None, egui_renderer, @@ -475,8 +519,26 @@ impl CanvasState { /// Aktualisiert die 2D-Kamera Uniforms (Scale & Offset im NDC-Raum) basierend auf Viewport und Kamera. pub fn update_uniforms(&mut self) { - let (scale, offset) = self.camera.ndc_transform(&self.viewport); - let camera_uniforms = CameraUniforms { + let camera_uniforms = self.camera_uniforms(0); + self.queue.write_buffer( + &self.camera_buffer, + 0, + bytemuck::cast_slice(&[camera_uniforms]), + ); + for neighbor in &self.neighbors { + self.queue.write_buffer( + &neighbor.camera_buffer, + 0, + bytemuck::cast_slice(&[self.camera_uniforms(neighbor.offset)]), + ); + } + } + + /// Kamera für die Seite `page_offset` Seiten unter (negativ: über) der aktuellen. + fn camera_uniforms(&self, page_offset: i64) -> CameraUniforms { + let (scale, mut offset) = self.camera.ndc_transform(&self.viewport); + offset[1] += page_offset as f32 * crate::viewport::PAGE_PITCH * scale[1]; + CameraUniforms { transform_scale: scale, transform_offset: offset, ink_params: [ @@ -489,12 +551,7 @@ impl CanvasState { 0.0, 0.0, ], - }; - self.queue.write_buffer( - &self.camera_buffer, - 0, - bytemuck::cast_slice(&[camera_uniforms]), - ); + } } /// Setzt die von seitlicher UI belegten Streifen (physische Pixel), damit die Seite @@ -585,6 +642,63 @@ impl CanvasState { self.camera.page_to_screen_vec2(page_pt, &self.viewport) } + /// Durchgehende Ansicht: Versatz (in Seiten) der Seite unter dem Bildschirmpunkt, + /// `None` neben den Seiten und zwischen zwei Seiten. + pub fn page_offset_at_screen(&self, screen_x: f32, screen_y: f32) -> Option { + let p = self + .camera + .screen_to_page_unclamped(screen_x, screen_y, &self.viewport); + if !(0.0..=crate::document::PAGE_WIDTH).contains(&p.x) { + return None; + } + crate::viewport::page_offset_at(p.y) + } + + /// Durchgehende Ansicht: Versatz (in Seiten) der Seite in der Mitte des Fensters. + pub fn page_offset_at_view_center(&self) -> Option { + let p = self.camera.screen_to_page_unclamped( + 0.0, + self.size.height as f32 / 2.0, + &self.viewport, + ); + crate::viewport::page_offset_at(p.y) + } + + /// Durchgehende Ansicht: Versätze der sichtbaren Nachbarseiten der aktuellen Seite. + pub fn visible_neighbor_offsets(&self, current: usize, page_count: usize) -> Vec { + let top = self + .camera + .screen_to_page_unclamped(0.0, 0.0, &self.viewport); + let bottom = + self.camera + .screen_to_page_unclamped(0.0, self.size.height as f32, &self.viewport); + crate::viewport::visible_neighbor_offsets(top.y, bottom.y, current, page_count) + } + + /// Durchgehende Ansicht: Hält die Fenstermitte über dem Notizbuch (von der Oberkante der + /// ersten bis zur Unterkante der letzten Seite, waagerecht innerhalb der Seitenbreite), damit + /// man nicht ins Leere scrollt. `true`, wenn der Ausschnitt korrigiert wurde. + pub fn clamp_scroll(&mut self, current: usize, page_count: usize) -> bool { + let center = [ + self.viewport.page_x + self.viewport.page_width / 2.0, + self.size.height as f32 / 2.0, + ]; + let changed = + self.camera + .clamp_continuous_center(center, current, page_count, &self.viewport); + if changed { + self.update_uniforms(); + } + changed + } + + /// Durchgehende Ansicht: Die Seite `k` Seiten weiter wird zur Bezugsseite, ohne dass sich + /// der Bildausschnitt bewegt. Die Nachbarseiten sind danach neu zu setzen. + pub fn shift_reference_page(&mut self, k: i64) { + self.camera.shift_reference_page(k); + self.update_uniforms(); + } + /// Ersetzt die Live-Vertices des aktiven Strichs durch ein bereinigtes Form-Mesh (für Draw-and-Hold). pub fn set_live_shape_mesh(&mut self, samples: Vec, vertices: Vec) { self.live_samples = samples; @@ -672,19 +786,10 @@ impl CanvasState { /// Synchronisiert die GPU-Puffer mit den Daten der aktiven Seite. pub fn sync_page(&mut self, page: &mut Page) { page.rebuild_cache_if_dirty(); + // Die aktuelle Seite kann sich jetzt ändern: als Nachbarseite später neu hochladen + self.neighbors.retain(|n| n.fingerprint.page_id != page.id); self.set_background(page.background); - // PDF-Seite zuerst, darüber die eingefügten Bilder - self.image_layer.set_quads( - page.pdf_background - .iter() - .map(|pdf| (pdf.key(), pdf.corners())) - .chain( - page.strokes - .iter() - .filter_map(|s| Some((s.image.clone()?, s.image_corners()?))), - ) - .collect(), - ); + self.image_layer.set_quads(page_image_quads(page)); self.committed_vertex_count = page.vertex_cache.len() as u32; self.committed_strokes = page.stroke_ranges.clone(); @@ -711,6 +816,74 @@ impl CanvasState { ); } + /// Durchgehende Ansicht: übernimmt die sichtbaren Nachbarseiten mit ihrem Versatz zur + /// aktuellen Seite (in Seiten). Die Vertex-Caches der Seiten müssen aktuell sein + /// ([`Page::rebuild_cache_if_dirty`]); unveränderte Seiten werden nicht erneut hochgeladen. + /// Eine leere Liste schaltet die Nachbarseiten ab. + pub fn set_neighbor_pages(&mut self, pages: &[(i64, &Page)]) { + let mut old = std::mem::take(&mut self.neighbors); + let mut image_groups = Vec::with_capacity(pages.len()); + for &(offset, page) in pages { + let fingerprint = NeighborFingerprint::of(page, self.paper_theme); + let mut neighbor = match old.iter().position(|n| n.fingerprint == fingerprint) { + Some(i) => old.swap_remove(i), + None => self.upload_neighbor(page, fingerprint), + }; + neighbor.offset = offset; + self.queue.write_buffer( + &neighbor.camera_buffer, + 0, + bytemuck::cast_slice(&[self.camera_uniforms(offset)]), + ); + self.neighbors.push(neighbor); + image_groups.push(page_image_quads(page)); + } + self.image_layer.set_neighbor_quads(image_groups); + } + + /// Lädt Raster und Striche einer Nachbarseite auf die GPU. + fn upload_neighbor(&self, page: &Page, fingerprint: NeighborFingerprint) -> NeighborPage { + let camera_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Neighbor Camera Uniform Buffer"), + size: std::mem::size_of::() as wgpu::BufferAddress, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let camera_bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Neighbor Camera Bind Group"), + layout: &self.camera_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: camera_buffer.as_entire_binding(), + }], + }); + let vertex_buffer_with = |label: &str, vertices: &[InkVertex]| { + (!vertices.is_empty()).then(|| { + let buffer = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some(label), + size: std::mem::size_of_val(vertices) as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + self.queue + .write_buffer(&buffer, 0, bytemuck::cast_slice(vertices)); + buffer + }) + }; + let background = + generate_background_mesh(page.background, self.paper_theme.pattern_color()); + NeighborPage { + fingerprint, + offset: 0, + camera_buffer, + camera_bind_group, + background_buffer: vertex_buffer_with("Neighbor Background Buffer", &background), + background_vertex_count: background.len() as u32, + vertex_buffer: vertex_buffer_with("Neighbor Strokes Buffer", &page.vertex_cache), + stroke_ranges: page.stroke_ranges.clone(), + } + } + /// Lädt Bilder und PDF-Seiten der aktuellen Seite, die noch nicht auf der GPU liegen. /// Vor jedem Zeichnen aufrufen; ohne Änderungen kostet das fast nichts. pub fn prepare_images( @@ -931,6 +1104,28 @@ impl CanvasState { occlusion_query_set: None, }); + // 0. Nachbarseiten der durchgehenden Ansicht (je mit eigener Kamera) + for (i, neighbor) in self.neighbors.iter().enumerate() { + render_pass.set_bind_group(0, &neighbor.camera_bind_group, &[]); + render_pass.set_pipeline(&self.triangle_pipeline); + render_pass.set_vertex_buffer(0, self.paper_buffer.slice(..)); + render_pass.draw(0..6, 0..1); + if let Some(buffer) = &neighbor.background_buffer { + render_pass.set_vertex_buffer(0, buffer.slice(..)); + render_pass.draw(0..neighbor.background_vertex_count, 0..1); + } + self.image_layer.draw(&mut render_pass, i + 1); + if let Some(buffer) = &neighbor.vertex_buffer { + render_pass.set_pipeline(&self.strip_pipeline); + render_pass.set_vertex_buffer(0, buffer.slice(..)); + for range in &neighbor.stroke_ranges { + if range.end > range.start { + render_pass.draw(range.clone(), 0..1); + } + } + } + } + // 2D-Kamera Uniforms (Scale & Offset) binden render_pass.set_bind_group(0, &self.camera_bind_group, &[]); @@ -946,7 +1141,7 @@ impl CanvasState { } // 3. Eingefügte Bilder (unter der Tinte) - self.image_layer.draw(&mut render_pass); + self.image_layer.draw(&mut render_pass, 0); // 4. Abgeschlossene Striche der aktiven Seite rendern (TriangleStrip mit Vertex-Farben) render_pass.set_pipeline(&self.strip_pipeline); @@ -1010,6 +1205,19 @@ impl CanvasState { } } +/// Bilder einer Seite für die Bildebene: zuerst die PDF-Seite, darüber die eingefügten Bilder. +fn page_image_quads(page: &Page) -> Vec<(String, [glam::Vec2; 4])> { + page.pdf_background + .iter() + .map(|pdf| (pdf.key(), pdf.corners())) + .chain( + page.strokes + .iter() + .filter_map(|s| Some((s.image.clone()?, s.image_corners()?))), + ) + .collect() +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/document.rs b/src/document.rs index b6bbfc4..00d2daa 100644 --- a/src/document.rs +++ b/src/document.rs @@ -1012,6 +1012,10 @@ impl Page { } } +/// Höhe des Bereichs am unteren Rand der letzten Seite, in dem Schreiben in der +/// durchgehenden Ansicht automatisch eine neue Seite anhängt. +pub const AUTO_APPEND_ZONE: f32 = PAGE_HEIGHT * 0.2; + /// Das Notizbuch mit diskreter Seitenverwaltung und Navigation. #[derive(Debug, Clone)] pub struct Notebook { @@ -1103,6 +1107,20 @@ impl Notebook { self.pages.len() - 1 } + /// Durchgehende Ansicht: Reicht ein neuer Strich auf der letzten Seite bis in deren + /// unteres Fünftel ([`AUTO_APPEND_ZONE`]), wird darunter eine leere Seite mit gleichem + /// Hintergrund angehängt. `true`, wenn eine Seite angehängt wurde. + pub fn append_page_if_writing_at_end(&mut self, stroke_bottom: f32) -> bool { + if self.current_page_index + 1 != self.pages.len() + || stroke_bottom < PAGE_HEIGHT - AUTO_APPEND_ZONE + { + return false; + } + let background = self.current_page().background; + self.add_page(background); + true + } + /// Wechselt zur nächsten Seite (PageDown / N). Hängt automatisch eine neue Seite an, falls am Ende. pub fn next_page(&mut self) { if self.current_page_index + 1 < self.pages.len() { @@ -1749,6 +1767,23 @@ mod tests { )); } + #[test] + fn test_append_page_when_writing_at_bottom_of_last_page() { + let mut nb = Notebook::new(); + nb.current_page_mut().background = PageBackground::Lined; + // Oben auf der Seite: nichts + assert!(!nb.append_page_if_writing_at_end(PAGE_HEIGHT * 0.5)); + assert_eq!(nb.pages.len(), 1); + // Unten: neue Seite mit gleichem Hintergrund, die aktuelle Seite bleibt + assert!(nb.append_page_if_writing_at_end(PAGE_HEIGHT - 10.0)); + assert_eq!(nb.pages.len(), 2); + assert_eq!(nb.current_page_index, 0); + assert_eq!(nb.pages[1].background, PageBackground::Lined); + // Nicht auf der letzten Seite: nichts + assert!(!nb.append_page_if_writing_at_end(PAGE_HEIGHT - 10.0)); + assert_eq!(nb.pages.len(), 2); + } + #[test] fn test_page_isolation() { let mut nb = Notebook::new(); diff --git a/src/image_layer.rs b/src/image_layer.rs index 28c5882..6e17b9b 100644 --- a/src/image_layer.rs +++ b/src/image_layer.rs @@ -86,8 +86,9 @@ pub struct ImageLayer { /// Texturen nach Schlüssel (Asset-Name bzw. `#`) mit belegtem Speicher textures: HashMap, texture_bytes: u64, - /// Bilder der aktuellen Seite: Asset-Name und Ecken - quads: Vec<(String, [glam::Vec2; 4])>, + /// Bilder je Seite (Gruppe 0: aktuelle Seite, danach die sichtbaren Nachbarseiten): + /// Asset-Name und Ecken in Koordinaten der jeweiligen Seite + groups: Vec>, vertex_buffer: Option, vertices_dirty: bool, } @@ -166,7 +167,7 @@ impl ImageLayer { sampler, textures: HashMap::new(), texture_bytes: 0, - quads: Vec::new(), + groups: vec![Vec::new()], vertex_buffer: None, vertices_dirty: false, } @@ -174,12 +175,25 @@ impl ImageLayer { /// Übernimmt die Bilder der aktuellen Seite (Asset-Name und Ecken in Seitenkoordinaten). pub fn set_quads(&mut self, quads: Vec<(String, [glam::Vec2; 4])>) { - if quads != self.quads { - self.quads = quads; + if quads != self.groups[0] { + self.groups[0] = quads; self.vertices_dirty = true; } } + /// Übernimmt die Bilder der sichtbaren Nachbarseiten (Gruppen 1, 2, …). + pub fn set_neighbor_quads(&mut self, neighbors: Vec>) { + if neighbors[..] != self.groups[1..] { + self.groups.truncate(1); + self.groups.extend(neighbors); + self.vertices_dirty = true; + } + } + + fn all_quads(&self) -> impl Iterator { + self.groups.iter().flatten() + } + /// Lädt fehlende Texturen und den Vertex-Puffer hoch. Vor jedem Zeichnen aufrufen; /// ohne Änderungen kostet das nichts. pub fn prepare( @@ -189,8 +203,7 @@ impl ImageLayer { load: &mut dyn FnMut(&str) -> TextureSource, ) { let missing: Vec = self - .quads - .iter() + .all_quads() .map(|(key, _)| key) .filter(|key| !self.textures.contains_key(*key)) .cloned() @@ -217,7 +230,7 @@ impl ImageLayer { // Nicht mehr benötigte Texturen freigeben, wenn der Grafikspeicher knapp wird if self.texture_bytes > TEXTURE_CACHE_BYTES { - let used: HashSet<&String> = self.quads.iter().map(|(a, _)| a).collect(); + let used: HashSet<&String> = self.groups.iter().flatten().map(|(a, _)| a).collect(); self.textures.retain(|name, _| used.contains(name)); self.texture_bytes = self.textures.values().map(|(_, b)| b).sum(); } @@ -227,8 +240,7 @@ impl ImageLayer { } self.vertices_dirty = false; let vertices: Vec = self - .quads - .iter() + .all_quads() .flat_map(|(_, corners)| quad_vertices(corners)) .collect(); if vertices.is_empty() { @@ -252,29 +264,33 @@ impl ImageLayer { } } - /// Fehlen noch Texturen der aktuellen Seite (z. B. PDF-Seite im Hintergrund-Rendering)? + /// Fehlen noch Texturen sichtbarer Seiten (z. B. PDF-Seite im Hintergrund-Rendering)? pub fn is_waiting(&self) -> bool { - self.quads - .iter() + self.all_quads() .any(|(key, _)| !self.textures.contains_key(key)) } - /// Zeichnet die Bilder der aktuellen Seite. Bind Group 0 (Kamera) muss gesetzt sein. - pub fn draw<'a>(&'a self, render_pass: &mut wgpu::RenderPass<'a>) { + /// Zeichnet die Bilder einer Seite (Gruppe 0: aktuelle Seite). Bind Group 0 (Kamera der + /// Seite) muss gesetzt sein. + pub fn draw<'a>(&'a self, render_pass: &mut wgpu::RenderPass<'a>, group: usize) { let Some(buffer) = &self.vertex_buffer else { return; }; - if self.quads.is_empty() || self.vertices_dirty { + let Some(quads) = self.groups.get(group) else { + return; + }; + if quads.is_empty() || self.vertices_dirty { return; } + let first: usize = self.groups[..group].iter().map(Vec::len).sum(); render_pass.set_pipeline(&self.pipeline); render_pass.set_vertex_buffer(0, buffer.slice(..)); - for (i, (asset, _)) in self.quads.iter().enumerate() { + for (i, (asset, _)) in quads.iter().enumerate() { let Some((bind_group, _)) = self.textures.get(asset) else { continue; }; render_pass.set_bind_group(1, bind_group, &[]); - let start = (i * 6) as u32; + let start = ((first + i) * 6) as u32; render_pass.draw(start..start + 6, 0..1); } } diff --git a/src/main.rs b/src/main.rs index e714cde..fde72d7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,6 +32,7 @@ use ink_notes::settings::{SidebarSide, UiSettings}; use ink_notes::storage::{self, NotebookManifest}; use ink_notes::ui::{self, ActiveTool, UiAction, UiState}; use ink_notes::updater::UpdaterController; +use ink_notes::viewport::PAGE_PITCH; /// Aktualisiert den Fenstertitel als minimales On-Screen-Display (OSD) inklusive Autosave-Status. /// Speichert die UI-Einstellungen; ein Fehler wird gemeldet, bricht aber nichts ab. @@ -151,11 +152,19 @@ fn commit_stroke_to_notebook( if let Some(mut stroke) = stroke_opt { stroke.tool = ui_state.active_tool.stroke_tool(stroke.shape.is_some()); + let stroke_bottom = stroke.aabb.max.y; let page = notebook.current_page_mut(); page.add_stroke(stroke); canvas.sync_page(page); manifest.next_stroke_id = notebook.next_stroke_id; autosave.mark_dirty(notebook, manifest); + // Durchgehende Ansicht: unten auf der letzten Seite geht es auf einer neuen weiter + if ui_state.continuous_scroll && notebook.append_page_if_writing_at_end(stroke_bottom) { + manifest.sync_from_notebook(notebook); + if let Some(new_page) = notebook.pages.last() { + autosave.mark_page_dirty(new_page, manifest); + } + } update_window_title( window, notebook, @@ -167,6 +176,83 @@ fn commit_stroke_to_notebook( } } +/// Macht die Seite `index` zur aktuellen: lädt sie bei Bedarf und aktualisiert GPU-Puffer, +/// Manifest und Fenstertitel. Mit `keep_view` (durchgehende Ansicht) bleibt der Bildausschnitt +/// samt Lineal stehen, sonst erscheint die neue Seite an der Stelle der alten. +#[allow(clippy::too_many_arguments)] +fn switch_to_page( + index: usize, + keep_view: bool, + canvas: &mut CanvasState, + notebook: &mut Notebook, + manifest: &mut NotebookManifest, + autosave: &mut AutosaveController, + storage_dir: &Path, + selection_state: &mut SelectionState, + ui_state: &mut UiState, + window: &Window, +) { + let current = notebook.current_page_index; + if index >= notebook.pages.len() || index == current { + return; + } + if keep_view { + let k = index as i64 - current as i64; + canvas.shift_reference_page(k); + if let Some(ruler) = &mut ui_state.ruler { + ruler.center.y -= k as f32 * PAGE_PITCH; + } + canvas.ruler = ui_state.ruler; + } + selection_state.clear(); + notebook.current_page_index = index; + let _ = storage::ensure_page_loaded(storage_dir, notebook, index); + canvas.sync_page(notebook.current_page_mut()); + manifest.sync_from_notebook(notebook); + autosave.mark_manifest_dirty(manifest); + update_window_title( + window, + notebook, + manifest, + ui_state.active_tool, + autosave.status(), + ); + window.request_redraw(); +} + +/// Durchgehende Ansicht: Index der Seite unter dem Bildschirmpunkt, falls dort eine andere +/// als die aktuelle Seite liegt und kein Griff der Auswahl getroffen wird. Zeichnen, +/// Radieren und Auswählen wechseln dann zuerst auf diese Seite. +fn page_to_focus( + canvas: &CanvasState, + notebook: &Notebook, + ui_state: &UiState, + selection_state: &SelectionState, + pixels_per_point: f32, + (sx, sy): (f32, f32), +) -> Option { + if !ui_state.continuous_scroll { + return None; + } + let k = canvas.page_offset_at_screen(sx, sy).filter(|&k| k != 0)?; + let index = usize::try_from(notebook.current_page_index as i64 + k).ok()?; + if index >= notebook.pages.len() { + return None; + } + let grabs_selection = ui_state.active_tool == ActiveTool::Select + && !selection_state.is_empty() + && !matches!( + selection_state.hit_test_handles( + notebook.current_page(), + canvas, + Vec2::new(sx, sy), + pixels_per_point + ), + SelectionHandleHit::None + ); + (!grabs_selection).then_some(index) +} + /// Zwischenablage: interne Objekte (Strg+C in ink-notes) und Bilder aus der /// Systemzwischenablage (Screenshots, aus anderen Programmen kopierte Bilder). struct ClipboardBridge { @@ -618,6 +704,7 @@ fn main() -> Result<(), Box> { ui_state.paper_theme = ui_settings.paper_theme; ui_state.adapt_ink_colors = ui_settings.adapt_ink_colors; ui_state.pressure_curve = ui_settings.pressure_curve; + ui_state.continuous_scroll = ui_settings.continuous_scroll; ui_state.current_notebook_id = manifest.id.clone(); ui_state.document_title = manifest.title.clone(); ui_state.available_notebooks = available_notebooks; @@ -727,7 +814,8 @@ fn main() -> Result<(), Box> { println!(" Strg + C / V : Ausgewählte Objekte kopieren & einfügen"); println!(" Entf/Backspace : Ausgewählte Objekte löschen (Undo mit Strg+Z)"); println!("Tastatur- & Maus-Shortcuts:"); - println!(" Mausrad : Stufenloser Zoom zentriert auf Cursor"); + println!(" Mausrad : Scrollen (durchgehende Ansicht), sonst Zoom"); + println!(" Strg + Mausrad : Stufenloser Zoom zentriert auf Cursor"); println!(" Mittlere Taste : Canvas verschieben (Pan)"); println!(" Leertaste+Drag : Canvas verschieben (Pan)"); println!(" Strg + 0 : Zoom zurücksetzen (100% zentriert)"); @@ -738,6 +826,38 @@ fn main() -> Result<(), Box> { println!(" ESC : Dialoge / Menüs schließen oder Selektion aufheben"); println!(" Strg + Q : Beenden (mit sicherem Disk-Flush)"); + // Durchgehende Ansicht: Scrollposition beim letzten Abgleich der aktuellen Seite (Zoom, + // Pan unabhängig von der Bezugsseite) + let mut last_scroll: Option<[f32; 3]> = None; + + // Durchgehende Ansicht: vor Zeichnen, Radieren oder Auswählen auf die Seite unter dem + // Bildschirmpunkt wechseln + macro_rules! focus_page_at { + ($pos:expr) => { + if let Some(index) = page_to_focus( + &canvas, + ¬ebook, + &ui_state, + &selection_state, + egui_ctx.pixels_per_point(), + $pos, + ) { + switch_to_page( + index, + true, + &mut canvas, + &mut notebook, + &mut manifest, + &mut autosave, + &storage_dir, + &mut selection_state, + &mut ui_state, + &window, + ); + } + }; + } + event_loop.run(move |event, target| { match event { Event::AboutToWait => { @@ -860,6 +980,7 @@ fn main() -> Result<(), Box> { } if is_erasing { + focus_page_at!((sx, sy)); if let Some(page_pt) = canvas.screen_to_page(sx, sy) { if notebook.erase_at(page_pt, eraser_radius, @@ -918,6 +1039,7 @@ fn main() -> Result<(), Box> { continue; } + focus_page_at!((sx, sy)); let should_erase = tool_is_hardware_eraser || stylus_barrel_pressed || ui_state.active_tool == ActiveTool::Eraser; @@ -1563,6 +1685,7 @@ fn main() -> Result<(), Box> { return; } + focus_page_at!(cursor_screen_pos); if is_eraser_action { is_erasing = true; let (sx, sy) = cursor_screen_pos; @@ -1832,6 +1955,7 @@ fn main() -> Result<(), Box> { } } } else if is_erasing { + focus_page_at!((sx, sy)); if let Some(page_pt) = canvas.screen_to_page(sx, sy) { if notebook.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode) { canvas.sync_page(notebook.current_page_mut()); @@ -1863,6 +1987,23 @@ fn main() -> Result<(), Box> { if is_pointer_in_ui { return; } + // Durchgehende Ansicht: Mausrad scrollt (mit Umschalt waagerecht), + // Strg + Mausrad zoomt + if ui_state.continuous_scroll && !modifiers.control_key() { + let (mut dx, mut dy) = match delta { + MouseScrollDelta::LineDelta(x, y) => { + let line = 60.0 * window.scale_factor() as f32; + (x * line, y * line) + } + MouseScrollDelta::PixelDelta(pos) => (pos.x as f32, pos.y as f32), + }; + if modifiers.shift_key() && dx == 0.0 { + (dx, dy) = (dy, 0.0); + } + canvas.pan(dx, dy); + window.request_redraw(); + return; + } let zoom_factor = match delta { MouseScrollDelta::LineDelta(_x, y) => { if y > 0.0 { @@ -1967,6 +2108,7 @@ fn main() -> Result<(), Box> { touch_pan_last_pos = (tx, ty); } else { // Modus Finger-Zeichnen aktiv + focus_page_at!((tx, ty)); if is_eraser_action { is_erasing = true; if let Some(page_pt) = canvas.screen_to_page(tx, ty) { @@ -2108,6 +2250,7 @@ fn main() -> Result<(), Box> { window.request_redraw(); } } else if is_erasing { + focus_page_at!((tx, ty)); if let Some(page_pt) = canvas.screen_to_page(tx, ty) { if notebook.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode) { canvas.sync_page(notebook.current_page_mut()); @@ -2248,6 +2391,48 @@ fn main() -> Result<(), Box> { } WindowEvent::RedrawRequested => { + // 0. Durchgehende Ansicht: Nach dem Scrollen oder Zoomen wird die Seite in + // der Fenstermitte zur aktuellen (nicht während eines Strichs, einer + // Auswahl oder beim Ziehen des Lineals). Wer auf einer Nachbarseite + // schreibt, bleibt dort, bis wieder gescrollt wird (Undo betrifft sie). + if ui_state.continuous_scroll { + canvas.clamp_scroll(notebook.current_page_index, notebook.pages.len()); + let scroll = [ + canvas.camera.zoom, + canvas.camera.pan_offset[0], + canvas.camera.pan_offset[1] + - notebook.current_page_index as f32 * PAGE_PITCH, + ]; + let scrolled = last_scroll + .is_none_or(|last: [f32; 3]| last.iter().zip(scroll).any(|(a, b)| (a - b).abs() > 1e-3)); + let busy = is_drawing + || is_erasing + || !selection_state.is_empty() + || selection_state.drag_mode != DragMode::None + || ui_state.ruler_drag.is_some(); + if scrolled && !busy { + last_scroll = Some(scroll); + let target = canvas + .page_offset_at_view_center() + .filter(|&k| k != 0) + .and_then(|k| usize::try_from(notebook.current_page_index as i64 + k).ok()); + if let Some(index) = target { + switch_to_page( + index, + true, + &mut canvas, + &mut notebook, + &mut manifest, + &mut autosave, + &storage_dir, + &mut selection_state, + &mut ui_state, + &window, + ); + } + } + } + // 1. egui Eingabe erfassen und Frame starten let raw_input = egui_winit_state.take_egui_input(&window); egui_ctx.begin_frame(raw_input); @@ -2851,6 +3036,11 @@ fn main() -> Result<(), Box> { save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state); window.request_redraw(); } + UiAction::SetContinuousScroll(on) => { + ui_settings.continuous_scroll = on; + save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state); + window.request_redraw(); + } UiAction::SavePressureCurve(curve) => { ui_settings.pressure_curve = curve; save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state); @@ -2893,6 +3083,26 @@ fn main() -> Result<(), Box> { } // 5. Canvas (Pass 1) und egui UI (Pass 2) in Surface-Textur rendern + // Durchgehende Ansicht: sichtbare Nachbarseiten laden und mitzeichnen + if ui_state.continuous_scroll { + let current = notebook.current_page_index; + let visible: Vec<(i64, usize)> = canvas + .visible_neighbor_offsets(current, notebook.pages.len()) + .into_iter() + .map(|k| (k, (current as i64 + k) as usize)) + .collect(); + for &(_, index) in &visible { + let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, index); + notebook.pages[index].rebuild_cache_if_dirty(); + } + let neighbors: Vec<(i64, &ink_notes::document::Page)> = visible + .iter() + .map(|&(k, index)| (k, ¬ebook.pages[index])) + .collect(); + canvas.set_neighbor_pages(&neighbors); + } else { + canvas.set_neighbor_pages(&[]); + } let assets = AssetStore::new(&storage_dir); canvas.prepare_images(&mut |key| texture_for(key, &assets, &mut pdf_renderer)); // Nächste PDF-Seite vorab rendern, damit Blättern sofort klappt diff --git a/src/settings.rs b/src/settings.rs index 4ba5884..241c29c 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -84,6 +84,8 @@ pub struct UiSettings { pub adapt_ink_colors: bool, /// Druckkennlinie des Stifts pub pressure_curve: PressureCurve, + /// Seiten durchgehend untereinander anzeigen (sonst einzeln blättern) + pub continuous_scroll: bool, } impl Default for UiSettings { @@ -93,6 +95,7 @@ impl Default for UiSettings { paper_theme: PaperTheme::default(), adapt_ink_colors: true, pressure_curve: PressureCurve::default(), + continuous_scroll: true, } } } @@ -149,6 +152,7 @@ mod tests { exponent: 1.8, threshold: 0.1, }, + continuous_scroll: false, }; settings.save_to(&path).unwrap(); assert_eq!(UiSettings::load_from(&path), settings); @@ -173,6 +177,7 @@ mod tests { assert_eq!(settings.paper_theme, PaperTheme::White); assert!(settings.adapt_ink_colors); assert_eq!(settings.pressure_curve, PressureCurve::default()); + assert!(settings.continuous_scroll); // Unsinnige Werte aus einer von Hand bearbeiteten Datei werden begrenzt std::fs::write( diff --git a/src/storage.rs b/src/storage.rs index 0f761b7..9440104 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -792,6 +792,14 @@ pub fn ensure_page_loaded( notebook.pages[page_idx] = page; Ok(()) } + // Angehängt, aber nie gespeichert (noch leer): leere Seite statt Ladefehler + Err(StorageError::PageNotFound(_)) => { + let placeholder = &mut notebook.pages[page_idx]; + let mut page = Page::new(page_id, placeholder.background); + page.title = placeholder.title.take(); + *placeholder = page; + Ok(()) + } Err(StorageError::CorruptedPage(id, err_msg)) => { let q_res = quarantine_corrupted_page(notebook_dir, id); let q_path = q_res.ok(); @@ -2318,6 +2326,26 @@ mod tests { assert_eq!(reloaded.current_page().strokes.len(), 2); } + #[test] + fn test_never_saved_page_loads_as_empty_page() { + // Angehängte, nie beschriebene Seite: steht im Manifest, hat aber keine Datei + let temp_dir = tempfile::tempdir().unwrap(); + let (mut notebook, mut manifest, dir) = + create_new_notebook(temp_dir.path(), "Angehängt").unwrap(); + notebook.add_page(PageBackground::Lined); + save_notebook(&dir, ¬ebook, &mut manifest).unwrap(); + let _ = fs::remove_file(dir.join(format!("page_{}.json", notebook.pages[1].id))); + + let (mut loaded, _) = load_notebook(&dir).unwrap(); + assert!(!loaded.pages[1].is_loaded); + ensure_page_loaded(&dir, &mut loaded, 1).unwrap(); + let page = &loaded.pages[1]; + assert!(page.is_loaded && !page.is_readonly()); + assert_eq!(page.background, PageBackground::Lined); + assert!(page.strokes.is_empty()); + assert!(loaded.load_warnings.is_empty()); + } + #[test] fn test_autosave_never_writes_unloaded_placeholder() { let temp_dir = tempfile::tempdir().unwrap(); diff --git a/src/ui.rs b/src/ui.rs index 52106b8..9189435 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -192,6 +192,8 @@ pub enum UiAction { }, /// Druckkurve speichern (gilt während des Ziehens bereits über `UiState::pressure_curve`) SavePressureCurve(PressureCurve), + /// Seiten durchgehend untereinander statt einzeln anzeigen (wird gespeichert) + SetContinuousScroll(bool), /// Bilddatei auf der aktuellen Seite einfügen InsertImageFile(std::path::PathBuf), /// PDF als neues Notizbuch importieren @@ -260,6 +262,8 @@ pub struct UiState { pub paper_theme: PaperTheme, /// Dunkle Tinte auf dunklem Papier aufhellen pub adapt_ink_colors: bool, + /// Seiten hängen untereinander und werden durchgehend gescrollt + pub continuous_scroll: bool, /// Druckkurve des Stifts pub pressure_curve: PressureCurve, /// Zuletzt gemessener Rohdruck des Stifts (Anzeige in der Druckkurve) @@ -321,6 +325,7 @@ impl Default for UiState { eraser_mode: EraserMode::All, paper_theme: PaperTheme::White, adapt_ink_colors: true, + continuous_scroll: true, pressure_curve: PressureCurve::default(), last_raw_pressure: None, file_picker: None, @@ -2460,6 +2465,19 @@ pub fn render_ui( }); } + section(ui, "ANSICHT"); + if ui + .checkbox(&mut state.continuous_scroll, "Seiten durchgehend scrollen") + .on_hover_text( + "An: Seiten hängen untereinander, Mausrad und Finger scrollen \ + (Strg+Mausrad zoomt); wer unten auf der letzten Seite schreibt, \ + bekommt automatisch eine neue Seite.\nAus: Seite für Seite blättern.", + ) + .changed() + { + actions.push(UiAction::SetContinuousScroll(state.continuous_scroll)); + } + section(ui, "ZOOM"); ui.horizontal(|ui| { if ui diff --git a/src/viewport.rs b/src/viewport.rs index 33d748e..127e1f3 100644 --- a/src/viewport.rs +++ b/src/viewport.rs @@ -3,13 +3,54 @@ //! Berechnet die affine Transformation zwischen Bildschirmkoordinaten (Window-Pixel), //! virtuellen DIN-A4-Seitenkoordinaten ([0, 2100] x [0, 2970]) und dem GPU-NDC-Raum ([-1, 1]). -use crate::document::{PageViewport, PAGE_HEIGHT, PAGE_WIDTH}; +use crate::document::{PageViewport, PAGE_HEIGHT, PAGE_WIDTH, UNITS_PER_MM}; use glam::{Mat4, Vec2, Vec4}; /// Minimaler und maximaler Zoomfaktor. pub const MIN_ZOOM: f32 = 0.25; pub const MAX_ZOOM: f32 = 5.0; +/// Abstand zwischen zwei Seiten in der durchgehenden Ansicht (8 mm). +pub const PAGE_GAP: f32 = 8.0 * UNITS_PER_MM; +/// Vertikaler Abstand gleicher Punkte zweier aufeinanderfolgender Seiten. +pub const PAGE_PITCH: f32 = PAGE_HEIGHT + PAGE_GAP; +/// Höchstens so viele Nachbarseiten werden gleichzeitig dargestellt. +pub const MAX_NEIGHBOR_PAGES: usize = 12; + +/// Durchgehende Ansicht: Die Seiten hängen untereinander, Koordinaten beziehen sich auf die +/// aktuelle Seite. Liefert den Versatz (in Seiten) der Seite, auf der die Höhe `y` liegt, +/// oder `None` im Zwischenraum zweier Seiten. +pub fn page_offset_at(y: f32) -> Option { + let k = (y / PAGE_PITCH).floor(); + (y - k * PAGE_PITCH <= PAGE_HEIGHT).then_some(k as i64) +} + +/// Versätze der Seiten, die zwischen den Höhen `top` und `bottom` (Koordinaten der aktuellen +/// Seite) sichtbar sind, begrenzt auf die Seitenindizes `0..page_count` und ohne die aktuelle +/// Seite selbst. +pub fn visible_neighbor_offsets( + top: f32, + bottom: f32, + current: usize, + page_count: usize, +) -> Vec { + if !(top.is_finite() && bottom.is_finite()) || bottom < top { + return Vec::new(); + } + let first = (top / PAGE_PITCH).floor().max(-(current as f32)) as i64; + let last = (bottom / PAGE_PITCH) + .floor() + .min((page_count as f32) - 1.0 - current as f32) as i64; + let mut offsets: Vec = (first..=last).filter(|&k| k != 0).collect(); + // Sehr weit herausgezoomt: nur die nächstgelegenen Seiten + if offsets.len() > MAX_NEIGHBOR_PAGES { + offsets.sort_by_key(|k| k.unsigned_abs()); + offsets.truncate(MAX_NEIGHBOR_PAGES); + offsets.sort_unstable(); + } + offsets +} + /// Verwaltet den Viewport-Kamerazustand für stufenlosen Zoom und Pan. #[derive(Debug, Clone, Copy, PartialEq)] pub struct ViewportState { @@ -39,6 +80,35 @@ impl ViewportState { self.pan_offset = [0.0, 0.0]; } + /// Durchgehende Ansicht: Die Seite `k` Seiten weiter wird zur Bezugsseite, ohne dass sich + /// der Bildausschnitt bewegt. + pub fn shift_reference_page(&mut self, k: i64) { + self.pan_offset[1] += k as f32 * PAGE_PITCH; + } + + /// Durchgehende Ansicht: Verschiebt den Ausschnitt so, dass der Bildschirmpunkt `center` + /// über dem Notizbuch liegt (zwischen Oberkante der ersten und Unterkante der letzten + /// Seite, waagerecht innerhalb der Seitenbreite). `true`, wenn korrigiert wurde. + pub fn clamp_continuous_center( + &mut self, + center: [f32; 2], + current: usize, + page_count: usize, + page_viewport: &PageViewport, + ) -> bool { + let c = self.screen_to_page_unclamped(center[0], center[1], page_viewport); + let top = -(current as f32) * PAGE_PITCH; + let bottom = page_count.saturating_sub(1 + current) as f32 * PAGE_PITCH + PAGE_HEIGHT; + let clamped = Vec2::new(c.x.clamp(0.0, PAGE_WIDTH), c.y.clamp(top, bottom.max(top))); + if (clamped - c).length() < 1e-3 { + return false; + } + // Bildschirm = Viewport + (Seite + Pan) · Maßstab: der Pan gleicht die Differenz aus + self.pan_offset[0] += c.x - clamped.x; + self.pan_offset[1] += c.y - clamped.y; + true + } + /// Passt den Pan-Offset um eine Bildschirmverschiebung (in Fensterpixeln) an. pub fn pan_by_screen_delta( &mut self, @@ -190,6 +260,67 @@ impl ViewportState { mod tests { use super::*; + #[test] + fn test_continuous_layout() { + assert_eq!(page_offset_at(100.0), Some(0)); + assert_eq!(page_offset_at(PAGE_HEIGHT + PAGE_GAP / 2.0), None); + assert_eq!(page_offset_at(PAGE_PITCH + 5.0), Some(1)); + assert_eq!(page_offset_at(-5.0), None); + assert_eq!(page_offset_at(-PAGE_GAP - 5.0), Some(-1)); + + // Ende von Seite 2 und Anfang von Seite 3 sichtbar (aktuell: Seite 2 von 5) + assert_eq!( + visible_neighbor_offsets(PAGE_HEIGHT - 500.0, PAGE_PITCH + 500.0, 1, 5), + vec![1] + ); + // Ganz oben im Notizbuch: keine Seite vor der ersten + assert!(visible_neighbor_offsets(-PAGE_PITCH * 3.0, 500.0, 0, 5).is_empty()); + // Hinter der letzten Seite: keine weiteren + assert_eq!( + visible_neighbor_offsets(-PAGE_PITCH, PAGE_PITCH * 4.0, 3, 5), + vec![-1, 1] + ); + // Weit herausgezoomt: auf die nächstgelegenen Seiten begrenzt + let many = visible_neighbor_offsets(-PAGE_PITCH * 50.0, PAGE_PITCH * 50.0, 50, 200); + assert_eq!(many.len(), MAX_NEIGHBOR_PAGES); + assert!(many.iter().all(|k| k.unsigned_abs() <= 6)); + } + + #[test] + fn test_shift_reference_page_keeps_view() { + let mut vp = ViewportState::new(); + let page_vp = PageViewport::calculate(1920.0, 1080.0, 24.0); + vp.pan_by_screen_delta(0.0, -900.0, &page_vp); + // Punkt oben auf der nächsten Seite, in Koordinaten der aktuellen Seite + let before = vp.page_to_screen(300.0, PAGE_PITCH + 200.0, &page_vp); + vp.shift_reference_page(1); + let after = vp.page_to_screen(300.0, 200.0, &page_vp); + assert!((before[0] - after[0]).abs() < 0.01 && (before[1] - after[1]).abs() < 0.01); + } + + #[test] + fn test_continuous_scroll_stays_on_the_notebook() { + let page_vp = PageViewport::calculate(1280.0, 850.0, 24.0); + let center = [640.0, 425.0]; + let mut vp = ViewportState::new(); + // Mitte der Seite: nichts zu korrigieren + assert!(!vp.clamp_continuous_center(center, 1, 3, &page_vp)); + // Weit über die erste Seite hinaus nach oben gescrollt (aktuell: Seite 2 von 3) + vp.pan_by_screen_delta(0.0, 5000.0, &page_vp); + assert!(vp.clamp_continuous_center(center, 1, 3, &page_vp)); + let c = vp.screen_to_page_unclamped(center[0], center[1], &page_vp); + assert!( + (c.y + PAGE_PITCH).abs() < 0.1, + "Oberkante der ersten Seite: {c:?}" + ); + // Weit nach unten: Unterkante der letzten Seite + vp.pan_by_screen_delta(-3000.0, -20000.0, &page_vp); + assert!(vp.clamp_continuous_center(center, 1, 3, &page_vp)); + let c = vp.screen_to_page_unclamped(center[0], center[1], &page_vp); + assert!((c.y - (PAGE_PITCH + PAGE_HEIGHT)).abs() < 0.1, "{c:?}"); + assert!((c.x - PAGE_WIDTH).abs() < 0.1, "{c:?}"); + } + #[test] fn test_viewport_zoom_limits() { let mut vp = ViewportState::new();