Compare commits

...
6 Commits
Author SHA1 Message Date
haraldandClaude Opus 5.5 6a51ca3aa4 chore(release): bump version to 0.9.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 5m52s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:52:01 +02:00
haraldandClaude Opus 5.5 6f2b414bb8 feat: durchgehendes Scrollen
Seiten hängen untereinander (8 mm Abstand) und werden mit Mausrad, Finger
oder Touchpad fließend gescrollt; Strg+Mausrad zoomt. Sichtbare Nachbarseiten
werden bei Bedarf geladen und mit eigener Kamera gezeichnet. Die Seite in der
Fenstermitte wird nach dem Scrollen zur aktuellen; Zeichnen, Radieren und
Auswählen auf einer Nachbarseite wechseln zuerst dorthin. Wer im unteren
Fünftel der letzten Seite schreibt, bekommt automatisch eine neue Seite.
Scrollen bleibt auf das Notizbuch begrenzt. Abschaltbar im ☰-Menü (Ansicht).

Nie gespeicherte, angehängte Seiten laden jetzt als leere Seite statt als
schreibgeschützte Fehlerseite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:36:19 +02:00
haraldandClaude Opus 5.5 9dec688b84 chore(release): bump version to 0.8.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 8m53s
Virtuelles Lineal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:52:19 +02:00
haraldandClaude Opus 5.5 7e05b33ee3 feat: virtuelles Lineal
- Lineal ein-/ausblenden über die Werkzeugleiste oder Taste L
  (26 cm, mm-/cm-Skala an beiden Kanten, Winkelanzeige)
- Fläche ziehen verschiebt, Griff ⟳ dreht; 15°-Schritte rasten ein
- Striche, die bis 6 mm neben einer Längskante beginnen, folgen bis zum
  Abheben exakt der Kante (Stift, Textmarker, Formen); die Tinte liegt
  um die halbe Strichbreite neben dem Lineal
- Lineal bleibt beim Blättern an seiner Stelle; nur seine Fläche und
  der Griff nehmen Eingaben an, an den Kanten wird gezeichnet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:46:08 +02:00
haraldandClaude Opus 5.5 587632b173 chore(release): bump version to 0.7.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 12m16s
Teilstrich-Radierer, Seiten-Export als PNG/SVG, Auswahl als Bild, Seitentitel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:32:56 +02:00
haraldandClaude Opus 5.5 701fa03f33 feat: Teilstrich-Radierer, Seiten-Export (PNG/SVG), Auswahl als Bild, Seitentitel
- Radierer-Modus „✂ Teilstrich“: entfernt nur den überstrichenen Teil, der Rest
  bleibt in Stücken; ein Radierzug ist ein Undo-Schritt, Formen werden ganz radiert
- ☰ → „Seite als PNG / PNG transparent / SVG“ nach ~/Documents (über den
  PDF-Export gerendert: Bilder und importierte PDF-Seiten inklusive)
- Strg+C legt die Auswahl zusätzlich als Bild in die Systemzwischenablage
  (für Chat, Mail, Office); in ink-notes fügt Strg+V weiter die Objekte ein
- Seitentitel in der Seitenübersicht, Ansicht „Inhalt“ als Inhaltsverzeichnis,
  Lesezeichen-Ecke an benannten Seiten, Titel im Fenstertitel und als
  PDF-Lesezeichen (Umlaute als UTF-16)
- Dateiformat-Version 6 (Seitentitel)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:19:13 +02:00
17 changed files with 2490 additions and 111 deletions
Generated
+23 -1
View File
@@ -1179,6 +1179,21 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa"
[[package]]
name = "hayro-svg"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7377affc9513cf5b66c4a8b5fa1e6058ae652eef3d13fdc4709004a03968488"
dependencies = [
"base64",
"hayro-interpret",
"image",
"kurbo",
"rustc-hash 2.1.3",
"siphasher",
"xmlwriter",
]
[[package]]
name = "hayro-syntax"
version = "0.8.0"
@@ -1378,7 +1393,7 @@ dependencies = [
[[package]]
name = "ink-notes"
version = "0.6.1"
version = "0.9.0"
dependencies = [
"arboard",
"bytemuck",
@@ -1388,6 +1403,7 @@ dependencies = [
"fs2",
"glam",
"hayro",
"hayro-svg",
"image",
"minisign-verify",
"naga",
@@ -3827,6 +3843,12 @@ version = "0.8.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xmlwriter"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "yoke"
version = "0.8.3"
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ink-notes"
version = "0.6.1"
version = "0.9.0"
edition = "2021"
description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux"
repository = "https://gitea.pansi.eu/harald/ink-notes"
@@ -46,6 +46,7 @@ arboard = { version = "3", default-features = false, features = ["image-data"] }
minisign-verify = "0.3.0"
fs2 = "0.4"
hayro = "0.8"
hayro-svg = "0.8"
[profile.dev]
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
+4 -2
View File
@@ -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` |
+5
View File
@@ -3,6 +3,11 @@
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)
- **PDF importieren & annotieren**: Vorlesungsfolien, Übungsblätter oder Formulare als neues Notizbuch öffnen (☰-Menü oder Drag & Drop), darauf schreiben und markieren; beim Export liegen die Originalseiten verlustfrei als Vektor unter den Notizen (Dateiformat-Version 5: ältere Versionen öffnen solche Notizbücher nicht)
- **Bilder einfügen**: Screenshots per Strg+V, Bilddateien über die eingebaute Dateiauswahl (Strg+I) oder per Drag & Drop; verschieben, drehen, skalieren und darauf schreiben – auch im PDF-Export (Dateiformat-Version 4: ältere Versionen öffnen solche Notizbücher nicht)
- **Dunkelmodus & Papierfarben**: Weiß, Creme, Dunkelgrau oder Schwarz; auf dunklem Papier werden dunkle Stiftfarben automatisch aufgehellt (nur Anzeige – Dateien und PDF-Export bleiben unverändert)
+257 -21
View File
@@ -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::*;
+400 -1
View File
@@ -122,6 +122,22 @@ pub fn point_in_convex_quad(p: glam::Vec2, quad: &[glam::Vec2; 4]) -> bool {
true
}
/// Größte Länge eines Seitentitels in Zeichen.
pub const MAX_PAGE_TITLE_CHARS: usize = 120;
/// Bereinigt einen Seitentitel: Zeilenumbrüche und doppelte Leerzeichen entfernen, auf
/// [`MAX_PAGE_TITLE_CHARS`] kürzen; leer bedeutet „kein Titel“.
pub fn normalize_page_title(title: &str) -> Option<String> {
let cleaned: String = title
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.chars()
.take(MAX_PAGE_TITLE_CHARS)
.collect();
(!cleaned.is_empty()).then_some(cleaned)
}
/// Verweis auf eine Seite eines importierten PDFs (Datei im Asset-Ordner).
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct PdfBackground {
@@ -221,18 +237,124 @@ pub enum EraserMode {
All,
/// Nur Textmarker – die Handschrift darunter bleibt erhalten
HighlighterOnly,
/// Nur den überstrichenen Teil eines Strichs entfernen; der Rest bleibt in Stücken
/// erhalten. Formen werden als Ganzes radiert.
Partial,
}
impl EraserMode {
/// Darf ein Strich dieses Werkzeugs radiert werden?
pub fn erases(self, tool: StrokeTool) -> bool {
match self {
EraserMode::All => true,
EraserMode::All | EraserMode::Partial => true,
EraserMode::HighlighterOnly => tool == StrokeTool::Highlighter,
}
}
}
/// Zeitfenster, in dem aufeinanderfolgende Teilradierungen zu einem Undo-Schritt
/// zusammengefasst werden (ein Radierzug besteht aus vielen kleinen Schnitten).
pub const PARTIAL_ERASE_MERGE_WINDOW: std::time::Duration = std::time::Duration::from_millis(500);
/// Zerlegt die Punktfolge eines Strichs an einem Kreis (Radierer): übrig bleiben die Stücke
/// außerhalb des Kreises, an den Kreisrand gekürzt. `None`, wenn der Kreis den Strich nicht
/// berührt. Stücke kürzer als `min_length` entfallen.
pub fn split_samples_at_circle(
samples: &[StylusSample],
center: glam::Vec2,
radius: f32,
min_length: f32,
) -> Option<Vec<Vec<StylusSample>>> {
let inside = |p: glam::Vec2| (p - center).length_squared() <= radius * radius;
let lerp = |a: &StylusSample, b: &StylusSample, t: f32| StylusSample {
position: a.position.lerp(b.position, t),
pressure: a.pressure + (b.pressure - a.pressure) * t,
timestamp: a.timestamp + (b.timestamp - a.timestamp) * t as f64,
};
// Schnittparameter t ∈ (0, 1) einer Strecke mit dem Kreisrand, aufsteigend
let crossings = |a: glam::Vec2, b: glam::Vec2| -> Vec<f32> {
let d = b - a;
let f = a - center;
let qa = d.dot(d);
if qa < 1e-12 {
return Vec::new();
}
let qb = 2.0 * f.dot(d);
let qc = f.dot(f) - radius * radius;
let disc = qb * qb - 4.0 * qa * qc;
if disc < 0.0 {
return Vec::new();
}
let root = disc.sqrt();
[(-qb - root) / (2.0 * qa), (-qb + root) / (2.0 * qa)]
.into_iter()
.filter(|t| *t > 0.0 && *t < 1.0)
.collect()
};
match samples {
[] => return None,
[single] => return inside(single.position).then(Vec::new),
_ => {}
}
let touched = samples.iter().any(|s| inside(s.position))
|| samples
.windows(2)
.any(|w| crossings(w[0].position, w[1].position).len() == 2);
if !touched {
return None;
}
let mut pieces = Vec::new();
let mut current: Vec<StylusSample> = Vec::new();
if !inside(samples[0].position) {
current.push(samples[0]);
}
for w in samples.windows(2) {
let (a, b) = (&w[0], &w[1]);
let (a_in, b_in) = (inside(a.position), inside(b.position));
let ts = crossings(a.position, b.position);
match (a_in, b_in) {
(false, false) => {
if let [t_enter, t_exit] = ts[..] {
// Strecke läuft durch den Kreis
current.push(lerp(a, b, t_enter));
pieces.push(std::mem::take(&mut current));
current.push(lerp(a, b, t_exit));
}
current.push(*b);
}
(false, true) => {
if let Some(&t) = ts.first() {
current.push(lerp(a, b, t));
}
pieces.push(std::mem::take(&mut current));
}
(true, false) => {
current.clear();
if let Some(&t) = ts.last() {
current.push(lerp(a, b, t));
}
current.push(*b);
}
(true, true) => {}
}
}
pieces.push(current);
let length = |p: &Vec<StylusSample>| -> f32 {
p.windows(2)
.map(|w| (w[1].position - w[0].position).length())
.sum()
};
Some(
pieces
.into_iter()
.filter(|p| p.len() >= 2 && length(p) >= min_length)
.collect(),
)
}
/// Ein einzelner Vektor-Strich auf einer Seite.
#[derive(Debug, Clone, PartialEq)]
pub struct Stroke {
@@ -532,6 +654,8 @@ pub struct Page {
pub background: PageBackground,
/// Importierte PDF-Seite als Hintergrund (unter Bildern und Tinte)
pub pdf_background: Option<PdfBackground>,
/// Seitentitel (Lesezeichen); auch im Manifest, damit er ohne Laden der Seite bekannt ist
pub title: Option<String>,
pub vertex_cache: Vec<InkVertex>,
pub stroke_ranges: Vec<Range<u32>>,
pub is_dirty: bool,
@@ -547,6 +671,7 @@ impl Page {
strokes: Vec::new(),
background,
pdf_background: None,
title: None,
vertex_cache: Vec::new(),
stroke_ranges: Vec::new(),
is_dirty: false,
@@ -563,6 +688,7 @@ impl Page {
strokes: Vec::new(),
background,
pdf_background: None,
title: None,
vertex_cache: Vec::new(),
stroke_ranges: Vec::new(),
is_dirty: false,
@@ -683,6 +809,66 @@ impl Page {
removed
}
/// Teilradierer: entfernt den Teil aller Striche im Kreis um `eraser_pos`. Getroffene
/// Striche werden durch ihre übrig gebliebenen Stücke ersetzt (neue IDs aus `next_id`),
/// Formen als Ganzes entfernt, Bilder nie. Aufeinanderfolgende Schnitte eines Radierzugs
/// bilden einen gemeinsamen Undo-Schritt. Gibt `true` zurück, wenn sich etwas geändert hat.
pub fn erase_partial_at(
&mut self,
eraser_pos: glam::Vec2,
eraser_radius: f32,
next_id: &mut u64,
) -> bool {
let mut removed = Vec::new();
let mut added = Vec::new();
let mut result = Vec::with_capacity(self.strokes.len());
for stroke in std::mem::take(&mut self.strokes) {
let reach = eraser_radius + stroke.width / 2.0;
if stroke.is_image() || !stroke.aabb.expanded(reach).contains_point(eraser_pos) {
result.push(stroke);
continue;
}
if stroke.shape.is_some() {
// Formen werden ganz radiert, sobald der Radierer sie berührt
if stroke.distance_to_point(eraser_pos) <= reach {
removed.push(stroke);
} else {
result.push(stroke);
}
continue;
}
match split_samples_at_circle(&stroke.points, eraser_pos, reach, stroke.width * 0.5) {
None => result.push(stroke),
Some(pieces) => {
for samples in pieces {
let vertices = crate::storage::reconstruct_stroke_vertices(
&samples,
stroke.color,
stroke.width,
stroke.tool,
true,
);
let mut piece = Stroke::new(*next_id, samples, vertices, stroke.color);
*next_id += 1;
piece.tool = stroke.tool;
piece.width = stroke.width;
piece.finalized = true;
result.push(piece.clone());
added.push(piece);
}
removed.push(stroke);
}
}
}
self.strokes = result;
if removed.is_empty() {
return false;
}
self.history.push_split(removed, added);
self.is_dirty = true;
true
}
/// Baut den gecachten Vertex-Puffer der Seite neu auf, wenn `is_dirty` gesetzt ist.
pub fn rebuild_cache_if_dirty(&mut self) {
if !self.is_dirty {
@@ -826,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 {
@@ -883,6 +1073,25 @@ impl Notebook {
&mut self.pages[self.current_page_index]
}
/// Radiert auf der aktuellen Seite gemäß `mode` (ganze Striche, nur Textmarker oder
/// Teilstriche). Gibt `true` zurück, wenn sich etwas geändert hat.
pub fn erase_at(
&mut self,
eraser_pos: glam::Vec2,
eraser_radius: f32,
max_stroke_radius: f32,
mode: EraserMode,
) -> bool {
let page = &mut self.pages[self.current_page_index];
if mode == EraserMode::Partial {
page.erase_partial_at(eraser_pos, eraser_radius, &mut self.next_stroke_id)
} else {
!page
.erase_at(eraser_pos, eraser_radius, max_stroke_radius, mode)
.is_empty()
}
}
/// Erzeugt eine eindeutige neue Stroke-ID.
pub fn next_stroke_id(&mut self) -> u64 {
let id = self.next_stroke_id;
@@ -898,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() {
@@ -929,6 +1152,20 @@ impl Notebook {
}
}
/// Setzt den Titel der Seite `page_id` (leer = kein Titel). `true`, wenn er sich geändert
/// hat.
pub fn set_page_title(&mut self, page_id: u64, title: &str) -> bool {
let title = normalize_page_title(title);
let Some(page) = self.pages.iter_mut().find(|p| p.id == page_id) else {
return false;
};
if page.title == title || !page.is_loaded || page.is_readonly() {
return false;
}
page.title = title;
true
}
/// Text für das On-Screen-Display (OSD), z. B. "Seite 3 / 12".
pub fn osd_text(&self) -> String {
format!(
@@ -1385,6 +1622,168 @@ mod tests {
assert!((rotated[0] - glam::Vec2::new(250.0, 50.0)).length() < 1e-3);
}
fn line_samples(points: &[(f32, f32)]) -> Vec<StylusSample> {
points
.iter()
.enumerate()
.map(|(i, &(x, y))| StylusSample {
position: glam::Vec2::new(x, y),
pressure: 0.5,
timestamp: i as f64,
})
.collect()
}
#[test]
fn test_split_samples_at_circle() {
let c = glam::Vec2::new(50.0, 0.0);
// Lange Strecke mit nur zwei Punkten, Kreis in der Mitte: zwei Stücke, am Rand gekürzt
let pieces =
split_samples_at_circle(&line_samples(&[(0.0, 0.0), (100.0, 0.0)]), c, 10.0, 1.0)
.unwrap();
assert_eq!(pieces.len(), 2);
assert!((pieces[0].last().unwrap().position.x - 40.0).abs() < 1e-3);
assert!((pieces[1][0].position.x - 60.0).abs() < 1e-3);
// Druck und Zeit werden am Schnittpunkt interpoliert
assert!((pieces[0].last().unwrap().timestamp - 0.4).abs() < 1e-3);
// Viele Punkte, Kreis am Ende: ein Stück bleibt
let dense: Vec<(f32, f32)> = (0..=10).map(|i| (i as f32 * 10.0, 0.0)).collect();
let pieces = split_samples_at_circle(
&line_samples(&dense),
glam::Vec2::new(100.0, 0.0),
15.0,
1.0,
)
.unwrap();
assert_eq!(pieces.len(), 1);
assert!((pieces[0].last().unwrap().position.x - 85.0).abs() < 1e-3);
// Ganz im Kreis: nichts bleibt; Kreis weit weg: unberührt
assert_eq!(
split_samples_at_circle(&line_samples(&[(48.0, 0.0), (52.0, 0.0)]), c, 10.0, 1.0),
Some(Vec::new())
);
assert_eq!(
split_samples_at_circle(
&line_samples(&dense),
glam::Vec2::new(50.0, 40.0),
10.0,
1.0
),
None
);
// Winziger Rest wird verworfen
let pieces = split_samples_at_circle(
&line_samples(&[(0.0, 0.0), (100.0, 0.0)]),
glam::Vec2::new(5.0, 0.0),
4.0,
2.0,
)
.unwrap();
assert_eq!(pieces.len(), 1);
assert!((pieces[0][0].position.x - 9.0).abs() < 1e-3);
}
#[test]
fn test_partial_erase_splits_and_undoes_in_one_step() {
let mut notebook = Notebook::new();
let mut stroke = Stroke::new(
1,
line_samples(&[(100.0, 100.0), (500.0, 100.0)]),
Vec::new(),
[0.0, 0.0, 0.0, 1.0],
);
stroke.width = 5.0;
notebook.next_stroke_id = 10;
let page = notebook.current_page_mut();
page.add_stroke(stroke);
page.add_stroke(Stroke::new_image(
2,
"a.png".into(),
[
glam::Vec2::new(0.0, 0.0),
glam::Vec2::new(900.0, 0.0),
glam::Vec2::new(900.0, 900.0),
glam::Vec2::new(0.0, 900.0),
],
));
let mut shape = crate::geometry::shape_to_stroke(
crate::geometry::RecognizedShape::Line {
start: glam::Vec2::new(100.0, 300.0),
end: glam::Vec2::new(500.0, 300.0),
},
3,
[0.0, 0.0, 0.0, 1.0],
5.0,
);
shape.recompute_aabb();
page.add_stroke(shape);
let undo_depth = page.history.undo_stack.len();
// Zwei Schnitte desselben Zugs durch die Linie
assert!(notebook.erase_at(
glam::Vec2::new(200.0, 100.0),
20.0,
12.0,
EraserMode::Partial
));
assert!(notebook.erase_at(
glam::Vec2::new(400.0, 100.0),
20.0,
12.0,
EraserMode::Partial
));
// Form wird als Ganzes radiert
assert!(notebook.erase_at(
glam::Vec2::new(300.0, 300.0),
20.0,
12.0,
EraserMode::Partial
));
let page = notebook.current_page();
let ink: Vec<&Stroke> = page.strokes.iter().filter(|s| !s.is_image()).collect();
assert_eq!(ink.len(), 3, "Linie in drei Stücke zerlegt, Form entfernt");
assert!(ink.iter().all(|s| s.id >= 10 && !s.vertices.is_empty()));
assert!(page.strokes.iter().any(|s| s.id == 2), "Bild bleibt");
assert!(notebook.next_stroke_id > 10);
// Ein einziger Undo-Schritt stellt alles wieder her
let page = notebook.current_page_mut();
assert_eq!(page.history.undo_stack.len(), undo_depth + 1);
assert!(page.undo());
let mut ids: Vec<u64> = page.strokes.iter().map(|s| s.id).collect();
ids.sort();
assert_eq!(ids, vec![1, 2, 3]);
assert!(page.redo());
assert_eq!(page.strokes.iter().filter(|s| !s.is_image()).count(), 3);
// Danebenradieren ändert nichts
assert!(!notebook.erase_at(
glam::Vec2::new(800.0, 800.0),
20.0,
12.0,
EraserMode::Partial
));
}
#[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();
+109 -2
View File
@@ -79,6 +79,20 @@ pub fn resolve_pdf_export_path_in_dir(dir: &Path, title: &str) -> PathBuf {
}
}
/// Kollisionsfreier Pfad `~/Documents/<name>.<ext>` (Verzeichnis wird angelegt).
pub fn resolve_export_path(name: &str, ext: &str) -> PathBuf {
let dir = default_documents_dir();
let _ = std::fs::create_dir_all(&dir);
let base = sanitize_filename_component(name);
let mut candidate = dir.join(format!("{base}.{ext}"));
let mut counter = 1;
while candidate.exists() {
candidate = dir.join(format!("{base}_{counter}.{ext}"));
counter += 1;
}
candidate
}
/// Ermittelt das Ziel für den PDF-Export unter Berücksichtigung von XDG_DOCUMENTS_DIR
/// und automatischer Namenskollisionsvermeidung. Erstellt das Verzeichnis, falls nötig.
pub fn resolve_pdf_export_path(title: &str) -> PathBuf {
@@ -132,7 +146,7 @@ pub fn export_notebook_to_bytes(
assets: Option<&AssetStore>,
) -> Result<Vec<u8>, Box<dyn Error>> {
let doc = generate_pdf_document(notebook, strategy, assets)?;
let bytes = doc.save_to_bytes()?;
let bytes = fix_text_encoding(doc.save_to_bytes()?, notebook)?;
// Importierte PDF-Seiten als Vektorgrafik unterlegen
let underlays = pdf_underlays(notebook);
@@ -158,7 +172,7 @@ pub fn export_notebook_to_bytes(
raster.insert(underlay.target_page);
}
let doc = generate_pdf_document_with(notebook, strategy, assets, &raster)?;
let bytes = doc.save_to_bytes()?;
let bytes = fix_text_encoding(doc.save_to_bytes()?, notebook)?;
let remaining: Vec<_> = underlays
.into_iter()
.filter(|u| !raster.contains(&u.target_page))
@@ -170,6 +184,49 @@ pub fn export_notebook_to_bytes(
Ok(merged)
}
/// printpdf schreibt Lesezeichen-Titel als rohes UTF-8, PDF-Betrachter lesen Textstrings aber
/// als PDFDocEncoding oder UTF-16BE: Umlaute und Gedankenstriche würden verstümmelt. Titel
/// mit Nicht-ASCII-Zeichen werden deshalb nach UTF-16BE (mit BOM) umgeschrieben.
fn fix_text_encoding(bytes: Vec<u8>, notebook: &Notebook) -> Result<Vec<u8>, Box<dyn Error>> {
let needs_fix = notebook
.pages
.iter()
.filter_map(|p| p.title.as_deref())
.any(|t| !t.is_ascii());
if !needs_fix {
return Ok(bytes);
}
let mut doc = printpdf::lopdf::Document::load_mem(&bytes)?;
for object in doc.objects.values_mut() {
let Ok(dict) = object.as_dict_mut() else {
continue;
};
if let Ok(printpdf::lopdf::Object::String(raw, format)) = dict.get_mut(b"Title") {
if let Some(utf16) = utf16be_text(raw) {
*raw = utf16;
*format = printpdf::lopdf::StringFormat::Hexadecimal;
}
}
}
let mut out = Vec::new();
doc.save_to(&mut out)?;
Ok(out)
}
/// UTF-8-Text als PDF-Textstring in UTF-16BE mit BOM; `None` bei reinem ASCII oder
/// ungültigem UTF-8.
fn utf16be_text(raw: &[u8]) -> Option<Vec<u8>> {
let text = std::str::from_utf8(raw).ok()?;
if text.is_ascii() {
return None;
}
let mut out = vec![0xFE, 0xFF];
for unit in text.encode_utf16() {
out.extend_from_slice(&unit.to_be_bytes());
}
Some(out)
}
/// Unterlagen für alle Seiten mit importierter PDF-Seite.
fn pdf_underlays(notebook: &Notebook) -> Vec<crate::pdf_merge::Underlay> {
notebook
@@ -235,6 +292,11 @@ fn generate_pdf_document_with(
let (doc, page1_idx, layer1_bg) =
PdfDocument::new(title, Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "Background");
// Seitentitel werden zu Lesezeichen (Inhaltsverzeichnis im PDF-Betrachter)
if let Some(title) = &notebook.pages[0].title {
doc.add_bookmark(title.clone(), page1_idx);
}
// Seite 1 befüllen
let page1_ref = doc.get_page(page1_idx);
let layer1_bg_ref = page1_ref.get_layer(layer1_bg);
@@ -252,6 +314,9 @@ fn generate_pdf_document_with(
// 2. Alle nachfolgenden Seiten hinzufügen
for (i, page) in notebook.pages.iter().enumerate().skip(1) {
let (page_idx, layer_bg) = doc.add_page(Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "Background");
if let Some(title) = &page.title {
doc.add_bookmark(title.clone(), page_idx);
}
let page_ref = doc.get_page(page_idx);
let layer_bg_ref = page_ref.get_layer(layer_bg);
render_background_layer(&layer_bg_ref, page.background);
@@ -1398,6 +1463,48 @@ mod tests {
assert!(export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon, None).is_ok());
}
#[test]
fn test_page_titles_become_pdf_bookmarks() {
let mut notebook = Notebook::new();
notebook.add_page(PageBackground::Blank);
notebook.add_page(PageBackground::Blank);
let ids: Vec<u64> = notebook.pages.iter().map(|p| p.id).collect();
notebook.set_page_title(ids[0], "Einleitung");
notebook.set_page_title(ids[2], "Übung 2 – Integrale");
let bytes =
export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon, None).unwrap();
let doc = printpdf::lopdf::Document::load_mem(&bytes).unwrap();
let catalog = doc.catalog().unwrap();
assert!(catalog.has(b"Outlines"), "Lesezeichen fehlen");
// Titel als PDF-Textstrings: ASCII unverändert, Umlaute als UTF-16BE mit BOM
let titles: Vec<String> = doc
.objects
.values()
.filter_map(|o| o.as_dict().ok())
.filter(|d| d.has(b"Parent"))
.filter_map(|d| match d.get(b"Title") {
Ok(printpdf::lopdf::Object::String(raw, _)) => {
Some(if raw.starts_with(&[0xFE, 0xFF]) {
let units: Vec<u16> = raw[2..]
.chunks(2)
.map(|c| u16::from_be_bytes([c[0], c[1]]))
.collect();
String::from_utf16(&units).unwrap()
} else {
String::from_utf8(raw.clone()).unwrap()
})
}
_ => None,
})
.collect();
assert!(titles.contains(&"Einleitung".to_string()), "{titles:?}");
assert!(
titles.contains(&"Übung 2 – Integrale".to_string()),
"{titles:?}"
);
}
#[test]
fn test_jpeg_component_count() {
let color = crate::assets::tests::sample_image_bytes(8, 8, image::ImageFormat::Jpeg);
+53
View File
@@ -18,6 +18,13 @@ pub enum HistoryAction {
before: Vec<Stroke>,
after: Vec<Stroke>,
},
/// Teilradierer: Striche wurden durch ihre übrig gebliebenen Stücke ersetzt.
SplitStrokes {
removed: Vec<Stroke>,
added: Vec<Stroke>,
/// Zeitpunkt des letzten Schnitts (zum Zusammenfassen eines Radierzugs)
at: std::time::Instant,
},
}
/// History-Stack für Transaktionsverwaltung (Undo / Redo).
@@ -41,6 +48,40 @@ impl HistoryStack {
self.redo_stack.clear();
}
/// Registriert einen Teilradier-Schnitt. Folgt er innerhalb von
/// [`crate::document::PARTIAL_ERASE_MERGE_WINDOW`] auf einen vorherigen, werden beide zu
/// einem Undo-Schritt zusammengefasst: Stücke, die im selben Zug wieder zerschnitten
/// wurden, verschwinden aus der Bilanz.
pub fn push_split(&mut self, removed: Vec<Stroke>, added: Vec<Stroke>) {
let now = std::time::Instant::now();
if let Some(HistoryAction::SplitStrokes {
removed: prev_removed,
added: prev_added,
at,
}) = self.undo_stack.last_mut()
{
if now.duration_since(*at) <= crate::document::PARTIAL_ERASE_MERGE_WINDOW
&& self.redo_stack.is_empty()
{
for stroke in removed {
if let Some(pos) = prev_added.iter().position(|s| s.id == stroke.id) {
prev_added.remove(pos);
} else {
prev_removed.push(stroke);
}
}
prev_added.extend(added);
*at = now;
return;
}
}
self.push(HistoryAction::SplitStrokes {
removed,
added,
at: now,
});
}
/// Gibt zurück, ob Undo verfügbar ist.
pub fn can_undo(&self) -> bool {
!self.undo_stack.is_empty()
@@ -95,6 +136,12 @@ impl HistoryStack {
self.redo_stack
.push(HistoryAction::ModifyStrokes { before, after });
}
HistoryAction::SplitStrokes { removed, added, at } => {
strokes.retain(|s| !added.iter().any(|a| a.id == s.id));
strokes.extend(removed.iter().cloned());
self.redo_stack
.push(HistoryAction::SplitStrokes { removed, added, at });
}
}
true
@@ -140,6 +187,12 @@ impl HistoryStack {
self.undo_stack
.push(HistoryAction::ModifyStrokes { before, after });
}
HistoryAction::SplitStrokes { removed, added, at } => {
strokes.retain(|s| !removed.iter().any(|r| r.id == s.id));
strokes.extend(added.iter().cloned());
self.undo_stack
.push(HistoryAction::SplitStrokes { removed, added, at });
}
}
true
+34 -18
View File
@@ -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);
}
}
+2
View File
@@ -11,9 +11,11 @@ pub mod filter;
pub mod geometry;
pub mod history;
pub mod image_layer;
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;
+356 -34
View File
@@ -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.
@@ -62,10 +63,16 @@ fn update_window_title(
PageBackground::DotGrid => "Punktraster (5mm)",
};
let page_title = page
.title
.as_deref()
.map(|t| format!(" – {t}"))
.unwrap_or_default();
let title = format!(
"ink-notes | [{}] {} | [{}] | {} | Hintergrund: {} | [Ctrl+Z] Undo [Ctrl+Y] Redo [E] Radierer [Ctrl+E/S] PDF-Export",
"ink-notes | [{}] {}{} | [{}] | {} | Hintergrund: {} | [Ctrl+Z] Undo [Ctrl+Y] Redo [E] Radierer [Ctrl+E/S] PDF-Export",
manifest.title,
notebook.osd_text(),
page_title,
status.display_text(),
tool.name(),
bg_name
@@ -145,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,
@@ -161,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 {
@@ -203,6 +295,44 @@ impl ClipboardBridge {
self.image_hash_at_copy = self.system_image().map(|(_, hash)| hash);
self.source_dir = Some(storage_dir.to_path_buf());
}
/// Internes Kopieren: die Auswahl zusätzlich als Bild in die Systemzwischenablage legen,
/// damit sie sich in Chat, Mail oder Office einfügen lässt. In ink-notes selbst fügt
/// Strg+V weiterhin die Objekte (verlustfrei) ein.
fn copy_selection(
&mut self,
page: &ink_notes::document::Page,
ids: &[u64],
storage_dir: &Path,
) -> bool {
let assets = AssetStore::new(storage_dir);
let image = match ink_notes::page_export::selection_image(page, ids, Some(&assets)) {
Ok(Some(image)) => image,
Ok(None) => return false,
Err(err) => {
eprintln!("[Zwischenablage] Auswahl nicht als Bild darstellbar: {err}");
return false;
}
};
if self.system.is_none() {
self.system = arboard::Clipboard::new().ok();
}
let Some(system) = self.system.as_mut() else {
return false;
};
let data = arboard::ImageData {
width: image.width as usize,
height: image.height as usize,
bytes: std::borrow::Cow::Owned(image.pixels),
};
match system.set_image(data) {
Ok(()) => true,
Err(err) => {
eprintln!("[Zwischenablage] Bild nicht gesetzt: {err}");
false
}
}
}
}
/// Fügt ein importiertes Bild um `center` (Seitenkoordinaten) auf der aktuellen Seite ein,
@@ -265,6 +395,61 @@ 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")
} else {
format!("{count} Objekt(e) kopiert")
}
}
/// Exportiert die aktuelle Seite im Hintergrund als PNG oder SVG nach ~/Documents.
fn start_page_export(
kind: ui::PageExportKind,
notebook: &Notebook,
storage_dir: &Path,
title: &str,
result_tx: Sender<Result<PathBuf, String>>,
window: Arc<Window>,
) {
use ink_notes::page_export::{page_png, page_svg, ImageBackground};
let page = notebook.current_page().clone();
let number = notebook.current_page_index + 1;
let storage_dir = storage_dir.to_path_buf();
let (suffix, ext) = match kind {
ui::PageExportKind::Png => ("", "png"),
ui::PageExportKind::PngTransparent => ("_transparent", "png"),
ui::PageExportKind::Svg => ("", "svg"),
};
let path = export::resolve_export_path(&format!("{title}_Seite_{number}{suffix}"), ext);
std::thread::spawn(move || {
let assets = AssetStore::new(&storage_dir);
let bytes = match kind {
ui::PageExportKind::Png => page_png(&page, Some(&assets), ImageBackground::Paper),
ui::PageExportKind::PngTransparent => {
page_png(&page, Some(&assets), ImageBackground::Transparent)
}
ui::PageExportKind::Svg => page_svg(&page, Some(&assets)).map(String::into_bytes),
};
let result = bytes
.map_err(|e| e.to_string())
.and_then(|bytes| std::fs::write(&path, bytes).map_err(|e| e.to_string()))
.map(|()| path);
let _ = result_tx.send(result);
window.request_redraw();
});
}
/// Seitenmitte des sichtbaren Bereichs (Ziel für eingefügte Bilder).
fn visible_page_center(canvas: &CanvasState) -> Vec2 {
canvas.screen_to_page_clamped(
@@ -519,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;
@@ -628,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)");
@@ -639,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,
&notebook,
&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 => {
@@ -650,8 +869,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
while let Ok(result) = export_rx.try_recv() {
match result {
Ok(path) => ui_state.set_export_message(format!("PDF gespeichert: {}", path.display())),
Err(err) => ui_state.set_export_message(format!("PDF-Export fehlgeschlagen: {err}")),
Ok(path) => ui_state.set_export_message(format!("Gespeichert: {}", path.display())),
Err(err) => ui_state.set_export_message(format!("Export fehlgeschlagen: {err}")),
}
window.request_redraw();
}
@@ -761,14 +980,12 @@ 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) {
let page = notebook.current_page_mut();
let removed = page.erase_at(
page_pt,
if notebook.erase_at(page_pt,
eraser_radius,
stroke_max_radius, ui_state.eraser_mode);
if !removed.is_empty() {
canvas.sync_page(page);
stroke_max_radius, ui_state.eraser_mode) {
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(
&window,
@@ -822,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;
@@ -829,13 +1047,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if should_erase {
is_erasing = true;
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
let page = notebook.current_page_mut();
let removed = page.erase_at(
page_pt,
if notebook.erase_at(page_pt,
eraser_radius,
stroke_max_radius, ui_state.eraser_mode);
if !removed.is_empty() {
canvas.sync_page(page);
stroke_max_radius, ui_state.eraser_mode) {
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(
&window,
@@ -1146,8 +1361,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
Key::Character(c) if c.eq_ignore_ascii_case("c") && ctrl => {
let count = selection_state.copy_to_clipboard(notebook.current_page());
if count > 0 {
let as_image = clipboard.copy_selection(notebook.current_page(), &selection_state.selected_ids, &storage_dir);
clipboard.note_internal_copy(&storage_dir);
ui_state.set_export_message(format!("{count} Objekt(e) kopiert"));
ui_state.set_export_message(copy_message(count, as_image));
window.request_redraw();
}
}
@@ -1162,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();
@@ -1462,14 +1685,13 @@ 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;
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
let page = notebook.current_page_mut();
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode);
if !removed.is_empty() {
canvas.sync_page(page);
if notebook.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode) {
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
@@ -1733,11 +1955,10 @@ 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) {
let page = notebook.current_page_mut();
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode);
if !removed.is_empty() {
canvas.sync_page(page);
if notebook.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode) {
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
@@ -1766,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 {
@@ -1870,13 +2108,12 @@ 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) {
let page = notebook.current_page_mut();
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode);
if !removed.is_empty() {
canvas.sync_page(page);
if notebook.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode) {
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
@@ -2013,11 +2250,10 @@ 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) {
let page = notebook.current_page_mut();
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode);
if !removed.is_empty() {
canvas.sync_page(page);
if notebook.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode) {
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
@@ -2155,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);
@@ -2164,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, &notebook, 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;
@@ -2247,8 +2527,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
UiAction::CopySelected => {
let count = selection_state.copy_to_clipboard(notebook.current_page());
if count > 0 {
let as_image = clipboard.copy_selection(notebook.current_page(), &selection_state.selected_ids, &storage_dir);
clipboard.note_internal_copy(&storage_dir);
ui_state.set_export_message(format!("{count} Objekt(e) kopiert"));
ui_state.set_export_message(copy_message(count, as_image));
window.request_redraw();
}
}
@@ -2342,6 +2623,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.map(|p| storage::PageMeta {
id: p.id,
background: Some(p.background),
title: p.title.clone(),
})
.collect();
manifest.sync_from_notebook(&notebook);
@@ -2350,6 +2632,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
}
UiAction::ExportPage(kind) => {
start_page_export(kind, &notebook, &storage_dir, &manifest.title, export_tx.clone(), window.clone());
ui_state.set_export_message("Seite wird exportiert …".to_string());
window.request_redraw();
}
UiAction::SetPageTitle { page_id, title } => {
if notebook.set_page_title(page_id, &title) {
if let Some(page) = notebook.pages.iter().find(|p| p.id == page_id) {
manifest.sync_from_notebook(&notebook);
autosave.mark_page_dirty(page, &manifest);
}
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
}
}
UiAction::AddPage => {
selection_state.clear();
let bg = notebook.current_page().background;
@@ -2362,6 +2658,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.map(|p| storage::PageMeta {
id: p.id,
background: Some(p.background),
title: p.title.clone(),
})
.collect();
canvas.sync_page(notebook.current_page_mut());
@@ -2739,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);
@@ -2781,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, &notebook.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
+233
View File
@@ -0,0 +1,233 @@
//! Export einzelner Seiten als PNG oder SVG und einer Auswahl als Bild.
//!
//! Grundlage ist immer der PDF-Export: die Seite wird als einseitiges PDF erzeugt (mit
//! Bildern, importierten PDF-Seiten und Formen) und anschließend mit `hayro` gerastert bzw.
//! mit `hayro-svg` nach SVG umgewandelt. So sehen PDF, PNG und SVG gleich aus.
use std::error::Error;
use hayro::hayro_interpret::InterpreterSettings;
use hayro::hayro_syntax::Pdf;
use hayro::vello_cpu::color::palette::css::{TRANSPARENT, WHITE};
use hayro::{PixmapSettings, RenderCache, RenderSettings};
use crate::assets::{AssetStore, RgbaImage};
use crate::document::{Aabb, Notebook, Page, PageBackground, A4_WIDTH_MM, PAGE_WIDTH};
use crate::export::{export_notebook_to_bytes, ExportStrategy};
/// Auflösung des PNG-Exports einer Seite.
pub const PAGE_PNG_DPI: f32 = 200.0;
/// Auflösung einer in die Zwischenablage kopierten Auswahl.
pub const SELECTION_PNG_DPI: f32 = 150.0;
/// Hintergrund beim Bild-Export.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageBackground {
/// Weißes Papier mit Raster bzw. importierter PDF-Seite
Paper,
/// Transparent, nur Bilder und Tinte (ohne Raster und ohne importierte PDF-Seite)
Transparent,
}
/// Erzeugt ein einseitiges PDF aus `page`.
fn single_page_pdf(page: &Page, assets: Option<&AssetStore>) -> Result<Vec<u8>, Box<dyn Error>> {
let notebook = Notebook::from_pages(vec![page.clone()], 0, 1, page.id + 1);
export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon, assets)
}
/// Seite ohne Raster und PDF-Hintergrund (für transparenten Export und Auswahlen).
fn content_only(page: &Page) -> Page {
let mut page = page.clone();
page.background = PageBackground::Blank;
page.pdf_background = None;
page
}
/// Rastert die erste Seite eines PDFs mit `dpi`; Farben ohne Vormultiplikation.
fn rasterize(pdf_bytes: Vec<u8>, dpi: f32, transparent: bool) -> Result<RgbaImage, Box<dyn Error>> {
let pdf = Pdf::new(pdf_bytes).map_err(|e| format!("PDF nicht lesbar: {e:?}"))?;
let page = pdf.pages().first().ok_or("PDF ohne Seite")?;
let scale = dpi / 72.0;
let pixmap = hayro::render(
page,
&RenderCache::new(),
&InterpreterSettings::default(),
&RenderSettings::default(),
&PixmapSettings {
x_scale: scale,
y_scale: scale,
bg_color: if transparent { TRANSPARENT } else { WHITE },
},
);
let mut pixels = pixmap.data_as_u8_slice().to_vec();
// hayro liefert vormultiplizierte Farben; PNG erwartet gerade Farben
for px in pixels.as_chunks_mut::<4>().0 {
let a = px[3] as u32;
if a > 0 && a < 255 {
for c in &mut px[..3] {
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
}
}
}
Ok(RgbaImage {
width: pixmap.width() as u32,
height: pixmap.height() as u32,
pixels,
})
}
/// Kodiert ein Bild als PNG.
pub fn encode_png(image: &RgbaImage) -> Result<Vec<u8>, Box<dyn Error>> {
let buffer = image::RgbaImage::from_raw(image.width, image.height, image.pixels.clone())
.ok_or("Pixeldaten passen nicht zur Bildgröße")?;
let mut out = Vec::new();
image::DynamicImage::ImageRgba8(buffer)
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)?;
Ok(out)
}
/// Seite als PNG (A4 mit [`PAGE_PNG_DPI`]).
pub fn page_png(
page: &Page,
assets: Option<&AssetStore>,
background: ImageBackground,
) -> Result<Vec<u8>, Box<dyn Error>> {
let image = match background {
ImageBackground::Paper => rasterize(single_page_pdf(page, assets)?, PAGE_PNG_DPI, false)?,
ImageBackground::Transparent => rasterize(
single_page_pdf(&content_only(page), assets)?,
PAGE_PNG_DPI,
true,
)?,
};
encode_png(&image)
}
/// Seite als SVG (Vektor; Schriften importierter PDFs werden zu Pfaden).
pub fn page_svg(page: &Page, assets: Option<&AssetStore>) -> Result<String, Box<dyn Error>> {
let pdf =
Pdf::new(single_page_pdf(page, assets)?).map_err(|e| format!("PDF nicht lesbar: {e:?}"))?;
let pdf_page = pdf.pages().first().ok_or("PDF ohne Seite")?;
Ok(hayro_svg::convert(
pdf_page,
&hayro_svg::RenderCache::new(),
&InterpreterSettings::default(),
&hayro_svg::SvgRenderSettings {
bg_color: [255, 255, 255, 255],
},
))
}
/// Bild der Objekte `ids` auf `page`, auf ihren Umriss zugeschnitten, mit transparentem
/// Hintergrund. `None`, wenn keines der Objekte auf der Seite liegt.
pub fn selection_image(
page: &Page,
ids: &[u64],
assets: Option<&AssetStore>,
) -> Result<Option<RgbaImage>, Box<dyn Error>> {
let mut selected = content_only(page);
selected.strokes.retain(|s| ids.contains(&s.id));
let Some(bounds) = Page::selection_aabb(&selected.strokes) else {
return Ok(None);
};
let max_width = selected.strokes.iter().map(|s| s.width).fold(0.0, f32::max);
let bounds: Aabb = bounds.expanded(max_width / 2.0 + 4.0);
let full = rasterize(single_page_pdf(&selected, assets)?, SELECTION_PNG_DPI, true)?;
// Seiteneinheiten → Pixel
let px_per_unit = SELECTION_PNG_DPI / 25.4 * A4_WIDTH_MM / PAGE_WIDTH;
let x0 = ((bounds.min.x * px_per_unit).floor().max(0.0) as u32).min(full.width);
let y0 = ((bounds.min.y * px_per_unit).floor().max(0.0) as u32).min(full.height);
let x1 = ((bounds.max.x * px_per_unit).ceil().max(0.0) as u32).min(full.width);
let y1 = ((bounds.max.y * px_per_unit).ceil().max(0.0) as u32).min(full.height);
if x1 <= x0 || y1 <= y0 {
return Ok(None);
}
let (w, h) = (x1 - x0, y1 - y0);
let mut pixels = Vec::with_capacity((w * h * 4) as usize);
for y in y0..y1 {
let start = ((y * full.width + x0) * 4) as usize;
pixels.extend_from_slice(&full.pixels[start..start + (w * 4) as usize]);
}
Ok(Some(RgbaImage {
width: w,
height: h,
pixels,
}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::{Stroke, StylusSample, PAGE_HEIGHT};
fn page_with_line() -> Page {
let mut page = Page::new(1, PageBackground::Grid5mm);
let samples: Vec<StylusSample> = (0..=20)
.map(|i| StylusSample {
position: glam::Vec2::new(500.0 + i as f32 * 20.0, 1000.0),
pressure: 0.6,
timestamp: i as f64 * 0.01,
})
.collect();
let vertices = crate::storage::reconstruct_stroke_vertices(
&samples,
[0.9, 0.1, 0.1, 1.0],
20.0,
Default::default(),
true,
);
let mut stroke = Stroke::new(7, samples, vertices, [0.9, 0.1, 0.1, 1.0]);
stroke.width = 20.0;
page.strokes.push(stroke);
page
}
fn decode(png: &[u8]) -> image::RgbaImage {
image::load_from_memory(png).unwrap().into_rgba8()
}
#[test]
fn test_page_png_paper_and_transparent() {
let page = page_with_line();
let paper = decode(&page_png(&page, None, ImageBackground::Paper).unwrap());
// A4 mit 200 dpi
assert_eq!(paper.width(), (A4_WIDTH_MM / 25.4 * PAGE_PNG_DPI) as u32);
assert_eq!(paper.get_pixel(5, 5).0[3], 255, "Papier ist deckend");
let clear = decode(&page_png(&page, None, ImageBackground::Transparent).unwrap());
assert_eq!(clear.get_pixel(5, 5).0[3], 0, "Hintergrund ist transparent");
// Die Linie ist rot und deckend
let px_per_unit = clear.width() as f32 / PAGE_WIDTH;
let line = clear.get_pixel((700.0 * px_per_unit) as u32, (1000.0 * px_per_unit) as u32);
assert!(
line.0[0] > 180 && line.0[1] < 80 && line.0[3] > 200,
"{line:?}"
);
assert!(clear.height() as f32 / clear.width() as f32 - PAGE_HEIGHT / PAGE_WIDTH < 0.01);
}
#[test]
fn test_page_svg_contains_ink() {
let svg = page_svg(&page_with_line(), None).unwrap();
assert!(svg.starts_with("<svg") || svg.contains("<svg"));
assert!(svg.contains("<path"));
assert!(svg.trim_end().ends_with("</svg>"));
}
#[test]
fn test_selection_image_is_cropped() {
let page = page_with_line();
let image = selection_image(&page, &[7], None).unwrap().unwrap();
// Linie 400 Einheiten (40 mm) lang: Bild etwas breiter als 40 mm bei 150 dpi
let expected = 40.0 / 25.4 * SELECTION_PNG_DPI;
assert!(
(image.width as f32) > expected && (image.width as f32) < expected * 1.25,
"{} px",
image.width
);
assert!(image.height < image.width / 3);
assert!(image.pixels.as_chunks::<4>().0.iter().any(|p| p[3] > 200));
assert!(selection_image(&page, &[99], None).unwrap().is_none());
}
}
+239
View File
@@ -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);
}
}
+5
View File
@@ -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(
+97 -3
View File
@@ -152,7 +152,8 @@ impl StrokeData {
/// das unbekannte Feld ignorieren, Bilder als Rechteckstrich anzeigen und beim Speichern
/// verlieren.
/// - Version 5: importierte PDF-Seiten als Seitenhintergrund (`pdf_background`).
pub const CURRENT_FORMAT_VERSION: u32 = 5;
/// - Version 6: Seitentitel (`title` in Seite und Manifest).
pub const CURRENT_FORMAT_VERSION: u32 = 6;
/// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0).
pub fn legacy_format_version() -> u32 {
@@ -169,6 +170,9 @@ pub struct PageData {
/// Importierte PDF-Seite als Hintergrund
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pdf_background: Option<crate::document::PdfBackground>,
/// Seitentitel (steht zusätzlich im Manifest)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
pub strokes: Vec<StrokeData>,
}
@@ -179,6 +183,7 @@ impl From<&Page> for PageData {
id: page.id,
background: page.background,
pdf_background: page.pdf_background.clone(),
title: page.title.clone(),
strokes: page.strokes.iter().map(StrokeData::from).collect(),
}
}
@@ -189,6 +194,7 @@ impl PageData {
pub fn to_page(&self) -> Page {
let mut page = Page::new(self.id, self.background);
page.pdf_background = self.pdf_background.clone();
page.title = self.title.clone();
for stroke_data in &self.strokes {
page.strokes.push(stroke_data.to_stroke());
}
@@ -205,11 +211,18 @@ pub struct PageMeta {
pub id: u64,
#[serde(default)]
pub background: Option<PageBackground>,
/// Seitentitel (Lesezeichen), damit die Seitenübersicht ihn ohne Laden der Seite kennt
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
}
impl PageMeta {
pub fn new(id: u64, background: Option<PageBackground>) -> Self {
Self { id, background }
Self {
id,
background,
title: None,
}
}
}
@@ -259,6 +272,13 @@ impl NotebookManifest {
}
}
pub fn title_for_page(&self, page_id: u64) -> Option<String> {
self.pages
.iter()
.find(|p| p.id == page_id)
.and_then(|p| p.title.clone())
}
pub fn background_for_page(&self, page_id: u64) -> Option<PageBackground> {
self.pages
.iter()
@@ -274,6 +294,7 @@ impl NotebookManifest {
.map(|p| PageMeta {
id: p.id,
background: Some(p.background),
title: p.title.clone(),
})
.collect();
self.page_ids = self.pages.iter().map(|p| p.id).collect();
@@ -721,6 +742,13 @@ pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest)
pages.push(Page::new_unloaded(page_id, bg));
}
// Titel aus dem Manifest (Platzhalter und Ersatzseiten kennen sie sonst nicht)
for page in &mut pages {
if page.title.is_none() {
page.title = manifest.title_for_page(page.id);
}
}
let mut notebook = Notebook::from_pages(
pages,
manifest.current_page_index,
@@ -758,7 +786,18 @@ pub fn ensure_page_loaded(
let page_id = notebook.pages[page_idx].id;
match load_page(notebook_dir, page_id) {
Ok(data) => {
notebook.pages[page_idx] = data.to_page();
let title = notebook.pages[page_idx].title.take();
let mut page = data.to_page();
page.title = page.title.or(title);
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)) => {
@@ -1302,12 +1341,14 @@ pub fn load_all_pages(notebook_dir: &Path, notebook: &mut Notebook) -> Result<()
));
}
if !page.is_loaded {
let title = page.title.take();
*page = match load_page(notebook_dir, page.id) {
Ok(data) => data.to_page(),
// Wie beim Lazy Loading: nie gespeicherte Seite = leere Seite
Err(StorageError::PageNotFound(_)) => Page::new(page.id, page.background),
Err(e) => return Err(e),
};
page.title = page.title.take().or(title);
}
}
Ok(())
@@ -2285,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, &notebook, &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();
@@ -2449,4 +2510,37 @@ mod tests {
);
assert_eq!(std::fs::read_dir(temp_dir.path()).unwrap().count(), before);
}
#[test]
fn test_page_titles_survive_restart_and_lazy_loading() {
let temp_dir = tempfile::tempdir().unwrap();
let (mut notebook, mut manifest, dir) =
create_new_notebook(temp_dir.path(), "Mit Titeln").unwrap();
notebook.add_page(PageBackground::Lined);
notebook.add_page(PageBackground::Lined);
let ids: Vec<u64> = notebook.pages.iter().map(|p| p.id).collect();
assert!(notebook.set_page_title(ids[0], " Einleitung \n"));
assert!(notebook.set_page_title(ids[2], "Kapitel 2 – Integrale"));
assert!(!notebook.set_page_title(ids[2], "Kapitel 2 – Integrale"));
save_notebook(&dir, &notebook, &mut manifest).unwrap();
assert_eq!(manifest.pages[0].title.as_deref(), Some("Einleitung"));
// Neustart: Folgeseiten sind Platzhalter, kennen ihren Titel aber aus dem Manifest
let (mut reloaded, _) = load_notebook(&dir).unwrap();
assert!(!reloaded.pages[2].is_loaded);
assert_eq!(
reloaded.pages[2].title.as_deref(),
Some("Kapitel 2 – Integrale")
);
ensure_page_loaded(&dir, &mut reloaded, 2).unwrap();
assert_eq!(
reloaded.pages[2].title.as_deref(),
Some("Kapitel 2 – Integrale")
);
assert_eq!(reloaded.pages[1].title, None);
// Titel entfernen
assert!(reloaded.set_page_title(ids[0], " "));
assert_eq!(reloaded.pages[0].title, None);
}
}
+517 -5
View File
@@ -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,45 @@ 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 {
Png,
PngTransparent,
Svg,
}
/// Aktionen, die von der UI an die Anwendungslogik signalisiert werden.
#[derive(Debug, Clone, PartialEq)]
pub enum UiAction {
@@ -152,10 +192,19 @@ 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
ImportPdf(std::path::PathBuf),
/// Aktuelle Seite als Bild oder SVG exportieren
ExportPage(PageExportKind),
/// Titel einer Seite setzen (leer = entfernen)
SetPageTitle {
page_id: u64,
title: String,
},
// Beenden-Sicherheit bei Fehlschlag
RetryShutdown,
ExportRecoveryAndExit,
@@ -213,12 +262,26 @@ 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>,
/// Seitenübersicht als Liste mit Titeln (Inhaltsverzeichnis) statt Kacheln
pub page_picker_list: bool,
/// Aktionen, die außerhalb der Oberfläche ausgelöst wurden (z. B. ins Fenster gezogene
/// PDF-Datei); `render_ui` gibt sie beim nächsten Bild mit aus
pub queued_actions: Vec<UiAction>,
@@ -262,10 +325,18 @@ 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,
last_picker_dir: None,
}
}
@@ -291,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);
@@ -419,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;
@@ -531,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.
@@ -633,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 = [
@@ -875,6 +1014,19 @@ fn page_thumbnail(
egui::Color32::from_gray(140),
);
}
// Lesezeichen-Ecke für benannte Seiten
if page.title.is_some() {
let corner = thumb.right_top();
painter.add(egui::Shape::convex_polygon(
vec![
corner + egui::vec2(-14.0, 0.0),
corner,
corner + egui::vec2(0.0, 14.0),
],
egui::Color32::from_rgb(90, 150, 255),
egui::Stroke::NONE,
));
}
let border = if is_current {
egui::Stroke::new(2.5_f32, egui::Color32::from_rgb(90, 150, 255))
} else if response.hovered() {
@@ -895,7 +1047,43 @@ fn page_thumbnail(
},
);
}
response.on_hover_text(format!("Seite {number}"))
match &page.title {
Some(title) => response.on_hover_text(format!("Seite {number} – {title}")),
None => response.on_hover_text(format!("Seite {number}")),
}
}
/// Inhaltsverzeichnis der Seitenübersicht: alle Seiten mit Nummer und Titel, benannte
/// Seiten hervorgehoben. Gibt die angetippte Seite zurück.
fn page_title_list(ui: &mut egui::Ui, notebook: &Notebook) -> Option<usize> {
let mut chosen = None;
if notebook.pages.iter().all(|p| p.title.is_none()) {
ui.label(
egui::RichText::new(
"Noch keine Seite hat einen Titel. Gib oben einen Titel für die aktuelle Seite ein.",
)
.color(egui::Color32::from_gray(150)),
);
ui.add_space(4.0);
}
for (idx, page) in notebook.pages.iter().enumerate() {
let is_current = idx == notebook.current_page_index;
let text = match &page.title {
Some(title) => egui::RichText::new(format!("{:>3} {title}", idx + 1))
.strong()
.color(egui::Color32::WHITE),
None => egui::RichText::new(format!("{:>3} –", idx + 1))
.color(egui::Color32::from_gray(130)),
};
let response = ui.add_sized(
[ui.available_width(), 32.0],
egui::SelectableLabel::new(is_current, text),
);
if response.clicked() {
chosen = Some(idx);
}
}
chosen
}
/// Auswahlknopf für eine Papierfarbe: Farbmuster und Name.
@@ -1004,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>) {
@@ -1261,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;
@@ -1420,7 +1753,7 @@ pub fn render_ui(
(
ActiveTool::Eraser,
SidebarIcon::Eraser,
"Radierer (E oder rechte Maustaste) – darunter: alles oder nur Textmarker",
"Radierer (E oder rechte Maustaste) – darunter: ganze Striche, Teilstrich oder nur Textmarker",
),
(
ActiveTool::Select,
@@ -1447,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 {
@@ -1492,6 +1841,12 @@ pub fn render_ui(
None,
"Alle Striche radieren",
),
(
EraserMode::Partial,
SidebarIcon::Glyph("✂"),
None,
"Teilstrich radieren – nur den überstrichenen Teil entfernen (Formen ganz)",
),
(
EraserMode::HighlighterOnly,
SidebarIcon::Glyph("🖊"),
@@ -2004,6 +2359,19 @@ pub fn render_ui(
actions.push(UiAction::ExportPdf);
state.is_more_menu_open = false;
}
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Seite als").color(egui::Color32::from_gray(190)));
for (label, kind, hint) in [
("PNG", PageExportKind::Png, "Bild mit Papier und Raster (200 dpi)"),
("PNG transp.", PageExportKind::PngTransparent, "Bild mit transparentem Hintergrund, nur Inhalt"),
("SVG", PageExportKind::Svg, "Vektorgrafik, z. B. für Inkscape oder LaTeX"),
] {
if ui.button(label).on_hover_text(hint).clicked() {
actions.push(UiAction::ExportPage(kind));
state.is_more_menu_open = false;
}
}
});
if ui
.add_sized([ui.available_width(), 30.0], egui::Button::new("➕ Neue Seite anhängen"))
.clicked()
@@ -2097,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
@@ -2217,18 +2598,80 @@ pub fn render_ui(
if dialog_header(ui, &title, true) {
state.show_page_jump_popup = false;
}
// Titel der aktuellen Seite
let current = notebook.current_page();
if state.page_title_for != Some(current.id) {
state.page_title_for = Some(current.id);
state.page_title_input = current.title.clone().unwrap_or_default();
}
ui.label(
egui::RichText::new(format!(
"TITEL VON SEITE {}",
notebook.current_page_index + 1
))
.size(10.5)
.strong()
.color(egui::Color32::from_gray(160)),
);
let field = ui.add(
egui::TextEdit::singleline(&mut state.page_title_input)
.hint_text("z. B. Kapitel 2 – Integrale")
.margin(egui::vec2(8.0, 8.0))
.desired_width(f32::INFINITY),
);
// Bei jeder Änderung übernehmen: schließt sich die Übersicht, geht nichts verloren
if field.changed() {
actions.push(UiAction::SetPageTitle {
page_id: current.id,
title: state.page_title_input.clone(),
});
}
ui.add_space(2.0);
ui.horizontal(|ui| {
for (list, label) in [(false, "🖼 Kacheln"), (true, "📑 Inhalt")]
{
if ui
.add_sized(
[140.0, 30.0],
egui::SelectableLabel::new(
state.page_picker_list == list,
label,
),
)
.clicked()
{
state.page_picker_list = list;
}
}
});
ui.add_space(2.0);
// Noch nicht geladene Seiten für die Vorschau nachladen lassen
if notebook.pages.iter().any(|p| !p.is_loaded) {
if !state.page_picker_list
&& notebook.pages.iter().any(|p| !p.is_loaded)
{
actions.push(UiAction::LoadAllPages);
}
if state.page_picker_list {
egui::ScrollArea::vertical()
.max_height(420.0)
.show(ui, |ui| {
ui.set_width(ui.available_width());
if let Some(idx) = page_title_list(ui, notebook) {
actions.push(UiAction::GoToPage(idx));
state.show_page_jump_popup = false;
}
});
} else {
egui::ScrollArea::vertical()
.max_height(420.0)
.show(ui, |ui| {
egui::Grid::new("page_picker_grid")
.spacing([8.0, 8.0])
.show(ui, |ui| {
for (idx, page) in notebook.pages.iter().enumerate() {
let is_current = idx == notebook.current_page_index;
for (idx, page) in notebook.pages.iter().enumerate()
{
let is_current =
idx == notebook.current_page_index;
if page_thumbnail(
ui,
state,
@@ -2247,6 +2690,7 @@ pub fn render_ui(
}
});
});
}
ui.add_space(4.0);
if ui
.add_sized(
@@ -3362,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, &notebook, 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
View File
@@ -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();