From 2c60a03460075cd7c35dfe68f45247bab0333bf4 Mon Sep 17 00:00:00 2001 From: Harald Date: Wed, 7 Oct 2026 07:03:08 +0200 Subject: [PATCH] feat(ui): Vorschaubilder in der Seitenauswahl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Kacheln zeigen eine verkleinerte A4-Vorschau jeder Seite (Striche als Linien, Textmarker halbtransparent, volle Seiten auf ~1500 Punkte ausgedünnt), Seitenzahl darunter, aktuelle Seite blau umrandet - Noch nicht geladene Seiten werden bei offener Seitenauswahl nachgeladen (UiAction::LoadAllPages) - Kachelgröße über PAGE_TILE (vorher feste Werte) Co-Authored-By: Claude Opus 5.5 --- src/main.rs | 10 +++ src/ui.rs | 199 ++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 194 insertions(+), 15 deletions(-) diff --git a/src/main.rs b/src/main.rs index f7beb1e..84f6432 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2068,6 +2068,16 @@ fn main() -> Result<(), Box> { update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } + UiAction::LoadAllPages => { + // Für die Vorschaubilder der Seitenauswahl; Fehler werden wie beim + // Blättern behandelt (Ersatzseite, Warnung beim Öffnen der Seite) + for idx in 0..notebook.pages.len() { + if !notebook.pages[idx].is_loaded { + let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, idx); + } + } + window.request_redraw(); + } UiAction::GoToPage(idx) => { if idx < notebook.pages.len() { selection_state.clear(); diff --git a/src/ui.rs b/src/ui.rs index 6ede8ce..dc6a035 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -12,7 +12,7 @@ use std::time::Instant; use crate::autosave::AutosaveStatus; -use crate::document::{Notebook, PageBackground}; +use crate::document::{Notebook, Page, PageBackground, StrokeTool, PAGE_HEIGHT, PAGE_WIDTH}; use crate::settings::SidebarSide; use crate::storage::NotebookSummary; @@ -96,6 +96,8 @@ pub enum UiAction { NextPage, AddPage, GoToPage(usize), + /// Alle noch nicht geladenen Seiten laden (für die Vorschaubilder der Seitenauswahl) + LoadAllPages, SetBackground(PageBackground), ExportPdf, ZoomIn, @@ -699,8 +701,94 @@ fn color_circle_swatch( /// Spalten der Seitenauswahl. const PAGE_PICKER_COLUMNS: usize = 4; -/// Größe einer Kachel der Seitenauswahl. -const PAGE_TILE: egui::Vec2 = egui::vec2(56.0, 48.0); +/// Größe einer Kachel der Seitenauswahl: A4-Vorschau plus Zeile für die Seitenzahl. +const PAGE_TILE: egui::Vec2 = egui::vec2(66.0, 66.0 * PAGE_HEIGHT / PAGE_WIDTH + 18.0); +/// Höchstzahl gezeichneter Punkte pro Vorschau (dünnt volle Seiten aus). +const THUMBNAIL_POINT_BUDGET: usize = 1500; + +/// Zeichenbefehle für die verkleinerte Vorschau einer Seite in `rect` (A4-Format). +/// Striche werden als Linien gezeichnet und bei vielen Punkten gleichmäßig ausgedünnt. +pub fn page_thumbnail_shapes(page: &Page, rect: egui::Rect) -> Vec { + let mut shapes = vec![egui::Shape::rect_filled( + rect, + egui::Rounding::same(3.0), + egui::Color32::WHITE, + )]; + let scale = rect.width() / PAGE_WIDTH; + let to_screen = |p: glam::Vec2| rect.min + egui::vec2(p.x * scale, p.y * scale); + + let total: usize = page.strokes.iter().map(|s| s.points.len()).sum(); + let step = total.div_ceil(THUMBNAIL_POINT_BUDGET).max(1); + + for stroke in &page.strokes { + let mut color = color32_from(stroke.color); + if stroke.tool == StrokeTool::Highlighter { + color = color.gamma_multiply(0.45); + } + let width = (stroke.width * scale).clamp(0.6, 4.0); + let last = stroke.points.len().saturating_sub(1); + let points: Vec = stroke + .points + .iter() + .enumerate() + .filter(|(i, _)| i % step == 0 || *i == last) + .map(|(_, s)| to_screen(s.position)) + .collect(); + match points.len() { + 0 => {} + 1 => shapes.push(egui::Shape::circle_filled(points[0], width / 2.0, color)), + _ => shapes.push(egui::Shape::line(points, egui::Stroke::new(width, color))), + } + } + shapes +} + +/// Kachel der Seitenauswahl: Vorschau der Seite mit Seitenzahl darunter. +fn page_thumbnail( + ui: &mut egui::Ui, + page: &Page, + number: usize, + is_current: bool, +) -> egui::Response { + let (rect, response) = ui.allocate_exact_size(PAGE_TILE, egui::Sense::click()); + if ui.is_rect_visible(rect) { + let thumb = egui::Rect::from_min_size( + rect.min, + egui::vec2(PAGE_TILE.x, PAGE_TILE.x * PAGE_HEIGHT / PAGE_WIDTH), + ); + let painter = ui.painter(); + painter.extend(page_thumbnail_shapes(page, thumb)); + if !page.is_loaded { + painter.text( + thumb.center(), + egui::Align2::CENTER_CENTER, + "…", + egui::FontId::proportional(16.0), + egui::Color32::from_gray(140), + ); + } + let border = if is_current { + egui::Stroke::new(2.5_f32, egui::Color32::from_rgb(90, 150, 255)) + } else if response.hovered() { + egui::Stroke::new(1.5_f32, egui::Color32::from_white_alpha(200)) + } else { + egui::Stroke::new(1.0_f32, egui::Color32::from_white_alpha(60)) + }; + painter.rect_stroke(thumb, egui::Rounding::same(3.0), border); + painter.text( + egui::pos2(rect.center().x, rect.max.y - 8.0), + egui::Align2::CENTER_CENTER, + number.to_string(), + egui::FontId::proportional(13.0), + if is_current { + egui::Color32::WHITE + } else { + egui::Color32::from_gray(180) + }, + ); + } + response.on_hover_text(format!("Seite {number}")) +} /// Rahmen der Popover neben der Werkzeugleiste. fn popover_frame() -> egui::Frame { @@ -1696,24 +1784,19 @@ pub fn render_ui( if dialog_header(ui, &title, true) { state.show_page_jump_popup = false; } + // Noch nicht geladene Seiten für die Vorschau nachladen lassen + if notebook.pages.iter().any(|p| !p.is_loaded) { + actions.push(UiAction::LoadAllPages); + } egui::ScrollArea::vertical() - .max_height(320.0) + .max_height(420.0) .show(ui, |ui| { egui::Grid::new("page_picker_grid") .spacing([8.0, 8.0]) .show(ui, |ui| { - for idx in 0..notebook.pages.len() { + for (idx, page) in notebook.pages.iter().enumerate() { let is_current = idx == notebook.current_page_index; - let label = - egui::RichText::new(format!("{}", idx + 1)) - .size(16.0); - if ui - .add_sized( - [56.0, 48.0], - egui::SelectableLabel::new( - is_current, label, - ), - ) + if page_thumbnail(ui, page, idx + 1, is_current) .clicked() { actions.push(UiAction::GoToPage(idx)); @@ -3005,4 +3088,90 @@ mod tests { } assert_eq!(reached, (0..6).collect(), "alle Seiten erreichbar"); } + + /// Seite mit `strokes` Strichen à `points` Punkten (diagonal über die Seite). + fn page_with_strokes(strokes: usize, points: usize) -> Page { + let mut page = Page::new(1, PageBackground::Grid5mm); + for i in 0..strokes { + let samples: Vec = (0..points) + .map(|j| crate::document::StylusSample { + position: glam::Vec2::new( + 100.0 + j as f32 * 1800.0 / points as f32, + 100.0 + i as f32 * 20.0, + ), + pressure: 0.5, + timestamp: j as f64 * 0.01, + }) + .collect(); + page.strokes.push(crate::document::Stroke::new( + i as u64 + 1, + samples, + Vec::new(), + [0.1, 0.1, 0.1, 1.0], + )); + } + page + } + + fn shape_points(shapes: &[egui::Shape]) -> Vec { + shapes + .iter() + .flat_map(|s| match s { + egui::Shape::Path(p) => p.points.clone(), + egui::Shape::Circle(c) => vec![c.center], + _ => Vec::new(), + }) + .collect() + } + + #[test] + fn test_page_thumbnail_shapes() { + let rect = egui::Rect::from_min_size(egui::pos2(50.0, 80.0), egui::vec2(66.0, 93.0)); + + // Leere Seite: nur das weiße Blatt + let empty = Page::new(1, PageBackground::Blank); + assert_eq!(page_thumbnail_shapes(&empty, rect).len(), 1); + + // Striche liegen innerhalb der Kachel + let page = page_with_strokes(3, 20); + let shapes = page_thumbnail_shapes(&page, rect); + assert_eq!(shapes.len(), 1 + 3); + let pts = shape_points(&shapes); + assert!(!pts.is_empty()); + assert!( + pts.iter().all(|p| rect.contains(*p)), + "Punkte außerhalb der Kachel" + ); + + // Sehr volle Seite wird auf das Punktbudget ausgedünnt (+ Endpunkte je Strich) + let full = page_with_strokes(100, 1000); + let pts = shape_points(&page_thumbnail_shapes(&full, rect)); + assert!( + pts.len() <= THUMBNAIL_POINT_BUDGET + 2 * 100, + "{} Punkte statt höchstens {}", + pts.len(), + THUMBNAIL_POINT_BUDGET + 200 + ); + } + + #[test] + fn test_page_picker_requests_loading_of_unloaded_pages() { + let updater = crate::updater::UpdaterController::new(); + for (unloaded, expected) in [(false, false), (true, true)] { + let mut notebook = Notebook::new(); + notebook.add_page(PageBackground::Grid5mm); + if unloaded { + notebook.pages[1] = + Page::new_unloaded(notebook.pages[1].id, PageBackground::Grid5mm); + } + let mut state = UiState::new(); + state.show_page_jump_popup = true; + let ctx = render_frames(&mut state, ¬ebook, 3); + ctx.begin_frame(egui::RawInput::default()); + let actions = render_ui(&ctx, &mut state, ¬ebook, AutosaveStatus::Saved, &updater); + let _ = ctx.end_frame(); + let requested = actions.iter().any(|a| matches!(a, UiAction::LoadAllPages)); + assert_eq!(requested, expected, "ungeladene Seite: {unloaded}"); + } + } }