Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a51ca3aa4 | ||
|
|
6f2b414bb8 | ||
|
|
9dec688b84 | ||
|
|
7e05b33ee3 |
Generated
+1
-1
@@ -1393,7 +1393,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ink-notes"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"bytemuck",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ink-notes"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
edition = "2021"
|
||||
description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux"
|
||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04).
|
||||
|
||||
### Highlights:
|
||||
- **Durchgehendes Scrollen**: Seiten untereinander mit Mausrad, Finger oder Touchpad scrollen (Strg+Mausrad zoomt); Schreiben im unteren Fünftel der letzten Seite hängt automatisch eine neue an; abschaltbar unter ☰ → Ansicht
|
||||
- **Virtuelles Lineal**: Ein- und ausblenden mit L oder über die Werkzeugleiste; verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden exakt gerade – mit Stift, Textmarker und Formwerkzeug
|
||||
- **Teilstrich-Radierer**: Nur den überstrichenen Teil eines Strichs entfernen – ein Radierzug ist ein Undo-Schritt
|
||||
- **Export & Zwischenablage**: Aktuelle Seite als PNG (auch transparent) oder SVG; Strg+C legt die Auswahl zusätzlich als Bild in die Zwischenablage für Chat, Mail oder Office
|
||||
- **Seitentitel & Inhaltsverzeichnis**: Seiten benennen, Inhaltsansicht in der Seitenübersicht, Titel werden zu PDF-Lesezeichen (Dateiformat-Version 6)
|
||||
|
||||
+257
-21
@@ -112,6 +112,44 @@ pub fn choose_present_mode(available: &[wgpu::PresentMode]) -> Option<wgpu::Pres
|
||||
.or_else(|| available.first().copied())
|
||||
}
|
||||
|
||||
/// Erkennt, ob die GPU-Daten einer Nachbarseite noch zur Seite passen. Änderungen an der
|
||||
/// aktuellen Seite verwerfen deren Eintrag ohnehin (siehe [`CanvasState::sync_page`]).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct NeighborFingerprint {
|
||||
page_id: u64,
|
||||
background: PageBackground,
|
||||
paper_theme: PaperTheme,
|
||||
vertex_count: usize,
|
||||
stroke_count: usize,
|
||||
last_stroke_id: Option<u64>,
|
||||
}
|
||||
|
||||
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<wgpu::Buffer>,
|
||||
background_vertex_count: u32,
|
||||
vertex_buffer: Option<wgpu::Buffer>,
|
||||
stroke_ranges: Vec<Range<u32>>,
|
||||
}
|
||||
|
||||
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,15 @@ pub struct CanvasState {
|
||||
// Eingefügte Bilder der aktuellen Seite
|
||||
image_layer: crate::image_layer::ImageLayer,
|
||||
|
||||
/// Durchgehende Ansicht: sichtbare Nachbarseiten der aktuellen Seite
|
||||
neighbors: Vec<NeighborPage>,
|
||||
camera_layout: wgpu::BindGroupLayout,
|
||||
|
||||
/// Eingeblendetes Lineal (Striche nahe einer Kante rasten ein)
|
||||
pub ruler: Option<crate::ruler::Ruler>,
|
||||
/// Kante, an der der aktuelle Live-Strich eingerastet ist
|
||||
ruler_lock: Option<crate::ruler::EdgeLock>,
|
||||
|
||||
// egui UI-Renderer
|
||||
pub egui_renderer: egui_wgpu::Renderer,
|
||||
}
|
||||
@@ -461,6 +508,10 @@ 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,
|
||||
camera: crate::viewport::ViewportState::new(),
|
||||
})
|
||||
@@ -468,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: [
|
||||
@@ -482,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
|
||||
@@ -578,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<i64> {
|
||||
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<i64> {
|
||||
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<i64> {
|
||||
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<StylusSample>, vertices: Vec<InkVertex>) {
|
||||
self.live_samples = samples;
|
||||
@@ -665,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();
|
||||
@@ -704,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::<CameraUniforms>() 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(
|
||||
@@ -726,6 +906,13 @@ impl CanvasState {
|
||||
self.live_vertices.clear();
|
||||
self.live_vertex_count = 0;
|
||||
|
||||
// Beginnt der Strich an einer Linealkante, bleibt er bis zum Abheben auf ihr
|
||||
let half_width = self.current_stroke_width_mm * crate::document::UNITS_PER_MM / 2.0;
|
||||
self.ruler_lock = self
|
||||
.ruler
|
||||
.and_then(|r| r.edge_lock(glam::Vec2::new(page_x, page_y), half_width));
|
||||
let (page_x, page_y) = self.constrain_to_ruler(page_x, page_y);
|
||||
|
||||
// 1. Zwingend durch den 1€-Filter leiten
|
||||
let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp);
|
||||
let pt = ControlPoint::new(fx, fy, fp);
|
||||
@@ -749,6 +936,7 @@ impl CanvasState {
|
||||
}
|
||||
|
||||
// 1. Zwingend durch den 1€-Filter leiten
|
||||
let (page_x, page_y) = self.constrain_to_ruler(page_x, page_y);
|
||||
let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp);
|
||||
let new_pt = ControlPoint::new(fx, fy, fp);
|
||||
|
||||
@@ -771,6 +959,19 @@ impl CanvasState {
|
||||
self.update_live_mesh();
|
||||
}
|
||||
|
||||
/// Projiziert einen Punkt auf die Linealkante, falls der Live-Strich dort eingerastet ist.
|
||||
fn constrain_to_ruler(&self, x: f32, y: f32) -> (f32, f32) {
|
||||
match self.ruler_lock {
|
||||
Some(lock) => lock.project(glam::Vec2::new(x, y)).into(),
|
||||
None => (x, y),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ist der aktuelle Live-Strich an einer Linealkante eingerastet?
|
||||
pub fn is_ruler_locked(&self) -> bool {
|
||||
self.ruler_lock.is_some()
|
||||
}
|
||||
|
||||
/// Bricht den aktuellen Live-Strich ab und setzt Puffer sowie 1€-Filter sauber zurück.
|
||||
pub fn cancel_stroke(&mut self) {
|
||||
self.live_samples.clear();
|
||||
@@ -903,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, &[]);
|
||||
|
||||
@@ -918,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);
|
||||
@@ -982,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::*;
|
||||
|
||||
@@ -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();
|
||||
|
||||
+34
-18
@@ -86,8 +86,9 @@ pub struct ImageLayer {
|
||||
/// Texturen nach Schlüssel (Asset-Name bzw. `<pdf>#<seite>`) mit belegtem Speicher
|
||||
textures: HashMap<String, (wgpu::BindGroup, u64)>,
|
||||
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<Vec<(String, [glam::Vec2; 4])>>,
|
||||
vertex_buffer: Option<wgpu::Buffer>,
|
||||
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<Vec<(String, [glam::Vec2; 4])>>) {
|
||||
if neighbors[..] != self.groups[1..] {
|
||||
self.groups.truncate(1);
|
||||
self.groups.extend(neighbors);
|
||||
self.vertices_dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn all_quads(&self) -> impl Iterator<Item = &(String, [glam::Vec2; 4])> {
|
||||
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<String> = 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<ImageVertex> = 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ pub mod page_export;
|
||||
pub mod pdf_doc;
|
||||
pub mod pdf_merge;
|
||||
pub mod platform;
|
||||
pub mod ruler;
|
||||
pub mod selection;
|
||||
pub mod settings;
|
||||
pub mod spline;
|
||||
|
||||
+230
-1
@@ -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<usize> {
|
||||
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 {
|
||||
@@ -309,6 +395,16 @@ fn texture_for(
|
||||
}
|
||||
}
|
||||
|
||||
/// Abbildung Seite ↔ logische Bildschirmpunkte für die Oberfläche (Lineal).
|
||||
fn page_view(canvas: &CanvasState, scale_factor: f32) -> ui::PageView {
|
||||
let origin = canvas.page_to_screen(Vec2::ZERO);
|
||||
let unit = canvas.page_to_screen(Vec2::new(1000.0, 0.0));
|
||||
ui::PageView {
|
||||
origin: egui::pos2(origin[0] / scale_factor, origin[1] / scale_factor),
|
||||
scale: (unit[0] - origin[0]) / 1000.0 / scale_factor,
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_message(count: usize, as_image: bool) -> String {
|
||||
if as_image {
|
||||
format!("{count} Objekt(e) kopiert – auch als Bild für andere Programme")
|
||||
@@ -608,6 +704,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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;
|
||||
@@ -717,7 +814,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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)");
|
||||
@@ -728,6 +826,38 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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 => {
|
||||
@@ -850,6 +980,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -908,6 +1039,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
continue;
|
||||
}
|
||||
|
||||
focus_page_at!((sx, sy));
|
||||
let should_erase = tool_is_hardware_eraser
|
||||
|| stylus_barrel_pressed
|
||||
|| ui_state.active_tool == ActiveTool::Eraser;
|
||||
@@ -1246,6 +1378,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
window.request_redraw();
|
||||
}
|
||||
|
||||
// Lineal ein-/ausblenden: L
|
||||
Key::Character(c) if c.eq_ignore_ascii_case("l") && !ctrl => {
|
||||
ui_state.toggle_ruler();
|
||||
canvas.ruler = ui_state.ruler;
|
||||
window.request_redraw();
|
||||
}
|
||||
|
||||
// Bild einfügen: Strg + I
|
||||
Key::Character(c) if c.eq_ignore_ascii_case("i") && ctrl => {
|
||||
ui_state.open_image_picker();
|
||||
@@ -1546,6 +1685,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
return;
|
||||
}
|
||||
|
||||
focus_page_at!(cursor_screen_pos);
|
||||
if is_eraser_action {
|
||||
is_erasing = true;
|
||||
let (sx, sy) = cursor_screen_pos;
|
||||
@@ -1815,6 +1955,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
} 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());
|
||||
@@ -1846,6 +1987,23 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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 {
|
||||
@@ -1950,6 +2108,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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) {
|
||||
@@ -2091,6 +2250,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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());
|
||||
@@ -2231,6 +2391,48 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -2240,7 +2442,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// 3. Toolbar & Widgets rendern
|
||||
updater.update();
|
||||
ui_state.page_view = Some(page_view(&canvas, window.scale_factor() as f32));
|
||||
let actions = ui::render_ui(&egui_ctx, &mut ui_state, ¬ebook, autosave.status(), &updater);
|
||||
canvas.ruler = ui_state.ruler;
|
||||
|
||||
// Seite neben der Werkzeugleiste zentrieren (Leistenbreite in physischen Pixeln)
|
||||
let reserved = ui_state.sidebar_reserved_width() * window.scale_factor() as f32;
|
||||
@@ -2832,6 +3036,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::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);
|
||||
@@ -2874,6 +3083,26 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
//! Virtuelles Lineal: ein verschieb- und drehbares Lineal über dem Blatt. Striche, die nahe
|
||||
//! einer Längskante beginnen, rasten auf dieser Kante ein und werden zu geraden Linien.
|
||||
//!
|
||||
//! Alle Maße in Seiteneinheiten (10 Einheiten = 1 mm).
|
||||
|
||||
use glam::Vec2;
|
||||
|
||||
use crate::document::UNITS_PER_MM;
|
||||
|
||||
/// Länge des Lineals (26 cm).
|
||||
pub const RULER_LENGTH: f32 = 260.0 * UNITS_PER_MM;
|
||||
/// Breite des Lineals (3,2 cm).
|
||||
pub const RULER_WIDTH: f32 = 32.0 * UNITS_PER_MM;
|
||||
/// Abstand zu einer Längskante, innerhalb dessen ein Strich einrastet (innen wie außen).
|
||||
pub const SNAP_DISTANCE: f32 = 6.0 * UNITS_PER_MM;
|
||||
/// Radius des Dreh-Griffs.
|
||||
pub const HANDLE_RADIUS: f32 = 8.0 * UNITS_PER_MM;
|
||||
/// Winkel rasten in diesen Schritten ein …
|
||||
pub const ANGLE_SNAP_STEP_DEG: f32 = 15.0;
|
||||
/// … wenn sie höchstens so weit davon entfernt sind.
|
||||
pub const ANGLE_SNAP_TOLERANCE_DEG: f32 = 2.0;
|
||||
|
||||
/// Lage des Lineals auf dem Blatt.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Ruler {
|
||||
/// Mittelpunkt
|
||||
pub center: Vec2,
|
||||
/// Drehwinkel der Längsachse in Bogenmaß (0 = waagerecht)
|
||||
pub angle: f32,
|
||||
}
|
||||
|
||||
/// Was an einer Stelle des Lineals liegt.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RulerHit {
|
||||
/// Fläche zum Verschieben
|
||||
Body,
|
||||
/// Griff zum Drehen
|
||||
RotateHandle,
|
||||
}
|
||||
|
||||
/// Eine Kante, an der ein Strich eingerastet ist: Gerade durch `origin` in Richtung `dir`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct EdgeLock {
|
||||
pub origin: Vec2,
|
||||
pub dir: Vec2,
|
||||
}
|
||||
|
||||
impl EdgeLock {
|
||||
/// Fußpunkt von `p` auf der Geraden.
|
||||
pub fn project(&self, p: Vec2) -> Vec2 {
|
||||
self.origin + self.dir * (p - self.origin).dot(self.dir)
|
||||
}
|
||||
}
|
||||
|
||||
impl Ruler {
|
||||
pub fn new(center: Vec2) -> Self {
|
||||
Self { center, angle: 0.0 }
|
||||
}
|
||||
|
||||
/// Einheitsvektor entlang der Längsachse.
|
||||
pub fn axis(&self) -> Vec2 {
|
||||
Vec2::from_angle(self.angle)
|
||||
}
|
||||
|
||||
/// Einheitsvektor quer zur Längsachse (zur „unteren“ Kante, bei 0° nach unten).
|
||||
pub fn normal(&self) -> Vec2 {
|
||||
self.axis().perp()
|
||||
}
|
||||
|
||||
/// Lage von `p` im Lineal-Koordinatensystem: (entlang der Achse, quer dazu).
|
||||
pub fn local(&self, p: Vec2) -> Vec2 {
|
||||
let d = p - self.center;
|
||||
Vec2::new(d.dot(self.axis()), d.dot(self.normal()))
|
||||
}
|
||||
|
||||
/// Ecken: oben links, oben rechts, unten rechts, unten links (bei 0°).
|
||||
pub fn corners(&self) -> [Vec2; 4] {
|
||||
let a = self.axis() * (RULER_LENGTH / 2.0);
|
||||
let n = self.normal() * (RULER_WIDTH / 2.0);
|
||||
[
|
||||
self.center - a - n,
|
||||
self.center + a - n,
|
||||
self.center + a + n,
|
||||
self.center - a + n,
|
||||
]
|
||||
}
|
||||
|
||||
/// Mittelpunkt des Dreh-Griffs (nahe dem rechten Ende).
|
||||
pub fn handle_center(&self) -> Vec2 {
|
||||
self.center + self.axis() * (RULER_LENGTH / 2.0 - HANDLE_RADIUS * 1.6)
|
||||
}
|
||||
|
||||
/// Was liegt an `p`? Die Streifen entlang der Kanten gehören nicht dazu: dort beginnen
|
||||
/// eingerastete Striche.
|
||||
pub fn hit(&self, p: Vec2) -> Option<RulerHit> {
|
||||
if (p - self.handle_center()).length() <= HANDLE_RADIUS {
|
||||
return Some(RulerHit::RotateHandle);
|
||||
}
|
||||
let l = self.local(p);
|
||||
let inside_band = l.y.abs() <= RULER_WIDTH / 2.0 - SNAP_DISTANCE;
|
||||
(l.x.abs() <= RULER_LENGTH / 2.0 && inside_band).then_some(RulerHit::Body)
|
||||
}
|
||||
|
||||
/// Kante, an der ein bei `p` beginnender Strich einrastet, verschoben um `offset` nach
|
||||
/// außen (halbe Strichbreite: die Tinte liegt dann neben dem Lineal, nicht darunter).
|
||||
pub fn edge_lock(&self, p: Vec2, offset: f32) -> Option<EdgeLock> {
|
||||
let l = self.local(p);
|
||||
if l.x.abs() > RULER_LENGTH / 2.0 + SNAP_DISTANCE {
|
||||
return None;
|
||||
}
|
||||
let half = RULER_WIDTH / 2.0;
|
||||
let side = if (l.y - half).abs() <= SNAP_DISTANCE {
|
||||
1.0
|
||||
} else if (l.y + half).abs() <= SNAP_DISTANCE {
|
||||
-1.0
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some(EdgeLock {
|
||||
origin: self.center + self.normal() * side * (half + offset),
|
||||
dir: self.axis(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Dreht das Lineal so, dass die Achse vom Mittelpunkt auf `toward` zeigt; Vielfache von
|
||||
/// 15° rasten ein.
|
||||
pub fn rotate_toward(&mut self, toward: Vec2) {
|
||||
let d = toward - self.center;
|
||||
if d.length_squared() < 1e-6 {
|
||||
return;
|
||||
}
|
||||
self.angle = snap_angle(d.y.atan2(d.x));
|
||||
}
|
||||
|
||||
/// Winkel für die Anzeige in Grad, 0–179 (ein Lineal hat keine Richtung).
|
||||
pub fn display_degrees(&self) -> u32 {
|
||||
(self.angle.to_degrees().round() as i32).rem_euclid(180) as u32
|
||||
}
|
||||
}
|
||||
|
||||
/// Rastet einen Winkel (Bogenmaß) auf das nächste Vielfache von 15° ein, wenn er nahe genug ist.
|
||||
pub fn snap_angle(angle: f32) -> f32 {
|
||||
let deg = angle.to_degrees();
|
||||
let nearest = (deg / ANGLE_SNAP_STEP_DEG).round() * ANGLE_SNAP_STEP_DEG;
|
||||
if (deg - nearest).abs() <= ANGLE_SNAP_TOLERANCE_DEG {
|
||||
nearest.to_radians()
|
||||
} else {
|
||||
angle
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn close(a: Vec2, b: Vec2) -> bool {
|
||||
(a - b).length() < 1e-2
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_geometry_and_hits() {
|
||||
let ruler = Ruler::new(Vec2::new(1000.0, 1000.0));
|
||||
let [tl, tr, br, bl] = ruler.corners();
|
||||
assert!(close(
|
||||
tl,
|
||||
Vec2::new(1000.0 - RULER_LENGTH / 2.0, 1000.0 - RULER_WIDTH / 2.0)
|
||||
));
|
||||
assert!(close(
|
||||
br,
|
||||
Vec2::new(1000.0 + RULER_LENGTH / 2.0, 1000.0 + RULER_WIDTH / 2.0)
|
||||
));
|
||||
assert!(close(
|
||||
tr - tl,
|
||||
bl - tl + Vec2::new(RULER_LENGTH, -RULER_WIDTH)
|
||||
));
|
||||
|
||||
assert_eq!(ruler.hit(ruler.center), Some(RulerHit::Body));
|
||||
assert_eq!(
|
||||
ruler.hit(ruler.handle_center()),
|
||||
Some(RulerHit::RotateHandle)
|
||||
);
|
||||
// Kantenstreifen und außerhalb: kein Treffer (dort wird gezeichnet)
|
||||
assert_eq!(
|
||||
ruler.hit(Vec2::new(1000.0, 1000.0 - RULER_WIDTH / 2.0 + 10.0)),
|
||||
None
|
||||
);
|
||||
assert_eq!(ruler.hit(Vec2::new(1000.0, 1300.0)), None);
|
||||
assert_eq!(ruler.hit(Vec2::new(1000.0 + RULER_LENGTH, 1000.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edge_lock_projects_onto_offset_edge() {
|
||||
let ruler = Ruler::new(Vec2::new(1000.0, 1000.0));
|
||||
let edge_y = 1000.0 - RULER_WIDTH / 2.0;
|
||||
// Knapp oberhalb der oberen Kante: rastet ein, Linie liegt um den Versatz außerhalb
|
||||
let lock = ruler
|
||||
.edge_lock(Vec2::new(900.0, edge_y - 20.0), 3.0)
|
||||
.unwrap();
|
||||
let p = lock.project(Vec2::new(1300.0, edge_y - 55.0));
|
||||
assert!(close(p, Vec2::new(1300.0, edge_y - 3.0)));
|
||||
// Knapp innerhalb der unteren Kante
|
||||
let lock = ruler
|
||||
.edge_lock(Vec2::new(900.0, 1000.0 + RULER_WIDTH / 2.0 - 20.0), 0.0)
|
||||
.unwrap();
|
||||
assert!(close(
|
||||
lock.project(Vec2::new(0.0, 0.0)),
|
||||
Vec2::new(0.0, 1000.0 + RULER_WIDTH / 2.0)
|
||||
));
|
||||
// Mitte oder weit weg: kein Einrasten
|
||||
assert!(ruler.edge_lock(ruler.center, 0.0).is_none());
|
||||
assert!(ruler.edge_lock(Vec2::new(1000.0, 500.0), 0.0).is_none());
|
||||
assert!(ruler
|
||||
.edge_lock(Vec2::new(1000.0 + RULER_LENGTH, edge_y), 0.0)
|
||||
.is_none());
|
||||
|
||||
// Gedrehtes Lineal: die Linie folgt der Drehung
|
||||
let mut tilted = ruler;
|
||||
tilted.angle = 30f32.to_radians();
|
||||
let near_edge = tilted.center - tilted.normal() * (RULER_WIDTH / 2.0 + 10.0);
|
||||
let lock = tilted.edge_lock(near_edge, 0.0).unwrap();
|
||||
let a = lock.project(near_edge);
|
||||
let b = lock.project(near_edge + Vec2::new(300.0, 0.0));
|
||||
let dir = (b - a).normalize();
|
||||
assert!((dir.y.atan2(dir.x).to_degrees() - 30.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rotation_snaps_to_15_degrees() {
|
||||
let mut ruler = Ruler::new(Vec2::ZERO);
|
||||
ruler.rotate_toward(Vec2::from_angle(44.0f32.to_radians()) * 100.0);
|
||||
assert_eq!(ruler.display_degrees(), 45);
|
||||
ruler.rotate_toward(Vec2::from_angle(37.0f32.to_radians()) * 100.0);
|
||||
assert_eq!(ruler.display_degrees(), 37);
|
||||
// Gegenrichtung zeigt denselben Winkel
|
||||
ruler.rotate_toward(Vec2::from_angle(-150.0f32.to_radians()) * 100.0);
|
||||
assert_eq!(ruler.display_degrees(), 30);
|
||||
assert_eq!(snap_angle(1.0f32.to_radians()), 0.0);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::document::{
|
||||
EraserMode, Notebook, Page, PageBackground, StrokeTool, PAGE_HEIGHT, PAGE_WIDTH,
|
||||
};
|
||||
use crate::file_picker::{FilePicker, PickerKind};
|
||||
use crate::ruler::{Ruler, RulerHit};
|
||||
use crate::settings::{PressureCurve, SidebarSide};
|
||||
use crate::storage::NotebookSummary;
|
||||
|
||||
@@ -97,6 +98,37 @@ pub const PEN_WIDTH_PRESETS: [(f32, &str); 4] = [
|
||||
pub const HIGHLIGHTER_WIDTH_PRESETS: [(f32, &str); 3] =
|
||||
[(4.0, "4.0 mm"), (6.0, "6.0 mm"), (8.0, "8.0 mm")];
|
||||
|
||||
/// Abbildung zwischen Seitenkoordinaten und logischen Bildschirmpunkten (egui), vom
|
||||
/// Hauptprogramm je Bild aus Ansicht und Zoom gesetzt.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct PageView {
|
||||
/// Bildschirmposition des Seitenursprungs (oben links)
|
||||
pub origin: egui::Pos2,
|
||||
/// Bildschirmpunkte je Seiteneinheit
|
||||
pub scale: f32,
|
||||
}
|
||||
|
||||
impl PageView {
|
||||
pub fn to_screen(&self, p: glam::Vec2) -> egui::Pos2 {
|
||||
self.origin + egui::vec2(p.x, p.y) * self.scale
|
||||
}
|
||||
|
||||
pub fn to_page(&self, p: egui::Pos2) -> glam::Vec2 {
|
||||
let d = (p - self.origin) / self.scale.max(1e-6);
|
||||
glam::Vec2::new(d.x, d.y)
|
||||
}
|
||||
}
|
||||
|
||||
/// Laufende Bedienung des Lineals.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum RulerDrag {
|
||||
/// Verschieben; Abstand vom Griffpunkt zum Mittelpunkt
|
||||
Move {
|
||||
grab_offset: glam::Vec2,
|
||||
},
|
||||
Rotate,
|
||||
}
|
||||
|
||||
/// Format für den Export der aktuellen Seite.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PageExportKind {
|
||||
@@ -160,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
|
||||
@@ -228,12 +262,21 @@ 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)
|
||||
pub last_raw_pressure: Option<f32>,
|
||||
/// Geöffnete Dateiauswahl („Bild einfügen“ oder „PDF importieren“)
|
||||
pub file_picker: Option<FilePicker>,
|
||||
/// Eingeblendetes Lineal
|
||||
pub ruler: Option<Ruler>,
|
||||
pub ruler_drag: Option<RulerDrag>,
|
||||
/// Aktuelle Abbildung Seite ↔ Bildschirm (für Lineal und Treffertests)
|
||||
pub page_view: Option<PageView>,
|
||||
/// Fenstergröße in logischen Punkten (vom letzten Bild)
|
||||
pub screen_size: egui::Vec2,
|
||||
/// Eingabefeld „Titel dieser Seite“ in der Seitenübersicht und die Seite, zu der es gehört
|
||||
pub page_title_input: String,
|
||||
pub page_title_for: Option<u64>,
|
||||
@@ -282,10 +325,15 @@ 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,
|
||||
queued_actions: Vec::new(),
|
||||
ruler: None,
|
||||
ruler_drag: None,
|
||||
page_view: None,
|
||||
screen_size: egui::Vec2::ZERO,
|
||||
page_title_input: String::new(),
|
||||
page_title_for: None,
|
||||
page_picker_list: false,
|
||||
@@ -314,6 +362,39 @@ impl UiState {
|
||||
self.file_picker = None;
|
||||
}
|
||||
|
||||
/// Blendet das Lineal ein (waagerecht in der Mitte des sichtbaren Blattbereichs) oder aus.
|
||||
pub fn toggle_ruler(&mut self) {
|
||||
self.ruler_drag = None;
|
||||
self.ruler = match self.ruler {
|
||||
Some(_) => None,
|
||||
None => {
|
||||
let center = self
|
||||
.page_view
|
||||
.map(|v| {
|
||||
let screen = self.visible_canvas_center();
|
||||
v.to_page(screen)
|
||||
})
|
||||
.unwrap_or(glam::Vec2::new(
|
||||
crate::document::PAGE_WIDTH / 2.0,
|
||||
crate::document::PAGE_HEIGHT / 2.0,
|
||||
));
|
||||
Some(Ruler::new(center))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Mitte des Bereichs neben der Werkzeugleiste (logische Punkte).
|
||||
fn visible_canvas_center(&self) -> egui::Pos2 {
|
||||
(self.screen_size / 2.0).to_pos2()
|
||||
+ egui::vec2(
|
||||
match self.sidebar_side {
|
||||
SidebarSide::Left => self.sidebar_reserved_width() / 2.0,
|
||||
SidebarSide::Right => -self.sidebar_reserved_width() / 2.0,
|
||||
},
|
||||
0.0,
|
||||
)
|
||||
}
|
||||
|
||||
/// Öffnet die Dateiauswahl „Bild einfügen“ im zuletzt benutzten Ordner.
|
||||
pub fn open_image_picker(&mut self) {
|
||||
self.open_file_picker(PickerKind::Image);
|
||||
@@ -442,6 +523,15 @@ impl UiState {
|
||||
if self.is_point_inside_toolbar(px, py) {
|
||||
return true;
|
||||
}
|
||||
// Lineal: Fläche und Dreh-Griff bedient die Oberfläche, die Kanten bleiben zum Zeichnen
|
||||
if self.ruler_drag.is_some() {
|
||||
return true;
|
||||
}
|
||||
if let (Some(ruler), Some(view)) = (self.ruler, self.page_view) {
|
||||
if ruler.hit(view.to_page(egui::pos2(px, py))).is_some() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if let Some(pop_rect) = self.popover_rect {
|
||||
if pop_rect.contains(egui::pos2(px, py)) {
|
||||
return true;
|
||||
@@ -554,6 +644,8 @@ enum SidebarIcon<'a> {
|
||||
Eraser,
|
||||
/// Selbst gezeichnetes gestricheltes Auswahlrechteck
|
||||
Lasso,
|
||||
/// Selbst gezeichnetes Lineal mit Skala
|
||||
Ruler,
|
||||
}
|
||||
|
||||
/// Quadratischer Symbol-Knopf der Werkzeugleiste mit optionalem Farbbalken an der Unterkante.
|
||||
@@ -656,6 +748,30 @@ fn sidebar_button(
|
||||
stroke,
|
||||
));
|
||||
}
|
||||
SidebarIcon::Ruler => {
|
||||
let dir = egui::vec2(1.0, -1.0).normalized();
|
||||
let perp = egui::vec2(dir.y, -dir.x);
|
||||
let (half_len, half_w) = (12.0, 4.5);
|
||||
let p = |along: f32, across: f32| c + dir * along + perp * across;
|
||||
painter.add(egui::Shape::convex_polygon(
|
||||
vec![
|
||||
p(-half_len, -half_w),
|
||||
p(half_len, -half_w),
|
||||
p(half_len, half_w),
|
||||
p(-half_len, half_w),
|
||||
],
|
||||
egui::Color32::TRANSPARENT,
|
||||
egui::Stroke::new(1.5_f32, color),
|
||||
));
|
||||
for i in 1..6 {
|
||||
let along = -half_len + i as f32 * 4.0;
|
||||
let tick = if i % 2 == 0 { 4.0 } else { 2.5 };
|
||||
painter.line_segment(
|
||||
[p(along, -half_w), p(along, -half_w + tick)],
|
||||
egui::Stroke::new(1.2_f32, color),
|
||||
);
|
||||
}
|
||||
}
|
||||
SidebarIcon::Lasso => {
|
||||
let r = egui::Rect::from_center_size(c, egui::vec2(20.0, 16.0));
|
||||
let pts = [
|
||||
@@ -1076,6 +1192,149 @@ fn pressure_curve_editor(ui: &mut egui::Ui, state: &mut UiState) -> Option<Press
|
||||
save.then_some(state.pressure_curve)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
state.ruler_drag = None;
|
||||
return;
|
||||
};
|
||||
|
||||
// Bedienung: Druck auf Fläche oder Griff beginnt das Ziehen (nicht über Leiste/Popovern)
|
||||
let (pressed, down, pos) = ctx.input(|i| {
|
||||
(
|
||||
i.pointer.primary_pressed(),
|
||||
i.pointer.primary_down(),
|
||||
i.pointer.interact_pos(),
|
||||
)
|
||||
});
|
||||
if pressed && !ctx.is_pointer_over_area() {
|
||||
if let Some(pos) = pos {
|
||||
let page = view.to_page(pos);
|
||||
state.ruler_drag = match ruler.hit(page) {
|
||||
Some(RulerHit::Body) => Some(RulerDrag::Move {
|
||||
grab_offset: ruler.center - page,
|
||||
}),
|
||||
Some(RulerHit::RotateHandle) => Some(RulerDrag::Rotate),
|
||||
None => None,
|
||||
};
|
||||
}
|
||||
}
|
||||
match (state.ruler_drag, down, pos) {
|
||||
(Some(RulerDrag::Move { grab_offset }), true, Some(pos)) => {
|
||||
ruler.center = view.to_page(pos) + grab_offset;
|
||||
}
|
||||
(Some(RulerDrag::Rotate), true, Some(pos)) => ruler.rotate_toward(view.to_page(pos)),
|
||||
(_, false, _) => state.ruler_drag = None,
|
||||
_ => {}
|
||||
}
|
||||
state.ruler = Some(ruler);
|
||||
|
||||
// Zeichnen (unter Leiste und Dialogen)
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(
|
||||
egui::Order::Background,
|
||||
egui::Id::new("ruler_overlay"),
|
||||
));
|
||||
let corners: Vec<egui::Pos2> = ruler.corners().iter().map(|&c| view.to_screen(c)).collect();
|
||||
painter.add(egui::Shape::convex_polygon(
|
||||
corners.clone(),
|
||||
egui::Color32::from_rgba_unmultiplied(214, 222, 235, 165),
|
||||
egui::Stroke::new(
|
||||
1.0_f32,
|
||||
egui::Color32::from_rgba_unmultiplied(60, 70, 90, 200),
|
||||
),
|
||||
));
|
||||
|
||||
let ink = egui::Color32::from_rgb(45, 52, 66);
|
||||
let axis = ruler.axis();
|
||||
let normal = ruler.normal();
|
||||
let mm = crate::document::UNITS_PER_MM;
|
||||
let px_per_mm = view.scale * mm;
|
||||
let half_len_mm = (crate::ruler::RULER_LENGTH / mm / 2.0) as i32;
|
||||
let half_w = crate::ruler::RULER_WIDTH / 2.0;
|
||||
// Skala an beiden Längskanten, von der Mitte aus: Millimeter nur, wenn sie nicht verschwimmen
|
||||
for side in [-1.0_f32, 1.0] {
|
||||
let edge = ruler.center + normal * side * half_w;
|
||||
for i in -half_len_mm..=half_len_mm {
|
||||
let tick_mm = if i % 10 == 0 {
|
||||
4.0
|
||||
} else if i % 5 == 0 {
|
||||
2.5
|
||||
} else if px_per_mm >= 3.0 {
|
||||
1.5
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let base = edge + axis * (i as f32 * mm);
|
||||
let tip = base - normal * side * tick_mm * mm;
|
||||
painter.line_segment(
|
||||
[view.to_screen(base), view.to_screen(tip)],
|
||||
egui::Stroke::new(1.0_f32, ink),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Zentimeter-Zahlen an der oberen Kante, von der linken Ecke an gezählt
|
||||
if px_per_mm >= 1.2 {
|
||||
let font = egui::FontId::proportional((px_per_mm * 2.6).clamp(8.0, 13.0));
|
||||
let edge = ruler.center - normal * half_w;
|
||||
for cm in 0..=(half_len_mm * 2 / 10) {
|
||||
let along = -crate::ruler::RULER_LENGTH / 2.0 + cm as f32 * 10.0 * mm;
|
||||
let anchor = view.to_screen(edge + axis * along + normal * 5.0 * mm);
|
||||
let galley = ctx.fonts(|f| f.layout_no_wrap(cm.to_string(), font.clone(), ink));
|
||||
let offset = egui::vec2(galley.size().x / 2.0, 0.0);
|
||||
let offset = egui::vec2(
|
||||
offset.x * axis.x - offset.y * axis.y,
|
||||
offset.x * axis.y + offset.y * axis.x,
|
||||
);
|
||||
let mut text = egui::epaint::TextShape::new(anchor - offset, galley, ink);
|
||||
text.angle = ruler.angle;
|
||||
painter.add(text);
|
||||
}
|
||||
}
|
||||
|
||||
// Winkelanzeige und Dreh-Griff
|
||||
let center = view.to_screen(ruler.center);
|
||||
let label = format!("{}°", ruler.display_degrees());
|
||||
let galley = ctx.fonts(|f| {
|
||||
f.layout_no_wrap(
|
||||
label,
|
||||
egui::FontId::proportional(13.0),
|
||||
egui::Color32::WHITE,
|
||||
)
|
||||
});
|
||||
let pill = egui::Rect::from_center_size(center, galley.size() + egui::vec2(14.0, 6.0));
|
||||
painter.rect_filled(
|
||||
pill,
|
||||
egui::Rounding::same(9.0),
|
||||
egui::Color32::from_rgba_unmultiplied(40, 70, 130, 220),
|
||||
);
|
||||
painter.galley(
|
||||
pill.center() - galley.size() / 2.0,
|
||||
galley,
|
||||
egui::Color32::WHITE,
|
||||
);
|
||||
|
||||
let handle = view.to_screen(ruler.handle_center());
|
||||
let radius = (crate::ruler::HANDLE_RADIUS * view.scale).max(10.0);
|
||||
let active = state.ruler_drag == Some(RulerDrag::Rotate);
|
||||
painter.circle(
|
||||
handle,
|
||||
radius,
|
||||
if active {
|
||||
egui::Color32::from_rgb(70, 120, 210)
|
||||
} else {
|
||||
egui::Color32::from_rgba_unmultiplied(40, 70, 130, 200)
|
||||
},
|
||||
egui::Stroke::new(1.0_f32, egui::Color32::WHITE),
|
||||
);
|
||||
painter.text(
|
||||
handle,
|
||||
egui::Align2::CENTER_CENTER,
|
||||
"⟳",
|
||||
egui::FontId::proportional((radius * 1.1).clamp(10.0, 20.0)),
|
||||
egui::Color32::WHITE,
|
||||
);
|
||||
}
|
||||
|
||||
/// Dialog „Bild einfügen“ bzw. „PDF importieren“: Schnellzugriffe, Ordnerpfad und Liste aus
|
||||
/// Ordnern und passenden Dateien.
|
||||
fn file_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Vec<UiAction>) {
|
||||
@@ -1333,6 +1592,8 @@ pub fn render_ui(
|
||||
updater: &crate::updater::UpdaterController,
|
||||
) -> Vec<UiAction> {
|
||||
let mut actions = std::mem::take(&mut state.queued_actions);
|
||||
state.screen_size = ctx.screen_rect().size();
|
||||
ruler_overlay(ctx, state);
|
||||
|
||||
let mut pen_btn_rect = None;
|
||||
let mut high_btn_rect = None;
|
||||
@@ -1519,6 +1780,22 @@ pub fn render_ui(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Lineal (ein-/ausblenden) ---
|
||||
if sidebar_button(
|
||||
ui,
|
||||
btn,
|
||||
SidebarIcon::Ruler,
|
||||
state.ruler.is_some(),
|
||||
true,
|
||||
None,
|
||||
"Lineal (L): Fläche ziehen = verschieben, Griff ⟳ = drehen; \
|
||||
Striche an einer Kante werden gerade",
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
state.toggle_ruler();
|
||||
}
|
||||
|
||||
// --- Farben des aktiven Werkzeugs ---
|
||||
let palette: Option<(&[ColorPreset], [f32; 4])> =
|
||||
match state.active_tool {
|
||||
@@ -2188,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
|
||||
@@ -3516,6 +3806,74 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ruler_drag_moves_and_rotates() {
|
||||
let ctx = egui::Context::default();
|
||||
let notebook = Notebook::new();
|
||||
let updater = crate::updater::UpdaterController::new();
|
||||
let mut state = UiState::new();
|
||||
// 1 Seiteneinheit = 0,5 Punkte, Seite beginnt bei (100, 0)
|
||||
let view = PageView {
|
||||
origin: egui::pos2(100.0, 0.0),
|
||||
scale: 0.5,
|
||||
};
|
||||
state.page_view = Some(view);
|
||||
state.ruler = Some(Ruler::new(glam::Vec2::new(1000.0, 1000.0)));
|
||||
|
||||
let frame = |state: &mut UiState, events: Vec<egui::Event>| {
|
||||
let input = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(
|
||||
egui::Pos2::ZERO,
|
||||
egui::vec2(1600.0, 1000.0),
|
||||
)),
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
ctx.begin_frame(input);
|
||||
let _ = render_ui(&ctx, state, ¬ebook, AutosaveStatus::Saved, &updater);
|
||||
let _ = ctx.end_frame();
|
||||
};
|
||||
let press = |pos: egui::Pos2, pressed: bool| egui::Event::PointerButton {
|
||||
pos,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed,
|
||||
modifiers: egui::Modifiers::default(),
|
||||
};
|
||||
|
||||
// Fläche und Griff gehören zur Oberfläche, die Kanten zum Zeichnen
|
||||
let center = view.to_screen(glam::Vec2::new(1000.0, 1000.0));
|
||||
assert!(state.is_point_in_ui(center.x, center.y));
|
||||
let edge = view.to_screen(glam::Vec2::new(
|
||||
1000.0,
|
||||
1000.0 - crate::ruler::RULER_WIDTH / 2.0,
|
||||
));
|
||||
assert!(!state.is_point_in_ui(edge.x, edge.y));
|
||||
|
||||
// Verschieben: Fläche greifen und um 50 Punkte (= 100 Einheiten) nach unten ziehen
|
||||
frame(&mut state, vec![egui::Event::PointerMoved(center)]);
|
||||
frame(&mut state, vec![press(center, true)]);
|
||||
let target = center + egui::vec2(0.0, 50.0);
|
||||
frame(&mut state, vec![egui::Event::PointerMoved(target)]);
|
||||
assert!(state.ruler_drag.is_some());
|
||||
frame(&mut state, vec![press(target, false)]);
|
||||
let moved = state.ruler.unwrap();
|
||||
assert!((moved.center - glam::Vec2::new(1000.0, 1100.0)).length() < 0.5);
|
||||
assert!(state.ruler_drag.is_none());
|
||||
|
||||
// Drehen: Griff greifen und nach unten rechts ziehen → 45° (eingerastet)
|
||||
let handle = view.to_screen(moved.handle_center());
|
||||
frame(&mut state, vec![egui::Event::PointerMoved(handle)]);
|
||||
frame(&mut state, vec![press(handle, true)]);
|
||||
let diag = view.to_screen(moved.center + glam::Vec2::new(700.0, 690.0));
|
||||
frame(&mut state, vec![egui::Event::PointerMoved(diag)]);
|
||||
frame(&mut state, vec![press(diag, false)]);
|
||||
assert_eq!(state.ruler.unwrap().display_degrees(), 45);
|
||||
|
||||
// Ausblenden
|
||||
state.toggle_ruler();
|
||||
assert!(state.ruler.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stroke_tool_follows_active_tool() {
|
||||
assert_eq!(ActiveTool::Pen.stroke_tool(false), StrokeTool::Pen);
|
||||
|
||||
+132
-1
@@ -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<i64> {
|
||||
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<i64> {
|
||||
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<i64> = (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();
|
||||
|
||||
Reference in New Issue
Block a user