Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f76fad92be | ||
|
|
2c60a03460 | ||
|
|
b6580f197d |
Generated
+1
-1
@@ -1048,7 +1048,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ink-notes"
|
||||
version = "0.3.2"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"egui",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -2068,6 +2068,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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();
|
||||
|
||||
@@ -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, ¬ebook, &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, ¬ebook, &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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, ¬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