Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea9ee6c0d7 | ||
|
|
d1f0b46fac | ||
|
|
82a297f395 | ||
|
|
e33062fe0a | ||
|
|
36a1a3cd2e |
Generated
+1
-1
@@ -1393,7 +1393,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ink-notes"
|
||||
version = "0.17.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"arboard",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ink-notes"
|
||||
version = "0.17.0"
|
||||
version = "0.19.0"
|
||||
edition = "2021"
|
||||
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||
|
||||
@@ -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.
|
||||
- **Glättung in zwei Stufen:** Während des Schreibens glättet ein 1€-Filter das Zittern, die Linie erscheint sofort. Beim Absetzen wird der Strich nachbearbeitet: Ecken (Knicke über 65°) bleiben erhalten, der Druckverlauf wird geglättet, überflüssige Punkte entfallen, die Enden werden rund.
|
||||
- **Druckkurve** im ☰-Menü an den eigenen Stift anpassen (weicher/härter, Schwelle gegen Grundrauschen).
|
||||
- **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker. Stiftrückseite und Seitentaste des Stifts radieren ebenfalls.
|
||||
- **Tasten am Stift** im ☰-Menü belegen: Radieren (Standard), Auswählen, Textmarker, Stift oder nichts – gilt, solange die Taste gedrückt ist. Jede Taste erscheint dort nach dem ersten Drücken.
|
||||
- **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker. Die Stiftrückseite radiert ebenfalls.
|
||||
- **Virtuelles Lineal** (`L`): verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden gerade.
|
||||
|
||||
### Formerkennung
|
||||
@@ -53,6 +54,7 @@ Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
|
||||
|
||||
### Notizbücher und Speichern
|
||||
- Mehrere **Notizbücher** anlegen, umbenennen, wechseln und löschen (gelöschte landen zunächst im Papierkorb und lassen sich wiederherstellen).
|
||||
- **Ordner** für Notizbücher, beliebig verschachtelt (z. B. 3. Semester › Mathe): anlegen, umbenennen, löschen (der Inhalt rückt eine Ebene nach oben) und Notizbücher wie Ordner (samt Inhalt) mit 📂 verschieben. Neue Notizbücher landen im gerade geöffneten Ordner.
|
||||
- **Automatisches Speichern** im Hintergrund: 1,5 Sekunden nach der letzten Änderung, bei ununterbrochenem Schreiben spätestens alle 10 Sekunden. Zeichnen wird dabei nicht unterbrochen.
|
||||
- Jede Datei wird erst vollständig in eine temporäre Datei geschrieben und dann ersetzt – ein Absturz beim Speichern hinterlässt keine halbe Datei.
|
||||
- **Beim Schließen** (Fenster oder `Strg + Q`) und beim Wechsel des Notizbuchs wird alles Ausstehende geschrieben, bevor es weitergeht. Schlägt das fehl, bleibt die App offen und bietet Wiederholen oder eine Notfall-Sicherung an.
|
||||
@@ -162,6 +164,7 @@ src/
|
||||
├── document.rs # Datenmodell: Notizbuch, Seiten, Striche, Seitengrößen
|
||||
├── history.rs # Rückgängig/Wiederholen
|
||||
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb
|
||||
├── library.rs # Ordner der Notizbücher (library.json)
|
||||
├── autosave.rs # Speichern im Hintergrund
|
||||
├── export.rs # PDF-Export (printpdf)
|
||||
├── page_export.rs # Seite als PNG/SVG, Auswahl als Bild
|
||||
|
||||
@@ -9,6 +9,7 @@ Dieses Dokument beschreibt, wo und wie ink-notes Notizbücher speichert, und die
|
||||
```text
|
||||
~/.local/share/ink-notes/ ($XDG_DATA_HOME/ink-notes)
|
||||
├── notebooks/
|
||||
│ ├── library.json Ordner der Notizbücher (siehe unten)
|
||||
│ ├── <notizbuch-id>/
|
||||
│ │ ├── manifest.json Metadaten und Seitenreihenfolge
|
||||
│ │ ├── manifest.json.bak Kopie des vorherigen Manifests
|
||||
@@ -29,6 +30,21 @@ Dieses Dokument beschreibt, wo und wie ink-notes Notizbücher speichert, und die
|
||||
- **Assets** sind nach dem SHA-256-Hash ihres Inhalts benannt; dasselbe Bild wird nur einmal gespeichert. Bilder werden als PNG oder (unverändert) JPEG abgelegt, PDFs unverändert.
|
||||
- Alle Dateien werden mit Rechten `0600`, Ordner mit `0700` angelegt.
|
||||
|
||||
### Ordner (`library.json`)
|
||||
Ordner wie „3. Semester › Mathe“ sind rein logisch: Die Notizbücher bleiben in ihren Verzeichnissen, `library.json` hält nur fest, welcher Ordner welches Notizbuch enthält. Umbenennen, Verschieben (samt Inhalt) und Löschen von Ordnern ändern deshalb nie eine Notizbuch-Datei.
|
||||
|
||||
```json
|
||||
{
|
||||
"folders": ["3. Semester", "3. Semester/Mathe", "4. Semester"],
|
||||
"notebooks": { "analysis_1775460000": "3. Semester/Mathe" }
|
||||
}
|
||||
```
|
||||
|
||||
- Ordner sind Pfade mit `/` als Trenner; Namen enthalten selbst kein `/` und höchstens 80 Zeichen.
|
||||
- Notizbücher ohne Eintrag liegen auf der obersten Ebene.
|
||||
- Wird ein Ordner gelöscht, rücken seine Notizbücher und Unterordner eine Ebene nach oben.
|
||||
- Fehlt die Datei oder ist sie unlesbar, liegen alle Notizbücher auf der obersten Ebene; fehlende übergeordnete Ordner werden beim Laden ergänzt.
|
||||
|
||||
---
|
||||
|
||||
## 2. Manifest (`manifest.json`)
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit Zorin OS 18 / Ubuntu 24.04).
|
||||
|
||||
### Highlights:
|
||||
- **Ordner verschieben**: Ordner lassen sich jetzt wie Notizbücher mit 📂 samt Inhalt in andere Ordner oder auf die oberste Ebene verschieben
|
||||
- **Ordner für Notizbücher**: Beliebig verschachtelte Ordner (z. B. 3. Semester › Mathe) im Dokumenten-Manager anlegen, umbenennen und löschen, Notizbücher mit 📂 verschieben; neue Notizbücher landen im geöffneten Ordner. Die Notizbuch-Dateien bleiben unverändert (Zuordnung in library.json)
|
||||
- **Einfügen per Finger**: Lange (½ s) mit dem Finger aufs Blatt drücken öffnet ein Menü „Einfügen“ – Screenshot, kopierter Text oder kopierte Objekte landen genau an dieser Stelle (wie in Rnote)
|
||||
- **Eigene Farben**: Jede Farbe der Werkzeugleiste (Stifte, Textmarker, Text) lässt sich anpassen – erneut antippen, Vorgabe oder mit 🎨 frei wählen; die Leiste merkt sich die Farben. Farben am Bildschirm wie im PDF-/Bildexport (Schwarz ist schwarz, Textmarker mischt wie im PDF); Karos und Linien im PDF so hell wie am Bildschirm
|
||||
- **Beta-Feedback II**: Vier Stifte mit eigener Farbe und Stärke, die sich Änderungen merken; Textmarker wird innerhalb eines Strichs nicht dunkler und ist jetzt auch im PDF-/PNG-/SVG-Export durchsichtig; Seiten lückenlos untereinander mit Trennlinie; neue Seite per ➕ in der Werkzeugleiste; Seite löschen im ☰-Menü (wiederherstellbar); Koordinatensystem ohne Loch am Ursprung
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod filter;
|
||||
pub mod geometry;
|
||||
pub mod history;
|
||||
pub mod image_layer;
|
||||
pub mod library;
|
||||
pub mod page_export;
|
||||
pub mod pdf_doc;
|
||||
pub mod pdf_merge;
|
||||
|
||||
+441
@@ -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::geometry::{self, RecognizedShape};
|
||||
use ink_notes::image_layer::TextureSource;
|
||||
use ink_notes::library::Library;
|
||||
use ink_notes::pdf_doc::PdfRenderService;
|
||||
use ink_notes::platform::{self, DisplayBackend};
|
||||
use ink_notes::selection::{
|
||||
render_selection_overlay, DragMode, SelectionHandleHit, SelectionState,
|
||||
};
|
||||
use ink_notes::settings::{SidebarSide, UiSettings};
|
||||
use ink_notes::settings::{
|
||||
SidebarSide, StylusAction, StylusButton, UiSettings, MAX_STYLUS_BUTTONS,
|
||||
};
|
||||
use ink_notes::storage::{self, NotebookManifest};
|
||||
use ink_notes::ui::{self, ActiveTool, UiAction, UiState};
|
||||
use ink_notes::updater::UpdaterController;
|
||||
|
||||
/// Wählt `tool` und stellt Farbe und Stärke des Live-Strichs passend ein.
|
||||
fn activate_tool(tool: ActiveTool, ui_state: &mut UiState, canvas: &mut CanvasState) {
|
||||
ui_state.active_tool = tool;
|
||||
match tool {
|
||||
ActiveTool::Pen | ActiveTool::Shape => {
|
||||
canvas.set_stroke_color(ui_state.pen_color);
|
||||
canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm);
|
||||
}
|
||||
ActiveTool::Highlighter => {
|
||||
canvas.set_stroke_color(ui_state.highlighter_color);
|
||||
canvas.set_stroke_width_mm(ui_state.highlighter_stroke_width_mm);
|
||||
}
|
||||
ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kennung einer Stifttaste, die über Programmstarts hinweg gleich bleibt (die Tablet-
|
||||
/// Schnittstelle liefert nur eine undurchsichtige, aber vergleichbare Kennung).
|
||||
fn stylus_button_key(id: &octotablet::tool::ButtonID) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
// DefaultHasher::new() verwendet feste Schlüssel: gleiche Taste, gleicher Wert
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
id.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
/// Liest Notizbücher und Ordner für den Dokumenten-Manager neu ein.
|
||||
fn refresh_notebooks(base_dir: &Path, ui_state: &mut UiState) {
|
||||
ui_state.available_notebooks = storage::list_notebooks(base_dir);
|
||||
ui_state.library = Library::load(base_dir);
|
||||
}
|
||||
|
||||
/// Ändert die Ordner (`library.json`) und liest sie neu ein; Fehler erscheinen als Meldung.
|
||||
/// Gibt bei Erfolg das Ergebnis von `change` zurück.
|
||||
fn change_library<T>(
|
||||
base_dir: &Path,
|
||||
ui_state: &mut UiState,
|
||||
change: impl FnOnce(&mut Library) -> Result<T, String>,
|
||||
) -> Option<T> {
|
||||
let mut library = Library::load(base_dir);
|
||||
let result = change(&mut library).and_then(|value| {
|
||||
library
|
||||
.save(base_dir)
|
||||
.map(|()| value)
|
||||
.map_err(|e| format!("Ordner nicht gespeichert: {e}"))
|
||||
});
|
||||
ui_state.library = Library::load(base_dir);
|
||||
match result {
|
||||
Ok(value) => Some(value),
|
||||
Err(message) => {
|
||||
ui_state.set_export_message(message);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Aktualisiert den Fenstertitel als minimales On-Screen-Display (OSD) inklusive Autosave-Status.
|
||||
/// Speichert die UI-Einstellungen; ein Fehler wird gemeldet, bricht aber nichts ab.
|
||||
fn save_ui_settings(settings: &UiSettings, path: Option<&std::path::Path>, ui_state: &mut UiState) {
|
||||
@@ -1027,10 +1086,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
"[Storage] Erstelle neues Notizbuch in: {}",
|
||||
storage_dir.display()
|
||||
);
|
||||
(
|
||||
Notebook::new(),
|
||||
NotebookManifest::new(&initial_id, &initial_title),
|
||||
)
|
||||
let notebook = Notebook::new();
|
||||
let mut manifest = NotebookManifest::new(&initial_id, &initial_title);
|
||||
// Ganz neu (z. B. erster Start): sofort speichern, sonst steht es bis zur ersten
|
||||
// Änderung ohne Manifest als „beschädigt“ in der Notizbuchliste. Ein vorhandener
|
||||
// Ordner ohne Manifest bleibt unangetastet.
|
||||
if !storage_dir.exists() {
|
||||
if let Err(err) = storage::save_notebook(&storage_dir, ¬ebook, &mut manifest) {
|
||||
eprintln!("[Storage] Neues Notizbuch nicht gespeichert: {err}");
|
||||
}
|
||||
}
|
||||
(notebook, manifest)
|
||||
};
|
||||
|
||||
// 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.continuous_scroll = ui_settings.continuous_scroll;
|
||||
ui_state.pen_presets = ui_settings.pen_presets;
|
||||
ui_state.stylus_buttons = ui_settings.stylus_buttons.clone();
|
||||
ui_state.select_pen(ui_settings.active_pen);
|
||||
ui_state.highlighter_colors = ui_settings.highlighter_colors;
|
||||
ui_state.select_highlighter_color(ui_settings.active_highlighter);
|
||||
@@ -1078,8 +1145,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
ui_state.current_notebook_id = manifest.id.clone();
|
||||
ui_state.document_title = manifest.title.clone();
|
||||
ui_state.available_notebooks = available_notebooks;
|
||||
ui_state.library = Library::load(&storage_base_dir);
|
||||
if let Some(warning) = startup_warning {
|
||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
||||
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||
ui_state.set_export_message(warning);
|
||||
}
|
||||
let mut selection_state = SelectionState::new();
|
||||
@@ -1147,7 +1215,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut is_stylus_selecting = false;
|
||||
let mut is_stylus_in_proximity = false;
|
||||
let mut stylus_tool_id: Option<octotablet::tool::ID> = None;
|
||||
let mut stylus_barrel_pressed = false;
|
||||
// Gedrückte Stifttaste (Kennung, Belegung) und das Werkzeug, das während eines Strichs mit
|
||||
// einer Werkzeug-Taste (Auswählen, Textmarker, Stift) vertreten wird
|
||||
let mut stylus_held: Option<(u64, StylusAction)> = None;
|
||||
let mut tool_before_button: Option<ActiveTool> = None;
|
||||
let mut stylus_screen_pos = (0.0_f32, 0.0_f32);
|
||||
// Stift hat auf der UI aufgesetzt: alle Ereignisse bis zum Abheben gehen an egui
|
||||
let mut stylus_ui_capture = false;
|
||||
@@ -1318,23 +1389,45 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state);
|
||||
window.request_redraw();
|
||||
}
|
||||
if let Some(tool) = tool_before_button.take() {
|
||||
activate_tool(tool, &mut ui_state, &mut canvas);
|
||||
}
|
||||
stylus_held = None;
|
||||
ui_state.pressed_stylus_button = None;
|
||||
}
|
||||
}
|
||||
octotablet::events::ToolEvent::Button { pressed, .. } => {
|
||||
stylus_barrel_pressed = pressed;
|
||||
if pressed && is_stylus_drawing {
|
||||
is_stylus_drawing = false;
|
||||
is_drawing = false;
|
||||
canvas.cancel_stroke();
|
||||
is_erasing = true;
|
||||
window.request_redraw();
|
||||
} else if !pressed
|
||||
&& is_erasing
|
||||
&& !tool_is_hardware_eraser
|
||||
&& ui_state.active_tool != ActiveTool::Eraser
|
||||
{
|
||||
is_erasing = false;
|
||||
octotablet::events::ToolEvent::Button { button_id, pressed } => {
|
||||
// Tasten werden beim ersten Drücken gelernt (Standard: Radieren)
|
||||
let key = stylus_button_key(&button_id);
|
||||
let mut index = ui_state.stylus_buttons.iter().position(|b| b.id == key);
|
||||
if index.is_none() && pressed && ui_state.stylus_buttons.len() < MAX_STYLUS_BUTTONS {
|
||||
ui_state.stylus_buttons.push(StylusButton { id: key, action: StylusAction::Erase });
|
||||
index = Some(ui_state.stylus_buttons.len() - 1);
|
||||
ui_settings.stylus_buttons = ui_state.stylus_buttons.clone();
|
||||
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
||||
}
|
||||
let action = index.map_or(StylusAction::Erase, |i| ui_state.stylus_buttons[i].action);
|
||||
if pressed {
|
||||
stylus_held = Some((key, action));
|
||||
ui_state.pressed_stylus_button = index;
|
||||
if action == StylusAction::Erase && is_stylus_drawing {
|
||||
is_stylus_drawing = false;
|
||||
is_drawing = false;
|
||||
canvas.cancel_stroke();
|
||||
is_erasing = true;
|
||||
}
|
||||
} else if stylus_held.is_some_and(|(held, _)| held == key) {
|
||||
stylus_held = None;
|
||||
ui_state.pressed_stylus_button = None;
|
||||
if action == StylusAction::Erase
|
||||
&& is_erasing
|
||||
&& !tool_is_hardware_eraser
|
||||
&& ui_state.active_tool != ActiveTool::Eraser
|
||||
{
|
||||
is_erasing = false;
|
||||
}
|
||||
}
|
||||
window.request_redraw();
|
||||
}
|
||||
octotablet::events::ToolEvent::Pose(pose) => {
|
||||
is_stylus_in_proximity = true;
|
||||
@@ -1432,8 +1525,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
focus_page_at!((sx, sy));
|
||||
// Werkzeug-Taste gedrückt: für diesen Strich dieses Werkzeug
|
||||
let button_action = stylus_held.map(|(_, action)| action);
|
||||
let button_tool = match button_action {
|
||||
Some(StylusAction::Select) => Some(ActiveTool::Select),
|
||||
Some(StylusAction::Highlighter) => Some(ActiveTool::Highlighter),
|
||||
Some(StylusAction::Pen) => Some(ActiveTool::Pen),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(tool) = button_tool
|
||||
.filter(|&t| !tool_is_hardware_eraser && t != ui_state.active_tool)
|
||||
{
|
||||
tool_before_button = Some(ui_state.active_tool);
|
||||
activate_tool(tool, &mut ui_state, &mut canvas);
|
||||
}
|
||||
let should_erase = tool_is_hardware_eraser
|
||||
|| stylus_barrel_pressed
|
||||
|| button_action == Some(StylusAction::Erase)
|
||||
|| ui_state.active_tool == ActiveTool::Eraser;
|
||||
|
||||
if !should_erase && ui_state.active_tool == ActiveTool::Text {
|
||||
@@ -1511,6 +1618,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
let restore_tool = tool_before_button.take();
|
||||
|
||||
if is_stylus_drawing {
|
||||
is_stylus_drawing = false;
|
||||
@@ -1545,6 +1653,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
snapped_shape,
|
||||
);
|
||||
}
|
||||
// Nach einem Strich mit Werkzeug-Taste wieder das gewählte Werkzeug
|
||||
if let Some(tool) = restore_tool {
|
||||
activate_tool(tool, &mut ui_state, &mut canvas);
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -2692,22 +2805,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
for action in actions {
|
||||
match action {
|
||||
UiAction::SetTool(tool) => {
|
||||
ui_state.active_tool = tool;
|
||||
match tool {
|
||||
ActiveTool::Pen => {
|
||||
canvas.set_stroke_color(ui_state.pen_color);
|
||||
canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm);
|
||||
}
|
||||
ActiveTool::Highlighter => {
|
||||
canvas.set_stroke_color(ui_state.highlighter_color);
|
||||
canvas.set_stroke_width_mm(ui_state.highlighter_stroke_width_mm);
|
||||
}
|
||||
ActiveTool::Shape => {
|
||||
canvas.set_stroke_color(ui_state.pen_color);
|
||||
canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm);
|
||||
}
|
||||
ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text => {}
|
||||
}
|
||||
activate_tool(tool, &mut ui_state, &mut canvas);
|
||||
update_window_title(&window, ¬ebook, &manifest, tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -2969,8 +3067,81 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
window.request_redraw();
|
||||
}
|
||||
|
||||
UiAction::CreateFolder { parent, name } => {
|
||||
if let Some(path) = change_library(&storage_base_dir, &mut ui_state, |library| {
|
||||
library.create_folder(&parent, &name)
|
||||
}) {
|
||||
ui_state.set_export_message(format!(
|
||||
"Ordner „{}“ angelegt",
|
||||
ink_notes::library::folder_name(&path)
|
||||
));
|
||||
}
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::RenameFolder { path, new_name } => {
|
||||
if let Some(new_path) = change_library(&storage_base_dir, &mut ui_state, |library| {
|
||||
library.rename_folder(&path, &new_name)
|
||||
}) {
|
||||
// Der gezeigte Ordner kann darin liegen
|
||||
let shown = &ui_state.notebook_folder;
|
||||
if shown == &path || shown.starts_with(&format!("{path}/")) {
|
||||
ui_state.notebook_folder = format!("{new_path}{}", &shown[path.len()..]);
|
||||
}
|
||||
}
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::DeleteFolder(path) => {
|
||||
if change_library(&storage_base_dir, &mut ui_state, |library| {
|
||||
library.delete_folder(&path);
|
||||
Ok(())
|
||||
})
|
||||
.is_some()
|
||||
{
|
||||
ui_state.set_export_message(format!(
|
||||
"Ordner „{}“ gelöscht, sein Inhalt liegt eine Ebene höher",
|
||||
ink_notes::library::folder_name(&path)
|
||||
));
|
||||
}
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::MoveFolder { path, new_parent } => {
|
||||
if let Some(new_path) = change_library(&storage_base_dir, &mut ui_state, |library| {
|
||||
library.move_folder(&path, &new_parent)
|
||||
}) {
|
||||
// Der gezeigte Ordner kann darin liegen
|
||||
let shown = &ui_state.notebook_folder;
|
||||
if shown == &path || shown.starts_with(&format!("{path}/")) {
|
||||
ui_state.notebook_folder = format!("{new_path}{}", &shown[path.len()..]);
|
||||
}
|
||||
let target = if new_parent.is_empty() {
|
||||
"die oberste Ebene".to_string()
|
||||
} else {
|
||||
format!("„{}“", ink_notes::library::folder_name(&new_parent))
|
||||
};
|
||||
ui_state.set_export_message(format!(
|
||||
"Ordner „{}“ verschoben in {target}",
|
||||
ink_notes::library::folder_name(&path)
|
||||
));
|
||||
}
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::MoveNotebook { id, folder } => {
|
||||
if change_library(&storage_base_dir, &mut ui_state, |library| {
|
||||
library.move_notebook(&id, &folder)
|
||||
})
|
||||
.is_some()
|
||||
{
|
||||
let target = if folder.is_empty() {
|
||||
"die oberste Ebene".to_string()
|
||||
} else {
|
||||
format!("„{}“", ink_notes::library::folder_name(&folder))
|
||||
};
|
||||
ui_state.set_export_message(format!("Verschoben in {target}"));
|
||||
}
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::RefreshNotebooks => {
|
||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
||||
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::OpenNotebook(id) => {
|
||||
@@ -3006,7 +3177,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
selection_state.clear();
|
||||
ui_state.current_notebook_id = manifest.id.clone();
|
||||
ui_state.document_title = manifest.title.clone();
|
||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
||||
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||
ui_state.is_document_menu_open = false;
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
@@ -3023,6 +3194,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
UiAction::ImportPdf(path) => {
|
||||
// Neben das gerade geöffnete Notizbuch
|
||||
let folder = ui_state.library.folder_of(&manifest.id).to_string();
|
||||
let title = path
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
@@ -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));
|
||||
match result {
|
||||
Ok((new_nb, new_manifest, new_dir)) => {
|
||||
let id = new_manifest.id.clone();
|
||||
change_library(&storage_base_dir, &mut ui_state, |library| {
|
||||
library.move_notebook(&id, &folder)
|
||||
});
|
||||
let _ = autosave.flush();
|
||||
_notebook_lock = storage::NotebookLock::acquire(&new_dir).ok();
|
||||
autosave.set_read_only(_notebook_lock.is_none());
|
||||
@@ -3042,7 +3219,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
selection_state.clear();
|
||||
ui_state.current_notebook_id = manifest.id.clone();
|
||||
ui_state.document_title = manifest.title.clone();
|
||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
||||
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||
canvas.reset_view();
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
@@ -3059,10 +3236,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::CreateNotebook(title) => {
|
||||
UiAction::CreateNotebook { title, folder } => {
|
||||
let _ = autosave.flush();
|
||||
match storage::create_new_notebook(&storage_base_dir, &title) {
|
||||
Ok((new_nb, new_manifest, new_dir)) => {
|
||||
let id = new_manifest.id.clone();
|
||||
change_library(&storage_base_dir, &mut ui_state, |library| {
|
||||
library.move_notebook(&id, &folder)
|
||||
});
|
||||
_notebook_lock = storage::NotebookLock::acquire(&new_dir).ok();
|
||||
autosave.set_read_only(_notebook_lock.is_none());
|
||||
autosave.switch_notebook_dir(new_dir.clone());
|
||||
@@ -3072,7 +3253,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
selection_state.clear();
|
||||
ui_state.current_notebook_id = manifest.id.clone();
|
||||
ui_state.document_title = manifest.title.clone();
|
||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
||||
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||
ui_state.is_document_menu_open = false;
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
update_window_title(&window, ¬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) {
|
||||
Ok(()) => {
|
||||
ui_state.document_title = manifest.title.clone();
|
||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
||||
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||
manifest.sync_from_notebook(¬ebook);
|
||||
autosave.mark_manifest_dirty(&manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
@@ -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(mut other_manifest) = serde_json::from_str::<NotebookManifest>(&content) {
|
||||
let _ = storage::rename_notebook(&target_dir, &mut other_manifest, &new_title);
|
||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
||||
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||
ui_state.set_export_message(format!("Umbenannt in '{}'", new_title));
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -3133,7 +3314,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
match storage::delete_notebook(&target_dir, &storage_base_dir) {
|
||||
Ok(trash_path) => {
|
||||
ui_state.last_deleted_notebook = Some((trash_path, deleted_title));
|
||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
||||
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||
if is_current {
|
||||
if let Some(first) = ui_state
|
||||
.available_notebooks
|
||||
@@ -3171,7 +3352,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
selection_state.clear();
|
||||
ui_state.current_notebook_id = manifest.id.clone();
|
||||
ui_state.document_title = manifest.title.clone();
|
||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
||||
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
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() {
|
||||
match storage::restore_notebook_from_trash(&storage_base_dir, &trash_path) {
|
||||
Ok(_restored_dir) => {
|
||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
||||
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||
ui_state.set_export_message("Notizbuch wiederhergestellt".to_string());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -3206,7 +3387,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let target_dir = storage_base_dir.join(&id);
|
||||
match storage::restore_manifest_from_backup(&target_dir) {
|
||||
Ok(()) => {
|
||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
||||
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||
ui_state.set_export_message("Manifest aus Backup wiederhergestellt".to_string());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -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);
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::SaveStylusButtons => {
|
||||
ui_settings.stylus_buttons = ui_state.stylus_buttons.clone();
|
||||
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
||||
}
|
||||
UiAction::SavePressureCurve(curve) => {
|
||||
ui_settings.pressure_curve = curve;
|
||||
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
||||
|
||||
@@ -150,6 +150,53 @@ fn sanitized_color(color: [f32; 4], fallback: [f32; 4]) -> [f32; 4] {
|
||||
out
|
||||
}
|
||||
|
||||
/// Was eine Taste am Stift bewirkt, solange sie gedrückt ist.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum StylusAction {
|
||||
/// Radieren (Standard, wie bisher)
|
||||
#[default]
|
||||
Erase,
|
||||
/// Auswählen und verschieben
|
||||
Select,
|
||||
/// Mit dem Textmarker schreiben
|
||||
Highlighter,
|
||||
/// Mit dem gewählten Stift schreiben (z. B. während der Textmarker aktiv ist)
|
||||
Pen,
|
||||
/// Taste nicht verwenden
|
||||
Nothing,
|
||||
}
|
||||
|
||||
impl StylusAction {
|
||||
pub const ALL: [StylusAction; 5] = [
|
||||
StylusAction::Erase,
|
||||
StylusAction::Select,
|
||||
StylusAction::Highlighter,
|
||||
StylusAction::Pen,
|
||||
StylusAction::Nothing,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
StylusAction::Erase => "Radieren",
|
||||
StylusAction::Select => "Auswählen",
|
||||
StylusAction::Highlighter => "Textmarker",
|
||||
StylusAction::Pen => "Stift",
|
||||
StylusAction::Nothing => "Nichts",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Taste am Stift: Kennung (aus der Tablet-Schnittstelle, beim ersten Drücken gelernt)
|
||||
/// und ihre Belegung.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StylusButton {
|
||||
pub id: u64,
|
||||
pub action: StylusAction,
|
||||
}
|
||||
|
||||
/// Höchstens so viele Stifttasten werden gemerkt.
|
||||
pub const MAX_STYLUS_BUTTONS: usize = 4;
|
||||
|
||||
/// Benutzereinstellungen der Oberfläche.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
@@ -173,6 +220,8 @@ pub struct UiSettings {
|
||||
pub active_highlighter: usize,
|
||||
pub text_colors: [[f32; 4]; 5],
|
||||
pub active_text_color: usize,
|
||||
/// Tasten am Stift in der Reihenfolge, in der sie zuerst gedrückt wurden
|
||||
pub stylus_buttons: Vec<StylusButton>,
|
||||
}
|
||||
|
||||
impl Default for UiSettings {
|
||||
@@ -190,6 +239,7 @@ impl Default for UiSettings {
|
||||
active_highlighter: 0,
|
||||
text_colors: DEFAULT_TEXT_COLORS,
|
||||
active_text_color: 0,
|
||||
stylus_buttons: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -237,6 +287,7 @@ impl UiSettings {
|
||||
settings.active_text_color = settings
|
||||
.active_text_color
|
||||
.min(settings.text_colors.len() - 1);
|
||||
settings.stylus_buttons.truncate(MAX_STYLUS_BUTTONS);
|
||||
settings
|
||||
}
|
||||
|
||||
@@ -277,6 +328,16 @@ mod tests {
|
||||
active_highlighter: 3,
|
||||
text_colors: [[0.3, 0.1, 0.6, 1.0]; 5],
|
||||
active_text_color: 4,
|
||||
stylus_buttons: vec![
|
||||
StylusButton {
|
||||
id: 17,
|
||||
action: StylusAction::Select,
|
||||
},
|
||||
StylusButton {
|
||||
id: 42,
|
||||
action: StylusAction::Nothing,
|
||||
},
|
||||
],
|
||||
};
|
||||
settings.save_to(&path).unwrap();
|
||||
assert_eq!(UiSettings::load_from(&path), settings);
|
||||
|
||||
@@ -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.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ContextMenu {
|
||||
@@ -228,7 +236,31 @@ pub enum UiAction {
|
||||
RotateSelected,
|
||||
// Dokumenten- & Notizbuch-Verwaltung
|
||||
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 {
|
||||
id: String,
|
||||
new_title: String,
|
||||
@@ -256,6 +288,8 @@ pub enum UiAction {
|
||||
theme: PaperTheme,
|
||||
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`)
|
||||
SavePressureCurve(PressureCurve),
|
||||
/// Seiten durchgehend untereinander statt einzeln anzeigen (wird gespeichert)
|
||||
@@ -306,6 +340,14 @@ pub struct UiState {
|
||||
pub available_notebooks: Vec<NotebookSummary>,
|
||||
pub new_notebook_title_input: 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 document_modal_rect: Option<egui::Rect>,
|
||||
/// Gitea Release- & Update-Manager
|
||||
@@ -336,6 +378,9 @@ pub struct UiState {
|
||||
pub show_color_picker: bool,
|
||||
/// Kontextmenü nach langem Drücken (Einfügen an dieser Stelle)
|
||||
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
|
||||
pub confirm_delete_page: bool,
|
||||
/// Seitenzahl der zuletzt gelöschten Seite, solange sie wiederhergestellt werden kann
|
||||
@@ -408,6 +453,11 @@ impl Default for UiState {
|
||||
available_notebooks: Vec::new(),
|
||||
new_notebook_title_input: String::new(),
|
||||
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(),
|
||||
document_modal_rect: None,
|
||||
is_update_dialog_open: false,
|
||||
@@ -426,6 +476,8 @@ impl Default for UiState {
|
||||
is_text_color_menu_open: false,
|
||||
show_color_picker: false,
|
||||
context_menu: None,
|
||||
stylus_buttons: Vec::new(),
|
||||
pressed_stylus_button: None,
|
||||
confirm_delete_page: false,
|
||||
restorable_page: None,
|
||||
shutdown_error: None,
|
||||
@@ -1590,6 +1642,70 @@ fn pattern_color_button(
|
||||
/// 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
|
||||
/// (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> {
|
||||
let mut save = false;
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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“.
|
||||
fn delete_page_buttons(
|
||||
ui: &mut egui::Ui,
|
||||
@@ -2288,6 +2638,14 @@ pub fn render_ui(
|
||||
state.close_overlays();
|
||||
state.is_document_menu_open = 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);
|
||||
}
|
||||
}
|
||||
@@ -3273,6 +3631,9 @@ pub fn render_ui(
|
||||
if let Some(curve) = pressure_curve_editor(ui, state) {
|
||||
actions.push(UiAction::SavePressureCurve(curve));
|
||||
}
|
||||
if stylus_buttons_editor(ui, state) {
|
||||
actions.push(UiAction::SaveStylusButtons);
|
||||
}
|
||||
|
||||
section(ui, "WERKZEUGLEISTE");
|
||||
ui.horizontal(|ui| {
|
||||
@@ -3505,51 +3866,89 @@ pub fn render_ui(
|
||||
state.is_document_menu_open = false;
|
||||
}
|
||||
|
||||
// Neues Dokument anlegen
|
||||
ui.label(
|
||||
egui::RichText::new("NEUES NOTIZBUCH ERSTELLEN")
|
||||
.size(10.5)
|
||||
.strong()
|
||||
.color(egui::Color32::from_gray(160)),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
// Gezeigter Ordner (gibt es ihn nicht mehr, die oberste Ebene)
|
||||
if !state.notebook_folder.is_empty()
|
||||
&& !state.library.folders.contains(&state.notebook_folder)
|
||||
{
|
||||
state.notebook_folder.clear();
|
||||
}
|
||||
let folder = state.notebook_folder.clone();
|
||||
folder_breadcrumbs(ui, state, &folder);
|
||||
ui.add_space(6.0);
|
||||
|
||||
// Neues Notizbuch oder neuer Ordner hier
|
||||
ui.horizontal(|ui| {
|
||||
let text_edit = ui.add(
|
||||
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))
|
||||
.desired_width(270.0),
|
||||
.desired_width(190.0),
|
||||
);
|
||||
let create_btn = ui.button(egui::RichText::new("➕ Erstellen").strong());
|
||||
let submit = create_btn.clicked()
|
||||
|| (text_edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)));
|
||||
|
||||
if submit {
|
||||
let title = state.new_notebook_title_input.trim();
|
||||
if !title.is_empty() {
|
||||
actions.push(UiAction::CreateNotebook(title.to_string()));
|
||||
let create_btn = ui
|
||||
.button(egui::RichText::new("➕ Notizbuch").strong())
|
||||
.on_hover_text("Neues Notizbuch in diesem Ordner anlegen und öffnen");
|
||||
let folder_btn = ui
|
||||
.button(egui::RichText::new("📁 Ordner").strong())
|
||||
.on_hover_text("Neuen Ordner hier anlegen (z. B. Semester oder Fach)");
|
||||
let title = state.new_notebook_title_input.trim().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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(12.0);
|
||||
ui.separator();
|
||||
ui.add_space(8.0);
|
||||
ui.separator();
|
||||
ui.add_space(6.0);
|
||||
|
||||
// Liste vorhandener Dokumente
|
||||
ui.label(
|
||||
egui::RichText::new("VORHANDENE DOKUMENTE")
|
||||
.size(10.5)
|
||||
.strong()
|
||||
.color(egui::Color32::from_gray(160)),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
// Zielordner für „Verschieben“ wählen
|
||||
if let Some(moving) = state.moving.clone() {
|
||||
move_target_list(ui, state, &moving, &mut actions);
|
||||
return;
|
||||
}
|
||||
|
||||
egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| {
|
||||
for summary in &state.available_notebooks {
|
||||
let summaries: Vec<NotebookSummary> = state
|
||||
.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;
|
||||
ui.horizontal(|ui| {
|
||||
ui.push_id(&summary.id, |ui| ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing = egui::vec2(6.0, 0.0);
|
||||
|
||||
if state.renaming_notebook_id.as_deref() == Some(&summary.id) {
|
||||
@@ -3596,39 +3995,9 @@ pub fn render_ui(
|
||||
.rounding(egui::Rounding::same(8.0))
|
||||
.inner_margin(egui::Margin::symmetric(10.0, 8.0))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let title_area = ui.vertical(|ui| {
|
||||
ui.horizontal(|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| {
|
||||
// Erst die Knöpfe (rechts), der Titel füllt den Rest: Die ganze
|
||||
// Titelfläche öffnet das Notizbuch, ohne die Knöpfe zu überdecken
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if summary.status == crate::storage::NotebookStatus::Damaged {
|
||||
if ui
|
||||
.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()));
|
||||
}
|
||||
|
||||
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))
|
||||
.on_hover_text("Titel umbenennen")
|
||||
.clicked()
|
||||
@@ -3662,7 +4039,42 @@ pub fn render_ui(
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
});
|
||||
@@ -4957,6 +5369,43 @@ mod tests {
|
||||
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]
|
||||
fn test_long_press_detection() {
|
||||
let t0 = Instant::now();
|
||||
|
||||
Reference in New Issue
Block a user