Compare commits

..
3 Commits
Author SHA1 Message Date
haraldandClaude Opus 5.5 f76fad92be chore(release): bump version to 0.3.3
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 9m10s
Behebt Datenverlust nach Neustart auf einer späteren Seite (v0.2.3 – v0.3.2); Seitenauswahl mit Vorschaubildern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:32:58 +02:00
haraldandClaude Opus 5.5 2c60a03460 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>
2026-10-07 07:03:08 +02:00
haraldandClaude Opus 5.5 b6580f197d fix(storage): Inhalt der zuletzt geöffneten Seite nach Neustart nicht überschreiben
Beim Start wurde nur die erste Seite geladen. War zuletzt eine spätere Seite
offen, war sie nach dem Neustart ein leerer, ungeladener Platzhalter: Das
Blatt erschien leer, und Zeichnen/Radieren/Hintergrund wechseln speicherte
den Platzhalter und überschrieb den bisherigen Inhalt der Seite.

- load_notebook lädt zusätzlich die aktuelle Seite
- Autosave schreibt nie ungeladene Platzhalter (Schutz ging beim Merge des
  Storage-Branches verloren)
- Regressionstests für beide Fälle

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:03:08 +02:00
7 changed files with 270 additions and 19 deletions
Generated
+1 -1
View File
@@ -1048,7 +1048,7 @@ dependencies = [
[[package]]
name = "ink-notes"
version = "0.3.2"
version = "0.3.3"
dependencies = [
"bytemuck",
"egui",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ink-notes"
version = "0.3.2"
version = "0.3.3"
edition = "2021"
description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux"
repository = "https://gitea.pansi.eu/harald/ink-notes"
+2 -1
View File
@@ -3,8 +3,9 @@
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04).
### Highlights:
- **Wichtige Fehlerbehebung**: Nach einem Neustart auf einer späteren Seite konnte Weiterschreiben den bisherigen Inhalt dieser Seite überschreiben (betrifft v0.2.3 – v0.3.2)
- **Neue Werkzeugleiste**: Schmale senkrechte Leiste mit Symbol-Knöpfen, links oder rechts andockbar; selten genutzte Funktionen im ☰-Menü, das Blatt wird daneben zentriert
- **Einheitliche Dialoge**: Deckende Fenster ohne doppelte Titel, Seitenauswahl als Kachelraster, größere Bedienelemente in allen Popovern
- **Einheitliche Dialoge**: Deckende Fenster ohne doppelte Titel, Seitenauswahl mit Vorschaubildern, größere Bedienelemente in allen Popovern
- **UI per Stift und Touch bedienbar**: Werkzeugleiste, Popover und Dialoge reagieren jetzt auch auf Stift und Finger, auch bei Display-Skalierung
- **Aktive Stylus-Unterstützung (Lazarite / MPP / AES)**: Natives Wayland-Tablet-Protokoll mit präziser Druck- und Neigungserfassung
- **Touchscreen Multi-Touch**: Stufenloses 2-Finger Pinch-to-Zoom und Panning mit umschaltbarem Finger-Zeichenmodus
+6 -1
View File
@@ -312,7 +312,12 @@ impl AutosaveController {
/// Markiert eine Seite als geändert und startet/erneuert das Debounce-Fenster.
pub fn mark_page_dirty(&mut self, page: &Page, manifest: &NotebookManifest) {
if self.read_only || page.persist == crate::document::PersistState::ReadOnlyAfterLoadError {
// Nicht geladene Platzhalter nie schreiben: Sie sind leer und würden den Inhalt
// der Seitendatei überschreiben
if self.read_only
|| !page.is_loaded
|| page.persist == crate::document::PersistState::ReadOnlyAfterLoadError
{
return;
}
let page_data = PageData::from(page);
+10
View File
@@ -2068,6 +2068,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
update_window_title(&window, &notebook, &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();
+66
View File
@@ -697,6 +697,15 @@ pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest)
);
notebook.load_warnings = load_warnings;
// Die zuletzt geöffnete Seite sofort laden: Sie ist nach dem Start die aktuelle Seite,
// und ein leerer Platzhalter würde beim Weiterschreiben ihren Inhalt überschreiben.
let current = notebook.current_page_index;
if !notebook.pages[current].is_loaded {
if let Err(e) = ensure_page_loaded(notebook_dir, &mut notebook, current) {
eprintln!("[Storage] Aktuelle Seite {current} konnte nicht geladen werden: {e}");
}
}
Ok((notebook, manifest))
}
@@ -2167,4 +2176,61 @@ mod tests {
assert_eq!(loaded.pages[1].background, PageBackground::Lined);
assert!(crate::export::generate_pdf_document(&loaded, Default::default()).is_ok());
}
#[test]
fn test_restart_on_later_page_keeps_its_content() {
// Regression: Beim Start wurde nur Seite 1 geladen; war zuletzt eine spätere Seite
// offen, war sie ein leerer Platzhalter, und Weiterschreiben überschrieb ihren Inhalt.
let temp_dir = tempfile::tempdir().unwrap();
let (mut notebook, mut manifest, dir) =
create_new_notebook(temp_dir.path(), "Neustart").unwrap();
notebook.add_page(PageBackground::Lined);
notebook.pages[1] = page_with_one_stroke(notebook.pages[1].id, PageBackground::Lined);
notebook.current_page_index = 1;
save_notebook(&dir, &notebook, &mut manifest).unwrap();
let (mut loaded, mut loaded_manifest) = load_notebook(&dir).unwrap();
assert_eq!(loaded.current_page_index, 1);
assert!(
loaded.current_page().is_loaded,
"aktuelle Seite muss geladen sein"
);
assert_eq!(loaded.current_page().strokes.len(), 1);
// Weiterschreiben und speichern darf den bisherigen Inhalt nicht verlieren
let extra = page_with_one_stroke(99, PageBackground::Lined).strokes[0].clone();
loaded.current_page_mut().strokes.push(extra);
let mut autosave = AutosaveController::new(dir.clone(), Duration::from_secs(60));
autosave.mark_dirty(&loaded, &mut loaded_manifest);
autosave.flush().unwrap();
autosave.shutdown().unwrap();
let (reloaded, _) = load_notebook(&dir).unwrap();
assert_eq!(reloaded.current_page().strokes.len(), 2);
}
#[test]
fn test_autosave_never_writes_unloaded_placeholder() {
let temp_dir = tempfile::tempdir().unwrap();
let (mut notebook, mut manifest, dir) =
create_new_notebook(temp_dir.path(), "Platzhalter").unwrap();
notebook.add_page(PageBackground::Lined);
notebook.pages[1] = page_with_one_stroke(notebook.pages[1].id, PageBackground::Lined);
save_notebook(&dir, &notebook, &mut manifest).unwrap();
let page_file = dir.join(format!("page_{}.json", notebook.pages[1].id));
let before = fs::read(&page_file).unwrap();
// Platzhalter (nicht geladen) als aktuelle Seite markieren und speichern
let placeholder = Page::new_unloaded(notebook.pages[1].id, PageBackground::Lined);
let mut autosave = AutosaveController::new(dir.clone(), Duration::from_secs(60));
autosave.mark_page_dirty(&placeholder, &manifest);
autosave.flush().unwrap();
autosave.shutdown().unwrap();
assert_eq!(
fs::read(&page_file).unwrap(),
before,
"Seitendatei wurde überschrieben"
);
}
}
+184 -15
View File
@@ -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<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.
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<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, &notebook, 3);
ctx.begin_frame(egui::RawInput::default());
let actions = render_ui(&ctx, &mut state, &notebook, AutosaveStatus::Saved, &updater);
let _ = ctx.end_frame();
let requested = actions.iter().any(|a| matches!(a, UiAction::LoadAllPages));
assert_eq!(requested, expected, "ungeladene Seite: {unloaded}");
}
}
}