Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea9ee6c0d7 | ||
|
|
d1f0b46fac | ||
|
|
82a297f395 | ||
|
|
e33062fe0a | ||
|
|
36a1a3cd2e |
Generated
+1
-1
@@ -1393,7 +1393,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ink-notes"
|
name = "ink-notes"
|
||||||
version = "0.17.0"
|
version = "0.19.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ab_glyph",
|
"ab_glyph",
|
||||||
"arboard",
|
"arboard",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ink-notes"
|
name = "ink-notes"
|
||||||
version = "0.17.0"
|
version = "0.19.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
||||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
|
|||||||
- **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.
|
- **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.
|
- **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).
|
- **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.
|
- **Virtuelles Lineal** (`L`): verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden gerade.
|
||||||
|
|
||||||
### Formerkennung
|
### Formerkennung
|
||||||
@@ -53,6 +54,7 @@ Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
|
|||||||
|
|
||||||
### Notizbücher und Speichern
|
### Notizbücher und Speichern
|
||||||
- Mehrere **Notizbücher** anlegen, umbenennen, wechseln und löschen (gelöschte landen zunächst im Papierkorb und lassen sich wiederherstellen).
|
- 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.
|
- **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.
|
- 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.
|
- **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.
|
||||||
@@ -162,6 +164,7 @@ src/
|
|||||||
├── document.rs # Datenmodell: Notizbuch, Seiten, Striche, Seitengrößen
|
├── document.rs # Datenmodell: Notizbuch, Seiten, Striche, Seitengrößen
|
||||||
├── history.rs # Rückgängig/Wiederholen
|
├── history.rs # Rückgängig/Wiederholen
|
||||||
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb
|
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb
|
||||||
|
├── library.rs # Ordner der Notizbücher (library.json)
|
||||||
├── autosave.rs # Speichern im Hintergrund
|
├── autosave.rs # Speichern im Hintergrund
|
||||||
├── export.rs # PDF-Export (printpdf)
|
├── export.rs # PDF-Export (printpdf)
|
||||||
├── page_export.rs # Seite als PNG/SVG, Auswahl als Bild
|
├── page_export.rs # Seite als PNG/SVG, Auswahl als Bild
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ Dieses Dokument beschreibt, wo und wie ink-notes Notizbücher speichert, und die
|
|||||||
```text
|
```text
|
||||||
~/.local/share/ink-notes/ ($XDG_DATA_HOME/ink-notes)
|
~/.local/share/ink-notes/ ($XDG_DATA_HOME/ink-notes)
|
||||||
├── notebooks/
|
├── notebooks/
|
||||||
|
│ ├── library.json Ordner der Notizbücher (siehe unten)
|
||||||
│ ├── <notizbuch-id>/
|
│ ├── <notizbuch-id>/
|
||||||
│ │ ├── manifest.json Metadaten und Seitenreihenfolge
|
│ │ ├── manifest.json Metadaten und Seitenreihenfolge
|
||||||
│ │ ├── manifest.json.bak Kopie des vorherigen Manifests
|
│ │ ├── 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.
|
- **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.
|
- 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`)
|
## 2. Manifest (`manifest.json`)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit Zorin OS 18 / Ubuntu 24.04).
|
Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit Zorin OS 18 / Ubuntu 24.04).
|
||||||
|
|
||||||
### Highlights:
|
### 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)
|
- **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
|
- **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
|
- **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
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub mod filter;
|
|||||||
pub mod geometry;
|
pub mod geometry;
|
||||||
pub mod history;
|
pub mod history;
|
||||||
pub mod image_layer;
|
pub mod image_layer;
|
||||||
|
pub mod library;
|
||||||
pub mod page_export;
|
pub mod page_export;
|
||||||
pub mod pdf_doc;
|
pub mod pdf_doc;
|
||||||
pub mod pdf_merge;
|
pub mod pdf_merge;
|
||||||
|
|||||||
+441
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+234
-49
@@ -24,16 +24,75 @@ use ink_notes::document::{Notebook, PageBackground};
|
|||||||
use ink_notes::export::{self, export_notebook_to_pdf, ExportStrategy};
|
use ink_notes::export::{self, export_notebook_to_pdf, ExportStrategy};
|
||||||
use ink_notes::geometry::{self, RecognizedShape};
|
use ink_notes::geometry::{self, RecognizedShape};
|
||||||
use ink_notes::image_layer::TextureSource;
|
use ink_notes::image_layer::TextureSource;
|
||||||
|
use ink_notes::library::Library;
|
||||||
use ink_notes::pdf_doc::PdfRenderService;
|
use ink_notes::pdf_doc::PdfRenderService;
|
||||||
use ink_notes::platform::{self, DisplayBackend};
|
use ink_notes::platform::{self, DisplayBackend};
|
||||||
use ink_notes::selection::{
|
use ink_notes::selection::{
|
||||||
render_selection_overlay, DragMode, SelectionHandleHit, SelectionState,
|
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::storage::{self, NotebookManifest};
|
||||||
use ink_notes::ui::{self, ActiveTool, UiAction, UiState};
|
use ink_notes::ui::{self, ActiveTool, UiAction, UiState};
|
||||||
use ink_notes::updater::UpdaterController;
|
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.
|
/// Aktualisiert den Fenstertitel als minimales On-Screen-Display (OSD) inklusive Autosave-Status.
|
||||||
/// Speichert die UI-Einstellungen; ein Fehler wird gemeldet, bricht aber nichts ab.
|
/// 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) {
|
fn save_ui_settings(settings: &UiSettings, path: Option<&std::path::Path>, ui_state: &mut UiState) {
|
||||||
@@ -1027,10 +1086,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
"[Storage] Erstelle neues Notizbuch in: {}",
|
"[Storage] Erstelle neues Notizbuch in: {}",
|
||||||
storage_dir.display()
|
storage_dir.display()
|
||||||
);
|
);
|
||||||
(
|
let notebook = Notebook::new();
|
||||||
Notebook::new(),
|
let mut manifest = NotebookManifest::new(&initial_id, &initial_title);
|
||||||
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
|
// 2. Asynchronen Autosave-Controller mit 1.5s Debounce starten
|
||||||
@@ -1069,6 +1135,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
ui_state.pressure_curve = ui_settings.pressure_curve;
|
ui_state.pressure_curve = ui_settings.pressure_curve;
|
||||||
ui_state.continuous_scroll = ui_settings.continuous_scroll;
|
ui_state.continuous_scroll = ui_settings.continuous_scroll;
|
||||||
ui_state.pen_presets = ui_settings.pen_presets;
|
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.select_pen(ui_settings.active_pen);
|
||||||
ui_state.highlighter_colors = ui_settings.highlighter_colors;
|
ui_state.highlighter_colors = ui_settings.highlighter_colors;
|
||||||
ui_state.select_highlighter_color(ui_settings.active_highlighter);
|
ui_state.select_highlighter_color(ui_settings.active_highlighter);
|
||||||
@@ -1078,8 +1145,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
ui_state.current_notebook_id = manifest.id.clone();
|
ui_state.current_notebook_id = manifest.id.clone();
|
||||||
ui_state.document_title = manifest.title.clone();
|
ui_state.document_title = manifest.title.clone();
|
||||||
ui_state.available_notebooks = available_notebooks;
|
ui_state.available_notebooks = available_notebooks;
|
||||||
|
ui_state.library = Library::load(&storage_base_dir);
|
||||||
if let Some(warning) = startup_warning {
|
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);
|
ui_state.set_export_message(warning);
|
||||||
}
|
}
|
||||||
let mut selection_state = SelectionState::new();
|
let mut selection_state = SelectionState::new();
|
||||||
@@ -1147,7 +1215,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let mut is_stylus_selecting = false;
|
let mut is_stylus_selecting = false;
|
||||||
let mut is_stylus_in_proximity = false;
|
let mut is_stylus_in_proximity = false;
|
||||||
let mut stylus_tool_id: Option<octotablet::tool::ID> = None;
|
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);
|
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
|
// Stift hat auf der UI aufgesetzt: alle Ereignisse bis zum Abheben gehen an egui
|
||||||
let mut stylus_ui_capture = false;
|
let mut stylus_ui_capture = false;
|
||||||
@@ -1318,23 +1389,45 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state);
|
selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state);
|
||||||
window.request_redraw();
|
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, .. } => {
|
octotablet::events::ToolEvent::Button { button_id, pressed } => {
|
||||||
stylus_barrel_pressed = pressed;
|
// Tasten werden beim ersten Drücken gelernt (Standard: Radieren)
|
||||||
if pressed && is_stylus_drawing {
|
let key = stylus_button_key(&button_id);
|
||||||
is_stylus_drawing = false;
|
let mut index = ui_state.stylus_buttons.iter().position(|b| b.id == key);
|
||||||
is_drawing = false;
|
if index.is_none() && pressed && ui_state.stylus_buttons.len() < MAX_STYLUS_BUTTONS {
|
||||||
canvas.cancel_stroke();
|
ui_state.stylus_buttons.push(StylusButton { id: key, action: StylusAction::Erase });
|
||||||
is_erasing = true;
|
index = Some(ui_state.stylus_buttons.len() - 1);
|
||||||
window.request_redraw();
|
ui_settings.stylus_buttons = ui_state.stylus_buttons.clone();
|
||||||
} else if !pressed
|
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
||||||
&& is_erasing
|
|
||||||
&& !tool_is_hardware_eraser
|
|
||||||
&& ui_state.active_tool != ActiveTool::Eraser
|
|
||||||
{
|
|
||||||
is_erasing = false;
|
|
||||||
}
|
}
|
||||||
|
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) => {
|
octotablet::events::ToolEvent::Pose(pose) => {
|
||||||
is_stylus_in_proximity = true;
|
is_stylus_in_proximity = true;
|
||||||
@@ -1432,8 +1525,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
focus_page_at!((sx, sy));
|
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
|
let should_erase = tool_is_hardware_eraser
|
||||||
|| stylus_barrel_pressed
|
|| button_action == Some(StylusAction::Erase)
|
||||||
|| ui_state.active_tool == ActiveTool::Eraser;
|
|| ui_state.active_tool == ActiveTool::Eraser;
|
||||||
|
|
||||||
if !should_erase && ui_state.active_tool == ActiveTool::Text {
|
if !should_erase && ui_state.active_tool == ActiveTool::Text {
|
||||||
@@ -1511,6 +1618,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let restore_tool = tool_before_button.take();
|
||||||
|
|
||||||
if is_stylus_drawing {
|
if is_stylus_drawing {
|
||||||
is_stylus_drawing = false;
|
is_stylus_drawing = false;
|
||||||
@@ -1545,6 +1653,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
snapped_shape,
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -2692,22 +2805,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
for action in actions {
|
for action in actions {
|
||||||
match action {
|
match action {
|
||||||
UiAction::SetTool(tool) => {
|
UiAction::SetTool(tool) => {
|
||||||
ui_state.active_tool = tool;
|
activate_tool(tool, &mut ui_state, &mut canvas);
|
||||||
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 => {}
|
|
||||||
}
|
|
||||||
update_window_title(&window, ¬ebook, &manifest, tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, tool, autosave.status());
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
@@ -2969,8 +3067,81 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
window.request_redraw();
|
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 => {
|
UiAction::RefreshNotebooks => {
|
||||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
UiAction::OpenNotebook(id) => {
|
UiAction::OpenNotebook(id) => {
|
||||||
@@ -3006,7 +3177,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
selection_state.clear();
|
selection_state.clear();
|
||||||
ui_state.current_notebook_id = manifest.id.clone();
|
ui_state.current_notebook_id = manifest.id.clone();
|
||||||
ui_state.document_title = manifest.title.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;
|
ui_state.is_document_menu_open = false;
|
||||||
canvas.sync_page(notebook.current_page_mut());
|
canvas.sync_page(notebook.current_page_mut());
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
@@ -3023,6 +3194,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
UiAction::ImportPdf(path) => {
|
UiAction::ImportPdf(path) => {
|
||||||
|
// Neben das gerade geöffnete Notizbuch
|
||||||
|
let folder = ui_state.library.folder_of(&manifest.id).to_string();
|
||||||
let title = path
|
let title = path
|
||||||
.file_stem()
|
.file_stem()
|
||||||
.map(|s| s.to_string_lossy().into_owned())
|
.map(|s| s.to_string_lossy().into_owned())
|
||||||
@@ -3032,6 +3205,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.and_then(|bytes| storage::create_notebook_from_pdf(&storage_base_dir, &title, bytes));
|
.and_then(|bytes| storage::create_notebook_from_pdf(&storage_base_dir, &title, bytes));
|
||||||
match result {
|
match result {
|
||||||
Ok((new_nb, new_manifest, new_dir)) => {
|
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();
|
let _ = autosave.flush();
|
||||||
_notebook_lock = storage::NotebookLock::acquire(&new_dir).ok();
|
_notebook_lock = storage::NotebookLock::acquire(&new_dir).ok();
|
||||||
autosave.set_read_only(_notebook_lock.is_none());
|
autosave.set_read_only(_notebook_lock.is_none());
|
||||||
@@ -3042,7 +3219,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
selection_state.clear();
|
selection_state.clear();
|
||||||
ui_state.current_notebook_id = manifest.id.clone();
|
ui_state.current_notebook_id = manifest.id.clone();
|
||||||
ui_state.document_title = manifest.title.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.reset_view();
|
||||||
canvas.sync_page(notebook.current_page_mut());
|
canvas.sync_page(notebook.current_page_mut());
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
@@ -3059,10 +3236,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
UiAction::CreateNotebook(title) => {
|
UiAction::CreateNotebook { title, folder } => {
|
||||||
let _ = autosave.flush();
|
let _ = autosave.flush();
|
||||||
match storage::create_new_notebook(&storage_base_dir, &title) {
|
match storage::create_new_notebook(&storage_base_dir, &title) {
|
||||||
Ok((new_nb, new_manifest, new_dir)) => {
|
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();
|
_notebook_lock = storage::NotebookLock::acquire(&new_dir).ok();
|
||||||
autosave.set_read_only(_notebook_lock.is_none());
|
autosave.set_read_only(_notebook_lock.is_none());
|
||||||
autosave.switch_notebook_dir(new_dir.clone());
|
autosave.switch_notebook_dir(new_dir.clone());
|
||||||
@@ -3072,7 +3253,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
selection_state.clear();
|
selection_state.clear();
|
||||||
ui_state.current_notebook_id = manifest.id.clone();
|
ui_state.current_notebook_id = manifest.id.clone();
|
||||||
ui_state.document_title = manifest.title.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;
|
ui_state.is_document_menu_open = false;
|
||||||
canvas.sync_page(notebook.current_page_mut());
|
canvas.sync_page(notebook.current_page_mut());
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
@@ -3091,7 +3272,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
match storage::rename_notebook(&target_dir, &mut manifest, &new_title) {
|
match storage::rename_notebook(&target_dir, &mut manifest, &new_title) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
ui_state.document_title = manifest.title.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);
|
||||||
manifest.sync_from_notebook(¬ebook);
|
manifest.sync_from_notebook(¬ebook);
|
||||||
autosave.mark_manifest_dirty(&manifest);
|
autosave.mark_manifest_dirty(&manifest);
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
@@ -3108,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(content) = std::fs::read_to_string(&manifest_path) {
|
||||||
if let Ok(mut other_manifest) = serde_json::from_str::<NotebookManifest>(&content) {
|
if let Ok(mut other_manifest) = serde_json::from_str::<NotebookManifest>(&content) {
|
||||||
let _ = storage::rename_notebook(&target_dir, &mut other_manifest, &new_title);
|
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));
|
ui_state.set_export_message(format!("Umbenannt in '{}'", new_title));
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
@@ -3133,7 +3314,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
match storage::delete_notebook(&target_dir, &storage_base_dir) {
|
match storage::delete_notebook(&target_dir, &storage_base_dir) {
|
||||||
Ok(trash_path) => {
|
Ok(trash_path) => {
|
||||||
ui_state.last_deleted_notebook = Some((trash_path, deleted_title));
|
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 is_current {
|
||||||
if let Some(first) = ui_state
|
if let Some(first) = ui_state
|
||||||
.available_notebooks
|
.available_notebooks
|
||||||
@@ -3171,7 +3352,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
selection_state.clear();
|
selection_state.clear();
|
||||||
ui_state.current_notebook_id = manifest.id.clone();
|
ui_state.current_notebook_id = manifest.id.clone();
|
||||||
ui_state.document_title = manifest.title.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());
|
canvas.sync_page(notebook.current_page_mut());
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
ui_state.set_export_message("Standard-Notizbuch erstellt".to_string());
|
ui_state.set_export_message("Standard-Notizbuch erstellt".to_string());
|
||||||
@@ -3191,7 +3372,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
if let Some((trash_path, _title)) = ui_state.last_deleted_notebook.take() {
|
if let Some((trash_path, _title)) = ui_state.last_deleted_notebook.take() {
|
||||||
match storage::restore_notebook_from_trash(&storage_base_dir, &trash_path) {
|
match storage::restore_notebook_from_trash(&storage_base_dir, &trash_path) {
|
||||||
Ok(_restored_dir) => {
|
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());
|
ui_state.set_export_message("Notizbuch wiederhergestellt".to_string());
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
@@ -3206,7 +3387,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let target_dir = storage_base_dir.join(&id);
|
let target_dir = storage_base_dir.join(&id);
|
||||||
match storage::restore_manifest_from_backup(&target_dir) {
|
match storage::restore_manifest_from_backup(&target_dir) {
|
||||||
Ok(()) => {
|
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());
|
ui_state.set_export_message("Manifest aus Backup wiederhergestellt".to_string());
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
@@ -3311,6 +3492,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
||||||
window.request_redraw();
|
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) => {
|
UiAction::SavePressureCurve(curve) => {
|
||||||
ui_settings.pressure_curve = curve;
|
ui_settings.pressure_curve = curve;
|
||||||
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
||||||
|
|||||||
@@ -150,6 +150,53 @@ fn sanitized_color(color: [f32; 4], fallback: [f32; 4]) -> [f32; 4] {
|
|||||||
out
|
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.
|
/// Benutzereinstellungen der Oberfläche.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -173,6 +220,8 @@ pub struct UiSettings {
|
|||||||
pub active_highlighter: usize,
|
pub active_highlighter: usize,
|
||||||
pub text_colors: [[f32; 4]; 5],
|
pub text_colors: [[f32; 4]; 5],
|
||||||
pub active_text_color: usize,
|
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 {
|
impl Default for UiSettings {
|
||||||
@@ -190,6 +239,7 @@ impl Default for UiSettings {
|
|||||||
active_highlighter: 0,
|
active_highlighter: 0,
|
||||||
text_colors: DEFAULT_TEXT_COLORS,
|
text_colors: DEFAULT_TEXT_COLORS,
|
||||||
active_text_color: 0,
|
active_text_color: 0,
|
||||||
|
stylus_buttons: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -237,6 +287,7 @@ impl UiSettings {
|
|||||||
settings.active_text_color = settings
|
settings.active_text_color = settings
|
||||||
.active_text_color
|
.active_text_color
|
||||||
.min(settings.text_colors.len() - 1);
|
.min(settings.text_colors.len() - 1);
|
||||||
|
settings.stylus_buttons.truncate(MAX_STYLUS_BUTTONS);
|
||||||
settings
|
settings
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,6 +328,16 @@ mod tests {
|
|||||||
active_highlighter: 3,
|
active_highlighter: 3,
|
||||||
text_colors: [[0.3, 0.1, 0.6, 1.0]; 5],
|
text_colors: [[0.3, 0.1, 0.6, 1.0]; 5],
|
||||||
active_text_color: 4,
|
active_text_color: 4,
|
||||||
|
stylus_buttons: vec![
|
||||||
|
StylusButton {
|
||||||
|
id: 17,
|
||||||
|
action: StylusAction::Select,
|
||||||
|
},
|
||||||
|
StylusButton {
|
||||||
|
id: 42,
|
||||||
|
action: StylusAction::Nothing,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
settings.save_to(&path).unwrap();
|
settings.save_to(&path).unwrap();
|
||||||
assert_eq!(UiSettings::load_from(&path), settings);
|
assert_eq!(UiSettings::load_from(&path), settings);
|
||||||
|
|||||||
@@ -171,6 +171,14 @@ impl LongPress {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Was im Dokumenten-Manager gerade verschoben wird.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum MoveSubject {
|
||||||
|
Notebook(String),
|
||||||
|
/// Pfad des Ordners
|
||||||
|
Folder(String),
|
||||||
|
}
|
||||||
|
|
||||||
/// Kontextmenü nach langem Drücken auf das Blatt.
|
/// Kontextmenü nach langem Drücken auf das Blatt.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
pub struct ContextMenu {
|
pub struct ContextMenu {
|
||||||
@@ -228,7 +236,31 @@ pub enum UiAction {
|
|||||||
RotateSelected,
|
RotateSelected,
|
||||||
// Dokumenten- & Notizbuch-Verwaltung
|
// Dokumenten- & Notizbuch-Verwaltung
|
||||||
OpenNotebook(String),
|
OpenNotebook(String),
|
||||||
CreateNotebook(String),
|
/// Neues Notizbuch im Ordner `folder` anlegen und öffnen
|
||||||
|
CreateNotebook {
|
||||||
|
title: String,
|
||||||
|
folder: String,
|
||||||
|
},
|
||||||
|
/// Ordnerverwaltung (siehe `library.rs`)
|
||||||
|
CreateFolder {
|
||||||
|
parent: String,
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
RenameFolder {
|
||||||
|
path: String,
|
||||||
|
new_name: String,
|
||||||
|
},
|
||||||
|
/// Ordner löschen; Inhalt rückt eine Ebene nach oben
|
||||||
|
DeleteFolder(String),
|
||||||
|
MoveNotebook {
|
||||||
|
id: String,
|
||||||
|
folder: String,
|
||||||
|
},
|
||||||
|
/// Ordner samt Inhalt nach `new_parent` verschieben
|
||||||
|
MoveFolder {
|
||||||
|
path: String,
|
||||||
|
new_parent: String,
|
||||||
|
},
|
||||||
RenameNotebook {
|
RenameNotebook {
|
||||||
id: String,
|
id: String,
|
||||||
new_title: String,
|
new_title: String,
|
||||||
@@ -256,6 +288,8 @@ pub enum UiAction {
|
|||||||
theme: PaperTheme,
|
theme: PaperTheme,
|
||||||
adapt_ink: bool,
|
adapt_ink: bool,
|
||||||
},
|
},
|
||||||
|
/// Belegung der Stifttasten speichern (gilt sofort über `UiState::stylus_buttons`)
|
||||||
|
SaveStylusButtons,
|
||||||
/// Druckkurve speichern (gilt während des Ziehens bereits über `UiState::pressure_curve`)
|
/// Druckkurve speichern (gilt während des Ziehens bereits über `UiState::pressure_curve`)
|
||||||
SavePressureCurve(PressureCurve),
|
SavePressureCurve(PressureCurve),
|
||||||
/// Seiten durchgehend untereinander statt einzeln anzeigen (wird gespeichert)
|
/// Seiten durchgehend untereinander statt einzeln anzeigen (wird gespeichert)
|
||||||
@@ -306,6 +340,14 @@ pub struct UiState {
|
|||||||
pub available_notebooks: Vec<NotebookSummary>,
|
pub available_notebooks: Vec<NotebookSummary>,
|
||||||
pub new_notebook_title_input: String,
|
pub new_notebook_title_input: String,
|
||||||
pub renaming_notebook_id: Option<String>,
|
pub renaming_notebook_id: Option<String>,
|
||||||
|
/// Ordner der Notizbücher und der gerade angezeigte Ordner im Dokumenten-Manager
|
||||||
|
pub library: crate::library::Library,
|
||||||
|
pub notebook_folder: String,
|
||||||
|
/// Ordner, der gerade umbenannt wird, bzw. dessen Löschen bestätigt werden soll
|
||||||
|
pub renaming_folder: Option<String>,
|
||||||
|
pub confirm_delete_folder: Option<String>,
|
||||||
|
/// Notizbuch oder Ordner, für das gerade ein Zielordner gewählt wird
|
||||||
|
pub moving: Option<MoveSubject>,
|
||||||
pub renaming_title_input: String,
|
pub renaming_title_input: String,
|
||||||
pub document_modal_rect: Option<egui::Rect>,
|
pub document_modal_rect: Option<egui::Rect>,
|
||||||
/// Gitea Release- & Update-Manager
|
/// Gitea Release- & Update-Manager
|
||||||
@@ -336,6 +378,9 @@ pub struct UiState {
|
|||||||
pub show_color_picker: bool,
|
pub show_color_picker: bool,
|
||||||
/// Kontextmenü nach langem Drücken (Einfügen an dieser Stelle)
|
/// Kontextmenü nach langem Drücken (Einfügen an dieser Stelle)
|
||||||
pub context_menu: Option<ContextMenu>,
|
pub context_menu: Option<ContextMenu>,
|
||||||
|
/// Tasten am Stift und ihre Belegung; die gerade gedrückte (Index) wird hervorgehoben
|
||||||
|
pub stylus_buttons: Vec<crate::settings::StylusButton>,
|
||||||
|
pub pressed_stylus_button: Option<usize>,
|
||||||
/// Rückfrage „Seite löschen?“ im ☰-Menü ist offen
|
/// Rückfrage „Seite löschen?“ im ☰-Menü ist offen
|
||||||
pub confirm_delete_page: bool,
|
pub confirm_delete_page: bool,
|
||||||
/// Seitenzahl der zuletzt gelöschten Seite, solange sie wiederhergestellt werden kann
|
/// Seitenzahl der zuletzt gelöschten Seite, solange sie wiederhergestellt werden kann
|
||||||
@@ -408,6 +453,11 @@ impl Default for UiState {
|
|||||||
available_notebooks: Vec::new(),
|
available_notebooks: Vec::new(),
|
||||||
new_notebook_title_input: String::new(),
|
new_notebook_title_input: String::new(),
|
||||||
renaming_notebook_id: None,
|
renaming_notebook_id: None,
|
||||||
|
library: crate::library::Library::default(),
|
||||||
|
notebook_folder: String::new(),
|
||||||
|
renaming_folder: None,
|
||||||
|
confirm_delete_folder: None,
|
||||||
|
moving: None,
|
||||||
renaming_title_input: String::new(),
|
renaming_title_input: String::new(),
|
||||||
document_modal_rect: None,
|
document_modal_rect: None,
|
||||||
is_update_dialog_open: false,
|
is_update_dialog_open: false,
|
||||||
@@ -426,6 +476,8 @@ impl Default for UiState {
|
|||||||
is_text_color_menu_open: false,
|
is_text_color_menu_open: false,
|
||||||
show_color_picker: false,
|
show_color_picker: false,
|
||||||
context_menu: None,
|
context_menu: None,
|
||||||
|
stylus_buttons: Vec::new(),
|
||||||
|
pressed_stylus_button: None,
|
||||||
confirm_delete_page: false,
|
confirm_delete_page: false,
|
||||||
restorable_page: None,
|
restorable_page: None,
|
||||||
shutdown_error: None,
|
shutdown_error: None,
|
||||||
@@ -1590,6 +1642,70 @@ fn pattern_color_button(
|
|||||||
/// Schieberegler für die Druckkurve mit Vorschau der Kennlinie. Änderungen gelten sofort
|
/// Schieberegler für die Druckkurve mit Vorschau der Kennlinie. Änderungen gelten sofort
|
||||||
/// (`state.pressure_curve`); zurückgegeben wird die Kurve, sobald sie gespeichert werden soll
|
/// (`state.pressure_curve`); zurückgegeben wird die Kurve, sobald sie gespeichert werden soll
|
||||||
/// (Regler losgelassen oder per Tastatur geändert).
|
/// (Regler losgelassen oder per Tastatur geändert).
|
||||||
|
/// Belegung der Tasten am Stift. Tasten erscheinen, sobald sie einmal gedrückt wurden; die
|
||||||
|
/// gerade gedrückte ist markiert. `true`, wenn sich etwas geändert hat.
|
||||||
|
fn stylus_buttons_editor(ui: &mut egui::Ui, state: &mut UiState) -> bool {
|
||||||
|
use crate::settings::StylusAction;
|
||||||
|
let mut changed = false;
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.label(egui::RichText::new("Tasten am Stift").color(egui::Color32::from_gray(200)));
|
||||||
|
if state.stylus_buttons.is_empty() {
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(
|
||||||
|
"Drücke eine Taste am Stift (über dem Bildschirm) – sie erscheint dann hier. \
|
||||||
|
Bis dahin radieren alle Tasten.",
|
||||||
|
)
|
||||||
|
.size(11.0)
|
||||||
|
.color(egui::Color32::from_gray(150)),
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut remove = None;
|
||||||
|
for i in 0..state.stylus_buttons.len() {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let pressed = state.pressed_stylus_button == Some(i);
|
||||||
|
let label = egui::RichText::new(format!("Taste {}", i + 1));
|
||||||
|
ui.label(if pressed {
|
||||||
|
label.strong().color(egui::Color32::from_rgb(46, 204, 113))
|
||||||
|
} else {
|
||||||
|
label
|
||||||
|
})
|
||||||
|
.on_hover_text("Leuchtet grün, solange die Taste gedrückt ist");
|
||||||
|
let current = state.stylus_buttons[i].action;
|
||||||
|
egui::ComboBox::from_id_source(("stylus_button", i))
|
||||||
|
.selected_text(current.label())
|
||||||
|
.width(130.0)
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
for action in StylusAction::ALL {
|
||||||
|
if ui
|
||||||
|
.selectable_label(action == current, action.label())
|
||||||
|
.clicked()
|
||||||
|
&& action != current
|
||||||
|
{
|
||||||
|
state.stylus_buttons[i].action = action;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if ui
|
||||||
|
.small_button("🗙")
|
||||||
|
.on_hover_text(
|
||||||
|
"Vergessen (erscheint beim nächsten Drücken wieder, dann mit Radieren)",
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
remove = Some(i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(i) = remove {
|
||||||
|
state.stylus_buttons.remove(i);
|
||||||
|
state.pressed_stylus_button = None;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
fn pressure_curve_editor(ui: &mut egui::Ui, state: &mut UiState) -> Option<PressureCurve> {
|
fn pressure_curve_editor(ui: &mut egui::Ui, state: &mut UiState) -> Option<PressureCurve> {
|
||||||
let mut save = false;
|
let mut save = false;
|
||||||
let mut track = |response: egui::Response| {
|
let mut track = |response: egui::Response| {
|
||||||
@@ -1670,6 +1786,240 @@ fn pressure_curve_editor(ui: &mut egui::Ui, state: &mut UiState) -> Option<Press
|
|||||||
save.then_some(state.pressure_curve)
|
save.then_some(state.pressure_curve)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pfad des gezeigten Ordners im Dokumenten-Manager: „📚 Alle › 3. Semester › Mathe“, jeder
|
||||||
|
/// Teil antippbar.
|
||||||
|
fn folder_breadcrumbs(ui: &mut egui::Ui, state: &mut UiState, folder: &str) {
|
||||||
|
ui.horizontal_wrapped(|ui| {
|
||||||
|
ui.spacing_mut().item_spacing.x = 4.0;
|
||||||
|
let mut target = None;
|
||||||
|
if ui
|
||||||
|
.selectable_label(folder.is_empty(), egui::RichText::new("📚 Alle").size(13.0))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
target = Some(String::new());
|
||||||
|
}
|
||||||
|
let mut path = String::new();
|
||||||
|
for name in folder.split('/').filter(|n| !n.is_empty()) {
|
||||||
|
ui.label(egui::RichText::new("›").color(egui::Color32::from_gray(140)));
|
||||||
|
if !path.is_empty() {
|
||||||
|
path.push('/');
|
||||||
|
}
|
||||||
|
path.push_str(name);
|
||||||
|
if ui
|
||||||
|
.selectable_label(path == folder, egui::RichText::new(name).size(13.0))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
target = Some(path.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(target) = target {
|
||||||
|
state.notebook_folder = target;
|
||||||
|
state.renaming_folder = None;
|
||||||
|
state.confirm_delete_folder = None;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zeile eines Unterordners: antippen öffnet ihn; umbenennen und löschen (mit Rückfrage).
|
||||||
|
fn folder_row(ui: &mut egui::Ui, state: &mut UiState, path: &str, actions: &mut Vec<UiAction>) {
|
||||||
|
let name = crate::library::folder_name(path);
|
||||||
|
if state.renaming_folder.as_deref() == Some(path) {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let edit = ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut state.renaming_title_input)
|
||||||
|
.margin(egui::vec2(8.0, 8.0))
|
||||||
|
.desired_width(260.0),
|
||||||
|
);
|
||||||
|
if ui.button("✔").clicked()
|
||||||
|
|| (edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)))
|
||||||
|
{
|
||||||
|
actions.push(UiAction::RenameFolder {
|
||||||
|
path: path.to_string(),
|
||||||
|
new_name: state.renaming_title_input.trim().to_string(),
|
||||||
|
});
|
||||||
|
state.renaming_folder = None;
|
||||||
|
}
|
||||||
|
if ui.button("🗙").clicked() {
|
||||||
|
state.renaming_folder = None;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if state.confirm_delete_folder.as_deref() == Some(path) {
|
||||||
|
egui::Frame::none()
|
||||||
|
.fill(egui::Color32::from_rgba_unmultiplied(170, 50, 45, 40))
|
||||||
|
.rounding(egui::Rounding::same(8.0))
|
||||||
|
.inner_margin(egui::Margin::symmetric(10.0, 8.0))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
ui.add(
|
||||||
|
egui::Label::new(format!(
|
||||||
|
"Ordner „{name}“ löschen? Seine Notizbücher und Unterordner rücken eine \
|
||||||
|
Ebene nach oben, gelöscht wird kein Notizbuch."
|
||||||
|
))
|
||||||
|
.wrap(true),
|
||||||
|
);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui
|
||||||
|
.button(
|
||||||
|
egui::RichText::new("Ordner löschen").color(egui::Color32::WHITE),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(UiAction::DeleteFolder(path.to_string()));
|
||||||
|
state.confirm_delete_folder = None;
|
||||||
|
}
|
||||||
|
if ui.button("Abbrechen").clicked() {
|
||||||
|
state.confirm_delete_folder = None;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ids = state.available_notebooks.iter().map(|s| s.id.as_str());
|
||||||
|
let count = state.library.count_within(path, ids);
|
||||||
|
let mut open = false;
|
||||||
|
egui::Frame::none()
|
||||||
|
.fill(egui::Color32::from_rgba_unmultiplied(255, 255, 255, 8))
|
||||||
|
.stroke(egui::Stroke::new(
|
||||||
|
1.0_f32,
|
||||||
|
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 16),
|
||||||
|
))
|
||||||
|
.rounding(egui::Rounding::same(8.0))
|
||||||
|
.inner_margin(egui::Margin::symmetric(10.0, 8.0))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
// Erst die Knöpfe (rechts), der Name füllt den Rest und öffnet den Ordner
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
if ui
|
||||||
|
.button(
|
||||||
|
egui::RichText::new("🗑")
|
||||||
|
.size(16.0)
|
||||||
|
.color(egui::Color32::from_rgb(255, 120, 120)),
|
||||||
|
)
|
||||||
|
.on_hover_text("Ordner löschen (die Notizbücher bleiben erhalten)")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
state.confirm_delete_folder = Some(path.to_string());
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.button(egui::RichText::new("📂").size(16.0))
|
||||||
|
.on_hover_text("Ordner samt Inhalt in einen anderen Ordner verschieben")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
state.moving = Some(MoveSubject::Folder(path.to_string()));
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.button(egui::RichText::new("✏").size(16.0))
|
||||||
|
.on_hover_text("Ordner umbenennen")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
state.renaming_folder = Some(path.to_string());
|
||||||
|
state.renaming_title_input = name.to_string();
|
||||||
|
}
|
||||||
|
let title_area = ui
|
||||||
|
.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
ui.set_min_width(ui.available_width());
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(format!("📁 {name}"))
|
||||||
|
.strong()
|
||||||
|
.size(13.0)
|
||||||
|
.color(egui::Color32::WHITE),
|
||||||
|
);
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(match count {
|
||||||
|
1 => "1 Notizbuch".to_string(),
|
||||||
|
n => format!("{n} Notizbücher"),
|
||||||
|
})
|
||||||
|
.size(11.0)
|
||||||
|
.color(egui::Color32::from_gray(160)),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.inner;
|
||||||
|
open = title_area
|
||||||
|
.response
|
||||||
|
.interact(egui::Sense::click())
|
||||||
|
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
|
.clicked();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if open {
|
||||||
|
state.notebook_folder = path.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zielordner für das Verschieben eines Notizbuchs oder Ordners: oberste Ebene und alle
|
||||||
|
/// Ordner als Baum (ein Ordner nicht in sich selbst oder seine Unterordner).
|
||||||
|
fn move_target_list(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
state: &mut UiState,
|
||||||
|
subject: &MoveSubject,
|
||||||
|
actions: &mut Vec<UiAction>,
|
||||||
|
) {
|
||||||
|
let (title, current, folders) = match subject {
|
||||||
|
MoveSubject::Notebook(id) => (
|
||||||
|
state
|
||||||
|
.available_notebooks
|
||||||
|
.iter()
|
||||||
|
.find(|s| &s.id == id)
|
||||||
|
.map_or(id.as_str(), |s| s.title.as_str())
|
||||||
|
.to_string(),
|
||||||
|
state.library.folder_of(id).to_string(),
|
||||||
|
state.library.all_folders(),
|
||||||
|
),
|
||||||
|
MoveSubject::Folder(path) => (
|
||||||
|
format!("📁 {}", crate::library::folder_name(path)),
|
||||||
|
crate::library::parent_folder(path).to_string(),
|
||||||
|
state.library.folder_move_targets(path),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
ui.label(egui::RichText::new(format!("„{title}“ verschieben nach:")).strong());
|
||||||
|
ui.add_space(4.0);
|
||||||
|
let mut targets = vec![(String::new(), "📚 Oberste Ebene".to_string(), 0)];
|
||||||
|
for path in folders {
|
||||||
|
let depth = path.matches('/').count() + 1;
|
||||||
|
targets.push((
|
||||||
|
path.to_string(),
|
||||||
|
format!("📁 {}", crate::library::folder_name(path)),
|
||||||
|
depth,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.max_height(300.0)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
for (path, label, depth) in targets {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.add_space(depth as f32 * 18.0);
|
||||||
|
let here = path == current;
|
||||||
|
if ui
|
||||||
|
.add_enabled(!here, egui::Button::new(label))
|
||||||
|
.on_disabled_hover_text("Liegt bereits hier")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(match subject {
|
||||||
|
MoveSubject::Notebook(id) => UiAction::MoveNotebook {
|
||||||
|
id: id.clone(),
|
||||||
|
folder: path.clone(),
|
||||||
|
},
|
||||||
|
MoveSubject::Folder(folder) => UiAction::MoveFolder {
|
||||||
|
path: folder.clone(),
|
||||||
|
new_parent: path.clone(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
state.moving = None;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.add_space(6.0);
|
||||||
|
if ui.button("Abbrechen").clicked() {
|
||||||
|
state.moving = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// „Seite löschen“ mit Rückfrage im ☰-Menü und „Gelöschte Seite wiederherstellen“.
|
/// „Seite löschen“ mit Rückfrage im ☰-Menü und „Gelöschte Seite wiederherstellen“.
|
||||||
fn delete_page_buttons(
|
fn delete_page_buttons(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
@@ -2288,6 +2638,14 @@ pub fn render_ui(
|
|||||||
state.close_overlays();
|
state.close_overlays();
|
||||||
state.is_document_menu_open = open;
|
state.is_document_menu_open = open;
|
||||||
if open {
|
if open {
|
||||||
|
// Im Ordner des geöffneten Notizbuchs beginnen
|
||||||
|
state.notebook_folder = state
|
||||||
|
.library
|
||||||
|
.folder_of(&state.current_notebook_id)
|
||||||
|
.to_string();
|
||||||
|
state.renaming_folder = None;
|
||||||
|
state.confirm_delete_folder = None;
|
||||||
|
state.moving = None;
|
||||||
actions.push(UiAction::RefreshNotebooks);
|
actions.push(UiAction::RefreshNotebooks);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3273,6 +3631,9 @@ pub fn render_ui(
|
|||||||
if let Some(curve) = pressure_curve_editor(ui, state) {
|
if let Some(curve) = pressure_curve_editor(ui, state) {
|
||||||
actions.push(UiAction::SavePressureCurve(curve));
|
actions.push(UiAction::SavePressureCurve(curve));
|
||||||
}
|
}
|
||||||
|
if stylus_buttons_editor(ui, state) {
|
||||||
|
actions.push(UiAction::SaveStylusButtons);
|
||||||
|
}
|
||||||
|
|
||||||
section(ui, "WERKZEUGLEISTE");
|
section(ui, "WERKZEUGLEISTE");
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
@@ -3505,51 +3866,89 @@ pub fn render_ui(
|
|||||||
state.is_document_menu_open = false;
|
state.is_document_menu_open = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Neues Dokument anlegen
|
// Gezeigter Ordner (gibt es ihn nicht mehr, die oberste Ebene)
|
||||||
ui.label(
|
if !state.notebook_folder.is_empty()
|
||||||
egui::RichText::new("NEUES NOTIZBUCH ERSTELLEN")
|
&& !state.library.folders.contains(&state.notebook_folder)
|
||||||
.size(10.5)
|
{
|
||||||
.strong()
|
state.notebook_folder.clear();
|
||||||
.color(egui::Color32::from_gray(160)),
|
}
|
||||||
);
|
let folder = state.notebook_folder.clone();
|
||||||
ui.add_space(4.0);
|
folder_breadcrumbs(ui, state, &folder);
|
||||||
|
ui.add_space(6.0);
|
||||||
|
|
||||||
|
// Neues Notizbuch oder neuer Ordner hier
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
let text_edit = ui.add(
|
let text_edit = ui.add(
|
||||||
egui::TextEdit::singleline(&mut state.new_notebook_title_input)
|
egui::TextEdit::singleline(&mut state.new_notebook_title_input)
|
||||||
.hint_text("Titel des neuen Notizbuchs …")
|
.hint_text("Name …")
|
||||||
.margin(egui::vec2(8.0, 8.0))
|
.margin(egui::vec2(8.0, 8.0))
|
||||||
.desired_width(270.0),
|
.desired_width(190.0),
|
||||||
);
|
);
|
||||||
let create_btn = ui.button(egui::RichText::new("➕ Erstellen").strong());
|
let create_btn = ui
|
||||||
let submit = create_btn.clicked()
|
.button(egui::RichText::new("➕ Notizbuch").strong())
|
||||||
|| (text_edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)));
|
.on_hover_text("Neues Notizbuch in diesem Ordner anlegen und öffnen");
|
||||||
|
let folder_btn = ui
|
||||||
if submit {
|
.button(egui::RichText::new("📁 Ordner").strong())
|
||||||
let title = state.new_notebook_title_input.trim();
|
.on_hover_text("Neuen Ordner hier anlegen (z. B. Semester oder Fach)");
|
||||||
if !title.is_empty() {
|
let title = state.new_notebook_title_input.trim().to_string();
|
||||||
actions.push(UiAction::CreateNotebook(title.to_string()));
|
if !title.is_empty() {
|
||||||
|
if create_btn.clicked()
|
||||||
|
|| (text_edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)))
|
||||||
|
{
|
||||||
|
actions.push(UiAction::CreateNotebook {
|
||||||
|
title,
|
||||||
|
folder: folder.clone(),
|
||||||
|
});
|
||||||
|
state.new_notebook_title_input.clear();
|
||||||
|
} else if folder_btn.clicked() {
|
||||||
|
actions.push(UiAction::CreateFolder {
|
||||||
|
parent: folder.clone(),
|
||||||
|
name: title,
|
||||||
|
});
|
||||||
state.new_notebook_title_input.clear();
|
state.new_notebook_title_input.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ui.add_space(12.0);
|
|
||||||
ui.separator();
|
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
|
ui.separator();
|
||||||
|
ui.add_space(6.0);
|
||||||
|
|
||||||
// Liste vorhandener Dokumente
|
// Zielordner für „Verschieben“ wählen
|
||||||
ui.label(
|
if let Some(moving) = state.moving.clone() {
|
||||||
egui::RichText::new("VORHANDENE DOKUMENTE")
|
move_target_list(ui, state, &moving, &mut actions);
|
||||||
.size(10.5)
|
return;
|
||||||
.strong()
|
}
|
||||||
.color(egui::Color32::from_gray(160)),
|
|
||||||
);
|
|
||||||
ui.add_space(4.0);
|
|
||||||
|
|
||||||
egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| {
|
let summaries: Vec<NotebookSummary> = state
|
||||||
for summary in &state.available_notebooks {
|
.available_notebooks
|
||||||
|
.iter()
|
||||||
|
.filter(|s| state.library.folder_of(&s.id) == folder)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
let subfolders: Vec<String> = state
|
||||||
|
.library
|
||||||
|
.subfolders(&folder)
|
||||||
|
.into_iter()
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
egui::ScrollArea::vertical().max_height(340.0).show(ui, |ui| {
|
||||||
|
for path in &subfolders {
|
||||||
|
ui.push_id(path, |ui| {
|
||||||
|
ui.horizontal(|ui| folder_row(ui, state, path, &mut actions))
|
||||||
|
});
|
||||||
|
ui.add_space(4.0);
|
||||||
|
}
|
||||||
|
if subfolders.is_empty() && summaries.is_empty() {
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new("Dieser Ordner ist leer.")
|
||||||
|
.color(egui::Color32::from_gray(150)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for summary in &summaries {
|
||||||
let is_current = summary.id == state.current_notebook_id;
|
let is_current = summary.id == state.current_notebook_id;
|
||||||
ui.horizontal(|ui| {
|
ui.push_id(&summary.id, |ui| ui.horizontal(|ui| {
|
||||||
ui.spacing_mut().item_spacing = egui::vec2(6.0, 0.0);
|
ui.spacing_mut().item_spacing = egui::vec2(6.0, 0.0);
|
||||||
|
|
||||||
if state.renaming_notebook_id.as_deref() == Some(&summary.id) {
|
if state.renaming_notebook_id.as_deref() == Some(&summary.id) {
|
||||||
@@ -3596,39 +3995,9 @@ pub fn render_ui(
|
|||||||
.rounding(egui::Rounding::same(8.0))
|
.rounding(egui::Rounding::same(8.0))
|
||||||
.inner_margin(egui::Margin::symmetric(10.0, 8.0))
|
.inner_margin(egui::Margin::symmetric(10.0, 8.0))
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.horizontal(|ui| {
|
// Erst die Knöpfe (rechts), der Titel füllt den Rest: Die ganze
|
||||||
let title_area = ui.vertical(|ui| {
|
// Titelfläche öffnet das Notizbuch, ohne die Knöpfe zu überdecken
|
||||||
ui.horizontal(|ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
ui.label(
|
|
||||||
egui::RichText::new(&summary.title)
|
|
||||||
.strong()
|
|
||||||
.size(13.0)
|
|
||||||
.color(egui::Color32::WHITE),
|
|
||||||
);
|
|
||||||
if summary.status == crate::storage::NotebookStatus::Damaged {
|
|
||||||
ui.colored_label(egui::Color32::from_rgb(255, 100, 100), "⚠ Beschädigt");
|
|
||||||
} else if is_current {
|
|
||||||
ui.colored_label(egui::Color32::from_rgb(46, 204, 113), "⚫ Aktiv");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let subtext = if let Some(ref err) = summary.error {
|
|
||||||
format!("Fehler: {err}")
|
|
||||||
} else {
|
|
||||||
format!("{} Seite(n)", summary.page_count)
|
|
||||||
};
|
|
||||||
ui.label(
|
|
||||||
egui::RichText::new(subtext)
|
|
||||||
.size(11.0)
|
|
||||||
.color(egui::Color32::from_gray(160)),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
open_clicked = title_area
|
|
||||||
.response
|
|
||||||
.interact(egui::Sense::click())
|
|
||||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
|
||||||
.clicked();
|
|
||||||
|
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
|
||||||
if summary.status == crate::storage::NotebookStatus::Damaged {
|
if summary.status == crate::storage::NotebookStatus::Damaged {
|
||||||
if ui
|
if ui
|
||||||
.button(egui::RichText::new("🔄 Backup").size(13.0).color(egui::Color32::from_rgb(100, 200, 255)))
|
.button(egui::RichText::new("🔄 Backup").size(13.0).color(egui::Color32::from_rgb(100, 200, 255)))
|
||||||
@@ -3654,6 +4023,14 @@ pub fn render_ui(
|
|||||||
actions.push(UiAction::DeleteNotebook(summary.id.clone()));
|
actions.push(UiAction::DeleteNotebook(summary.id.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ui
|
||||||
|
.button(egui::RichText::new("📂").size(16.0))
|
||||||
|
.on_hover_text("In einen anderen Ordner verschieben")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
state.moving = Some(MoveSubject::Notebook(summary.id.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
if ui.button(egui::RichText::new("✏").size(16.0))
|
if ui.button(egui::RichText::new("✏").size(16.0))
|
||||||
.on_hover_text("Titel umbenennen")
|
.on_hover_text("Titel umbenennen")
|
||||||
.clicked()
|
.clicked()
|
||||||
@@ -3662,7 +4039,42 @@ pub fn render_ui(
|
|||||||
state.renaming_title_input = summary.title.clone();
|
state.renaming_title_input = summary.title.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
let title_area = ui
|
||||||
|
.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
ui.set_min_width(ui.available_width());
|
||||||
|
ui.horizontal_wrapped(|ui| {
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(&summary.title)
|
||||||
|
.strong()
|
||||||
|
.size(13.0)
|
||||||
|
.color(egui::Color32::WHITE),
|
||||||
|
);
|
||||||
|
if summary.status == crate::storage::NotebookStatus::Damaged {
|
||||||
|
ui.colored_label(egui::Color32::from_rgb(255, 100, 100), "⚠ Beschädigt");
|
||||||
|
} else if is_current {
|
||||||
|
ui.colored_label(egui::Color32::from_rgb(46, 204, 113), "⚫ Aktiv");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let subtext = if let Some(ref err) = summary.error {
|
||||||
|
format!("Fehler: {err}")
|
||||||
|
} else {
|
||||||
|
format!("{} Seite(n)", summary.page_count)
|
||||||
|
};
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(subtext)
|
||||||
|
.size(11.0)
|
||||||
|
.color(egui::Color32::from_gray(160)),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.inner;
|
||||||
|
open_clicked = title_area
|
||||||
|
.response
|
||||||
|
.interact(egui::Sense::click())
|
||||||
|
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
|
.clicked();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3674,7 +4086,7 @@ pub fn render_ui(
|
|||||||
state.is_document_menu_open = false;
|
state.is_document_menu_open = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}));
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -4957,6 +5369,43 @@ mod tests {
|
|||||||
actions
|
actions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_stylus_buttons_in_menu() {
|
||||||
|
use crate::settings::{StylusAction, StylusButton};
|
||||||
|
let notebook = Notebook::new();
|
||||||
|
let mut state = UiState::new();
|
||||||
|
state.is_more_menu_open = true;
|
||||||
|
// Ohne gelernte Tasten: nur der Hinweis, keine Aktion
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
let updater = crate::updater::UpdaterController::new();
|
||||||
|
let shapes = ctx
|
||||||
|
.run(egui::RawInput::default(), |ctx| {
|
||||||
|
let _ = render_ui(ctx, &mut state, ¬ebook, AutosaveStatus::Saved, &updater);
|
||||||
|
})
|
||||||
|
.shapes
|
||||||
|
.len();
|
||||||
|
assert!(shapes > 0);
|
||||||
|
|
||||||
|
// Mit zwei Tasten: beide Zeilen, die gedrückte hervorgehoben, ohne Fehler
|
||||||
|
state.stylus_buttons = vec![
|
||||||
|
StylusButton {
|
||||||
|
id: 1,
|
||||||
|
action: StylusAction::Erase,
|
||||||
|
},
|
||||||
|
StylusButton {
|
||||||
|
id: 2,
|
||||||
|
action: StylusAction::Select,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
state.pressed_stylus_button = Some(1);
|
||||||
|
let output = ctx.run(egui::RawInput::default(), |ctx| {
|
||||||
|
let actions = render_ui(ctx, &mut state, ¬ebook, AutosaveStatus::Saved, &updater);
|
||||||
|
assert!(!actions.contains(&UiAction::SaveStylusButtons));
|
||||||
|
});
|
||||||
|
assert!(!output.shapes.is_empty());
|
||||||
|
assert_eq!(state.stylus_buttons.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_long_press_detection() {
|
fn test_long_press_detection() {
|
||||||
let t0 = Instant::now();
|
let t0 = Instant::now();
|
||||||
|
|||||||
Reference in New Issue
Block a user