Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e33062fe0a | ||
|
|
36a1a3cd2e | ||
|
|
70317caeb5 | ||
|
|
f9a6f8524d | ||
|
|
4a4492d034 | ||
|
|
bb9dbb52d0 |
No files matched your search
Generated
+1
-1
@@ -1393,7 +1393,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ink-notes"
|
||||
version = "0.15.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"arboard",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ink-notes"
|
||||
version = "0.15.0"
|
||||
version = "0.18.0"
|
||||
edition = "2021"
|
||||
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||
|
||||
@@ -19,6 +19,7 @@ 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.
|
||||
@@ -38,6 +39,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 +53,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 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 +163,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 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`)
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit Zorin OS 18 / Ubuntu 24.04).
|
||||
|
||||
### Highlights:
|
||||
- **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
@@ -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
@@ -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
@@ -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
@@ -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: &[],
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
+376
@@ -0,0 +1,376 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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_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);
|
||||
}
|
||||
}
|
||||
+207
-41
@@ -24,6 +24,7 @@ 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::{
|
||||
@@ -34,6 +35,36 @@ use ink_notes::storage::{self, NotebookManifest};
|
||||
use ink_notes::ui::{self, ActiveTool, UiAction, UiState};
|
||||
use ink_notes::updater::UpdaterController;
|
||||
|
||||
/// 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 +612,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 +620,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 +829,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 +857,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 +881,7 @@ fn paste(
|
||||
{
|
||||
insert_text_on_page(
|
||||
&text,
|
||||
at,
|
||||
canvas,
|
||||
notebook,
|
||||
manifest,
|
||||
@@ -874,17 +912,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 +1058,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, ¬ebook, &mut manifest) {
|
||||
eprintln!("[Storage] Neues Notizbuch nicht gespeichert: {err}");
|
||||
}
|
||||
}
|
||||
(notebook, manifest)
|
||||
};
|
||||
|
||||
// 2. Asynchronen Autosave-Controller mit 1.5s Debounce starten
|
||||
@@ -1060,19 +1108,23 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
ui_state.continuous_scroll = ui_settings.continuous_scroll;
|
||||
ui_state.pen_presets = ui_settings.pen_presets;
|
||||
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 +1176,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)
|
||||
@@ -1558,6 +1612,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 +1650,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 +1863,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, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
@@ -2373,6 +2452,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 +2531,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 +2600,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;
|
||||
@@ -2710,9 +2804,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, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
@@ -2910,8 +3008,60 @@ 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::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 +3097,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, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
@@ -2964,6 +3114,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 +3125,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 +3139,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, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
@@ -3000,10 +3156,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 +3173,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, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
@@ -3032,7 +3192,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(¬ebook);
|
||||
autosave.mark_manifest_dirty(&manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
@@ -3049,7 +3209,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 +3234,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 +3272,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, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
ui_state.set_export_message("Standard-Notizbuch erstellt".to_string());
|
||||
@@ -3132,7 +3292,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 +3307,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();
|
||||
}
|
||||
@@ -3263,16 +3423,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
@@ -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);
|
||||
|
||||
+73
-7
@@ -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,35 @@ 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
|
||||
}
|
||||
|
||||
/// Benutzereinstellungen der Oberfläche.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
@@ -145,6 +168,11 @@ 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,
|
||||
}
|
||||
|
||||
impl Default for UiSettings {
|
||||
@@ -158,6 +186,10 @@ 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,6 +221,22 @@ 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
|
||||
}
|
||||
|
||||
@@ -225,6 +273,10 @@ 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,
|
||||
};
|
||||
settings.save_to(&path).unwrap();
|
||||
assert_eq!(UiSettings::load_from(&path), settings);
|
||||
@@ -280,6 +332,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
@@ -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
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user