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>
This commit is contained in:
2026-10-08 17:41:05 +02:00
co-authored by Claude Opus 5.5
parent 70317caeb5
commit 36a1a3cd2e
6 changed files with 880 additions and 83 deletions
+2
View File
@@ -53,6 +53,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 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 +163,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 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`)
+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;
+376
View File
@@ -0,0 +1,376 @@
//! Ordner für Notizbücher (`<Basisordner>/library.json`).
//!
//! Ordner sind rein logisch: Die Notizbücher bleiben in ihren Verzeichnissen
//! `<Basisordner>/<id>/`, `library.json` hält nur fest, welcher Ordner welches Notizbuch
//! enthält. Umbenennen und Verschieben ändern so nie Notizbuch-Dateien. Fehlt die Datei oder
//! ist sie unlesbar, liegen alle Notizbücher auf der obersten Ebene – verloren geht nichts.
//!
//! Ordner werden als Pfade mit `/` als Trenner gespeichert (z. B. `3. Semester/Mathe`);
//! die oberste Ebene ist der leere Pfad.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::storage::{self, StorageError};
/// Oberste Ebene.
pub const ROOT: &str = "";
/// Längster erlaubter Ordnername (Zeichen).
const MAX_NAME_CHARS: usize = 80;
/// Ordnerbaum und Zuordnung der Notizbücher.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Library {
/// Alle Ordner als Pfade
pub folders: BTreeSet<String>,
/// Ordner je Notizbuch-Kennung; Notizbücher ohne Eintrag liegen auf oberster Ebene
pub notebooks: BTreeMap<String, String>,
}
/// Letzter Teil eines Ordnerpfads (der angezeigte Name).
pub fn folder_name(path: &str) -> &str {
path.rsplit('/').next().unwrap_or(path)
}
/// Übergeordneter Ordner (oberste Ebene für Ordner der obersten Ebene).
pub fn parent_folder(path: &str) -> &str {
path.rsplit_once('/').map_or(ROOT, |(parent, _)| parent)
}
/// Pfad des Unterordners `name` in `parent`.
fn join(parent: &str, name: &str) -> String {
if parent.is_empty() {
name.to_string()
} else {
format!("{parent}/{name}")
}
}
/// `path` liegt in `folder` oder ist `folder` selbst.
fn is_within(path: &str, folder: &str) -> bool {
path == folder
|| path
.strip_prefix(folder)
.is_some_and(|rest| rest.starts_with('/'))
}
/// Bereinigt einen Ordnernamen; Fehlermeldung für die Oberfläche bei ungültigen Namen.
pub fn validate_name(name: &str) -> Result<String, String> {
let name = name.trim();
if name.is_empty() {
return Err("Der Ordnername ist leer".to_string());
}
if name.contains('/') {
return Err("Ordnernamen dürfen kein „/“ enthalten".to_string());
}
if name.chars().count() > MAX_NAME_CHARS {
return Err(format!(
"Der Ordnername ist zu lang (höchstens {MAX_NAME_CHARS} Zeichen)"
));
}
Ok(name.to_string())
}
impl Library {
/// Ort von `library.json` im Basisordner der Notizbücher.
pub fn file_path(base_dir: &Path) -> PathBuf {
base_dir.join("library.json")
}
/// Lädt die Ordner; fehlt die Datei oder ist sie beschädigt, gibt es keine Ordner.
pub fn load(base_dir: &Path) -> Self {
let path = Self::file_path(base_dir);
let mut library: Self = match std::fs::read_to_string(&path) {
Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
eprintln!(
"[Ordner] '{}' ist ungültig ({e}), alle Notizbücher auf oberster Ebene.",
path.display()
);
Self::default()
}),
Err(_) => Self::default(),
};
library.repair();
library
}
/// Speichert die Ordner atomar.
pub fn save(&self, base_dir: &Path) -> Result<(), StorageError> {
storage::atomic_write_json(&Self::file_path(base_dir), self)
}
/// Macht eine (evtl. von Hand bearbeitete) Datei stimmig: leere oder ungültige Namen
/// fallen weg, jeder benutzte Ordner und seine übergeordneten Ordner existieren.
fn repair(&mut self) {
let valid = |path: &str| {
!path.is_empty()
&& path
.split('/')
.all(|name| validate_name(name).as_deref() == Ok(name))
};
self.folders.retain(|f| valid(f));
self.notebooks.retain(|_, f| f.is_empty() || valid(f));
let used: Vec<String> = self
.folders
.iter()
.chain(self.notebooks.values())
.cloned()
.collect();
for mut folder in used {
while !folder.is_empty() {
let parent = parent_folder(&folder).to_string();
self.folders.insert(folder);
folder = parent;
}
}
}
/// Ordner, in dem das Notizbuch liegt.
pub fn folder_of(&self, notebook_id: &str) -> &str {
self.notebooks.get(notebook_id).map_or(ROOT, String::as_str)
}
/// Direkte Unterordner von `parent` (vollständige Pfade, nach Name sortiert).
pub fn subfolders(&self, parent: &str) -> Vec<&str> {
let mut list: Vec<&str> = self
.folders
.iter()
.map(String::as_str)
.filter(|f| parent_folder(f) == parent)
.collect();
list.sort_by_key(|f| folder_name(f).to_lowercase());
list
}
/// Alle Ordner in Baumreihenfolge (Eltern vor Kindern, Geschwister nach Name).
pub fn all_folders(&self) -> Vec<&str> {
let mut out = Vec::with_capacity(self.folders.len());
let mut stack: Vec<&str> = self.subfolders(ROOT).into_iter().rev().collect();
while let Some(folder) = stack.pop() {
out.push(folder);
stack.extend(self.subfolders(folder).into_iter().rev());
}
out
}
/// Legt in `parent` den Ordner `name` an und gibt seinen Pfad zurück.
pub fn create_folder(&mut self, parent: &str, name: &str) -> Result<String, String> {
let name = validate_name(name)?;
if !parent.is_empty() && !self.folders.contains(parent) {
return Err("Der übergeordnete Ordner existiert nicht mehr".to_string());
}
let path = join(parent, &name);
if !self.folders.insert(path.clone()) {
return Err(format!("Den Ordner „{name}“ gibt es hier schon"));
}
Ok(path)
}
/// Benennt einen Ordner um (mitsamt Unterordnern und Zuordnungen); gibt den neuen Pfad
/// zurück.
pub fn rename_folder(&mut self, path: &str, new_name: &str) -> Result<String, String> {
let new_name = validate_name(new_name)?;
if !self.folders.contains(path) {
return Err("Den Ordner gibt es nicht mehr".to_string());
}
let new_path = join(parent_folder(path), &new_name);
if new_path == path {
return Ok(new_path);
}
if self.folders.contains(&new_path) {
return Err(format!("Den Ordner „{new_name}“ gibt es hier schon"));
}
self.move_contents(path, &new_path);
Ok(new_path)
}
/// Löscht einen Ordner. Seine Notizbücher und Unterordner rücken eine Ebene nach oben
/// (gleichnamige Unterordner werden zusammengelegt) – gelöscht wird kein Notizbuch.
pub fn delete_folder(&mut self, path: &str) {
if path.is_empty() || !self.folders.remove(path) {
return;
}
let parent = parent_folder(path).to_string();
let prefix = format!("{path}/");
let moved: Vec<String> = self
.folders
.iter()
.filter(|f| f.starts_with(&prefix))
.cloned()
.collect();
for folder in moved {
self.folders.remove(&folder);
self.folders.insert(join(&parent, &folder[prefix.len()..]));
}
for folder in self.notebooks.values_mut() {
if folder == path {
*folder = parent.clone();
} else if let Some(rest) = folder.strip_prefix(&prefix) {
*folder = join(&parent, rest);
}
}
self.notebooks.retain(|_, f| !f.is_empty());
}
/// Verschiebt einen Ordner samt Inhalt nach `new_path`.
fn move_contents(&mut self, path: &str, new_path: &str) {
let renamed = |f: &str| format!("{new_path}{}", &f[path.len()..]);
let moved: Vec<String> = self
.folders
.iter()
.filter(|f| is_within(f, path))
.cloned()
.collect();
for folder in moved {
self.folders.remove(&folder);
self.folders.insert(renamed(&folder));
}
for folder in self.notebooks.values_mut() {
if is_within(folder, path) {
*folder = renamed(folder);
}
}
}
/// Legt das Notizbuch in `folder` (oberste Ebene: [`ROOT`]).
pub fn move_notebook(&mut self, notebook_id: &str, folder: &str) -> Result<(), String> {
if folder.is_empty() {
self.notebooks.remove(notebook_id);
return Ok(());
}
if !self.folders.contains(folder) {
return Err("Den Ordner gibt es nicht mehr".to_string());
}
self.notebooks
.insert(notebook_id.to_string(), folder.to_string());
Ok(())
}
/// Wie viele der Notizbücher `ids` in `folder` liegen, Unterordner eingeschlossen.
pub fn count_within<'a>(&self, folder: &str, ids: impl IntoIterator<Item = &'a str>) -> usize {
ids.into_iter()
.filter(|id| is_within(self.folder_of(id), folder))
.count()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn library() -> Library {
let mut lib = Library::default();
let sem3 = lib.create_folder(ROOT, "3. Semester").unwrap();
let mathe = lib.create_folder(&sem3, "Mathe").unwrap();
lib.create_folder(&sem3, "Physik").unwrap();
lib.move_notebook("analysis", &mathe).unwrap();
lib.move_notebook("uebung", &sem3).unwrap();
lib
}
#[test]
fn test_paths_and_names() {
assert_eq!(folder_name("3. Semester/Mathe"), "Mathe");
assert_eq!(folder_name("Mathe"), "Mathe");
assert_eq!(parent_folder("3. Semester/Mathe"), "3. Semester");
assert_eq!(parent_folder("Mathe"), ROOT);
assert_eq!(validate_name(" Mathe ").unwrap(), "Mathe");
assert!(validate_name(" ").is_err());
assert!(validate_name("a/b").is_err());
assert!(validate_name(&"x".repeat(81)).is_err());
}
#[test]
fn test_create_and_list_folders() {
let mut lib = library();
assert_eq!(lib.subfolders(ROOT), ["3. Semester"]);
assert_eq!(
lib.subfolders("3. Semester"),
["3. Semester/Mathe", "3. Semester/Physik"]
);
assert_eq!(
lib.all_folders(),
["3. Semester", "3. Semester/Mathe", "3. Semester/Physik"]
);
assert_eq!(lib.folder_of("analysis"), "3. Semester/Mathe");
assert_eq!(lib.folder_of("unbekannt"), ROOT);
// Doppelte Namen und fehlende Eltern werden abgelehnt
assert!(lib.create_folder("3. Semester", " Mathe ").is_err());
assert!(lib.create_folder("Gibt es nicht", "X").is_err());
// Gleicher Name in einem anderen Ordner ist erlaubt
lib.create_folder(ROOT, "4. Semester").unwrap();
assert!(lib.create_folder("4. Semester", "Mathe").is_ok());
assert_eq!(
lib.count_within("3. Semester", ["analysis", "uebung", "lose"]),
2
);
}
#[test]
fn test_rename_folder_keeps_contents() {
let mut lib = library();
let new = lib.rename_folder("3. Semester", "WS 25-26").unwrap();
assert_eq!(new, "WS 25-26");
assert_eq!(lib.folder_of("analysis"), "WS 25-26/Mathe");
assert_eq!(lib.folder_of("uebung"), "WS 25-26");
assert!(lib.folders.contains("WS 25-26/Physik"));
assert!(!lib.folders.iter().any(|f| f.starts_with("3. Semester")));
// Ein Ordner mit ähnlichem Namen bleibt unberührt
let mut lib = library();
lib.create_folder(ROOT, "3. Semester alt").unwrap();
lib.rename_folder("3. Semester", "Drei").unwrap();
assert!(lib.folders.contains("3. Semester alt"));
// Kollision
assert!(lib.rename_folder("Drei", "3. Semester alt").is_err());
}
#[test]
fn test_delete_folder_moves_contents_up() {
let mut lib = library();
lib.delete_folder("3. Semester");
assert_eq!(lib.folder_of("analysis"), "Mathe");
assert_eq!(lib.folder_of("uebung"), ROOT);
assert!(!lib.notebooks.contains_key("uebung"));
assert_eq!(lib.subfolders(ROOT), ["Mathe", "Physik"]);
}
#[test]
fn test_move_notebook() {
let mut lib = library();
lib.move_notebook("uebung", "3. Semester/Physik").unwrap();
assert_eq!(lib.folder_of("uebung"), "3. Semester/Physik");
lib.move_notebook("uebung", ROOT).unwrap();
assert_eq!(lib.folder_of("uebung"), ROOT);
assert!(lib.move_notebook("uebung", "Gibt es nicht").is_err());
}
#[test]
fn test_load_save_and_repair() {
let dir = tempfile::tempdir().unwrap();
// Fehlende Datei: keine Ordner
assert_eq!(Library::load(dir.path()), Library::default());
let lib = library();
lib.save(dir.path()).unwrap();
assert_eq!(Library::load(dir.path()), lib);
// Beschädigt: alles auf oberster Ebene statt Absturz
std::fs::write(Library::file_path(dir.path()), b"{ kaputt").unwrap();
assert_eq!(Library::load(dir.path()), Library::default());
// Von Hand bearbeitet: fehlende Elternordner werden ergänzt, Unsinn fällt weg
std::fs::write(
Library::file_path(dir.path()),
br#"{"folders": ["A/B", "", "C//D"], "notebooks": {"x": "E/F", "y": "/kaputt"}}"#,
)
.unwrap();
let lib = Library::load(dir.path());
assert_eq!(lib.all_folders(), ["A", "A/B", "E", "E/F"]);
assert_eq!(lib.folder_of("x"), "E/F");
assert_eq!(lib.folder_of("y"), ROOT);
}
}
+117 -16
View File
@@ -24,6 +24,7 @@ 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::{
@@ -34,6 +35,36 @@ 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;
/// 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 +1058,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
@@ -1078,8 +1116,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();
@@ -2969,8 +3008,60 @@ 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::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 +3097,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 +3114,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 +3125,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 +3139,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 +3156,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 +3173,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 +3192,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 +3209,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 +3234,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 +3272,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 +3292,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 +3307,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();
} }
+365 -64
View File
@@ -228,7 +228,26 @@ 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,
},
RenameNotebook { RenameNotebook {
id: String, id: String,
new_title: String, new_title: String,
@@ -306,6 +325,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, für das gerade ein Zielordner gewählt wird
pub moving_notebook_id: Option<String>,
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
@@ -408,6 +435,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_notebook_id: 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,
@@ -1670,6 +1702,216 @@ 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 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: oberste Ebene und alle Ordner als Baum.
fn move_target_list(
ui: &mut egui::Ui,
state: &mut UiState,
notebook_id: &str,
actions: &mut Vec<UiAction>,
) {
let title = state
.available_notebooks
.iter()
.find(|s| s.id == notebook_id)
.map_or(notebook_id, |s| s.title.as_str())
.to_string();
let current = state.library.folder_of(notebook_id).to_string();
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 state.library.all_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(UiAction::MoveNotebook {
id: notebook_id.to_string(),
folder: path.clone(),
});
state.moving_notebook_id = None;
}
});
}
});
ui.add_space(6.0);
if ui.button("Abbrechen").clicked() {
state.moving_notebook_id = 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 +2530,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_notebook_id = None;
actions.push(UiAction::RefreshNotebooks); actions.push(UiAction::RefreshNotebooks);
} }
} }
@@ -3505,51 +3755,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)");
let title = state.new_notebook_title_input.trim().to_string();
if !title.is_empty() { if !title.is_empty() {
actions.push(UiAction::CreateNotebook(title.to_string())); 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_notebook_id.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)),
); 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); ui.add_space(4.0);
}
egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { if subfolders.is_empty() && summaries.is_empty() {
for summary in &state.available_notebooks { 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,38 +3884,8 @@ 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.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| { 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
@@ -3654,6 +3912,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_notebook_id = Some(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 +3928,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 +3975,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);
} }
}); });