Compare commits

...
Author SHA1 Message Date
haraldandClaude Opus 5.5 ea9ee6c0d7 feat: Tasten am Stift belegen (Issue #23)
- Im ☰-Menü unter Eingabe → „Tasten am Stift“: je Taste Radieren (Standard),
  Auswählen, Textmarker, Stift oder Nichts; gilt, solange die Taste gedrückt ist
- Tasten werden beim ersten Drücken gelernt und in ui.json gemerkt; die gerade
  gedrückte leuchtet im Menü grün
- Mit einer Werkzeug-Taste geschriebene Striche benutzen dieses Werkzeug, danach
  ist wieder das gewählte Werkzeug aktiv

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:05:09 +02:00
haraldandClaude Opus 5.5 d1f0b46fac chore(release): bump version to 0.19.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 7m40s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:47:28 +02:00
haraldandClaude Opus 5.5 82a297f395 feat: Ordner in andere Ordner verschieben (Issue #18)
- 📂 auch bei Ordnern: verschiebt den Ordner samt Unterordnern und
  Notizbüchern in einen anderen Ordner oder auf die oberste Ebene
- Nicht in sich selbst oder einen eigenen Unterordner; gibt es am Ziel schon
  einen gleichnamigen Ordner, kommt eine Meldung statt einer Zusammenlegung

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:46:51 +02:00
haraldandClaude Opus 5.5 e33062fe0a chore(release): bump version to 0.18.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 7m17s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:42:38 +02:00
haraldandClaude Opus 5.5 36a1a3cd2e feat: Ordner für Notizbücher (Issue #18)
- Beliebig verschachtelte Ordner (z. B. 3. Semester › Mathe) im
  Dokumenten-Manager: Pfadleiste zum Navigieren, Ordner anlegen, umbenennen,
  löschen (Inhalt rückt eine Ebene nach oben) und Notizbücher mit 📂 verschieben
- Neue Notizbücher landen im angezeigten Ordner, importierte PDFs neben dem
  geöffneten Notizbuch; der Manager öffnet im Ordner des aktuellen Notizbuchs
- Ordner sind logisch (library.json im Notizbuch-Basisordner), Notizbuch-
  Dateien werden nie verschoben; fehlt die Datei, liegt alles oben
- Die ganze Titelfläche einer Zeile öffnet Notizbuch bzw. Ordner
- Ein ganz neues Standard-Notizbuch wird sofort gespeichert (erschien sonst
  bis zur ersten Änderung als „beschädigt“)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:41:05 +02:00
haraldandClaude Opus 5.5 70317caeb5 chore(release): bump version to 0.17.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 7m34s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:10:14 +02:00
haraldandClaude Opus 5.5 f9a6f8524d feat: Lange drücken zum Einfügen (Issue #16)
- Ein Finger, der eine halbe Sekunde still auf dem Blatt liegt, öffnet ein
  Kontextmenü mit „Einfügen“ und „Bild aus Datei …“ (wie in Rnote)
- Bild, Text oder kopierte Objekte aus der Zwischenablage landen an der
  gedrückten Stelle (Strg+V weiterhin in der Mitte)
- Meldung, wenn die Zwischenablage leer ist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:08:55 +02:00
haraldandClaude Opus 5.5 4a4492d034 chore(release): bump version to 0.16.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 6m52s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:53:15 +02:00
haraldandClaude Opus 5.5 bb9dbb52d0 feat: Eigene Farben, Bildschirmfarben wie im Export (Issue #13)
- Jede Farbe der Werkzeugleiste (Stifte, Textmarker, Text) lässt sich anpassen:
  gewählte Farbe erneut antippen, Vorgabe oder mit 🎨 frei wählen (Farbwähler
  direkt im Popover). Die Leiste merkt sich die Farben (ui.json).
- Tintenfarben sind sRGB-Werte wie im Export: Gezeichnet wird ohne
  sRGB-Umrechnung der GPU, Papier und Muster rechnet der Shader um. Schwarz ist
  am Bildschirm jetzt schwarz statt grau, der Textmarker mischt wie im PDF.
- PDF-Export: Musterfarbe wird nach sRGB umgerechnet (war zu dunkel).
- Auswahl wird in die Farben der Stifte umgefärbt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:51:39 +02:00
18 changed files with 2012 additions and 296 deletions

No files matched your search

Generated
+1 -1
View File
@@ -1393,7 +1393,7 @@ dependencies = [
[[package]]
name = "ink-notes"
version = "0.15.0"
version = "0.19.0"
dependencies = [
"ab_glyph",
"arboard",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ink-notes"
version = "0.15.0"
version = "0.19.0"
edition = "2021"
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
repository = "https://gitea.pansi.eu/harald/ink-notes"
+6 -1
View File
@@ -19,9 +19,11 @@ Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
### Schreiben und Zeichnen
- **Vier Stifte** in der Werkzeugleiste, jeder mit eigener Farbe und Stärke (0,15–5 mm, mit Druckstärke). Ändert man Farbe oder Stärke, merkt sich der gewählte Stift das – auch über einen Neustart hinweg. **Textmarker** (halbtransparent, 4–8 mm) in vier Farben; innerhalb eines Strichs wird er nicht dunkler, erst ein neuer Strich deckt stärker.
- **Eigene Farben:** Jede Farbe in der Werkzeugleiste (Stifte, Textmarker, Text) lässt sich anpassen – gewählte Farbe erneut antippen, eine Vorgabe oder mit 🎨 einen freien Farbton wählen. Die Leiste merkt sich die Farben. Am Bildschirm erscheinen sie genauso wie im PDF- und Bildexport.
- **Glättung in zwei Stufen:** Während des Schreibens glättet ein 1€-Filter das Zittern, die Linie erscheint sofort. Beim Absetzen wird der Strich nachbearbeitet: Ecken (Knicke über 65°) bleiben erhalten, der Druckverlauf wird geglättet, überflüssige Punkte entfallen, die Enden werden rund.
- **Druckkurve** im ☰-Menü an den eigenen Stift anpassen (weicher/härter, Schwelle gegen Grundrauschen).
- **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker. Stiftrückseite und Seitentaste des Stifts radieren ebenfalls.
- **Tasten am Stift** im ☰-Menü belegen: Radieren (Standard), Auswählen, Textmarker, Stift oder nichts – gilt, solange die Taste gedrückt ist. Jede Taste erscheint dort nach dem ersten Drücken.
- **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker. Die Stiftrückseite radiert ebenfalls.
- **Virtuelles Lineal** (`L`): verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden gerade.
### Formerkennung
@@ -38,6 +40,7 @@ Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
### Text, Bilder und PDFs
- **Text-Werkzeug** (`T`): ins Blatt tippen und schreiben – normale oder Code-Schrift (feste Zeichenbreite, Tab rückt ein), 8–36 pt, Zeilenumbruch am Feldrand. Vorhandenen Text antippen zum Ändern, `Esc` beendet. Textfelder lassen sich wie Bilder verschieben, drehen und skalieren. In anderen Programmen kopierter Text wird mit `Strg + V` zum Textfeld.
- **Bilder** (PNG, JPEG, GIF, WebP, BMP) über `Strg + I`, aus der Zwischenablage oder per Ziehen ins Fenster einfügen.
- **Einfügen per Finger:** Lange (½ s) mit dem Finger aufs Blatt drücken öffnet ein Menü „Einfügen“ – Screenshot, kopierter Text oder kopierte Objekte landen genau an dieser Stelle.
- **PDF importieren** (☰-Menü): Jede PDF-Seite wird eine Notizbuchseite mit der PDF-Seite als Hintergrund. Hochformat-Seiten werden DIN A4, Querformat-Seiten (z. B. Folien) bekommen eine Querformat-Seite ohne leeren Streifen darunter. Beim Hineinzoomen werden PDF-Seiten schärfer nachgerendert.
### Seiten und Ansicht
@@ -51,6 +54,7 @@ Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
### Notizbücher und Speichern
- Mehrere **Notizbücher** anlegen, umbenennen, wechseln und löschen (gelöschte landen zunächst im Papierkorb und lassen sich wiederherstellen).
- **Ordner** für Notizbücher, beliebig verschachtelt (z. B. 3. Semester › Mathe): anlegen, umbenennen, löschen (der Inhalt rückt eine Ebene nach oben) und Notizbücher wie Ordner (samt Inhalt) mit 📂 verschieben. Neue Notizbücher landen im gerade geöffneten Ordner.
- **Automatisches Speichern** im Hintergrund: 1,5 Sekunden nach der letzten Änderung, bei ununterbrochenem Schreiben spätestens alle 10 Sekunden. Zeichnen wird dabei nicht unterbrochen.
- Jede Datei wird erst vollständig in eine temporäre Datei geschrieben und dann ersetzt – ein Absturz beim Speichern hinterlässt keine halbe Datei.
- **Beim Schließen** (Fenster oder `Strg + Q`) und beim Wechsel des Notizbuchs wird alles Ausstehende geschrieben, bevor es weitergeht. Schlägt das fehl, bleibt die App offen und bietet Wiederholen oder eine Notfall-Sicherung an.
@@ -160,6 +164,7 @@ src/
├── document.rs # Datenmodell: Notizbuch, Seiten, Striche, Seitengrößen
├── history.rs # Rückgängig/Wiederholen
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb
├── library.rs # Ordner der Notizbücher (library.json)
├── autosave.rs # Speichern im Hintergrund
├── export.rs # PDF-Export (printpdf)
├── page_export.rs # Seite als PNG/SVG, Auswahl als Bild
+1 -1
View File
@@ -115,7 +115,7 @@ Jeder Strich ist ein eigener Draw-Call mit eigener Nummer im Stencil-Puffer: Ein
- **Textfelder** werden mit `ab_glyph` gerastert, in einer Auflösung, die dem Zoom folgt (in Vierteln einer Zweierpotenz, höchstens 8 Pixel je Einheit). Der Texturschlüssel enthält einen Hash von Inhalt, Schrift und Farbe; jede Änderung erzeugt eine neue Textur.
### 4.2 Papier- und Musterfarben
Papierfarbe (`PaperTheme`) und Musterfarbe (`PatternColor`) stehen in `background.rs`. Alle Farben sind linear; die Fensterfläche ist sRGB, die Umrechnung übernimmt die GPU. Auf dunklem Papier kann der Shader dunkle Tinte aufhellen (`ink_params` in `CameraUniforms`), gespeichert werden immer die Originalfarben.
Papierfarbe (`PaperTheme`) und Musterfarbe (`PatternColor`) stehen in `background.rs`; sie sind linear angegeben und werden im Shader nach sRGB umgerechnet. Tintenfarben (Striche, Text) sind sRGB-Werte – dieselben Zahlen landen im PDF und in den Farbpunkten der Oberfläche. Gezeichnet wird ohne sRGB-Umrechnung der GPU (`render_format`), Texturen sind `Rgba8Unorm`: So wird halbtransparente Tinte wie im PDF in sRGB gemischt und sieht am Bildschirm genauso aus wie im Export. Auf dunklem Papier kann der Shader dunkle Tinte aufhellen (`ink_params` in `CameraUniforms`), gespeichert werden immer die Originalfarben.
---
+16
View File
@@ -9,6 +9,7 @@ Dieses Dokument beschreibt, wo und wie ink-notes Notizbücher speichert, und die
```text
~/.local/share/ink-notes/ ($XDG_DATA_HOME/ink-notes)
├── notebooks/
│ ├── library.json Ordner der Notizbücher (siehe unten)
│ ├── <notizbuch-id>/
│ │ ├── manifest.json Metadaten und Seitenreihenfolge
│ │ ├── manifest.json.bak Kopie des vorherigen Manifests
@@ -29,6 +30,21 @@ Dieses Dokument beschreibt, wo und wie ink-notes Notizbücher speichert, und die
- **Assets** sind nach dem SHA-256-Hash ihres Inhalts benannt; dasselbe Bild wird nur einmal gespeichert. Bilder werden als PNG oder (unverändert) JPEG abgelegt, PDFs unverändert.
- Alle Dateien werden mit Rechten `0600`, Ordner mit `0700` angelegt.
### Ordner (`library.json`)
Ordner wie „3. Semester › Mathe“ sind rein logisch: Die Notizbücher bleiben in ihren Verzeichnissen, `library.json` hält nur fest, welcher Ordner welches Notizbuch enthält. Umbenennen, Verschieben (samt Inhalt) und Löschen von Ordnern ändern deshalb nie eine Notizbuch-Datei.
```json
{
"folders": ["3. Semester", "3. Semester/Mathe", "4. Semester"],
"notebooks": { "analysis_1775460000": "3. Semester/Mathe" }
}
```
- Ordner sind Pfade mit `/` als Trenner; Namen enthalten selbst kein `/` und höchstens 80 Zeichen.
- Notizbücher ohne Eintrag liegen auf der obersten Ebene.
- Wird ein Ordner gelöscht, rücken seine Notizbücher und Unterordner eine Ebene nach oben.
- Fehlt die Datei oder ist sie unlesbar, liegen alle Notizbücher auf der obersten Ebene; fehlende übergeordnete Ordner werden beim Laden ergänzt.
---
## 2. Manifest (`manifest.json`)
+4
View File
@@ -3,6 +3,10 @@
Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit Zorin OS 18 / Ubuntu 24.04).
### Highlights:
- **Ordner verschieben**: Ordner lassen sich jetzt wie Notizbücher mit 📂 samt Inhalt in andere Ordner oder auf die oberste Ebene verschieben
- **Ordner für Notizbücher**: Beliebig verschachtelte Ordner (z. B. 3. Semester › Mathe) im Dokumenten-Manager anlegen, umbenennen und löschen, Notizbücher mit 📂 verschieben; neue Notizbücher landen im geöffneten Ordner. Die Notizbuch-Dateien bleiben unverändert (Zuordnung in library.json)
- **Einfügen per Finger**: Lange (½ s) mit dem Finger aufs Blatt drücken öffnet ein Menü „Einfügen“ – Screenshot, kopierter Text oder kopierte Objekte landen genau an dieser Stelle (wie in Rnote)
- **Eigene Farben**: Jede Farbe der Werkzeugleiste (Stifte, Textmarker, Text) lässt sich anpassen – erneut antippen, Vorgabe oder mit 🎨 frei wählen; die Leiste merkt sich die Farben. Farben am Bildschirm wie im PDF-/Bildexport (Schwarz ist schwarz, Textmarker mischt wie im PDF); Karos und Linien im PDF so hell wie am Bildschirm
- **Beta-Feedback II**: Vier Stifte mit eigener Farbe und Stärke, die sich Änderungen merken; Textmarker wird innerhalb eines Strichs nicht dunkler und ist jetzt auch im PDF-/PNG-/SVG-Export durchsichtig; Seiten lückenlos untereinander mit Trennlinie; neue Seite per ➕ in der Werkzeugleiste; Seite löschen im ☰-Menü (wiederherstellbar); Koordinatensystem ohne Loch am Ursprung
- **Fehlerbehebungen**: Umbenennen und Löschen in der Notizbuchverwaltung reagieren wieder; Karos, Linien und Punkte werden pro Pixel berechnet und liegen in jedem Zoom gleichmäßig (mindestens ein Pixel breit, kantengeglättet)
- **Strichqualität**: Kleine Schleifen wie in „o“, „a“ oder „e“ bleiben rund (der fertige Strich entsteht aus den ungefilterten Stiftpunkten); Strichkanten werden kantengeglättet (4× Multisampling), runde Strichenden sind feiner unterteilt
+20 -2
View File
@@ -71,7 +71,8 @@ pub const LINED_SPACING: f32 = 75.0;
/// Papierfarbe der Anzeige. Betrifft nur die Darstellung am Bildschirm; gespeicherte
/// Strichfarben und der PDF-Export bleiben unverändert (weißes Papier).
///
/// Alle Farben sind linear (die Oberfläche ist sRGB, die Umrechnung macht die GPU).
/// Papier- und Musterfarben sind linear (der Shader rechnet sie in sRGB um); Tintenfarben
/// dagegen sind sRGB-Werte wie im Export.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PaperTheme {
@@ -138,7 +139,24 @@ impl PaperTheme {
PaperTheme::DarkGray => (0.004, 0.004, 0.005),
PaperTheme::Black => (0.03, 0.03, 0.035),
};
wgpu::Color { r, g, b, a: 1.0 }
// Linear angegeben; gezeichnet wird ohne sRGB-Umrechnung
let srgb = |c: f64| f64::from(linear_to_srgb(c as f32));
wgpu::Color {
r: srgb(r),
g: srgb(g),
b: srgb(b),
a: 1.0,
}
}
}
/// Linearer Farbanteil (Papier, Muster) als sRGB-Wert (Tinte, Export).
pub fn linear_to_srgb(c: f32) -> f32 {
let c = c.clamp(0.0, 1.0);
if c <= 0.003_130_8 {
c * 12.92
} else {
1.055 * c.powf(1.0 / 2.4) - 0.055
}
}
+30 -14
View File
@@ -316,13 +316,20 @@ impl CanvasState {
)
.await?;
// 4. Geringe Bildverzögerung (Mailbox, sonst Fifo) & Frame-Latency 1
// 4. Geringe Bildverzögerung (Mailbox, sonst Fifo) & Frame-Latency 1.
// Gezeichnet wird ohne sRGB-Umrechnung (siehe `render_format`); bietet die Fläche
// nur sRGB-Formate an, wird eine Ansicht ohne sRGB verwendet.
let surface_format = surface_caps
.formats
.iter()
.copied()
.find(|f| f.is_srgb())
.find(|f| !f.is_srgb())
.unwrap_or(surface_caps.formats[0]);
let view_formats = if surface_format.is_srgb() {
vec![surface_format.remove_srgb_suffix()]
} else {
vec![]
};
let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
@@ -331,14 +338,14 @@ impl CanvasState {
height: size.height.max(1),
present_mode,
alpha_mode: surface_caps.alpha_modes[0],
view_formats: vec![],
view_formats,
desired_maximum_frame_latency: 1, // Minimiert Eingabelatenz auf 1 Frame
};
surface.configure(&device, &config);
// Kantenglättung der Zeichenfläche (4× Multisampling), falls das Format es erlaubt
let msaa_samples = if adapter
.get_texture_format_features(config.format)
.get_texture_format_features(render_format(&config))
.flags
.sample_count_supported(MSAA_SAMPLES)
{
@@ -414,7 +421,7 @@ impl CanvasState {
module: &shader,
entry_point: "fs_ink",
targets: &[Some(wgpu::ColorTargetState {
format: config.format,
format: render_format(&config),
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
@@ -450,7 +457,7 @@ impl CanvasState {
module: &shader,
entry_point: "fs_none",
targets: &[Some(wgpu::ColorTargetState {
format: config.format,
format: render_format(&config),
blend: None,
write_mask: wgpu::ColorWrites::empty(),
})],
@@ -477,7 +484,7 @@ impl CanvasState {
module: &shader,
entry_point: "fs_main",
targets: &[Some(wgpu::ColorTargetState {
format: config.format,
format: render_format(&config),
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
@@ -537,7 +544,7 @@ impl CanvasState {
module: &shader,
entry_point: "fs_pattern",
targets: &[Some(wgpu::ColorTargetState {
format: config.format,
format: render_format(&config),
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
@@ -570,12 +577,12 @@ impl CanvasState {
&device,
&shader,
&bind_group_layout,
config.format,
render_format(&config),
msaa_samples,
);
// 8. egui Renderer für das schwebende UI initialisieren
let egui_renderer = egui_wgpu::Renderer::new(&device, config.format, None, 1);
let egui_renderer = egui_wgpu::Renderer::new(&device, render_format(&config), None, 1);
Ok(Self {
surface,
@@ -1340,9 +1347,10 @@ impl CanvasState {
}
let output = self.surface.get_current_texture()?;
let view = output
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
let view = output.texture.create_view(&wgpu::TextureViewDescriptor {
format: Some(render_format(&self.config)),
..Default::default()
});
let mut encoder = self
.device
@@ -1509,6 +1517,14 @@ impl CanvasState {
}
}
/// Format, in das gezeichnet wird: ohne sRGB-Umrechnung. Die Farben werden also in sRGB
/// gemischt – wie im PDF-Export, damit halbtransparente Tinte (Textmarker) am Bildschirm
/// genauso aussieht. Tintenfarben sind sRGB-Werte, Papier- und Musterfarben rechnet der
/// Shader aus linearen Werten um.
fn render_format(config: &wgpu::SurfaceConfiguration) -> wgpu::TextureFormat {
config.format.remove_srgb_suffix()
}
/// Multisampling der Zeichenfläche gegen gezackte Strichkanten.
const MSAA_SAMPLES: u32 = 4;
@@ -1530,7 +1546,7 @@ fn create_msaa_view(
mip_level_count: 1,
sample_count: samples,
dimension: wgpu::TextureDimension::D2,
format: config.format,
format: render_format(config),
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
})
+4 -2
View File
@@ -445,8 +445,10 @@ fn render_background_layer(
) {
let background = page.background;
let (Mm(page_width_mm), Mm(page_height_mm)) = pdf_page_mm(page);
// Exportiert wird immer auf weißem Papier
let [r, g, b, _] = pattern.color(crate::background::PaperTheme::White);
// Exportiert wird immer auf weißem Papier; die Musterfarbe ist linear, PDF erwartet sRGB
let [r, g, b, _] = pattern
.color(crate::background::PaperTheme::White)
.map(crate::background::linear_to_srgb);
let grid_color = Color::Rgb(Rgb::new(r, g, b, None));
layer.set_outline_color(grid_color.clone());
layer.set_outline_thickness(0.4); // Feine 0.15 mm Haarlinie (ca. 0.4 pt)
+2 -1
View File
@@ -363,7 +363,8 @@ impl ImageLayer {
mip_level_count: levels.len() as u32,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8UnormSrgb,
// sRGB-Werte ohne Umrechnung: gezeichnet wird in sRGB (siehe canvas::render_format)
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
+1
View File
@@ -11,6 +11,7 @@ pub mod filter;
pub mod geometry;
pub mod history;
pub mod image_layer;
pub mod library;
pub mod page_export;
pub mod pdf_doc;
pub mod pdf_merge;
+441
View File
@@ -0,0 +1,441 @@
//! Ordner für Notizbücher (`<Basisordner>/library.json`).
//!
//! Ordner sind rein logisch: Die Notizbücher bleiben in ihren Verzeichnissen
//! `<Basisordner>/<id>/`, `library.json` hält nur fest, welcher Ordner welches Notizbuch
//! enthält. Umbenennen und Verschieben ändern so nie Notizbuch-Dateien. Fehlt die Datei oder
//! ist sie unlesbar, liegen alle Notizbücher auf der obersten Ebene – verloren geht nichts.
//!
//! Ordner werden als Pfade mit `/` als Trenner gespeichert (z. B. `3. Semester/Mathe`);
//! die oberste Ebene ist der leere Pfad.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::storage::{self, StorageError};
/// Oberste Ebene.
pub const ROOT: &str = "";
/// Längster erlaubter Ordnername (Zeichen).
const MAX_NAME_CHARS: usize = 80;
/// Ordnerbaum und Zuordnung der Notizbücher.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Library {
/// Alle Ordner als Pfade
pub folders: BTreeSet<String>,
/// Ordner je Notizbuch-Kennung; Notizbücher ohne Eintrag liegen auf oberster Ebene
pub notebooks: BTreeMap<String, String>,
}
/// Letzter Teil eines Ordnerpfads (der angezeigte Name).
pub fn folder_name(path: &str) -> &str {
path.rsplit('/').next().unwrap_or(path)
}
/// Übergeordneter Ordner (oberste Ebene für Ordner der obersten Ebene).
pub fn parent_folder(path: &str) -> &str {
path.rsplit_once('/').map_or(ROOT, |(parent, _)| parent)
}
/// Pfad des Unterordners `name` in `parent`.
fn join(parent: &str, name: &str) -> String {
if parent.is_empty() {
name.to_string()
} else {
format!("{parent}/{name}")
}
}
/// `path` liegt in `folder` oder ist `folder` selbst.
fn is_within(path: &str, folder: &str) -> bool {
path == folder
|| path
.strip_prefix(folder)
.is_some_and(|rest| rest.starts_with('/'))
}
/// Bereinigt einen Ordnernamen; Fehlermeldung für die Oberfläche bei ungültigen Namen.
pub fn validate_name(name: &str) -> Result<String, String> {
let name = name.trim();
if name.is_empty() {
return Err("Der Ordnername ist leer".to_string());
}
if name.contains('/') {
return Err("Ordnernamen dürfen kein „/“ enthalten".to_string());
}
if name.chars().count() > MAX_NAME_CHARS {
return Err(format!(
"Der Ordnername ist zu lang (höchstens {MAX_NAME_CHARS} Zeichen)"
));
}
Ok(name.to_string())
}
impl Library {
/// Ort von `library.json` im Basisordner der Notizbücher.
pub fn file_path(base_dir: &Path) -> PathBuf {
base_dir.join("library.json")
}
/// Lädt die Ordner; fehlt die Datei oder ist sie beschädigt, gibt es keine Ordner.
pub fn load(base_dir: &Path) -> Self {
let path = Self::file_path(base_dir);
let mut library: Self = match std::fs::read_to_string(&path) {
Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
eprintln!(
"[Ordner] '{}' ist ungültig ({e}), alle Notizbücher auf oberster Ebene.",
path.display()
);
Self::default()
}),
Err(_) => Self::default(),
};
library.repair();
library
}
/// Speichert die Ordner atomar.
pub fn save(&self, base_dir: &Path) -> Result<(), StorageError> {
storage::atomic_write_json(&Self::file_path(base_dir), self)
}
/// Macht eine (evtl. von Hand bearbeitete) Datei stimmig: leere oder ungültige Namen
/// fallen weg, jeder benutzte Ordner und seine übergeordneten Ordner existieren.
fn repair(&mut self) {
let valid = |path: &str| {
!path.is_empty()
&& path
.split('/')
.all(|name| validate_name(name).as_deref() == Ok(name))
};
self.folders.retain(|f| valid(f));
self.notebooks.retain(|_, f| f.is_empty() || valid(f));
let used: Vec<String> = self
.folders
.iter()
.chain(self.notebooks.values())
.cloned()
.collect();
for mut folder in used {
while !folder.is_empty() {
let parent = parent_folder(&folder).to_string();
self.folders.insert(folder);
folder = parent;
}
}
}
/// Ordner, in dem das Notizbuch liegt.
pub fn folder_of(&self, notebook_id: &str) -> &str {
self.notebooks.get(notebook_id).map_or(ROOT, String::as_str)
}
/// Direkte Unterordner von `parent` (vollständige Pfade, nach Name sortiert).
pub fn subfolders(&self, parent: &str) -> Vec<&str> {
let mut list: Vec<&str> = self
.folders
.iter()
.map(String::as_str)
.filter(|f| parent_folder(f) == parent)
.collect();
list.sort_by_key(|f| folder_name(f).to_lowercase());
list
}
/// Alle Ordner in Baumreihenfolge (Eltern vor Kindern, Geschwister nach Name).
pub fn all_folders(&self) -> Vec<&str> {
let mut out = Vec::with_capacity(self.folders.len());
let mut stack: Vec<&str> = self.subfolders(ROOT).into_iter().rev().collect();
while let Some(folder) = stack.pop() {
out.push(folder);
stack.extend(self.subfolders(folder).into_iter().rev());
}
out
}
/// Legt in `parent` den Ordner `name` an und gibt seinen Pfad zurück.
pub fn create_folder(&mut self, parent: &str, name: &str) -> Result<String, String> {
let name = validate_name(name)?;
if !parent.is_empty() && !self.folders.contains(parent) {
return Err("Der übergeordnete Ordner existiert nicht mehr".to_string());
}
let path = join(parent, &name);
if !self.folders.insert(path.clone()) {
return Err(format!("Den Ordner „{name}“ gibt es hier schon"));
}
Ok(path)
}
/// Benennt einen Ordner um (mitsamt Unterordnern und Zuordnungen); gibt den neuen Pfad
/// zurück.
pub fn rename_folder(&mut self, path: &str, new_name: &str) -> Result<String, String> {
let new_name = validate_name(new_name)?;
if !self.folders.contains(path) {
return Err("Den Ordner gibt es nicht mehr".to_string());
}
let new_path = join(parent_folder(path), &new_name);
if new_path == path {
return Ok(new_path);
}
if self.folders.contains(&new_path) {
return Err(format!("Den Ordner „{new_name}“ gibt es hier schon"));
}
self.move_contents(path, &new_path);
Ok(new_path)
}
/// Löscht einen Ordner. Seine Notizbücher und Unterordner rücken eine Ebene nach oben
/// (gleichnamige Unterordner werden zusammengelegt) – gelöscht wird kein Notizbuch.
pub fn delete_folder(&mut self, path: &str) {
if path.is_empty() || !self.folders.remove(path) {
return;
}
let parent = parent_folder(path).to_string();
let prefix = format!("{path}/");
let moved: Vec<String> = self
.folders
.iter()
.filter(|f| f.starts_with(&prefix))
.cloned()
.collect();
for folder in moved {
self.folders.remove(&folder);
self.folders.insert(join(&parent, &folder[prefix.len()..]));
}
for folder in self.notebooks.values_mut() {
if folder == path {
*folder = parent.clone();
} else if let Some(rest) = folder.strip_prefix(&prefix) {
*folder = join(&parent, rest);
}
}
self.notebooks.retain(|_, f| !f.is_empty());
}
/// Verschiebt einen Ordner samt Inhalt nach `new_path`.
fn move_contents(&mut self, path: &str, new_path: &str) {
let renamed = |f: &str| format!("{new_path}{}", &f[path.len()..]);
let moved: Vec<String> = self
.folders
.iter()
.filter(|f| is_within(f, path))
.cloned()
.collect();
for folder in moved {
self.folders.remove(&folder);
self.folders.insert(renamed(&folder));
}
for folder in self.notebooks.values_mut() {
if is_within(folder, path) {
*folder = renamed(folder);
}
}
}
/// Verschiebt einen Ordner samt Inhalt nach `new_parent` (oberste Ebene: [`ROOT`]) und
/// gibt seinen neuen Pfad zurück. Nicht in sich selbst oder einen eigenen Unterordner.
pub fn move_folder(&mut self, path: &str, new_parent: &str) -> Result<String, String> {
if !self.folders.contains(path) {
return Err("Den Ordner gibt es nicht mehr".to_string());
}
if !new_parent.is_empty() && !self.folders.contains(new_parent) {
return Err("Den Zielordner gibt es nicht mehr".to_string());
}
if is_within(new_parent, path) {
return Err("Ein Ordner kann nicht in sich selbst verschoben werden".to_string());
}
let name = folder_name(path);
let new_path = join(new_parent, name);
if new_path == path {
return Ok(new_path);
}
if self.folders.contains(&new_path) {
return Err(format!("Im Zielordner gibt es schon einen Ordner „{name}“"));
}
self.move_contents(path, &new_path);
Ok(new_path)
}
/// Mögliche Ziele für das Verschieben des Ordners `path`: alle Ordner außer ihm selbst und
/// seinen Unterordnern (die oberste Ebene ist immer ein Ziel).
pub fn folder_move_targets(&self, path: &str) -> Vec<&str> {
self.all_folders()
.into_iter()
.filter(|f| !is_within(f, path))
.collect()
}
/// Legt das Notizbuch in `folder` (oberste Ebene: [`ROOT`]).
pub fn move_notebook(&mut self, notebook_id: &str, folder: &str) -> Result<(), String> {
if folder.is_empty() {
self.notebooks.remove(notebook_id);
return Ok(());
}
if !self.folders.contains(folder) {
return Err("Den Ordner gibt es nicht mehr".to_string());
}
self.notebooks
.insert(notebook_id.to_string(), folder.to_string());
Ok(())
}
/// Wie viele der Notizbücher `ids` in `folder` liegen, Unterordner eingeschlossen.
pub fn count_within<'a>(&self, folder: &str, ids: impl IntoIterator<Item = &'a str>) -> usize {
ids.into_iter()
.filter(|id| is_within(self.folder_of(id), folder))
.count()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn library() -> Library {
let mut lib = Library::default();
let sem3 = lib.create_folder(ROOT, "3. Semester").unwrap();
let mathe = lib.create_folder(&sem3, "Mathe").unwrap();
lib.create_folder(&sem3, "Physik").unwrap();
lib.move_notebook("analysis", &mathe).unwrap();
lib.move_notebook("uebung", &sem3).unwrap();
lib
}
#[test]
fn test_paths_and_names() {
assert_eq!(folder_name("3. Semester/Mathe"), "Mathe");
assert_eq!(folder_name("Mathe"), "Mathe");
assert_eq!(parent_folder("3. Semester/Mathe"), "3. Semester");
assert_eq!(parent_folder("Mathe"), ROOT);
assert_eq!(validate_name(" Mathe ").unwrap(), "Mathe");
assert!(validate_name(" ").is_err());
assert!(validate_name("a/b").is_err());
assert!(validate_name(&"x".repeat(81)).is_err());
}
#[test]
fn test_create_and_list_folders() {
let mut lib = library();
assert_eq!(lib.subfolders(ROOT), ["3. Semester"]);
assert_eq!(
lib.subfolders("3. Semester"),
["3. Semester/Mathe", "3. Semester/Physik"]
);
assert_eq!(
lib.all_folders(),
["3. Semester", "3. Semester/Mathe", "3. Semester/Physik"]
);
assert_eq!(lib.folder_of("analysis"), "3. Semester/Mathe");
assert_eq!(lib.folder_of("unbekannt"), ROOT);
// Doppelte Namen und fehlende Eltern werden abgelehnt
assert!(lib.create_folder("3. Semester", " Mathe ").is_err());
assert!(lib.create_folder("Gibt es nicht", "X").is_err());
// Gleicher Name in einem anderen Ordner ist erlaubt
lib.create_folder(ROOT, "4. Semester").unwrap();
assert!(lib.create_folder("4. Semester", "Mathe").is_ok());
assert_eq!(
lib.count_within("3. Semester", ["analysis", "uebung", "lose"]),
2
);
}
#[test]
fn test_rename_folder_keeps_contents() {
let mut lib = library();
let new = lib.rename_folder("3. Semester", "WS 25-26").unwrap();
assert_eq!(new, "WS 25-26");
assert_eq!(lib.folder_of("analysis"), "WS 25-26/Mathe");
assert_eq!(lib.folder_of("uebung"), "WS 25-26");
assert!(lib.folders.contains("WS 25-26/Physik"));
assert!(!lib.folders.iter().any(|f| f.starts_with("3. Semester")));
// Ein Ordner mit ähnlichem Namen bleibt unberührt
let mut lib = library();
lib.create_folder(ROOT, "3. Semester alt").unwrap();
lib.rename_folder("3. Semester", "Drei").unwrap();
assert!(lib.folders.contains("3. Semester alt"));
// Kollision
assert!(lib.rename_folder("Drei", "3. Semester alt").is_err());
}
#[test]
fn test_delete_folder_moves_contents_up() {
let mut lib = library();
lib.delete_folder("3. Semester");
assert_eq!(lib.folder_of("analysis"), "Mathe");
assert_eq!(lib.folder_of("uebung"), ROOT);
assert!(!lib.notebooks.contains_key("uebung"));
assert_eq!(lib.subfolders(ROOT), ["Mathe", "Physik"]);
}
#[test]
fn test_move_folder() {
let mut lib = library();
lib.create_folder(ROOT, "Mathe-Sammlung").unwrap();
// Ordner samt Inhalt in einen anderen Ordner schieben
let new = lib
.move_folder("3. Semester/Mathe", "Mathe-Sammlung")
.unwrap();
assert_eq!(new, "Mathe-Sammlung/Mathe");
assert_eq!(lib.folder_of("analysis"), "Mathe-Sammlung/Mathe");
assert!(!lib.folders.contains("3. Semester/Mathe"));
// Auf die oberste Ebene und zurück
assert_eq!(
lib.move_folder("Mathe-Sammlung/Mathe", ROOT).unwrap(),
"Mathe"
);
assert_eq!(
lib.move_folder("Mathe", "3. Semester").unwrap(),
"3. Semester/Mathe"
);
// Nicht in sich selbst oder in einen eigenen Unterordner
assert!(lib.move_folder("3. Semester", "3. Semester").is_err());
assert!(lib.move_folder("3. Semester", "3. Semester/Mathe").is_err());
assert_eq!(lib.folder_move_targets("3. Semester"), ["Mathe-Sammlung"]);
// Namenskollision am Ziel
lib.create_folder("Mathe-Sammlung", "Physik").unwrap();
assert!(lib
.move_folder("3. Semester/Physik", "Mathe-Sammlung")
.is_err());
assert_eq!(lib.folder_of("uebung"), "3. Semester");
}
#[test]
fn test_move_notebook() {
let mut lib = library();
lib.move_notebook("uebung", "3. Semester/Physik").unwrap();
assert_eq!(lib.folder_of("uebung"), "3. Semester/Physik");
lib.move_notebook("uebung", ROOT).unwrap();
assert_eq!(lib.folder_of("uebung"), ROOT);
assert!(lib.move_notebook("uebung", "Gibt es nicht").is_err());
}
#[test]
fn test_load_save_and_repair() {
let dir = tempfile::tempdir().unwrap();
// Fehlende Datei: keine Ordner
assert_eq!(Library::load(dir.path()), Library::default());
let lib = library();
lib.save(dir.path()).unwrap();
assert_eq!(Library::load(dir.path()), lib);
// Beschädigt: alles auf oberster Ebene statt Absturz
std::fs::write(Library::file_path(dir.path()), b"{ kaputt").unwrap();
assert_eq!(Library::load(dir.path()), Library::default());
// Von Hand bearbeitet: fehlende Elternordner werden ergänzt, Unsinn fällt weg
std::fs::write(
Library::file_path(dir.path()),
br#"{"folders": ["A/B", "", "C//D"], "notebooks": {"x": "E/F", "y": "/kaputt"}}"#,
)
.unwrap();
let lib = Library::load(dir.path());
assert_eq!(lib.all_folders(), ["A", "A/B", "E", "E/F"]);
assert_eq!(lib.folder_of("x"), "E/F");
assert_eq!(lib.folder_of("y"), ROOT);
}
}
+324 -74
View File
@@ -24,16 +24,75 @@ use ink_notes::document::{Notebook, PageBackground};
use ink_notes::export::{self, export_notebook_to_pdf, ExportStrategy};
use ink_notes::geometry::{self, RecognizedShape};
use ink_notes::image_layer::TextureSource;
use ink_notes::library::Library;
use ink_notes::pdf_doc::PdfRenderService;
use ink_notes::platform::{self, DisplayBackend};
use ink_notes::selection::{
render_selection_overlay, DragMode, SelectionHandleHit, SelectionState,
};
use ink_notes::settings::{SidebarSide, UiSettings};
use ink_notes::settings::{
SidebarSide, StylusAction, StylusButton, UiSettings, MAX_STYLUS_BUTTONS,
};
use ink_notes::storage::{self, NotebookManifest};
use ink_notes::ui::{self, ActiveTool, UiAction, UiState};
use ink_notes::updater::UpdaterController;
/// Wählt `tool` und stellt Farbe und Stärke des Live-Strichs passend ein.
fn activate_tool(tool: ActiveTool, ui_state: &mut UiState, canvas: &mut CanvasState) {
ui_state.active_tool = tool;
match tool {
ActiveTool::Pen | ActiveTool::Shape => {
canvas.set_stroke_color(ui_state.pen_color);
canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm);
}
ActiveTool::Highlighter => {
canvas.set_stroke_color(ui_state.highlighter_color);
canvas.set_stroke_width_mm(ui_state.highlighter_stroke_width_mm);
}
ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text => {}
}
}
/// Kennung einer Stifttaste, die über Programmstarts hinweg gleich bleibt (die Tablet-
/// Schnittstelle liefert nur eine undurchsichtige, aber vergleichbare Kennung).
fn stylus_button_key(id: &octotablet::tool::ButtonID) -> u64 {
use std::hash::{Hash, Hasher};
// DefaultHasher::new() verwendet feste Schlüssel: gleiche Taste, gleicher Wert
let mut hasher = std::collections::hash_map::DefaultHasher::new();
id.hash(&mut hasher);
hasher.finish()
}
/// Liest Notizbücher und Ordner für den Dokumenten-Manager neu ein.
fn refresh_notebooks(base_dir: &Path, ui_state: &mut UiState) {
ui_state.available_notebooks = storage::list_notebooks(base_dir);
ui_state.library = Library::load(base_dir);
}
/// Ändert die Ordner (`library.json`) und liest sie neu ein; Fehler erscheinen als Meldung.
/// Gibt bei Erfolg das Ergebnis von `change` zurück.
fn change_library<T>(
base_dir: &Path,
ui_state: &mut UiState,
change: impl FnOnce(&mut Library) -> Result<T, String>,
) -> Option<T> {
let mut library = Library::load(base_dir);
let result = change(&mut library).and_then(|value| {
library
.save(base_dir)
.map(|()| value)
.map_err(|e| format!("Ordner nicht gespeichert: {e}"))
});
ui_state.library = Library::load(base_dir);
match result {
Ok(value) => Some(value),
Err(message) => {
ui_state.set_export_message(message);
None
}
}
}
/// Aktualisiert den Fenstertitel als minimales On-Screen-Display (OSD) inklusive Autosave-Status.
/// Speichert die UI-Einstellungen; ein Fehler wird gemeldet, bricht aber nichts ab.
fn save_ui_settings(settings: &UiSettings, path: Option<&std::path::Path>, ui_state: &mut UiState) {
@@ -581,6 +640,7 @@ fn selection_release(
#[allow(clippy::too_many_arguments)]
fn insert_text_on_page(
content: &str,
at: Option<Vec2>,
canvas: &mut CanvasState,
notebook: &mut Notebook,
manifest: &mut NotebookManifest,
@@ -588,10 +648,13 @@ fn insert_text_on_page(
selection_state: &mut SelectionState,
ui_state: &mut UiState,
) {
let center = visible_page_center(canvas);
let position = at.unwrap_or_else(|| {
let center = visible_page_center(canvas);
Vec2::new((center.x - 600.0).max(150.0), center.y)
});
let mut edit = ink_notes::text::TextEditing::new_at(
notebook.current_page().id,
Vec2::new((center.x - 600.0).max(150.0), center.y),
position,
notebook.current_page().size.x,
ui_state.text_size_pt * ink_notes::text::UNITS_PER_PT,
ui_state.text_font,
@@ -794,9 +857,11 @@ fn visible_page_center(canvas: &CanvasState) -> Vec2 {
}
/// Strg+V bzw. „Einfügen“: ein neu kopiertes Systembild hat Vorrang, sonst werden die intern
/// kopierten Objekte eingefügt. Gibt die Meldung für die Statuszeile zurück.
/// kopierten Objekte eingefügt. `at`: Stelle auf der Seite (lange gedrückt → Einfügen), sonst
/// die Mitte des sichtbaren Bereichs.
#[allow(clippy::too_many_arguments)]
fn paste(
at: Option<Vec2>,
clipboard: &mut ClipboardBridge,
storage_dir: &Path,
canvas: &mut CanvasState,
@@ -820,7 +885,7 @@ fn paste(
image.bytes.into_owned(),
) {
Ok(imported) => {
let center = visible_page_center(canvas);
let center = at.unwrap_or_else(|| visible_page_center(canvas));
insert_image_on_page(
imported,
center,
@@ -844,6 +909,7 @@ fn paste(
{
insert_text_on_page(
&text,
at,
canvas,
notebook,
manifest,
@@ -874,17 +940,20 @@ fn paste(
}
}
let mut next_id = notebook.next_stroke_id;
let new_ids = selection_state.paste_from_clipboard(notebook.current_page_mut(), &mut || {
let id = next_id;
next_id += 1;
id
});
let new_ids =
selection_state.paste_from_clipboard(notebook.current_page_mut(), at, &mut || {
let id = next_id;
next_id += 1;
id
});
notebook.next_stroke_id = next_id;
if !new_ids.is_empty() {
canvas.sync_page(notebook.current_page_mut());
manifest.next_stroke_id = notebook.next_stroke_id;
autosave.mark_dirty(notebook, manifest);
ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len()));
} else {
ui_state.set_export_message("Die Zwischenablage ist leer".to_string());
}
}
@@ -1017,10 +1086,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
"[Storage] Erstelle neues Notizbuch in: {}",
storage_dir.display()
);
(
Notebook::new(),
NotebookManifest::new(&initial_id, &initial_title),
)
let notebook = Notebook::new();
let mut manifest = NotebookManifest::new(&initial_id, &initial_title);
// Ganz neu (z. B. erster Start): sofort speichern, sonst steht es bis zur ersten
// Änderung ohne Manifest als „beschädigt“ in der Notizbuchliste. Ein vorhandener
// Ordner ohne Manifest bleibt unangetastet.
if !storage_dir.exists() {
if let Err(err) = storage::save_notebook(&storage_dir, &notebook, &mut manifest) {
eprintln!("[Storage] Neues Notizbuch nicht gespeichert: {err}");
}
}
(notebook, manifest)
};
// 2. Asynchronen Autosave-Controller mit 1.5s Debounce starten
@@ -1059,20 +1135,25 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
ui_state.pressure_curve = ui_settings.pressure_curve;
ui_state.continuous_scroll = ui_settings.continuous_scroll;
ui_state.pen_presets = ui_settings.pen_presets;
ui_state.stylus_buttons = ui_settings.stylus_buttons.clone();
ui_state.select_pen(ui_settings.active_pen);
ui_state.highlighter_colors = ui_settings.highlighter_colors;
ui_state.select_highlighter_color(ui_settings.active_highlighter);
ui_state.text_colors = ui_settings.text_colors;
ui_state.active_text_color = ui_settings.active_text_color;
ui_state.text_color = ui_settings.text_colors[ui_settings.active_text_color];
ui_state.current_notebook_id = manifest.id.clone();
ui_state.document_title = manifest.title.clone();
ui_state.available_notebooks = available_notebooks;
ui_state.library = Library::load(&storage_base_dir);
if let Some(warning) = startup_warning {
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
refresh_notebooks(&storage_base_dir, &mut ui_state);
ui_state.set_export_message(warning);
}
let mut selection_state = SelectionState::new();
// Zuletzt gelöschte Seite (Notizbuch-Kennung, Index, Seite) zum Wiederherstellen in dieser
// Sitzung
let mut last_deleted_page: Option<(String, usize, ink_notes::document::Page)> = None;
// Stifte geändert, aber noch nicht gespeichert
let mut pen_presets_dirty = false;
let mut clipboard = ClipboardBridge::new();
// PDF-Seiten werden im Hintergrund gerendert und wecken danach den Event-Loop
let mut pdf_renderer = {
@@ -1124,6 +1205,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut is_pinch_gesturing = false;
let mut is_touch_drawing = false;
let mut is_touch_panning = false;
// Langes Drücken mit einem Finger öffnet das Kontextmenü
let mut long_press = ui::LongPress::default();
let mut touch_pan_last_pos = (0.0_f32, 0.0_f32);
// Stylus / Digitizer Zustand (Octotablet / Wayland Tablet Protocol)
@@ -1132,7 +1215,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut is_stylus_selecting = false;
let mut is_stylus_in_proximity = false;
let mut stylus_tool_id: Option<octotablet::tool::ID> = None;
let mut stylus_barrel_pressed = false;
// Gedrückte Stifttaste (Kennung, Belegung) und das Werkzeug, das während eines Strichs mit
// einer Werkzeug-Taste (Auswählen, Textmarker, Stift) vertreten wird
let mut stylus_held: Option<(u64, StylusAction)> = None;
let mut tool_before_button: Option<ActiveTool> = None;
let mut stylus_screen_pos = (0.0_f32, 0.0_f32);
// Stift hat auf der UI aufgesetzt: alle Ereignisse bis zum Abheben gehen an egui
let mut stylus_ui_capture = false;
@@ -1303,23 +1389,45 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state);
window.request_redraw();
}
if let Some(tool) = tool_before_button.take() {
activate_tool(tool, &mut ui_state, &mut canvas);
}
stylus_held = None;
ui_state.pressed_stylus_button = None;
}
}
octotablet::events::ToolEvent::Button { pressed, .. } => {
stylus_barrel_pressed = pressed;
if pressed && is_stylus_drawing {
is_stylus_drawing = false;
is_drawing = false;
canvas.cancel_stroke();
is_erasing = true;
window.request_redraw();
} else if !pressed
&& is_erasing
&& !tool_is_hardware_eraser
&& ui_state.active_tool != ActiveTool::Eraser
{
is_erasing = false;
octotablet::events::ToolEvent::Button { button_id, pressed } => {
// Tasten werden beim ersten Drücken gelernt (Standard: Radieren)
let key = stylus_button_key(&button_id);
let mut index = ui_state.stylus_buttons.iter().position(|b| b.id == key);
if index.is_none() && pressed && ui_state.stylus_buttons.len() < MAX_STYLUS_BUTTONS {
ui_state.stylus_buttons.push(StylusButton { id: key, action: StylusAction::Erase });
index = Some(ui_state.stylus_buttons.len() - 1);
ui_settings.stylus_buttons = ui_state.stylus_buttons.clone();
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
}
let action = index.map_or(StylusAction::Erase, |i| ui_state.stylus_buttons[i].action);
if pressed {
stylus_held = Some((key, action));
ui_state.pressed_stylus_button = index;
if action == StylusAction::Erase && is_stylus_drawing {
is_stylus_drawing = false;
is_drawing = false;
canvas.cancel_stroke();
is_erasing = true;
}
} else if stylus_held.is_some_and(|(held, _)| held == key) {
stylus_held = None;
ui_state.pressed_stylus_button = None;
if action == StylusAction::Erase
&& is_erasing
&& !tool_is_hardware_eraser
&& ui_state.active_tool != ActiveTool::Eraser
{
is_erasing = false;
}
}
window.request_redraw();
}
octotablet::events::ToolEvent::Pose(pose) => {
is_stylus_in_proximity = true;
@@ -1417,8 +1525,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
focus_page_at!((sx, sy));
// Werkzeug-Taste gedrückt: für diesen Strich dieses Werkzeug
let button_action = stylus_held.map(|(_, action)| action);
let button_tool = match button_action {
Some(StylusAction::Select) => Some(ActiveTool::Select),
Some(StylusAction::Highlighter) => Some(ActiveTool::Highlighter),
Some(StylusAction::Pen) => Some(ActiveTool::Pen),
_ => None,
};
if let Some(tool) = button_tool
.filter(|&t| !tool_is_hardware_eraser && t != ui_state.active_tool)
{
tool_before_button = Some(ui_state.active_tool);
activate_tool(tool, &mut ui_state, &mut canvas);
}
let should_erase = tool_is_hardware_eraser
|| stylus_barrel_pressed
|| button_action == Some(StylusAction::Erase)
|| ui_state.active_tool == ActiveTool::Eraser;
if !should_erase && ui_state.active_tool == ActiveTool::Text {
@@ -1496,6 +1618,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
window.request_redraw();
}
}
let restore_tool = tool_before_button.take();
if is_stylus_drawing {
is_stylus_drawing = false;
@@ -1530,6 +1653,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
snapped_shape,
);
}
// Nach einem Strich mit Werkzeug-Taste wieder das gewählte Werkzeug
if let Some(tool) = restore_tool {
activate_tool(tool, &mut ui_state, &mut canvas);
window.request_redraw();
}
}
_ => {}
}
@@ -1558,6 +1686,27 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
}
// Langes Drücken mit dem Finger: Kontextmenü zum Einfügen an dieser Stelle
if let Some(pos) = long_press.fire(Instant::now()) {
if is_touch_drawing {
is_touch_drawing = false;
is_drawing = false;
canvas.cancel_stroke();
}
is_touch_panning = false;
focus_page_at!(pos);
if let Some(page_pt) = canvas.screen_to_page(pos.0, pos.1) {
let scale = window.scale_factor() as f32;
ui_state.close_overlays();
// Leicht versetzt, damit der Finger das Menü nicht verdeckt
ui_state.context_menu = Some(ui::ContextMenu {
screen: egui::pos2(pos.0 / scale + 12.0, pos.1 / scale + 12.0),
page: page_pt,
});
}
window.request_redraw();
}
// Von egui angefordertes, verzögertes Neuzeichnen (Tooltips, Animationen)
if let Some(at) = egui_repaint_at {
if Instant::now() >= at {
@@ -1575,7 +1724,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|| is_stylus_in_proximity
{
target.set_control_flow(ControlFlow::wait_duration(Duration::from_millis(8)));
} else if let Some(at) = egui_repaint_at {
} else if let Some(at) = [egui_repaint_at, long_press.deadline()]
.into_iter()
.flatten()
.min()
{
target.set_control_flow(ControlFlow::WaitUntil(at));
} else {
target.set_control_flow(ControlFlow::Wait);
@@ -1784,7 +1937,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Einfügen: Strg + V
Key::Character(c) if c.eq_ignore_ascii_case("v") && ctrl => {
paste(
&mut clipboard, &storage_dir, &mut canvas, &mut notebook, &mut manifest,
None, &mut clipboard, &storage_dir, &mut canvas, &mut notebook, &mut manifest,
&mut autosave, &mut selection_state, &mut ui_state,
);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
@@ -2373,6 +2526,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
active_touches.insert(touch.id, (tx, ty));
// Langes Drücken: nur beim Verschieben oder Zeichnen mit dem Finger, nicht
// beim Radieren, Auswählen oder Text (dort hält man auch mal still)
if active_touches.len() == 1
&& !is_pinch_gesturing
&& (!ui_state.touch_draw_enabled
|| ui_state.active_tool.is_drawing()
|| ui_state.active_tool == ActiveTool::Shape)
{
long_press.start(touch.id, (tx, ty), Instant::now());
} else {
long_press.cancel();
}
if active_touches.len() >= 2 {
// Multi-Touch Geste: Sobald 2+ Finger aufliegen, Inking sofort abbrechen und zoomen/pannen
if is_touch_drawing {
@@ -2439,6 +2605,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
TouchPhase::Moved => {
active_touches.insert(touch.id, (tx, ty));
long_press.moved(touch.id, (tx, ty), window.scale_factor() as f32);
if active_touches.len() >= 2 {
let pts: Vec<(f32, f32)> = active_touches.values().copied().take(2).collect();
@@ -2507,6 +2674,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
TouchPhase::Ended | TouchPhase::Cancelled => {
active_touches.remove(&touch.id);
long_press.cancel();
if active_touches.is_empty() {
is_pinch_gesturing = false;
@@ -2637,22 +2805,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
for action in actions {
match action {
UiAction::SetTool(tool) => {
ui_state.active_tool = tool;
match tool {
ActiveTool::Pen => {
canvas.set_stroke_color(ui_state.pen_color);
canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm);
}
ActiveTool::Highlighter => {
canvas.set_stroke_color(ui_state.highlighter_color);
canvas.set_stroke_width_mm(ui_state.highlighter_stroke_width_mm);
}
ActiveTool::Shape => {
canvas.set_stroke_color(ui_state.pen_color);
canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm);
}
ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text => {}
}
activate_tool(tool, &mut ui_state, &mut canvas);
update_window_title(&window, &notebook, &manifest, tool, autosave.status());
window.request_redraw();
}
@@ -2710,9 +2863,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
window.request_redraw();
}
}
UiAction::PasteSelected => {
UiAction::PasteSelected | UiAction::PasteAt(_) => {
let at = match action {
UiAction::PasteAt(at) => Some(at),
_ => None,
};
paste(
&mut clipboard, &storage_dir, &mut canvas, &mut notebook, &mut manifest,
at, &mut clipboard, &storage_dir, &mut canvas, &mut notebook, &mut manifest,
&mut autosave, &mut selection_state, &mut ui_state,
);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
@@ -2910,8 +3067,81 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
window.request_redraw();
}
UiAction::CreateFolder { parent, name } => {
if let Some(path) = change_library(&storage_base_dir, &mut ui_state, |library| {
library.create_folder(&parent, &name)
}) {
ui_state.set_export_message(format!(
"Ordner „{}“ angelegt",
ink_notes::library::folder_name(&path)
));
}
window.request_redraw();
}
UiAction::RenameFolder { path, new_name } => {
if let Some(new_path) = change_library(&storage_base_dir, &mut ui_state, |library| {
library.rename_folder(&path, &new_name)
}) {
// Der gezeigte Ordner kann darin liegen
let shown = &ui_state.notebook_folder;
if shown == &path || shown.starts_with(&format!("{path}/")) {
ui_state.notebook_folder = format!("{new_path}{}", &shown[path.len()..]);
}
}
window.request_redraw();
}
UiAction::DeleteFolder(path) => {
if change_library(&storage_base_dir, &mut ui_state, |library| {
library.delete_folder(&path);
Ok(())
})
.is_some()
{
ui_state.set_export_message(format!(
"Ordner „{}“ gelöscht, sein Inhalt liegt eine Ebene höher",
ink_notes::library::folder_name(&path)
));
}
window.request_redraw();
}
UiAction::MoveFolder { path, new_parent } => {
if let Some(new_path) = change_library(&storage_base_dir, &mut ui_state, |library| {
library.move_folder(&path, &new_parent)
}) {
// Der gezeigte Ordner kann darin liegen
let shown = &ui_state.notebook_folder;
if shown == &path || shown.starts_with(&format!("{path}/")) {
ui_state.notebook_folder = format!("{new_path}{}", &shown[path.len()..]);
}
let target = if new_parent.is_empty() {
"die oberste Ebene".to_string()
} else {
format!("„{}“", ink_notes::library::folder_name(&new_parent))
};
ui_state.set_export_message(format!(
"Ordner „{}“ verschoben in {target}",
ink_notes::library::folder_name(&path)
));
}
window.request_redraw();
}
UiAction::MoveNotebook { id, folder } => {
if change_library(&storage_base_dir, &mut ui_state, |library| {
library.move_notebook(&id, &folder)
})
.is_some()
{
let target = if folder.is_empty() {
"die oberste Ebene".to_string()
} else {
format!("„{}“", ink_notes::library::folder_name(&folder))
};
ui_state.set_export_message(format!("Verschoben in {target}"));
}
window.request_redraw();
}
UiAction::RefreshNotebooks => {
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
refresh_notebooks(&storage_base_dir, &mut ui_state);
window.request_redraw();
}
UiAction::OpenNotebook(id) => {
@@ -2947,7 +3177,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
selection_state.clear();
ui_state.current_notebook_id = manifest.id.clone();
ui_state.document_title = manifest.title.clone();
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
refresh_notebooks(&storage_base_dir, &mut ui_state);
ui_state.is_document_menu_open = false;
canvas.sync_page(notebook.current_page_mut());
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
@@ -2964,6 +3194,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
}
UiAction::ImportPdf(path) => {
// Neben das gerade geöffnete Notizbuch
let folder = ui_state.library.folder_of(&manifest.id).to_string();
let title = path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
@@ -2973,6 +3205,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.and_then(|bytes| storage::create_notebook_from_pdf(&storage_base_dir, &title, bytes));
match result {
Ok((new_nb, new_manifest, new_dir)) => {
let id = new_manifest.id.clone();
change_library(&storage_base_dir, &mut ui_state, |library| {
library.move_notebook(&id, &folder)
});
let _ = autosave.flush();
_notebook_lock = storage::NotebookLock::acquire(&new_dir).ok();
autosave.set_read_only(_notebook_lock.is_none());
@@ -2983,7 +3219,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
selection_state.clear();
ui_state.current_notebook_id = manifest.id.clone();
ui_state.document_title = manifest.title.clone();
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
refresh_notebooks(&storage_base_dir, &mut ui_state);
canvas.reset_view();
canvas.sync_page(notebook.current_page_mut());
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
@@ -3000,10 +3236,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
window.request_redraw();
}
UiAction::CreateNotebook(title) => {
UiAction::CreateNotebook { title, folder } => {
let _ = autosave.flush();
match storage::create_new_notebook(&storage_base_dir, &title) {
Ok((new_nb, new_manifest, new_dir)) => {
let id = new_manifest.id.clone();
change_library(&storage_base_dir, &mut ui_state, |library| {
library.move_notebook(&id, &folder)
});
_notebook_lock = storage::NotebookLock::acquire(&new_dir).ok();
autosave.set_read_only(_notebook_lock.is_none());
autosave.switch_notebook_dir(new_dir.clone());
@@ -3013,7 +3253,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
selection_state.clear();
ui_state.current_notebook_id = manifest.id.clone();
ui_state.document_title = manifest.title.clone();
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
refresh_notebooks(&storage_base_dir, &mut ui_state);
ui_state.is_document_menu_open = false;
canvas.sync_page(notebook.current_page_mut());
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
@@ -3032,7 +3272,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
match storage::rename_notebook(&target_dir, &mut manifest, &new_title) {
Ok(()) => {
ui_state.document_title = manifest.title.clone();
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
refresh_notebooks(&storage_base_dir, &mut ui_state);
manifest.sync_from_notebook(&notebook);
autosave.mark_manifest_dirty(&manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
@@ -3049,7 +3289,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if let Ok(content) = std::fs::read_to_string(&manifest_path) {
if let Ok(mut other_manifest) = serde_json::from_str::<NotebookManifest>(&content) {
let _ = storage::rename_notebook(&target_dir, &mut other_manifest, &new_title);
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
refresh_notebooks(&storage_base_dir, &mut ui_state);
ui_state.set_export_message(format!("Umbenannt in '{}'", new_title));
window.request_redraw();
}
@@ -3074,7 +3314,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
match storage::delete_notebook(&target_dir, &storage_base_dir) {
Ok(trash_path) => {
ui_state.last_deleted_notebook = Some((trash_path, deleted_title));
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
refresh_notebooks(&storage_base_dir, &mut ui_state);
if is_current {
if let Some(first) = ui_state
.available_notebooks
@@ -3112,7 +3352,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
selection_state.clear();
ui_state.current_notebook_id = manifest.id.clone();
ui_state.document_title = manifest.title.clone();
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
refresh_notebooks(&storage_base_dir, &mut ui_state);
canvas.sync_page(notebook.current_page_mut());
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
ui_state.set_export_message("Standard-Notizbuch erstellt".to_string());
@@ -3132,7 +3372,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if let Some((trash_path, _title)) = ui_state.last_deleted_notebook.take() {
match storage::restore_notebook_from_trash(&storage_base_dir, &trash_path) {
Ok(_restored_dir) => {
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
refresh_notebooks(&storage_base_dir, &mut ui_state);
ui_state.set_export_message("Notizbuch wiederhergestellt".to_string());
window.request_redraw();
}
@@ -3147,7 +3387,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let target_dir = storage_base_dir.join(&id);
match storage::restore_manifest_from_backup(&target_dir) {
Ok(()) => {
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
refresh_notebooks(&storage_base_dir, &mut ui_state);
ui_state.set_export_message("Manifest aus Backup wiederhergestellt".to_string());
window.request_redraw();
}
@@ -3252,6 +3492,10 @@ 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::SaveStylusButtons => {
ui_settings.stylus_buttons = ui_state.stylus_buttons.clone();
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
}
UiAction::SavePressureCurve(curve) => {
ui_settings.pressure_curve = curve;
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
@@ -3263,16 +3507,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
}
// Stifte: geänderte Farbe oder Stärke gehört zum gewählten Stift. Gespeichert
// wird erst nach dem Loslassen (nicht bei jedem Schritt eines Schiebereglers).
if ui_state.sync_pen_preset() || ui_settings.active_pen != ui_state.active_pen {
pen_presets_dirty = true;
ui_settings.active_pen = ui_state.active_pen;
}
if pen_presets_dirty && !egui_ctx.is_using_pointer() {
pen_presets_dirty = false;
ui_settings.pen_presets = ui_state.pen_presets;
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
// Stifte und eigene Farben: geänderte Farbe oder Stärke gehört zum gewählten
// Stift bzw. zur gewählten Farbe. Gespeichert wird erst nach dem Loslassen
// (nicht bei jedem Schritt eines Schiebereglers oder Farbwählers).
ui_state.sync_tool_colors();
if !egui_ctx.is_using_pointer() {
let mut updated = ui_settings.clone();
updated.pen_presets = ui_state.pen_presets;
updated.active_pen = ui_state.active_pen;
updated.highlighter_colors = ui_state.highlighter_colors;
updated.active_highlighter = ui_state.active_highlighter;
updated.text_colors = ui_state.text_colors;
updated.active_text_color = ui_state.active_text_color;
if updated != ui_settings {
ui_settings = updated;
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
}
}
// Textbearbeitung beenden: Esc, anderes Werkzeug oder andere Seite
+20 -2
View File
@@ -203,13 +203,19 @@ impl SelectionState {
pub fn paste_from_clipboard(
&mut self,
page: &mut Page,
at: Option<Vec2>,
next_id_gen: &mut impl FnMut() -> u64,
) -> Vec<u64> {
if self.clipboard.is_empty() {
return Vec::new();
}
let offset = Vec2::new(30.0, 30.0); // ca. 3 mm Versatz nach rechts-unten
// Mit `at` (lange gedrückt → Einfügen) landet die Mitte der Objekte dort, sonst
// ca. 3 mm nach rechts unten versetzt
let offset = match (at, Page::selection_aabb(&self.clipboard)) {
(Some(at), Some(aabb)) => at - (aabb.min + aabb.max) / 2.0,
_ => Vec2::new(30.0, 30.0),
};
let mut new_strokes = Vec::with_capacity(self.clipboard.len());
let mut new_ids = Vec::with_capacity(self.clipboard.len());
@@ -457,7 +463,7 @@ mod tests {
// Paste
let mut next_id = 100;
let new_ids = sel.paste_from_clipboard(&mut page, &mut || {
let new_ids = sel.paste_from_clipboard(&mut page, None, &mut || {
next_id += 1;
next_id
});
@@ -465,6 +471,18 @@ mod tests {
assert_eq!(page.strokes.len(), 3);
assert_eq!(sel.selected_ids, new_ids);
// An einer Stelle einfügen: die Mitte der Kopie liegt dort
let target = Vec2::new(1000.0, 1500.0);
let placed = sel.paste_from_clipboard(&mut page, Some(target), &mut || {
next_id += 1;
next_id
});
let aabb = sel.get_selection_aabb(&page).unwrap();
assert_eq!(sel.selected_ids, placed);
assert!(((aabb.min + aabb.max) / 2.0 - target).length() < 0.01);
sel.delete_selected(&mut page);
sel.selected_ids = new_ids.clone();
// Delete
let deleted = sel.delete_selected(&mut page);
assert_eq!(deleted.len(), 1);
+134 -7
View File
@@ -87,13 +87,7 @@ impl PenPreset {
/// Begrenzt Werte aus einer (evtl. von Hand bearbeiteten) Einstellungsdatei; Stifte sind
/// immer deckend.
pub fn sanitized(&self, fallback: PenPreset) -> Self {
let mut color = fallback.color;
if self.color.iter().all(|c| c.is_finite()) {
for (out, c) in color.iter_mut().zip(self.color).take(3) {
*out = c.clamp(0.0, 1.0);
}
}
color[3] = 1.0;
let color = sanitized_color(self.color, [0.0, 0.0, 0.0, 1.0]);
let width_mm = if self.width_mm.is_finite() {
self.width_mm
.clamp(*Self::WIDTH_RANGE.start(), *Self::WIDTH_RANGE.end())
@@ -127,6 +121,82 @@ pub const DEFAULT_PEN_PRESETS: [PenPreset; PEN_PRESET_COUNT] = [
},
];
/// Farben des Textmarkers in der Werkzeugleiste (halbtransparent).
pub const DEFAULT_HIGHLIGHTER_COLORS: [[f32; 4]; 4] = [
[1.0, 0.95, 0.1, 0.35],
[0.2, 0.9, 0.3, 0.35],
[0.25, 0.7, 1.0, 0.35],
[1.0, 0.3, 0.65, 0.35],
];
/// Farben des Text-Werkzeugs in der Werkzeugleiste.
pub const DEFAULT_TEXT_COLORS: [[f32; 4]; 5] = [
[0.12, 0.12, 0.14, 1.0],
[0.15, 0.35, 0.85, 1.0],
[0.85, 0.20, 0.20, 1.0],
[0.18, 0.65, 0.35, 1.0],
[0.45, 0.45, 0.48, 1.0],
];
/// Begrenzt eine Farbe aus der Einstellungsdatei auf 0–1; die Deckkraft kommt immer aus
/// `fallback` (Stift und Text deckend, Textmarker halbtransparent).
fn sanitized_color(color: [f32; 4], fallback: [f32; 4]) -> [f32; 4] {
let mut out = fallback;
if color.iter().all(|c| c.is_finite()) {
for (o, c) in out.iter_mut().zip(color).take(3) {
*o = c.clamp(0.0, 1.0);
}
}
out
}
/// Was eine Taste am Stift bewirkt, solange sie gedrückt ist.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum StylusAction {
/// Radieren (Standard, wie bisher)
#[default]
Erase,
/// Auswählen und verschieben
Select,
/// Mit dem Textmarker schreiben
Highlighter,
/// Mit dem gewählten Stift schreiben (z. B. während der Textmarker aktiv ist)
Pen,
/// Taste nicht verwenden
Nothing,
}
impl StylusAction {
pub const ALL: [StylusAction; 5] = [
StylusAction::Erase,
StylusAction::Select,
StylusAction::Highlighter,
StylusAction::Pen,
StylusAction::Nothing,
];
pub fn label(self) -> &'static str {
match self {
StylusAction::Erase => "Radieren",
StylusAction::Select => "Auswählen",
StylusAction::Highlighter => "Textmarker",
StylusAction::Pen => "Stift",
StylusAction::Nothing => "Nichts",
}
}
}
/// Eine Taste am Stift: Kennung (aus der Tablet-Schnittstelle, beim ersten Drücken gelernt)
/// und ihre Belegung.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct StylusButton {
pub id: u64,
pub action: StylusAction,
}
/// Höchstens so viele Stifttasten werden gemerkt.
pub const MAX_STYLUS_BUTTONS: usize = 4;
/// Benutzereinstellungen der Oberfläche.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
@@ -145,6 +215,13 @@ pub struct UiSettings {
/// Stifte der Werkzeugleiste und der gewählte davon
pub pen_presets: [PenPreset; PEN_PRESET_COUNT],
pub active_pen: usize,
/// Eigene Farben von Textmarker und Text-Werkzeug und die gewählte davon
pub highlighter_colors: [[f32; 4]; 4],
pub active_highlighter: usize,
pub text_colors: [[f32; 4]; 5],
pub active_text_color: usize,
/// Tasten am Stift in der Reihenfolge, in der sie zuerst gedrückt wurden
pub stylus_buttons: Vec<StylusButton>,
}
impl Default for UiSettings {
@@ -158,6 +235,11 @@ impl Default for UiSettings {
continuous_scroll: true,
pen_presets: DEFAULT_PEN_PRESETS,
active_pen: 0,
highlighter_colors: DEFAULT_HIGHLIGHTER_COLORS,
active_highlighter: 0,
text_colors: DEFAULT_TEXT_COLORS,
active_text_color: 0,
stylus_buttons: Vec::new(),
}
}
}
@@ -189,6 +271,23 @@ impl UiSettings {
*preset = preset.sanitized(fallback);
}
settings.active_pen = settings.active_pen.min(PEN_PRESET_COUNT - 1);
for (color, fallback) in settings
.highlighter_colors
.iter_mut()
.zip(DEFAULT_HIGHLIGHTER_COLORS)
{
*color = sanitized_color(*color, fallback);
}
settings.active_highlighter = settings
.active_highlighter
.min(settings.highlighter_colors.len() - 1);
for (color, fallback) in settings.text_colors.iter_mut().zip(DEFAULT_TEXT_COLORS) {
*color = sanitized_color(*color, fallback);
}
settings.active_text_color = settings
.active_text_color
.min(settings.text_colors.len() - 1);
settings.stylus_buttons.truncate(MAX_STYLUS_BUTTONS);
settings
}
@@ -225,6 +324,20 @@ mod tests {
width_mm: 1.5,
}; PEN_PRESET_COUNT],
active_pen: 2,
highlighter_colors: [[0.9, 0.5, 0.1, 0.35]; 4],
active_highlighter: 3,
text_colors: [[0.3, 0.1, 0.6, 1.0]; 5],
active_text_color: 4,
stylus_buttons: vec![
StylusButton {
id: 17,
action: StylusAction::Select,
},
StylusButton {
id: 42,
action: StylusAction::Nothing,
},
],
};
settings.save_to(&path).unwrap();
assert_eq!(UiSettings::load_from(&path), settings);
@@ -280,6 +393,20 @@ mod tests {
assert_eq!(settings.pen_presets[0].color, [1.0, 0.0, 0.5, 1.0]);
assert_eq!(settings.pen_presets[0].width_mm, 5.0);
assert_eq!(settings.pen_presets[1].width_mm, 0.15);
// Eigene Farben: Textmarker bleibt halbtransparent, Text deckend
std::fs::write(
&path,
br#"{"highlighter_colors": [[0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0],
[0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0]], "active_highlighter": 7,
"text_colors": [[5.0, 0.2, 0.3, 0.1], [0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0],
[0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0]]}"#,
)
.unwrap();
let settings = UiSettings::load_from(&path);
assert_eq!(settings.highlighter_colors[0], [0.1, 0.2, 0.3, 0.35]);
assert_eq!(settings.active_highlighter, 3);
assert_eq!(settings.text_colors[0], [1.0, 0.2, 0.3, 1.0]);
}
#[test]
+26 -13
View File
@@ -30,22 +30,33 @@ fn vs_main(in: VertexInput) -> VertexOutput {
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
return in.color;
// Linearer Farbwert (Papier, Muster) -> sRGB: Gezeichnet wird ohne sRGB-Umrechnung der GPU
// (siehe `render_format` in canvas.rs), gemischt wird also wie im PDF in sRGB
fn linear_to_srgb(c: vec3<f32>) -> vec3<f32> {
let low = c * 12.92;
let high = 1.055 * pow(c, vec3<f32>(1.0 / 2.4)) - vec3<f32>(0.055);
return select(high, low, c <= vec3<f32>(0.0031308));
}
// Striche: bei dunklem Papier wird die HSL-Helligkeit dunkler Farben gespiegelt
// (L -> 1 - L, Farbton und Sättigung bleiben). Entspricht `background::adapt_ink_color`.
// Papier: Farbe linear
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
return vec4<f32>(linear_to_srgb(clamp(in.color.rgb, vec3<f32>(0.0), vec3<f32>(1.0))), in.color.a);
}
// Striche: Tintenfarben sind sRGB-Werte wie im Export und in der Oberfläche. Bei dunklem
// Papier wird die HSL-Helligkeit dunkler Farben gespiegelt (L -> 1 - L, Farbton und
// Sättigung bleiben). Entspricht `background::adapt_ink_color`.
@fragment
fn fs_ink(in: VertexOutput) -> @location(0) vec4<f32> {
if (camera.ink_params.x < 0.5) {
return in.color;
var rgb = clamp(in.color.rgb, vec3<f32>(0.0), vec3<f32>(1.0));
if (camera.ink_params.x >= 0.5) {
let max_c = max(max(rgb.r, rgb.g), rgb.b);
let min_c = min(min(rgb.r, rgb.g), rgb.b);
let lift = max(1.0 - max_c - min_c, 0.0);
rgb = min(rgb + vec3<f32>(lift), vec3<f32>(1.0));
}
let max_c = max(max(in.color.r, in.color.g), in.color.b);
let min_c = min(min(in.color.r, in.color.g), in.color.b);
let lift = max(1.0 - max_c - min_c, 0.0);
return vec4<f32>(in.color.rgb + vec3<f32>(lift), in.color.a);
return vec4<f32>(rgb, in.color.a);
}
// Bildschirmfüllendes Dreieck ohne Farbe (setzt den Stencil-Puffer zurück)
@@ -61,7 +72,8 @@ fn fs_none() -> @location(0) vec4<f32> {
return vec4<f32>(0.0);
}
// Eingefügte Bilder: texturierte Vierecke unter der Tinte
// Eingefügte Bilder: texturierte Vierecke unter der Tinte (Texturen enthalten sRGB-Werte
// und werden unverändert übernommen)
struct ImageVertexInput {
@location(0) position: vec2<f32>,
@location(1) uv: vec2<f32>,
@@ -169,5 +181,6 @@ fn fs_pattern(in: PatternOutput) -> @location(0) vec4<f32> {
coverage = clamp(radius_px + 0.5 - d, 0.0, 1.0);
}
}
return vec4<f32>(in.color.rgb, in.color.a * coverage * fade);
let rgb = linear_to_srgb(clamp(in.color.rgb, vec3<f32>(0.0), vec3<f32>(1.0)));
return vec4<f32>(rgb, in.color.a * coverage * fade);
}
+4 -4
View File
@@ -348,7 +348,7 @@ const MAX_TEXTURE_SIDE: f32 = 4096.0;
const MAX_TEXTURE_PIXELS: f32 = 16.0 * 1024.0 * 1024.0;
/// Rastert ein Textfeld mit `px_per_unit` Pixeln je Seiteneinheit (begrenzt auf eine
/// handhabbare Texturgröße). `color` ist linear wie die Tinte; die Pixel sind sRGB.
/// handhabbare Texturgröße). `color` ist sRGB wie die Tinte, ebenso die Pixel.
pub fn rasterize(text: &TextBox, color: [f32; 4], px_per_unit: f32) -> RgbaImage {
let layout = text.layout();
let size = glam::Vec2::new(text.wrap_width.max(text.size), layout.height());
@@ -383,7 +383,7 @@ pub fn rasterize(text: &TextBox, color: [f32; 4], px_per_unit: f32) -> RgbaImage
}
}
let rgb = [0, 1, 2].map(|i| egui::ecolor::gamma_u8_from_linear_f32(color[i]));
let rgb = [0, 1, 2].map(|i| (color[i].clamp(0.0, 1.0) * 255.0).round() as u8);
let alpha = color[3].clamp(0.0, 1.0);
let mut pixels = Vec::with_capacity(coverage.len() * 4);
for c in coverage {
@@ -476,8 +476,8 @@ mod tests {
assert_eq!(image.height as f32, text.layout().height().ceil());
let opaque = image.pixels.chunks(4).filter(|p| p[3] > 200).count();
assert!(opaque > 100, "{opaque}");
// Farbe ist sRGB-kodiert
assert!(image.pixels.chunks(4).all(|p| p[0] > 230 && p[1] < 130));
// Die Farbe ist schon sRGB und wird unverändert übernommen
assert!(image.pixels.chunks(4).all(|p| p[..3] == [217, 51, 51]));
}
#[test]
+977 -173
View File
File diff suppressed because it is too large. Load diff