Compare commits

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:41:05 +02:00
10 changed files with 1277 additions and 119 deletions
Generated
+1 -1
View File
@@ -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
View File
@@ -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"
+4 -1
View File
@@ -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
+16
View File
@@ -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`)
+2
View File
@@ -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
+1
View File
@@ -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
View File
@@ -0,0 +1,441 @@
//! Ordner für Notizbücher (`<Basisordner>/library.json`).
//!
//! Ordner sind rein logisch: Die Notizbücher bleiben in ihren Verzeichnissen
//! `<Basisordner>/<id>/`, `library.json` hält nur fest, welcher Ordner welches Notizbuch
//! enthält. Umbenennen und Verschieben ändern so nie Notizbuch-Dateien. Fehlt die Datei oder
//! ist sie unlesbar, liegen alle Notizbücher auf der obersten Ebene – verloren geht nichts.
//!
//! Ordner werden als Pfade mit `/` als Trenner gespeichert (z. B. `3. Semester/Mathe`);
//! die oberste Ebene ist der leere Pfad.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::storage::{self, StorageError};
/// Oberste Ebene.
pub const ROOT: &str = "";
/// Längster erlaubter Ordnername (Zeichen).
const MAX_NAME_CHARS: usize = 80;
/// Ordnerbaum und Zuordnung der Notizbücher.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Library {
/// Alle Ordner als Pfade
pub folders: BTreeSet<String>,
/// Ordner je Notizbuch-Kennung; Notizbücher ohne Eintrag liegen auf oberster Ebene
pub notebooks: BTreeMap<String, String>,
}
/// Letzter Teil eines Ordnerpfads (der angezeigte Name).
pub fn folder_name(path: &str) -> &str {
path.rsplit('/').next().unwrap_or(path)
}
/// Übergeordneter Ordner (oberste Ebene für Ordner der obersten Ebene).
pub fn parent_folder(path: &str) -> &str {
path.rsplit_once('/').map_or(ROOT, |(parent, _)| parent)
}
/// Pfad des Unterordners `name` in `parent`.
fn join(parent: &str, name: &str) -> String {
if parent.is_empty() {
name.to_string()
} else {
format!("{parent}/{name}")
}
}
/// `path` liegt in `folder` oder ist `folder` selbst.
fn is_within(path: &str, folder: &str) -> bool {
path == folder
|| path
.strip_prefix(folder)
.is_some_and(|rest| rest.starts_with('/'))
}
/// Bereinigt einen Ordnernamen; Fehlermeldung für die Oberfläche bei ungültigen Namen.
pub fn validate_name(name: &str) -> Result<String, String> {
let name = name.trim();
if name.is_empty() {
return Err("Der Ordnername ist leer".to_string());
}
if name.contains('/') {
return Err("Ordnernamen dürfen kein „/“ enthalten".to_string());
}
if name.chars().count() > MAX_NAME_CHARS {
return Err(format!(
"Der Ordnername ist zu lang (höchstens {MAX_NAME_CHARS} Zeichen)"
));
}
Ok(name.to_string())
}
impl Library {
/// Ort von `library.json` im Basisordner der Notizbücher.
pub fn file_path(base_dir: &Path) -> PathBuf {
base_dir.join("library.json")
}
/// Lädt die Ordner; fehlt die Datei oder ist sie beschädigt, gibt es keine Ordner.
pub fn load(base_dir: &Path) -> Self {
let path = Self::file_path(base_dir);
let mut library: Self = match std::fs::read_to_string(&path) {
Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
eprintln!(
"[Ordner] '{}' ist ungültig ({e}), alle Notizbücher auf oberster Ebene.",
path.display()
);
Self::default()
}),
Err(_) => Self::default(),
};
library.repair();
library
}
/// Speichert die Ordner atomar.
pub fn save(&self, base_dir: &Path) -> Result<(), StorageError> {
storage::atomic_write_json(&Self::file_path(base_dir), self)
}
/// Macht eine (evtl. von Hand bearbeitete) Datei stimmig: leere oder ungültige Namen
/// fallen weg, jeder benutzte Ordner und seine übergeordneten Ordner existieren.
fn repair(&mut self) {
let valid = |path: &str| {
!path.is_empty()
&& path
.split('/')
.all(|name| validate_name(name).as_deref() == Ok(name))
};
self.folders.retain(|f| valid(f));
self.notebooks.retain(|_, f| f.is_empty() || valid(f));
let used: Vec<String> = self
.folders
.iter()
.chain(self.notebooks.values())
.cloned()
.collect();
for mut folder in used {
while !folder.is_empty() {
let parent = parent_folder(&folder).to_string();
self.folders.insert(folder);
folder = parent;
}
}
}
/// Ordner, in dem das Notizbuch liegt.
pub fn folder_of(&self, notebook_id: &str) -> &str {
self.notebooks.get(notebook_id).map_or(ROOT, String::as_str)
}
/// Direkte Unterordner von `parent` (vollständige Pfade, nach Name sortiert).
pub fn subfolders(&self, parent: &str) -> Vec<&str> {
let mut list: Vec<&str> = self
.folders
.iter()
.map(String::as_str)
.filter(|f| parent_folder(f) == parent)
.collect();
list.sort_by_key(|f| folder_name(f).to_lowercase());
list
}
/// Alle Ordner in Baumreihenfolge (Eltern vor Kindern, Geschwister nach Name).
pub fn all_folders(&self) -> Vec<&str> {
let mut out = Vec::with_capacity(self.folders.len());
let mut stack: Vec<&str> = self.subfolders(ROOT).into_iter().rev().collect();
while let Some(folder) = stack.pop() {
out.push(folder);
stack.extend(self.subfolders(folder).into_iter().rev());
}
out
}
/// Legt in `parent` den Ordner `name` an und gibt seinen Pfad zurück.
pub fn create_folder(&mut self, parent: &str, name: &str) -> Result<String, String> {
let name = validate_name(name)?;
if !parent.is_empty() && !self.folders.contains(parent) {
return Err("Der übergeordnete Ordner existiert nicht mehr".to_string());
}
let path = join(parent, &name);
if !self.folders.insert(path.clone()) {
return Err(format!("Den Ordner „{name}“ gibt es hier schon"));
}
Ok(path)
}
/// Benennt einen Ordner um (mitsamt Unterordnern und Zuordnungen); gibt den neuen Pfad
/// zurück.
pub fn rename_folder(&mut self, path: &str, new_name: &str) -> Result<String, String> {
let new_name = validate_name(new_name)?;
if !self.folders.contains(path) {
return Err("Den Ordner gibt es nicht mehr".to_string());
}
let new_path = join(parent_folder(path), &new_name);
if new_path == path {
return Ok(new_path);
}
if self.folders.contains(&new_path) {
return Err(format!("Den Ordner „{new_name}“ gibt es hier schon"));
}
self.move_contents(path, &new_path);
Ok(new_path)
}
/// Löscht einen Ordner. Seine Notizbücher und Unterordner rücken eine Ebene nach oben
/// (gleichnamige Unterordner werden zusammengelegt) – gelöscht wird kein Notizbuch.
pub fn delete_folder(&mut self, path: &str) {
if path.is_empty() || !self.folders.remove(path) {
return;
}
let parent = parent_folder(path).to_string();
let prefix = format!("{path}/");
let moved: Vec<String> = self
.folders
.iter()
.filter(|f| f.starts_with(&prefix))
.cloned()
.collect();
for folder in moved {
self.folders.remove(&folder);
self.folders.insert(join(&parent, &folder[prefix.len()..]));
}
for folder in self.notebooks.values_mut() {
if folder == path {
*folder = parent.clone();
} else if let Some(rest) = folder.strip_prefix(&prefix) {
*folder = join(&parent, rest);
}
}
self.notebooks.retain(|_, f| !f.is_empty());
}
/// Verschiebt einen Ordner samt Inhalt nach `new_path`.
fn move_contents(&mut self, path: &str, new_path: &str) {
let renamed = |f: &str| format!("{new_path}{}", &f[path.len()..]);
let moved: Vec<String> = self
.folders
.iter()
.filter(|f| is_within(f, path))
.cloned()
.collect();
for folder in moved {
self.folders.remove(&folder);
self.folders.insert(renamed(&folder));
}
for folder in self.notebooks.values_mut() {
if is_within(folder, path) {
*folder = renamed(folder);
}
}
}
/// Verschiebt einen Ordner samt Inhalt nach `new_parent` (oberste Ebene: [`ROOT`]) und
/// gibt seinen neuen Pfad zurück. Nicht in sich selbst oder einen eigenen Unterordner.
pub fn move_folder(&mut self, path: &str, new_parent: &str) -> Result<String, String> {
if !self.folders.contains(path) {
return Err("Den Ordner gibt es nicht mehr".to_string());
}
if !new_parent.is_empty() && !self.folders.contains(new_parent) {
return Err("Den Zielordner gibt es nicht mehr".to_string());
}
if is_within(new_parent, path) {
return Err("Ein Ordner kann nicht in sich selbst verschoben werden".to_string());
}
let name = folder_name(path);
let new_path = join(new_parent, name);
if new_path == path {
return Ok(new_path);
}
if self.folders.contains(&new_path) {
return Err(format!("Im Zielordner gibt es schon einen Ordner „{name}“"));
}
self.move_contents(path, &new_path);
Ok(new_path)
}
/// Mögliche Ziele für das Verschieben des Ordners `path`: alle Ordner außer ihm selbst und
/// seinen Unterordnern (die oberste Ebene ist immer ein Ziel).
pub fn folder_move_targets(&self, path: &str) -> Vec<&str> {
self.all_folders()
.into_iter()
.filter(|f| !is_within(f, path))
.collect()
}
/// Legt das Notizbuch in `folder` (oberste Ebene: [`ROOT`]).
pub fn move_notebook(&mut self, notebook_id: &str, folder: &str) -> Result<(), String> {
if folder.is_empty() {
self.notebooks.remove(notebook_id);
return Ok(());
}
if !self.folders.contains(folder) {
return Err("Den Ordner gibt es nicht mehr".to_string());
}
self.notebooks
.insert(notebook_id.to_string(), folder.to_string());
Ok(())
}
/// Wie viele der Notizbücher `ids` in `folder` liegen, Unterordner eingeschlossen.
pub fn count_within<'a>(&self, folder: &str, ids: impl IntoIterator<Item = &'a str>) -> usize {
ids.into_iter()
.filter(|id| is_within(self.folder_of(id), folder))
.count()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn library() -> Library {
let mut lib = Library::default();
let sem3 = lib.create_folder(ROOT, "3. Semester").unwrap();
let mathe = lib.create_folder(&sem3, "Mathe").unwrap();
lib.create_folder(&sem3, "Physik").unwrap();
lib.move_notebook("analysis", &mathe).unwrap();
lib.move_notebook("uebung", &sem3).unwrap();
lib
}
#[test]
fn test_paths_and_names() {
assert_eq!(folder_name("3. Semester/Mathe"), "Mathe");
assert_eq!(folder_name("Mathe"), "Mathe");
assert_eq!(parent_folder("3. Semester/Mathe"), "3. Semester");
assert_eq!(parent_folder("Mathe"), ROOT);
assert_eq!(validate_name(" Mathe ").unwrap(), "Mathe");
assert!(validate_name(" ").is_err());
assert!(validate_name("a/b").is_err());
assert!(validate_name(&"x".repeat(81)).is_err());
}
#[test]
fn test_create_and_list_folders() {
let mut lib = library();
assert_eq!(lib.subfolders(ROOT), ["3. Semester"]);
assert_eq!(
lib.subfolders("3. Semester"),
["3. Semester/Mathe", "3. Semester/Physik"]
);
assert_eq!(
lib.all_folders(),
["3. Semester", "3. Semester/Mathe", "3. Semester/Physik"]
);
assert_eq!(lib.folder_of("analysis"), "3. Semester/Mathe");
assert_eq!(lib.folder_of("unbekannt"), ROOT);
// Doppelte Namen und fehlende Eltern werden abgelehnt
assert!(lib.create_folder("3. Semester", " Mathe ").is_err());
assert!(lib.create_folder("Gibt es nicht", "X").is_err());
// Gleicher Name in einem anderen Ordner ist erlaubt
lib.create_folder(ROOT, "4. Semester").unwrap();
assert!(lib.create_folder("4. Semester", "Mathe").is_ok());
assert_eq!(
lib.count_within("3. Semester", ["analysis", "uebung", "lose"]),
2
);
}
#[test]
fn test_rename_folder_keeps_contents() {
let mut lib = library();
let new = lib.rename_folder("3. Semester", "WS 25-26").unwrap();
assert_eq!(new, "WS 25-26");
assert_eq!(lib.folder_of("analysis"), "WS 25-26/Mathe");
assert_eq!(lib.folder_of("uebung"), "WS 25-26");
assert!(lib.folders.contains("WS 25-26/Physik"));
assert!(!lib.folders.iter().any(|f| f.starts_with("3. Semester")));
// Ein Ordner mit ähnlichem Namen bleibt unberührt
let mut lib = library();
lib.create_folder(ROOT, "3. Semester alt").unwrap();
lib.rename_folder("3. Semester", "Drei").unwrap();
assert!(lib.folders.contains("3. Semester alt"));
// Kollision
assert!(lib.rename_folder("Drei", "3. Semester alt").is_err());
}
#[test]
fn test_delete_folder_moves_contents_up() {
let mut lib = library();
lib.delete_folder("3. Semester");
assert_eq!(lib.folder_of("analysis"), "Mathe");
assert_eq!(lib.folder_of("uebung"), ROOT);
assert!(!lib.notebooks.contains_key("uebung"));
assert_eq!(lib.subfolders(ROOT), ["Mathe", "Physik"]);
}
#[test]
fn test_move_folder() {
let mut lib = library();
lib.create_folder(ROOT, "Mathe-Sammlung").unwrap();
// Ordner samt Inhalt in einen anderen Ordner schieben
let new = lib
.move_folder("3. Semester/Mathe", "Mathe-Sammlung")
.unwrap();
assert_eq!(new, "Mathe-Sammlung/Mathe");
assert_eq!(lib.folder_of("analysis"), "Mathe-Sammlung/Mathe");
assert!(!lib.folders.contains("3. Semester/Mathe"));
// Auf die oberste Ebene und zurück
assert_eq!(
lib.move_folder("Mathe-Sammlung/Mathe", ROOT).unwrap(),
"Mathe"
);
assert_eq!(
lib.move_folder("Mathe", "3. Semester").unwrap(),
"3. Semester/Mathe"
);
// Nicht in sich selbst oder in einen eigenen Unterordner
assert!(lib.move_folder("3. Semester", "3. Semester").is_err());
assert!(lib.move_folder("3. Semester", "3. Semester/Mathe").is_err());
assert_eq!(lib.folder_move_targets("3. Semester"), ["Mathe-Sammlung"]);
// Namenskollision am Ziel
lib.create_folder("Mathe-Sammlung", "Physik").unwrap();
assert!(lib
.move_folder("3. Semester/Physik", "Mathe-Sammlung")
.is_err());
assert_eq!(lib.folder_of("uebung"), "3. Semester");
}
#[test]
fn test_move_notebook() {
let mut lib = library();
lib.move_notebook("uebung", "3. Semester/Physik").unwrap();
assert_eq!(lib.folder_of("uebung"), "3. Semester/Physik");
lib.move_notebook("uebung", ROOT).unwrap();
assert_eq!(lib.folder_of("uebung"), ROOT);
assert!(lib.move_notebook("uebung", "Gibt es nicht").is_err());
}
#[test]
fn test_load_save_and_repair() {
let dir = tempfile::tempdir().unwrap();
// Fehlende Datei: keine Ordner
assert_eq!(Library::load(dir.path()), Library::default());
let lib = library();
lib.save(dir.path()).unwrap();
assert_eq!(Library::load(dir.path()), lib);
// Beschädigt: alles auf oberster Ebene statt Absturz
std::fs::write(Library::file_path(dir.path()), b"{ kaputt").unwrap();
assert_eq!(Library::load(dir.path()), Library::default());
// Von Hand bearbeitet: fehlende Elternordner werden ergänzt, Unsinn fällt weg
std::fs::write(
Library::file_path(dir.path()),
br#"{"folders": ["A/B", "", "C//D"], "notebooks": {"x": "E/F", "y": "/kaputt"}}"#,
)
.unwrap();
let lib = Library::load(dir.path());
assert_eq!(lib.all_folders(), ["A", "A/B", "E", "E/F"]);
assert_eq!(lib.folder_of("x"), "E/F");
assert_eq!(lib.folder_of("y"), ROOT);
}
}
+234 -49
View File
@@ -24,16 +24,75 @@ use ink_notes::document::{Notebook, PageBackground};
use ink_notes::export::{self, export_notebook_to_pdf, ExportStrategy}; use ink_notes::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, &notebook, &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, &notebook, &manifest, tool, autosave.status()); update_window_title(&window, &notebook, &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, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &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, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &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, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &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(&notebook); manifest.sync_from_notebook(&notebook);
autosave.mark_manifest_dirty(&manifest); autosave.mark_manifest_dirty(&manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &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, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &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);
+61
View File
@@ -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);
+516 -67
View File
@@ -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, &notebook, 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, &notebook, 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();