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
+368 -67
View File
@@ -228,7 +228,26 @@ 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,
},
RenameNotebook {
id: String,
new_title: String,
@@ -306,6 +325,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, für das gerade ein Zielordner gewählt wird
pub moving_notebook_id: Option<String>,
pub renaming_title_input: String,
pub document_modal_rect: Option<egui::Rect>,
/// Gitea Release- & Update-Manager
@@ -408,6 +435,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_notebook_id: None,
renaming_title_input: String::new(),
document_modal_rect: None,
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)
}
/// 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“.
fn delete_page_buttons(
ui: &mut egui::Ui,
@@ -2288,6 +2530,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_notebook_id = None;
actions.push(UiAction::RefreshNotebooks);
}
}
@@ -3505,51 +3755,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_notebook_id.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 +3884,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 +3912,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_notebook_id = Some(summary.id.clone());
}
if ui.button(egui::RichText::new("✏").size(16.0))
.on_hover_text("Titel umbenennen")
.clicked()
@@ -3662,7 +3928,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 +3975,7 @@ pub fn render_ui(
state.is_document_menu_open = false;
}
}
});
}));
ui.add_space(4.0);
}
});