feat(ui): Vorschaubilder in der Seitenauswahl
- 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 <noreply@anthropic.com>
This commit is contained in:
+10
@@ -2068,6 +2068,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
window.request_redraw();
|
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) => {
|
UiAction::GoToPage(idx) => {
|
||||||
if idx < notebook.pages.len() {
|
if idx < notebook.pages.len() {
|
||||||
selection_state.clear();
|
selection_state.clear();
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use crate::autosave::AutosaveStatus;
|
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::settings::SidebarSide;
|
||||||
use crate::storage::NotebookSummary;
|
use crate::storage::NotebookSummary;
|
||||||
|
|
||||||
@@ -96,6 +96,8 @@ pub enum UiAction {
|
|||||||
NextPage,
|
NextPage,
|
||||||
AddPage,
|
AddPage,
|
||||||
GoToPage(usize),
|
GoToPage(usize),
|
||||||
|
/// Alle noch nicht geladenen Seiten laden (für die Vorschaubilder der Seitenauswahl)
|
||||||
|
LoadAllPages,
|
||||||
SetBackground(PageBackground),
|
SetBackground(PageBackground),
|
||||||
ExportPdf,
|
ExportPdf,
|
||||||
ZoomIn,
|
ZoomIn,
|
||||||
@@ -699,8 +701,94 @@ fn color_circle_swatch(
|
|||||||
|
|
||||||
/// Spalten der Seitenauswahl.
|
/// Spalten der Seitenauswahl.
|
||||||
const PAGE_PICKER_COLUMNS: usize = 4;
|
const PAGE_PICKER_COLUMNS: usize = 4;
|
||||||
/// Größe einer Kachel der Seitenauswahl.
|
/// Größe einer Kachel der Seitenauswahl: A4-Vorschau plus Zeile für die Seitenzahl.
|
||||||
const PAGE_TILE: egui::Vec2 = egui::vec2(56.0, 48.0);
|
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<egui::Shape> {
|
||||||
|
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<egui::Pos2> = 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.
|
/// Rahmen der Popover neben der Werkzeugleiste.
|
||||||
fn popover_frame() -> egui::Frame {
|
fn popover_frame() -> egui::Frame {
|
||||||
@@ -1696,24 +1784,19 @@ pub fn render_ui(
|
|||||||
if dialog_header(ui, &title, true) {
|
if dialog_header(ui, &title, true) {
|
||||||
state.show_page_jump_popup = false;
|
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()
|
egui::ScrollArea::vertical()
|
||||||
.max_height(320.0)
|
.max_height(420.0)
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
egui::Grid::new("page_picker_grid")
|
egui::Grid::new("page_picker_grid")
|
||||||
.spacing([8.0, 8.0])
|
.spacing([8.0, 8.0])
|
||||||
.show(ui, |ui| {
|
.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 is_current = idx == notebook.current_page_index;
|
||||||
let label =
|
if page_thumbnail(ui, page, idx + 1, is_current)
|
||||||
egui::RichText::new(format!("{}", idx + 1))
|
|
||||||
.size(16.0);
|
|
||||||
if ui
|
|
||||||
.add_sized(
|
|
||||||
[56.0, 48.0],
|
|
||||||
egui::SelectableLabel::new(
|
|
||||||
is_current, label,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
actions.push(UiAction::GoToPage(idx));
|
actions.push(UiAction::GoToPage(idx));
|
||||||
@@ -3005,4 +3088,90 @@ mod tests {
|
|||||||
}
|
}
|
||||||
assert_eq!(reached, (0..6).collect(), "alle Seiten erreichbar");
|
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<crate::document::StylusSample> = (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<egui::Pos2> {
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user