Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41323950dc | ||
|
|
e95b03e316 | ||
|
|
a22f081115 | ||
|
|
c2652ea05b | ||
|
|
7c69edab12 | ||
|
|
6f52d4801f | ||
|
|
bb9bb58790 | ||
|
|
9dbe625eef | ||
|
|
e7e161e70c | ||
|
|
043f487e9c | ||
|
|
3b0b4f51fb | ||
|
|
14abadbd03 | ||
|
|
f279790e00 | ||
|
|
7bd3d9e82f | ||
|
|
ab0f49199b | ||
|
|
38b860bc32 | ||
|
|
055e39c24e | ||
|
|
1b22ffa526 | ||
|
|
0d9e9aa2ea | ||
|
|
298932cb32 | ||
|
|
127bc8d730 |
No files matched your search
Generated
+1
-1
@@ -1540,7 +1540,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ink-notes"
|
||||
version = "0.42.0"
|
||||
version = "0.45.1"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"android_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ink-notes"
|
||||
version = "0.42.0"
|
||||
version = "0.45.1"
|
||||
edition = "2021"
|
||||
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||
|
||||
+3
-3
@@ -22,12 +22,12 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
|
||||
## Funktionen
|
||||
|
||||
### Schreiben und Zeichnen
|
||||
- **Vier Stifte** in der Werkzeugleiste, jeder mit eigener Farbe und Stärke (0,15–5 mm, mit Druckstärke). Ändert man Farbe oder Stärke, merkt sich der gewählte Stift das – auch über einen Neustart hinweg. **Textmarker** (halbtransparent, 4–8 mm) in vier Farben; innerhalb eines Strichs wird er nicht dunkler, erst ein neuer Strich deckt stärker.
|
||||
- **Eigene Farben:** Jede Farbe in der Werkzeugleiste (Stifte, Textmarker, Text) lässt sich anpassen – gewählte Farbe erneut antippen, eine Vorgabe oder mit 🎨 einen freien Farbton wählen. Die Leiste merkt sich die Farben. Am Bildschirm erscheinen sie genauso wie im PDF- und Bildexport.
|
||||
- **Sieben Stifte** in der Werkzeugleiste, jeder mit eigener Farbe und Stärke (0,15–5 mm, mit Druckstärke). Ändert man Farbe oder Stärke, merkt sich der gewählte Stift das – auch über einen Neustart hinweg. **Textmarker** (halbtransparent, 4–8 mm) in vier Farben; innerhalb eines Strichs wird er nicht dunkler, erst ein neuer Strich deckt stärker.
|
||||
- **Eigene Farben:** Jede Farbe in der Werkzeugleiste (Stifte, Textmarker, Text) lässt sich anpassen – gewählte Farbe erneut antippen, eine Vorgabe oder mit 🎨 einen freien Farbton wählen. Die Leiste merkt sich die Farben. Unter den Vorgaben legt „➕ Farbe merken“ den aktuellen Farbton in „Meine Farben“ ab (bis zu 12, gemeinsam für Stifte, Textmarker und Text; 🗑 entfernt die gewählte). Am Bildschirm erscheinen sie genauso wie im PDF- und Bildexport.
|
||||
- **Glättung in zwei Stufen:** Während des Schreibens glättet ein 1€-Filter das Zittern, die Linie erscheint sofort. Beim Absetzen wird der Strich nachbearbeitet: Ecken (Knicke über 65°) bleiben erhalten und werden rund verbunden, der Druckverlauf wird geglättet, überflüssige Punkte entfallen, die Enden werden rund.
|
||||
- **Druckkurve** im ☰-Menü an den eigenen Stift anpassen (weicher/härter, Schwelle gegen Grundrauschen).
|
||||
- **Tasten am Stift** im ☰-Menü belegen: Radieren (Standard), Auswählen, Textmarker, Stift oder nichts – gilt, solange die Taste gedrückt ist. Jede Taste erscheint dort nach dem ersten Drücken.
|
||||
- **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker; die Größe stellt man in einem Menü wie bei den Stiften ein (wird gemerkt). Die Stiftrückseite radiert ebenfalls.
|
||||
- **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker; berührte Textfelder verschwinden ganz, Bilder bleiben; die Größe stellt man in einem Menü wie bei den Stiften ein (wird gemerkt). Die Stiftrückseite radiert ebenfalls.
|
||||
- **Virtuelles Lineal** (`L`): verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden gerade.
|
||||
- **Nur ansehen** (👁 in der Werkzeugleiste): Stift, Finger und Maus scrollen und zoomen nur; die Werkzeuge verschwinden, nichts lässt sich versehentlich ändern. Erneut 👁 tippen, um zu schreiben.
|
||||
- **Platz schaffen** (↕ in der Werkzeugleiste): aufs Blatt tippen und nach unten ziehen – alles darunter rutscht mit, auch auf den folgenden Seiten. Was über ein Seitenende rutschen würde, beginnt auf der nächsten Seite; fehlende Seiten werden angehängt. Nach oben ziehen schließt leeren Platz. Ein Schritt für Rückgängig.
|
||||
|
||||
@@ -22,12 +22,12 @@ The interface is available in English and German; ink-notes follows the system l
|
||||
## Features
|
||||
|
||||
### Writing and drawing
|
||||
- **Four pens** in the toolbar, each with its own color and width (0.15–5 mm, pressure-sensitive). When you change a pen's color or width, it remembers that – also across restarts. **Highlighter** (semi-transparent, 4–8 mm) in four colors; it does not get darker within one stroke, only a new stroke covers more strongly.
|
||||
- **Custom colors:** Every color in the toolbar (pens, highlighter, text) can be changed – tap the selected color again and pick a preset or any shade with 🎨. The toolbar remembers the colors. They look the same on screen as in PDF and image exports.
|
||||
- **Seven pens** in the toolbar, each with its own color and width (0.15–5 mm, pressure-sensitive). When you change a pen's color or width, it remembers that – also across restarts. **Highlighter** (semi-transparent, 4–8 mm) in four colors; it does not get darker within one stroke, only a new stroke covers more strongly.
|
||||
- **Custom colors:** Every color in the toolbar (pens, highlighter, text) can be changed – tap the selected color again and pick a preset or any shade with 🎨. The toolbar remembers the colors. Under the presets, “➕ Save color” keeps the current shade in “My colors” (up to 12, shared by pens, highlighter and text; 🗑 removes the selected one). They look the same on screen as in PDF and image exports.
|
||||
- **Two-stage smoothing:** While you write, a 1€ filter removes jitter and the line appears immediately. When you lift the pen, the stroke is refined: corners (bends over 65°) are kept and drawn round, the pressure curve is smoothed, redundant points are dropped and the ends become round.
|
||||
- **Pressure curve** adjustable to your pen in the ☰ menu (softer/harder, threshold against base noise).
|
||||
- **Pen buttons** can be assigned in the ☰ menu: erase (default), select, highlighter, pen or nothing – active while the button is held. Each button appears there after it is pressed for the first time.
|
||||
- **Eraser** with three modes: whole strokes, only the part you wipe over (partial stroke), or highlighter only; its size is set in a menu like the pens (remembered). The back end of the pen erases too.
|
||||
- **Eraser** with three modes: whole strokes, only the part you wipe over (partial stroke), or highlighter only; text boxes it touches go as a whole, images stay; its size is set in a menu like the pens (remembered). The back end of the pen erases too.
|
||||
- **Virtual ruler** (`L`): move it, rotate it by its handle (snaps to 15°); strokes along an edge become straight.
|
||||
- **View only** (👁 in the toolbar): pen, finger and mouse only scroll and zoom; the tools disappear and nothing can be changed by accident. Tap 👁 again to write.
|
||||
- **Make space** (↕ in the toolbar): tap the page and drag down – everything below moves along, also on the following pages. Whatever would slide past the end of a page continues on the next one; missing pages are appended. Dragging up closes empty space. Undo reverts it in one step.
|
||||
|
||||
@@ -84,6 +84,36 @@
|
||||
</branding>
|
||||
<content_rating type="oars-1.1" />
|
||||
<releases>
|
||||
<release version="0.45.1" date="2026-11-14">
|
||||
<description>
|
||||
<p>Fixes from a review of the whole project, mainly for the safety of your notes: switching notebooks, the trash, copied notebook folders, scaled strokes and imports of broken files.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.45.0" date="2026-11-14">
|
||||
<description>
|
||||
<p>Own colors can be saved under the presets of the color menus, and copy and paste in text fields works with other apps.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.44.0" date="2026-11-09">
|
||||
<description>
|
||||
<p>Seven pens in the toolbar, dialogs that fit on phones, and touch lifts that arrive right away on GNOME, so the page glides after a swipe and the ruler no longer follows the hovering pen.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.43.0" date="2026-11-09">
|
||||
<description>
|
||||
<p>The pen draws without delay again on Wayland, and the eraser removes text boxes such as an inserted math result.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.42.2" date="2026-11-05">
|
||||
<description>
|
||||
<p>"Draw with finger" is remembered now and on by default on Android, so phones without a pen can write again after a restart or an update.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.42.1" date="2026-11-02">
|
||||
<description>
|
||||
<p>Swiping with a finger glides on again on Wayland: lifting the finger no longer arrives late and stops the page right away.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.42.0" date="2026-10-28">
|
||||
<description>
|
||||
<p>Built-in Nextcloud sync: sign in through the browser and the notebooks are synced with your Nextcloud, also private servers and without a sync client.</p>
|
||||
|
||||
@@ -84,6 +84,36 @@
|
||||
</branding>
|
||||
<content_rating type="oars-1.1" />
|
||||
<releases>
|
||||
<release version="0.45.1" date="2026-11-14">
|
||||
<description>
|
||||
<p>Fixes from a review of the whole project, mainly for the safety of your notes: switching notebooks, the trash, copied notebook folders, scaled strokes and imports of broken files.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.45.0" date="2026-11-14">
|
||||
<description>
|
||||
<p>Own colors can be saved under the presets of the color menus, and copy and paste in text fields works with other apps.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.44.0" date="2026-11-09">
|
||||
<description>
|
||||
<p>Seven pens in the toolbar, dialogs that fit on phones, and touch lifts that arrive right away on GNOME, so the page glides after a swipe and the ruler no longer follows the hovering pen.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.43.0" date="2026-11-09">
|
||||
<description>
|
||||
<p>The pen draws without delay again on Wayland, and the eraser removes text boxes such as an inserted math result.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.42.2" date="2026-11-05">
|
||||
<description>
|
||||
<p>"Draw with finger" is remembered now and on by default on Android, so phones without a pen can write again after a restart or an update.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.42.1" date="2026-11-02">
|
||||
<description>
|
||||
<p>Swiping with a finger glides on again on Wayland: lifting the finger no longer arrives late and stops the page right away.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.42.0" date="2026-10-28">
|
||||
<description>
|
||||
<p>Built-in Nextcloud sync: sign in through the browser and the notebooks are synced with your Nextcloud, also private servers and without a sync client.</p>
|
||||
|
||||
@@ -3,6 +3,17 @@
|
||||
Notebook app for pen, touch and mouse on Linux (tested on Zorin OS 18 / Ubuntu 24.04).
|
||||
|
||||
### Highlights:
|
||||
- **Safer notes**: A review of the whole project fixed a number of rare errors. Notebooks deleted after not being edited for 30 days were removed from the trash at the next start instead of 30 days later. If saving failed while switching notebooks, pages could end up in the other notebook, and an open text field moved along; the app now saves first and stays in the notebook if that fails. A copied notebook folder is a notebook of its own instead of opening the original. Strokes keep their width after scaling and a restart, shapes are exported to PDF with their line width, and a page that could not be loaded always shows its warning. Importing broken Rnote, Saber, SVG or PDF files can no longer make a page unreadable or stop the PDF display. One invalid value in the settings file no longer resets all settings, and updates are only installed if the signed package is newer than the running version
|
||||
- **My colors**: Under the presets of the color menus (pen, highlighter, text), "➕ Save color" keeps the current shade in a row of your own colors – up to 12, shared by all three tools and kept across restarts; 🗑 removes the selected one (Issue #56)
|
||||
- **Copy and paste in text fields**: Ctrl+C, Ctrl+X and Ctrl+V in a text field now use the system clipboard, so text moves between ink-notes and other apps. Before, they only worked within the app (Issue #57)
|
||||
- **Seven pens**: The toolbar now has seven pens instead of four (new: violet, orange, gray). Each remembers its color and width; tap the selected pen again to change them. Older versions keep reading the first four pens (Issue #56)
|
||||
- **Momentum scrolling on GNOME, second try**: GNOME could deliver the lift of a swipe only together with the next touch, because the library under winit held it back until the next touch frame. Lifts are now handed on right away, so the page glides on after a quick swipe (Issue #28)
|
||||
- **Ruler stays put**: After moving the ruler with a finger, it could follow the hovering pen (same cause as above). In addition, a finger drag of the ruler now ends as soon as the pen comes near (Issue #55)
|
||||
- **Dialogs on phones**: The notebook manager and the other dialogs were wider than a phone screen, partly under the toolbar and cut off at the right edge. They now fit beside the toolbar (Issue #50)
|
||||
- **Pen without lag on Wayland**: Pen events reached the app only when something else woke it, so strokes trailed behind the pen or appeared only later. Every event from the compositor now wakes the app; it still uses no CPU while idle (Issue #53)
|
||||
- **Eraser removes text**: The whole-stroke and partial erasers now remove a text box – typed text or an inserted math result – as soon as they touch it. The highlighter eraser leaves text alone, images are never erased (Issue #54)
|
||||
- **Writing with the finger on Android**: "Draw with finger" (☰ → Input) is now remembered and starts switched on on Android, where phones usually have no pen. Before, it was off after every start, so after an update one finger only moved the page (Issue #50)
|
||||
- **Momentum scrolling with the finger fixed**: On Wayland, lifting the finger after a swipe could reach the app only together with the next touch, so the page stopped dead instead of gliding on. The app now keeps listening while a finger is on the screen, so the page glides after a quick swipe and slows down gradually (Issue #28)
|
||||
- **Nextcloud built in**: ☰ → Storage location → Nextcloud syncs the notebooks directly with your Nextcloud – also on Android and without a sync client. Enter the server address (private servers in a subfolder, with their own port or a self-signed certificate work too), sign in in the browser; the app gets an app password of its own that "Disconnect" revokes again. Syncing runs every 30 seconds and shortly after saving and keeps working offline; a page changed on two devices is kept twice, deleted notebooks and pages go into the trash on both sides, changes arriving for the open notebook wait until it is saved (Issue #49)
|
||||
- **Security update**: rustls 0.23.46 (TLS library for update checks and the Nextcloud sync) fixes a way for key material to end up in logs (Issue #51); ink-notes itself was not affected, as it doesn't keep TLS sessions
|
||||
- **Android toolbar fix**: The toolbar and menus stay clear of the status bar, the navigation bar and the camera cutout, so the topmost button (📚 notebooks) can be tapped again; in landscape the page is centered between the side bars. Nothing changes on Linux and Windows (Issue #50)
|
||||
|
||||
+255
-115
@@ -125,9 +125,16 @@ fn space_release(
|
||||
return;
|
||||
};
|
||||
if let Some(run) = notebook.space_run(drag.page_index) {
|
||||
for index in run {
|
||||
for index in run.clone() {
|
||||
let _ = storage::ensure_page_loaded(storage_dir, notebook, index);
|
||||
}
|
||||
if notebook.pages[run].iter().any(|p| p.is_readonly()) {
|
||||
ui_state.set_export_message(
|
||||
t!("Platz schaffen nicht möglich: Eine der Seiten konnte nicht geladen werden")
|
||||
.to_string(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
let dy = drag.clamped_dy();
|
||||
if let Some(changed) = notebook.make_space(drag.page_index, drag.y, dy) {
|
||||
@@ -573,6 +580,19 @@ impl ClipboardBridge {
|
||||
(!text.trim().is_empty()).then_some(text)
|
||||
}
|
||||
|
||||
/// Text copied or cut in a text field goes to the system clipboard, so it can be pasted
|
||||
/// into other apps (Issue #57).
|
||||
fn set_system_text(&mut self, text: String) {
|
||||
if self.system.is_none() {
|
||||
self.system = platform::clipboard::Clipboard::new().ok();
|
||||
}
|
||||
if let Some(system) = self.system.as_mut() {
|
||||
if let Err(err) = system.set_text(text) {
|
||||
eprintln!("[Zwischenablage] Text nicht gesetzt: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Merkt sich beim internen Kopieren das aktuelle Systembild, den Text und das
|
||||
/// Quell-Notizbuch.
|
||||
fn note_internal_copy(&mut self, storage_dir: &Path) {
|
||||
@@ -1004,6 +1024,32 @@ fn finish_text_edit(
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
}
|
||||
|
||||
/// Before another notebook takes the place of the open one: finishes a running text edit
|
||||
/// (it belongs to this notebook, page ids repeat in every notebook) and saves everything.
|
||||
/// Returns `false` and tells the user when saving failed; the notebook then has to stay open,
|
||||
/// otherwise the unsaved changes would be lost.
|
||||
fn save_before_switch(
|
||||
canvas: &mut CanvasState,
|
||||
notebook: &mut Notebook,
|
||||
manifest: &mut NotebookManifest,
|
||||
autosave: &mut AutosaveController,
|
||||
ui_state: &mut UiState,
|
||||
) -> bool {
|
||||
finish_text_edit(canvas, notebook, manifest, autosave, ui_state);
|
||||
match autosave.flush() {
|
||||
Ok(()) => true,
|
||||
Err(err) => {
|
||||
let msg = err.messages.join(", ");
|
||||
eprintln!("[Autosave] Notebook not switched, saving failed: {msg}");
|
||||
ui_state.set_export_message(t!(
|
||||
"Nicht gewechselt, Speichern fehlgeschlagen: {err}",
|
||||
err = msg
|
||||
));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Text-Werkzeug: Tippen auf das Blatt beendet eine laufende Bearbeitung und öffnet das
|
||||
/// Textfeld unter `page_pt` zum Bearbeiten – oder ein neues an dieser Stelle.
|
||||
fn text_tool_click(
|
||||
@@ -1467,10 +1513,12 @@ pub fn run_with(
|
||||
ui_state.pattern_color = ui_settings.pattern_color;
|
||||
ui_state.pressure_curve = ui_settings.pressure_curve;
|
||||
ui_state.continuous_scroll = ui_settings.continuous_scroll;
|
||||
ui_state.pen_presets = ui_settings.pen_presets;
|
||||
ui_state.pen_presets = ui_settings.pens();
|
||||
ui_state.stylus_buttons = ui_settings.stylus_buttons.clone();
|
||||
ui_state.set_eraser_radius_mm(ui_settings.eraser_radius_mm);
|
||||
ui_state.math.set_enabled(ui_settings.math_assist);
|
||||
ui_state.touch_draw_enabled = ui_settings.touch_draw;
|
||||
ui_state.saved_colors = ui_settings.saved_colors.clone();
|
||||
ui_state.select_pen(ui_settings.active_pen);
|
||||
ui_state.highlighter_colors = ui_settings.highlighter_colors;
|
||||
ui_state.select_highlighter_color(ui_settings.active_highlighter);
|
||||
@@ -1900,6 +1948,10 @@ pub fn run_with(
|
||||
PenEvent::In => {
|
||||
is_stylus_in_proximity = true;
|
||||
stylus_tool_id = Some(tool);
|
||||
// A finger that moved the ruler is done once the pen comes near:
|
||||
// if its lift has not arrived yet, the ruler would follow the
|
||||
// hovering pen (Issue #55). The pen grabs the ruler only on touch.
|
||||
ui_state.ruler_drag = None;
|
||||
}
|
||||
PenEvent::Out => {
|
||||
if is_stylus_in_proximity {
|
||||
@@ -2471,6 +2523,11 @@ pub fn run_with(
|
||||
|| is_stylus_drawing
|
||||
|| is_stylus_in_proximity
|
||||
|| fling.is_active()
|
||||
// While a finger is down: on Wayland, the lift can already sit in winit's
|
||||
// queue (read from the socket by the graphics driver) without waking the
|
||||
// loop, and then only arrives with the next touch, which stops the glide
|
||||
// right away (Issue #28)
|
||||
|| !active_touches.is_empty()
|
||||
{
|
||||
target.set_control_flow(ControlFlow::wait_duration(Duration::from_millis(8)));
|
||||
} else if let Some(at) = [
|
||||
@@ -2521,6 +2578,28 @@ pub fn run_with(
|
||||
consumed: false,
|
||||
}
|
||||
} else {
|
||||
// Ctrl+V in a text field: egui-winit is built without its own clipboard
|
||||
// access and would only paste what was copied inside the app, so it gets the
|
||||
// text of the system clipboard first (Issue #57)
|
||||
if let WindowEvent::KeyboardInput {
|
||||
event:
|
||||
KeyEvent {
|
||||
logical_key: Key::Character(c),
|
||||
state: ElementState::Pressed,
|
||||
..
|
||||
},
|
||||
..
|
||||
} = &event
|
||||
{
|
||||
if c.eq_ignore_ascii_case("v")
|
||||
&& egui_winit_state.egui_input().modifiers.command
|
||||
&& egui_ctx.wants_keyboard_input()
|
||||
{
|
||||
if let Some(text) = clipboard.system_text() {
|
||||
egui_winit_state.set_clipboard_text(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
egui_winit_state.on_window_event(&window, &event)
|
||||
};
|
||||
// egui-winit asks for a repaint after every RedrawRequested as well; following
|
||||
@@ -3710,6 +3789,9 @@ pub fn run_with(
|
||||
match touch.phase {
|
||||
TouchPhase::Started => {
|
||||
// Jede neue Berührung hält ein gleitendes Blatt an
|
||||
if debug_fling && fling.is_active() {
|
||||
eprintln!("[Nachgleiten] Neuer Finger hält das Gleiten an");
|
||||
}
|
||||
fling.stop();
|
||||
// Event-Isolation für Touch-Eingaben
|
||||
let is_touch_in_ui = ui::is_screen_point_in_ui(
|
||||
@@ -4804,11 +4886,18 @@ pub fn run_with(
|
||||
if id == manifest.id {
|
||||
ui_state.is_document_menu_open = false;
|
||||
window.request_redraw();
|
||||
} else if !save_before_switch(
|
||||
&mut canvas,
|
||||
&mut notebook,
|
||||
&mut manifest,
|
||||
&mut autosave,
|
||||
&mut ui_state,
|
||||
) {
|
||||
window.request_redraw();
|
||||
} else {
|
||||
let new_notebook_dir = storage_base_dir.join(&id);
|
||||
match storage::load_notebook(&new_notebook_dir) {
|
||||
Ok((loaded_nb, loaded_manifest)) => {
|
||||
let _ = autosave.flush();
|
||||
match storage::NotebookLock::acquire(
|
||||
&new_notebook_dir,
|
||||
) {
|
||||
@@ -4871,71 +4960,78 @@ pub fn run_with(
|
||||
}
|
||||
}
|
||||
UiAction::ImportFile(path) => {
|
||||
// Neben das gerade geöffnete Notizbuch
|
||||
let folder =
|
||||
ui_state.library.folder_of(&manifest.id).to_string();
|
||||
let title = path
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "Import".to_string());
|
||||
match crate::import::import_file(
|
||||
&storage_base_dir,
|
||||
&title,
|
||||
&path,
|
||||
if save_before_switch(
|
||||
&mut canvas,
|
||||
&mut notebook,
|
||||
&mut manifest,
|
||||
&mut autosave,
|
||||
&mut ui_state,
|
||||
) {
|
||||
Ok((new_nb, new_manifest, new_dir, report)) => {
|
||||
let id = new_manifest.id.clone();
|
||||
change_library(
|
||||
&storage_base_dir,
|
||||
&mut ui_state,
|
||||
|library| library.move_notebook(&id, &folder),
|
||||
);
|
||||
let _ = autosave.flush();
|
||||
_notebook_lock =
|
||||
storage::NotebookLock::acquire(&new_dir).ok();
|
||||
autosave.set_read_only(_notebook_lock.is_none());
|
||||
autosave.switch_notebook_dir(new_dir.clone());
|
||||
notebook = new_nb;
|
||||
manifest = new_manifest;
|
||||
storage_dir = new_dir;
|
||||
selection_state.clear();
|
||||
ui_state.current_notebook_id = manifest.id.clone();
|
||||
ui_state.document_title = manifest.title.clone();
|
||||
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||
canvas.reset_view();
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
update_window_title(
|
||||
&window,
|
||||
¬ebook,
|
||||
&manifest,
|
||||
ui_state.active_tool,
|
||||
autosave.status(),
|
||||
);
|
||||
let mut message = t!(
|
||||
"'{title}' importiert ({count} Seiten)",
|
||||
title = manifest.title,
|
||||
count = notebook.pages.len()
|
||||
);
|
||||
if let Some(pdf) = &report.missing_pdf {
|
||||
message.push_str(&t!(
|
||||
", PDF-Hintergrund nicht gefunden: {file}",
|
||||
file = pdf
|
||||
// Neben das gerade geöffnete Notizbuch
|
||||
let folder =
|
||||
ui_state.library.folder_of(&manifest.id).to_string();
|
||||
let title = path
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "Import".to_string());
|
||||
match crate::import::import_file(
|
||||
&storage_base_dir,
|
||||
&title,
|
||||
&path,
|
||||
) {
|
||||
Ok((new_nb, new_manifest, new_dir, report)) => {
|
||||
let id = new_manifest.id.clone();
|
||||
change_library(
|
||||
&storage_base_dir,
|
||||
&mut ui_state,
|
||||
|library| library.move_notebook(&id, &folder),
|
||||
);
|
||||
_notebook_lock =
|
||||
storage::NotebookLock::acquire(&new_dir).ok();
|
||||
autosave.set_read_only(_notebook_lock.is_none());
|
||||
autosave.switch_notebook_dir(new_dir.clone());
|
||||
notebook = new_nb;
|
||||
manifest = new_manifest;
|
||||
storage_dir = new_dir;
|
||||
selection_state.clear();
|
||||
ui_state.current_notebook_id = manifest.id.clone();
|
||||
ui_state.document_title = manifest.title.clone();
|
||||
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||
canvas.reset_view();
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
update_window_title(
|
||||
&window,
|
||||
¬ebook,
|
||||
&manifest,
|
||||
ui_state.active_tool,
|
||||
autosave.status(),
|
||||
);
|
||||
let mut message = t!(
|
||||
"'{title}' importiert ({count} Seiten)",
|
||||
title = manifest.title,
|
||||
count = notebook.pages.len()
|
||||
);
|
||||
if let Some(pdf) = &report.missing_pdf {
|
||||
message.push_str(&t!(
|
||||
", PDF-Hintergrund nicht gefunden: {file}",
|
||||
file = pdf
|
||||
));
|
||||
}
|
||||
if report.skipped > 0 {
|
||||
message.push_str(&t!(
|
||||
", {count} Elemente übersprungen",
|
||||
count = report.skipped
|
||||
));
|
||||
}
|
||||
ui_state.set_export_message(message);
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("[Import] '{}': {err}", path.display());
|
||||
ui_state.set_export_message(t!(
|
||||
"Nicht importiert: {err}",
|
||||
err = err
|
||||
));
|
||||
}
|
||||
if report.skipped > 0 {
|
||||
message.push_str(&t!(
|
||||
", {count} Elemente übersprungen",
|
||||
count = report.skipped
|
||||
));
|
||||
}
|
||||
ui_state.set_export_message(message);
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("[Import] '{}': {err}", path.display());
|
||||
ui_state.set_export_message(t!(
|
||||
"Nicht importiert: {err}",
|
||||
err = err
|
||||
));
|
||||
}
|
||||
}
|
||||
window.request_redraw();
|
||||
@@ -4945,54 +5041,66 @@ pub fn run_with(
|
||||
folder,
|
||||
layout,
|
||||
} => {
|
||||
let _ = autosave.flush();
|
||||
match storage::create_new_notebook(&storage_base_dir, &title) {
|
||||
Ok((mut new_nb, mut new_manifest, new_dir)) => {
|
||||
let id = new_manifest.id.clone();
|
||||
new_manifest.layout = Some(layout);
|
||||
if layout == NotebookLayout::Endless {
|
||||
new_nb.make_endless();
|
||||
if save_before_switch(
|
||||
&mut canvas,
|
||||
&mut notebook,
|
||||
&mut manifest,
|
||||
&mut autosave,
|
||||
&mut ui_state,
|
||||
) {
|
||||
match storage::create_new_notebook(
|
||||
&storage_base_dir,
|
||||
&title,
|
||||
) {
|
||||
Ok((mut new_nb, mut new_manifest, new_dir)) => {
|
||||
let id = new_manifest.id.clone();
|
||||
new_manifest.layout = Some(layout);
|
||||
if layout == NotebookLayout::Endless {
|
||||
new_nb.make_endless();
|
||||
}
|
||||
change_library(
|
||||
&storage_base_dir,
|
||||
&mut ui_state,
|
||||
|library| library.move_notebook(&id, &folder),
|
||||
);
|
||||
_notebook_lock =
|
||||
storage::NotebookLock::acquire(&new_dir).ok();
|
||||
autosave.set_read_only(_notebook_lock.is_none());
|
||||
autosave.switch_notebook_dir(new_dir.clone());
|
||||
notebook = new_nb;
|
||||
manifest = new_manifest;
|
||||
storage_dir = new_dir;
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
canvas.reset_view();
|
||||
selection_state.clear();
|
||||
ui_state.current_notebook_id = manifest.id.clone();
|
||||
ui_state.document_title = manifest.title.clone();
|
||||
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||
ui_state.is_document_menu_open = false;
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
update_window_title(
|
||||
&window,
|
||||
¬ebook,
|
||||
&manifest,
|
||||
ui_state.active_tool,
|
||||
autosave.status(),
|
||||
);
|
||||
ui_state.set_export_message(t!(
|
||||
"Dokument '{title}' erstellt",
|
||||
title = manifest.title
|
||||
));
|
||||
window.request_redraw();
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("[Dokument-Fehler] Konnte Dokument '{title}' nicht erstellen: {err}");
|
||||
ui_state.set_export_message(t!(
|
||||
"Fehler beim Erstellen: {err}",
|
||||
err = err
|
||||
));
|
||||
}
|
||||
change_library(
|
||||
&storage_base_dir,
|
||||
&mut ui_state,
|
||||
|library| library.move_notebook(&id, &folder),
|
||||
);
|
||||
_notebook_lock =
|
||||
storage::NotebookLock::acquire(&new_dir).ok();
|
||||
autosave.set_read_only(_notebook_lock.is_none());
|
||||
autosave.switch_notebook_dir(new_dir.clone());
|
||||
notebook = new_nb;
|
||||
manifest = new_manifest;
|
||||
storage_dir = new_dir;
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
canvas.reset_view();
|
||||
selection_state.clear();
|
||||
ui_state.current_notebook_id = manifest.id.clone();
|
||||
ui_state.document_title = manifest.title.clone();
|
||||
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||
ui_state.is_document_menu_open = false;
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
update_window_title(
|
||||
&window,
|
||||
¬ebook,
|
||||
&manifest,
|
||||
ui_state.active_tool,
|
||||
autosave.status(),
|
||||
);
|
||||
ui_state.set_export_message(t!(
|
||||
"Dokument '{title}' erstellt",
|
||||
title = manifest.title
|
||||
));
|
||||
window.request_redraw();
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("[Dokument-Fehler] Konnte Dokument '{title}' nicht erstellen: {err}");
|
||||
ui_state.set_export_message(t!(
|
||||
"Fehler beim Erstellen: {err}",
|
||||
err = err
|
||||
));
|
||||
}
|
||||
} else {
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
UiAction::RenameNotebook { id, new_title } => {
|
||||
@@ -5062,7 +5170,21 @@ pub fn run_with(
|
||||
.unwrap_or_else(|| id.clone());
|
||||
|
||||
if is_current {
|
||||
let _ = autosave.flush();
|
||||
finish_text_edit(
|
||||
&mut canvas,
|
||||
&mut notebook,
|
||||
&mut manifest,
|
||||
&mut autosave,
|
||||
&mut ui_state,
|
||||
);
|
||||
if let Err(err) = autosave.flush() {
|
||||
eprintln!(
|
||||
"[Autosave] Not saved before deleting: {}",
|
||||
err.messages.join(", ")
|
||||
);
|
||||
}
|
||||
// Nothing may be written into the folder once it is in the trash
|
||||
autosave.abandon_pending();
|
||||
drop(_notebook_lock.take());
|
||||
}
|
||||
|
||||
@@ -5348,6 +5470,14 @@ pub fn run_with(
|
||||
&mut ui_state,
|
||||
);
|
||||
}
|
||||
UiAction::SetTouchDraw(on) => {
|
||||
ui_settings.touch_draw = on;
|
||||
save_ui_settings(
|
||||
&ui_settings,
|
||||
ui_settings_path.as_deref(),
|
||||
&mut ui_state,
|
||||
);
|
||||
}
|
||||
UiAction::SetSidebarSide(side) => {
|
||||
ui_settings.sidebar_side = side;
|
||||
save_ui_settings(
|
||||
@@ -5366,8 +5496,13 @@ pub fn run_with(
|
||||
ui_state.set_export_message(
|
||||
t!("Die Notizbücher liegen bereits dort").to_string(),
|
||||
);
|
||||
} else {
|
||||
let _ = autosave.flush();
|
||||
} else if save_before_switch(
|
||||
&mut canvas,
|
||||
&mut notebook,
|
||||
&mut manifest,
|
||||
&mut autosave,
|
||||
&mut ui_state,
|
||||
) {
|
||||
match storage::copy_storage(&storage_base_dir, &new_base) {
|
||||
Ok(copied) => {
|
||||
// Release lock and open marker in the old location
|
||||
@@ -5573,13 +5708,14 @@ pub fn run_with(
|
||||
ui_state.sync_tool_colors();
|
||||
if !egui_ctx.is_using_pointer() {
|
||||
let mut updated = ui_settings.clone();
|
||||
updated.pen_presets = ui_state.pen_presets;
|
||||
updated.set_pens(ui_state.pen_presets);
|
||||
updated.active_pen = ui_state.active_pen;
|
||||
updated.highlighter_colors = ui_state.highlighter_colors;
|
||||
updated.active_highlighter = ui_state.active_highlighter;
|
||||
updated.text_colors = ui_state.text_colors;
|
||||
updated.active_text_color = ui_state.active_text_color;
|
||||
updated.eraser_radius_mm = ui_state.eraser_radius_mm;
|
||||
updated.saved_colors.clone_from(&ui_state.saved_colors);
|
||||
if updated != ui_settings {
|
||||
ui_settings = updated;
|
||||
save_ui_settings(
|
||||
@@ -5617,6 +5753,10 @@ pub fn run_with(
|
||||
// 4. egui Frame beenden & Primitives tessellieren
|
||||
let full_output = egui_ctx.end_frame();
|
||||
let ime_active = full_output.platform_output.ime.is_some();
|
||||
if !full_output.platform_output.copied_text.is_empty() {
|
||||
clipboard
|
||||
.set_system_text(full_output.platform_output.copied_text.clone());
|
||||
}
|
||||
egui_winit_state
|
||||
.handle_platform_output(&window, full_output.platform_output);
|
||||
if let Some(requester) = keyboard_requester.as_mut() {
|
||||
|
||||
+137
-6
@@ -228,6 +228,8 @@ pub struct AutosaveController {
|
||||
pending_pages: HashMap<u64, PageData>,
|
||||
pending_manifest: Option<NotebookManifest>,
|
||||
retries: HashMap<u64, PageRetry>,
|
||||
/// Backoff of the manifest after a failed write
|
||||
manifest_retry: Option<PageRetry>,
|
||||
last_errors: Vec<String>,
|
||||
status: AutosaveStatus,
|
||||
in_flight_count: usize,
|
||||
@@ -281,6 +283,7 @@ impl AutosaveController {
|
||||
pending_pages: HashMap::new(),
|
||||
pending_manifest: None,
|
||||
retries: HashMap::new(),
|
||||
manifest_retry: None,
|
||||
last_errors: Vec::new(),
|
||||
status: AutosaveStatus::Saved,
|
||||
in_flight_count: 0,
|
||||
@@ -428,16 +431,25 @@ impl AutosaveController {
|
||||
retry.backoff = (retry.backoff * 2).min(Duration::from_secs(30));
|
||||
}
|
||||
},
|
||||
AutosaveResponse::ManifestSaved { result } => {
|
||||
if let Err((err_msg, failed_manifest)) = result {
|
||||
AutosaveResponse::ManifestSaved { result } => match result {
|
||||
Ok(()) => self.manifest_retry = None,
|
||||
Err((err_msg, failed_manifest)) => {
|
||||
eprintln!("[Autosave-Fehler] Manifest fehlgeschlagen: {err_msg}");
|
||||
self.status = AutosaveStatus::Error;
|
||||
self.last_errors.push(t!("Manifest: {msg}", msg = err_msg));
|
||||
if self.pending_manifest.is_none() {
|
||||
self.pending_manifest = Some(*failed_manifest);
|
||||
}
|
||||
// Dispatching reset the debounce timers, so without a retry time of
|
||||
// its own the manifest would wait for the next edit
|
||||
let retry = self.manifest_retry.get_or_insert(PageRetry {
|
||||
next_retry: now,
|
||||
backoff: Duration::from_secs(1),
|
||||
});
|
||||
retry.next_retry = now + retry.backoff;
|
||||
retry.backoff = (retry.backoff * 2).min(Duration::from_secs(30));
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
if self.in_flight_count == 0
|
||||
@@ -468,7 +480,12 @@ impl AutosaveController {
|
||||
.get(id)
|
||||
.map(|r| now >= r.next_retry)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
})
|
||||
|| (self.pending_manifest.is_some()
|
||||
&& self
|
||||
.manifest_retry
|
||||
.as_ref()
|
||||
.is_some_and(|r| now >= r.next_retry));
|
||||
|
||||
if max_wait_hit || debounce_hit || retry_ready {
|
||||
self.dispatch_pending();
|
||||
@@ -515,7 +532,11 @@ impl AutosaveController {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(manifest) = self.pending_manifest.take() {
|
||||
let manifest_ready = self
|
||||
.manifest_retry
|
||||
.as_ref()
|
||||
.is_none_or(|r| now >= r.next_retry);
|
||||
if let Some(manifest) = self.pending_manifest.take_if(|_| manifest_ready) {
|
||||
let cmd = AutosaveCommand::SaveManifest {
|
||||
notebook_dir: self.notebook_dir.clone(),
|
||||
manifest: manifest.clone(),
|
||||
@@ -546,6 +567,7 @@ impl AutosaveController {
|
||||
}
|
||||
// Backoff für den Flush ignorieren, damit sofort geschrieben wird
|
||||
self.retries.clear();
|
||||
self.manifest_retry = None;
|
||||
|
||||
if !self.pending_pages.is_empty() || self.pending_manifest.is_some() {
|
||||
self.dispatch_pending();
|
||||
@@ -618,11 +640,45 @@ impl AutosaveController {
|
||||
}
|
||||
|
||||
/// Wechselt das Zielverzeichnis für zukünftige Speicheroperationen (z. B. beim Dokumentenwechsel).
|
||||
///
|
||||
/// Whatever the flush could not write belongs to the old notebook and must never be
|
||||
/// written into the new folder, see `abandon_pending`.
|
||||
pub fn switch_notebook_dir(&mut self, new_notebook_dir: PathBuf) {
|
||||
let _ = self.flush();
|
||||
if let Err(err) = self.flush() {
|
||||
eprintln!(
|
||||
"[Autosave] Unsaved changes of '{}' dropped on notebook switch: {}",
|
||||
self.notebook_dir.display(),
|
||||
err.messages.join(", ")
|
||||
);
|
||||
}
|
||||
self.abandon_pending();
|
||||
self.notebook_dir = new_notebook_dir;
|
||||
}
|
||||
|
||||
/// Gives up on everything still waiting to be written: it goes into the recovery folder
|
||||
/// and leaves the queue. For the moment the notebook folder stops being the target
|
||||
/// (switch to another notebook, notebook moved to the trash).
|
||||
pub fn abandon_pending(&mut self) {
|
||||
if !self.pending_pages.is_empty() {
|
||||
match self.export_recovery(None) {
|
||||
Ok(files) => {
|
||||
for file in files {
|
||||
eprintln!("[Autosave] Recovery copy: {}", file.display());
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("[Autosave] Recovery copy failed: {e}"),
|
||||
}
|
||||
}
|
||||
self.pending_pages.clear();
|
||||
self.pending_manifest = None;
|
||||
self.retries.clear();
|
||||
self.manifest_retry = None;
|
||||
self.last_errors.clear();
|
||||
self.first_change_time = None;
|
||||
self.last_change_time = None;
|
||||
self.status = AutosaveStatus::Saved;
|
||||
}
|
||||
|
||||
/// Beendet den Hintergrund-Worker ordnungsgemäß nach einem Flush.
|
||||
pub fn shutdown(&mut self) -> Result<(), AutosaveError> {
|
||||
let res = self.flush();
|
||||
@@ -739,6 +795,7 @@ pub mod tests {
|
||||
/// Fake-PageStore zum Simulieren von I/O-Fehlern und Panics.
|
||||
pub struct FakePageStore {
|
||||
pub fail_page_writes: Mutex<usize>,
|
||||
pub fail_manifest_writes: Mutex<usize>,
|
||||
pub panic_on_page: Mutex<bool>,
|
||||
pub page_writes: Arc<AtomicUsize>,
|
||||
pub manifest_writes: Arc<AtomicUsize>,
|
||||
@@ -748,6 +805,7 @@ pub mod tests {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
fail_page_writes: Mutex::new(0),
|
||||
fail_manifest_writes: Mutex::new(0),
|
||||
panic_on_page: Mutex::new(false),
|
||||
page_writes: Arc::new(AtomicUsize::new(0)),
|
||||
manifest_writes: Arc::new(AtomicUsize::new(0)),
|
||||
@@ -782,11 +840,84 @@ pub mod tests {
|
||||
_dir: &Path,
|
||||
_manifest: &NotebookManifest,
|
||||
) -> Result<(), StorageError> {
|
||||
let mut fails = self.fail_manifest_writes.lock().unwrap();
|
||||
if *fails > 0 {
|
||||
*fails -= 1;
|
||||
return Err(StorageError::InvalidManifest(
|
||||
"Simulierter Store-Fehler".to_string(),
|
||||
));
|
||||
}
|
||||
self.manifest_writes.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_switch_drops_pages_the_flush_could_not_write() {
|
||||
let clock = Arc::new(MockClock::new(Instant::now()));
|
||||
let store = Arc::new(FakePageStore::new());
|
||||
*store.fail_page_writes.lock().unwrap() = 1;
|
||||
let recovery = tempfile::tempdir().unwrap();
|
||||
std::env::set_var("XDG_DATA_HOME", recovery.path());
|
||||
|
||||
let mut controller = AutosaveController::with_options(
|
||||
PathBuf::from("/tmp/old_nb"),
|
||||
Duration::from_millis(100),
|
||||
Duration::from_secs(10),
|
||||
Arc::clone(&store) as Arc<dyn PageStore>,
|
||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||
);
|
||||
let page = Page::new(7, PageBackground::Grid5mm);
|
||||
let manifest = NotebookManifest::new("old_nb", "Old");
|
||||
controller.mark_page_dirty(&page, &manifest);
|
||||
|
||||
controller.switch_notebook_dir(PathBuf::from("/tmp/new_nb"));
|
||||
let manifests_before = store.manifest_writes.load(Ordering::SeqCst);
|
||||
|
||||
// Nothing of the old notebook may reach the new folder
|
||||
assert!(controller.flush().is_ok());
|
||||
assert_eq!(store.page_writes.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(
|
||||
store.manifest_writes.load(Ordering::SeqCst),
|
||||
manifests_before
|
||||
);
|
||||
assert_eq!(controller.status(), AutosaveStatus::Saved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_failed_manifest_is_retried_without_another_edit() {
|
||||
let clock = Arc::new(MockClock::new(Instant::now()));
|
||||
let store = Arc::new(FakePageStore::new());
|
||||
*store.fail_manifest_writes.lock().unwrap() = 1;
|
||||
let mut controller = AutosaveController::with_options(
|
||||
PathBuf::from("/tmp/manifest_retry"),
|
||||
Duration::from_millis(100),
|
||||
Duration::from_secs(10),
|
||||
Arc::clone(&store) as Arc<dyn PageStore>,
|
||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||
);
|
||||
let manifest = NotebookManifest::new("nb", "Retry");
|
||||
controller.mark_manifest_dirty(&manifest);
|
||||
|
||||
clock.advance(Duration::from_millis(200));
|
||||
controller.update();
|
||||
controller.sync_worker();
|
||||
assert_eq!(controller.update(), AutosaveStatus::Error);
|
||||
assert_eq!(store.manifest_writes.load(Ordering::SeqCst), 0);
|
||||
|
||||
// Not before the backoff has passed
|
||||
controller.update();
|
||||
controller.sync_worker();
|
||||
controller.update();
|
||||
assert_eq!(store.manifest_writes.load(Ordering::SeqCst), 0);
|
||||
|
||||
clock.advance(Duration::from_secs(2));
|
||||
controller.update();
|
||||
controller.sync_worker();
|
||||
assert_eq!(controller.update(), AutosaveStatus::Saved);
|
||||
assert_eq!(store.manifest_writes.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_debounce_bundles_changes() {
|
||||
let clock = Arc::new(MockClock::new(Instant::now()));
|
||||
|
||||
+29
-2
@@ -1021,8 +1021,13 @@ impl CanvasState {
|
||||
}
|
||||
|
||||
if page.vertex_cache.len() > self.committed_vertex_capacity {
|
||||
self.committed_vertex_capacity =
|
||||
(page.vertex_cache.len() * 2).max(Self::INITIAL_COMMITTED_CAPACITY);
|
||||
let vertex_size = std::mem::size_of::<InkVertex>() as u64;
|
||||
let max_vertices = (self.device.limits().max_buffer_size / vertex_size) as usize;
|
||||
self.committed_vertex_capacity = grown_vertex_capacity(
|
||||
page.vertex_cache.len(),
|
||||
Self::INITIAL_COMMITTED_CAPACITY,
|
||||
max_vertices,
|
||||
);
|
||||
self.committed_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Committed Strokes Vertex Buffer (Reallocated)"),
|
||||
size: (self.committed_vertex_capacity * std::mem::size_of::<InkVertex>()) as u64,
|
||||
@@ -1735,10 +1740,32 @@ fn text_resolution(screen_px_per_unit: f32) -> f32 {
|
||||
2.0_f32.powf(steps / 4.0).clamp(0.125, 8.0)
|
||||
}
|
||||
|
||||
/// Capacity for a vertex buffer that has to hold `needed` vertices: twice as much, so that
|
||||
/// it is not reallocated with every stroke, but no more than the device allows. Doubling
|
||||
/// alone asked for a buffer beyond the limit as soon as a very full page used half of it,
|
||||
/// which ends in a validation panic.
|
||||
fn grown_vertex_capacity(needed: usize, initial: usize, max_vertices: usize) -> usize {
|
||||
needed
|
||||
.saturating_mul(2)
|
||||
.max(initial)
|
||||
.min(max_vertices)
|
||||
.max(needed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_vertex_capacity_doubles_up_to_the_device_limit() {
|
||||
assert_eq!(grown_vertex_capacity(10, 1000, 1_000_000), 1000);
|
||||
assert_eq!(grown_vertex_capacity(5000, 1000, 1_000_000), 10_000);
|
||||
// More than half the limit: the limit, not twice the need
|
||||
assert_eq!(grown_vertex_capacity(600_000, 1000, 1_000_000), 1_000_000);
|
||||
// Never less than what is needed
|
||||
assert_eq!(grown_vertex_capacity(2_000_000, 1000, 1_000_000), 2_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_text_resolution_is_quantized_and_never_below_screen() {
|
||||
for px in [0.2_f32, 0.27, 0.5, 1.3, 3.9] {
|
||||
|
||||
+42
-6
@@ -953,6 +953,16 @@ struct Shared {
|
||||
/// Finished sign-in, to be stored by the app
|
||||
signed_in: Option<Account>,
|
||||
stop: bool,
|
||||
/// Counts the started workers. A worker whose pass was still running when the next one
|
||||
/// was started must end afterwards, although `stop` is `false` again by then.
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
/// Is the worker of `generation` to end?
|
||||
fn retired(&self, generation: u64) -> bool {
|
||||
self.stop || self.generation != generation
|
||||
}
|
||||
}
|
||||
|
||||
type SharedState = Arc<(Mutex<Shared>, Condvar)>;
|
||||
@@ -986,6 +996,7 @@ impl CloudSync {
|
||||
changed: false,
|
||||
signed_in: None,
|
||||
stop: false,
|
||||
generation: 0,
|
||||
}),
|
||||
Condvar::new(),
|
||||
)),
|
||||
@@ -1009,15 +1020,19 @@ impl CloudSync {
|
||||
|
||||
fn start(&mut self, account: Account) {
|
||||
self.stop_worker();
|
||||
{
|
||||
let generation = {
|
||||
let mut shared = lock(&self.shared);
|
||||
shared.stop = false;
|
||||
shared.generation += 1;
|
||||
shared.status = Status::Idle { last: None };
|
||||
shared.next_pass = Instant::now();
|
||||
}
|
||||
shared.generation
|
||||
};
|
||||
let shared = self.shared.clone();
|
||||
let worker_account = account.clone();
|
||||
self.worker = Some(std::thread::spawn(move || worker(shared, worker_account)));
|
||||
self.worker = Some(std::thread::spawn(move || {
|
||||
worker(shared, worker_account, generation)
|
||||
}));
|
||||
self.account = Some(account);
|
||||
}
|
||||
|
||||
@@ -1211,7 +1226,7 @@ fn sign_in(
|
||||
}
|
||||
}
|
||||
|
||||
fn worker(shared: SharedState, account: Account) {
|
||||
fn worker(shared: SharedState, account: Account, generation: u64) {
|
||||
let Some(state_path) = SyncState::file_path() else {
|
||||
set_status(&shared, Status::Failed(t!("Kein Datenordner").to_string()));
|
||||
return;
|
||||
@@ -1228,7 +1243,7 @@ fn worker(shared: SharedState, account: Account) {
|
||||
let (dir, ctx) = {
|
||||
let mut guard = lock(&shared);
|
||||
loop {
|
||||
if guard.stop {
|
||||
if guard.retired(generation) {
|
||||
return;
|
||||
}
|
||||
let now = Instant::now();
|
||||
@@ -1252,7 +1267,7 @@ fn worker(shared: SharedState, account: Account) {
|
||||
eprintln!("[Nextcloud] Sync-Stand nicht gespeichert: {err}");
|
||||
}
|
||||
let mut guard = lock(&shared);
|
||||
if guard.stop {
|
||||
if guard.retired(generation) {
|
||||
return;
|
||||
}
|
||||
guard.status = match result {
|
||||
@@ -1282,6 +1297,27 @@ mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn worker_of_an_earlier_start_is_retired() {
|
||||
let mut shared = Shared {
|
||||
status: Status::Off,
|
||||
local_dir: PathBuf::new(),
|
||||
ctx: Context::default(),
|
||||
next_pass: Instant::now(),
|
||||
changed: false,
|
||||
signed_in: None,
|
||||
stop: false,
|
||||
generation: 1,
|
||||
};
|
||||
assert!(!shared.retired(1));
|
||||
// Stopped and started again while the pass of worker 1 was still running
|
||||
shared.generation = 2;
|
||||
assert!(shared.retired(1));
|
||||
assert!(!shared.retired(2));
|
||||
shared.stop = true;
|
||||
assert!(shared.retired(2));
|
||||
}
|
||||
|
||||
/// Server folder in memory; folder ETags change with anything inside, like Nextcloud's.
|
||||
#[derive(Default)]
|
||||
struct FakeRemote {
|
||||
|
||||
+103
-7
@@ -275,7 +275,7 @@ pub enum EraserMode {
|
||||
/// Nur Textmarker – die Handschrift darunter bleibt erhalten
|
||||
HighlighterOnly,
|
||||
/// Nur den überstrichenen Teil eines Strichs entfernen; der Rest bleibt in Stücken
|
||||
/// erhalten. Formen werden als Ganzes radiert.
|
||||
/// erhalten. Formen und Textfelder werden als Ganzes radiert.
|
||||
Partial,
|
||||
}
|
||||
|
||||
@@ -578,6 +578,12 @@ impl Stroke {
|
||||
min_dist
|
||||
}
|
||||
|
||||
/// A text box goes as a whole once the eraser touches it, like a typed or inserted math
|
||||
/// result (Issue #54). Images are never erased.
|
||||
fn erased_as_text(&self, eraser_pos: glam::Vec2, eraser_radius: f32) -> bool {
|
||||
self.is_text() && self.distance_to_point(eraser_pos) <= eraser_radius
|
||||
}
|
||||
|
||||
/// Prüft, ob dieser Strich von einem Radierer an Position `eraser_pos` mit Radius `eraser_radius` getroffen wird.
|
||||
pub fn intersects_eraser(
|
||||
&self,
|
||||
@@ -685,6 +691,15 @@ impl Stroke {
|
||||
if let Some(ref mut shape) = self.shape {
|
||||
shape.scale(origin, factors);
|
||||
}
|
||||
// The line width grows with the stroke. It is stored and the mesh is rebuilt from it
|
||||
// on load; without this a scaled stroke would be back at its old width after a restart.
|
||||
// Images and text fields have no line width.
|
||||
if !self.is_object() {
|
||||
let factor = (factors.x * factors.y).abs().sqrt();
|
||||
if factor.is_finite() && factor > 0.0 {
|
||||
self.width *= factor;
|
||||
}
|
||||
}
|
||||
self.recompute_aabb();
|
||||
}
|
||||
|
||||
@@ -942,10 +957,15 @@ impl Page {
|
||||
let mut hit_indices = Vec::new();
|
||||
|
||||
for (i, stroke) in self.strokes.iter().enumerate() {
|
||||
if !stroke.is_object()
|
||||
&& mode.erases(stroke.tool)
|
||||
&& stroke.intersects_eraser(eraser_pos, eraser_radius, max_stroke_radius)
|
||||
{
|
||||
let hit = if stroke.is_text() {
|
||||
mode != EraserMode::HighlighterOnly
|
||||
&& stroke.erased_as_text(eraser_pos, eraser_radius)
|
||||
} else {
|
||||
!stroke.is_object()
|
||||
&& mode.erases(stroke.tool)
|
||||
&& stroke.intersects_eraser(eraser_pos, eraser_radius, max_stroke_radius)
|
||||
};
|
||||
if hit {
|
||||
hit_indices.push(i);
|
||||
}
|
||||
}
|
||||
@@ -970,7 +990,7 @@ impl Page {
|
||||
|
||||
/// Teilradierer: entfernt den Teil aller Striche im Kreis um `eraser_pos`. Getroffene
|
||||
/// Striche werden durch ihre übrig gebliebenen Stücke ersetzt (neue IDs aus `next_id`),
|
||||
/// Formen als Ganzes entfernt, Bilder und Textfelder nie. Aufeinanderfolgende Schnitte
|
||||
/// Formen und Textfelder als Ganzes entfernt, Bilder nie. Aufeinanderfolgende Schnitte
|
||||
/// eines Radierzugs bilden einen gemeinsamen Undo-Schritt. Gibt `true` zurück, wenn sich etwas geändert hat.
|
||||
pub fn erase_partial_at(
|
||||
&mut self,
|
||||
@@ -982,6 +1002,14 @@ impl Page {
|
||||
let mut added = Vec::new();
|
||||
let mut result = Vec::with_capacity(self.strokes.len());
|
||||
for stroke in std::mem::take(&mut self.strokes) {
|
||||
if stroke.is_text() {
|
||||
if stroke.erased_as_text(eraser_pos, eraser_radius) {
|
||||
removed.push(stroke);
|
||||
} else {
|
||||
result.push(stroke);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let reach = eraser_radius + stroke.width / 2.0;
|
||||
if stroke.is_object() || !stroke.aabb.expanded(reach).contains_point(eraser_pos) {
|
||||
result.push(stroke);
|
||||
@@ -1555,7 +1583,12 @@ impl Notebook {
|
||||
/// der Seite und gibt die Kennungen der geänderten Seiten zurück (`None`: nichts zu tun).
|
||||
pub fn make_space(&mut self, page_index: usize, y: f32, dy: f32) -> Option<Vec<u64>> {
|
||||
let run = self.space_run(page_index)?;
|
||||
if self.pages[run.clone()].iter().any(|p| !p.is_loaded) {
|
||||
// A page that could not be loaded is never saved: strokes pushed onto it would be gone
|
||||
// after a restart, and its own content is unknown
|
||||
if self.pages[run.clone()]
|
||||
.iter()
|
||||
.any(|p| !p.is_loaded || p.is_readonly())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let dy = if dy < 0.0 {
|
||||
@@ -2177,6 +2210,57 @@ mod tests {
|
||||
assert!(page.strokes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_eraser_removes_text_boxes_but_not_images() {
|
||||
let corners = |x: f32| {
|
||||
[
|
||||
glam::Vec2::new(x, 100.0),
|
||||
glam::Vec2::new(x + 100.0, 100.0),
|
||||
glam::Vec2::new(x + 100.0, 150.0),
|
||||
glam::Vec2::new(x, 150.0),
|
||||
]
|
||||
};
|
||||
let text = || {
|
||||
Stroke::new_text(
|
||||
1,
|
||||
crate::text::TextBox::new("10".into(), 40.0, crate::text::TextFont::Sans, 100.0),
|
||||
corners(100.0),
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
)
|
||||
};
|
||||
for mode in [EraserMode::All, EraserMode::Partial] {
|
||||
let mut page = Page::new(1, PageBackground::Grid5mm);
|
||||
page.add_stroke(text());
|
||||
page.add_stroke(Stroke::new_image(2, "a".into(), corners(300.0)));
|
||||
let mut next_id = 10;
|
||||
let mut erase = |page: &mut Page, at: glam::Vec2| match mode {
|
||||
EraserMode::Partial => page.erase_partial_at(at, 10.0, &mut next_id),
|
||||
_ => !page.erase_at(at, 10.0, 2.0, mode).is_empty(),
|
||||
};
|
||||
// Next to the box: nothing; touching its edge or inside: the whole box goes
|
||||
assert!(!erase(&mut page, glam::Vec2::new(85.0, 120.0)));
|
||||
assert!(!erase(&mut page, glam::Vec2::new(350.0, 120.0)));
|
||||
assert!(erase(&mut page, glam::Vec2::new(95.0, 120.0)));
|
||||
assert_eq!(
|
||||
page.strokes.iter().map(|s| s.id).collect::<Vec<_>>(),
|
||||
vec![2]
|
||||
);
|
||||
assert!(page.undo());
|
||||
assert_eq!(page.strokes.len(), 2);
|
||||
}
|
||||
// The highlighter eraser leaves text alone
|
||||
let mut page = Page::new(1, PageBackground::Grid5mm);
|
||||
page.add_stroke(text());
|
||||
assert!(page
|
||||
.erase_at(
|
||||
glam::Vec2::new(150.0, 120.0),
|
||||
10.0,
|
||||
2.0,
|
||||
EraserMode::HighlighterOnly
|
||||
)
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pdf_page_size_follows_orientation() {
|
||||
// Hochformat (A4, Letter) bleibt A4 hoch
|
||||
@@ -2610,6 +2694,18 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_make_space_refuses_pages_that_failed_to_load() {
|
||||
let mut nb = Notebook::new();
|
||||
nb.add_page(PageBackground::Blank);
|
||||
nb.pages[0].strokes = vec![block(1, 1000.0, 100.0)];
|
||||
nb.pages[1].persist = PersistState::ReadOnlyAfterLoadError;
|
||||
assert!(nb.make_space(0, 900.0, 300.0).is_none());
|
||||
assert_eq!(top_of(&nb, 0, 1), Some(1000.0));
|
||||
nb.pages[1].persist = PersistState::Normal;
|
||||
assert!(nb.make_space(0, 900.0, 300.0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_and_restore_current_page() {
|
||||
let mut nb = Notebook::new();
|
||||
|
||||
+27
-4
@@ -553,7 +553,7 @@ fn render_ink_layer(
|
||||
));
|
||||
}
|
||||
if let Some(ref shape) = stroke.shape {
|
||||
render_recognized_shape(layer, shape, stroke.color);
|
||||
render_recognized_shape(layer, shape, stroke.color, stroke.width);
|
||||
} else {
|
||||
match strategy {
|
||||
ExportStrategy::FilledPolygon => {
|
||||
@@ -831,20 +831,31 @@ fn render_pdf_raster(
|
||||
draw_image_xobject(layer, xobject, &pdf.corners(page.size));
|
||||
}
|
||||
|
||||
/// Line width of a shape in PDF points, as wide as its mesh on screen
|
||||
/// (`geometry::shape_to_mesh`: the stroke width in page units, at least 2). Shapes without a
|
||||
/// stored width get the former fixed 1.5 pt (about 0.5 mm).
|
||||
fn shape_outline_pt(width_units: f32) -> f32 {
|
||||
if width_units <= 0.0 || !width_units.is_finite() {
|
||||
return 1.5;
|
||||
}
|
||||
width_units.max(2.0) * (A4_WIDTH_MM / PAGE_WIDTH) * (72.0 / 25.4)
|
||||
}
|
||||
|
||||
/// Rendert eine mathematisch bereinigte Form (Gerade, Kreis, Rechteck, Koordinatensystem)
|
||||
/// als verlustfreien Vektorpfad im PDF.
|
||||
fn render_recognized_shape(
|
||||
layer: &crate::pdf_writer::PdfLayerReference,
|
||||
shape: &crate::geometry::RecognizedShape,
|
||||
color: [f32; 4],
|
||||
width_units: f32,
|
||||
) {
|
||||
use crate::geometry::RecognizedShape;
|
||||
|
||||
let stroke_color = Color::Rgb(Rgb::new(color[0], color[1], color[2], None));
|
||||
layer.set_outline_color(stroke_color);
|
||||
layer.set_outline_thickness(1.5); // ca. 0.5 mm Standard-Liniendicke
|
||||
// Wie auf dem Bildschirm: spitze Ecken und stumpfe Enden (der Grafikzustand kann noch
|
||||
// runde Verbindungen von einem vorherigen Freihandstrich enthalten)
|
||||
layer.set_outline_thickness(shape_outline_pt(width_units));
|
||||
// Wie auf dem Bildschirm: spitze Ecken und stumpfe Enden (der Grafikzustand kann noch
|
||||
// runde Verbindungen von einem vorherigen Freihandstrich enthalten)
|
||||
layer.set_line_cap_style(crate::pdf_writer::LineCapStyle::Butt);
|
||||
layer.set_line_join_style(crate::pdf_writer::LineJoinStyle::Miter);
|
||||
|
||||
@@ -1588,6 +1599,18 @@ mod tests {
|
||||
assert!(bytes.starts_with(b"%PDF-1."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shape_outline_follows_stroke_width() {
|
||||
let mm = |pt: f32| pt * 25.4 / 72.0;
|
||||
// 5 page units are 0.5 mm, a 100 unit highlighter line is 10 mm wide
|
||||
assert!((mm(shape_outline_pt(5.0)) - 0.5).abs() < 1e-3);
|
||||
assert!((mm(shape_outline_pt(100.0)) - 10.0).abs() < 1e-3);
|
||||
// The mesh is never thinner than 2 units
|
||||
assert!((mm(shape_outline_pt(0.5)) - 0.2).abs() < 1e-3);
|
||||
assert_eq!(shape_outline_pt(0.0), 1.5);
|
||||
assert_eq!(shape_outline_pt(f32::NAN), 1.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_matrix_maps_unit_square_to_corners() {
|
||||
// Bild über die volle Seitenbreite, oberes Viertel der Seite
|
||||
|
||||
@@ -108,6 +108,10 @@ pub const EN: &[(&str, &str)] = &[
|
||||
("Dunkelgrau", "Dark gray"),
|
||||
("Dunkle Stiftfarben aufhellen", "Lighten dark pen colors"),
|
||||
("Eigene Farbe wählen", "Choose a custom color"),
|
||||
("Meine Farbe", "My color"),
|
||||
("➕ Farbe merken", "➕ Save color"),
|
||||
("Diese Farbe aus „Meine Farben“ entfernen", "Remove this color from “My colors”"),
|
||||
("Die aktuelle Farbe zu „Meine Farben“ hinzufügen", "Add the current color to “My colors”"),
|
||||
("Ein Ordner kann nicht in sich selbst verschoben werden", "A folder cannot be moved into itself"),
|
||||
("Ein-/Ausgabefehler: {e}", "I/O error: {e}"),
|
||||
("Eine Fläche ohne Rand: in alle Richtungen schreiben, verschieben und zoomen. Wechsel nur bei einer einzigen Seite ohne PDF bzw. zurück, solange alles im Startausschnitt liegt.", "One surface without edges: write, pan and zoom in every direction. Switching only works for a single page without a PDF, and back only while everything lies in the starting area."),
|
||||
@@ -129,6 +133,10 @@ pub const EN: &[(&str, &str)] = &[
|
||||
("Fehler beim Schreiben auf die Festplatte: {e}", "Error writing to disk: {e}"),
|
||||
("Fehler beim Umbenennen: {err}", "Error while renaming: {err}"),
|
||||
("Fehler beim Wiederherstellen: {err}", "Error while restoring: {err}"),
|
||||
(
|
||||
"Das PDF kann nicht verarbeitet werden (interner Fehler)",
|
||||
"The PDF cannot be processed (internal error)",
|
||||
),
|
||||
("Fehler beim Öffnen: {err}", "Error while opening: {err}"),
|
||||
("Fehler: {err}", "Error: {err}"),
|
||||
("Fehlermeldung: {message}", "Error message: {message}"),
|
||||
@@ -171,6 +179,10 @@ pub const EN: &[(&str, &str)] = &[
|
||||
("Kariert", "Grid"),
|
||||
("KAROS, LINIEN UND PUNKTE", "GRID, LINES AND DOTS"),
|
||||
("kein Asset-Ordner", "no asset folder"),
|
||||
(
|
||||
"'{asset_name}' ist keine neuere Version und wird nicht installiert.",
|
||||
"'{asset_name}' is not a newer version and will not be installed.",
|
||||
),
|
||||
("Kein Eintrag für '{asset_name}' in SHA256SUMS.txt gefunden. Heruntergeladene Datei wurde gelöscht.", "No entry for '{asset_name}' found in SHA256SUMS.txt. The downloaded file was deleted."),
|
||||
("Kein Release ausgewählt. Download abgelehnt.", "No release selected. Download refused."),
|
||||
("Keine manifest.json vorhanden", "No manifest.json present"),
|
||||
@@ -490,6 +502,14 @@ pub const EN: &[(&str, &str)] = &[
|
||||
("🚀 Updates & Versionen", "🚀 Updates & versions"),
|
||||
(", PDF-Hintergrund nicht gefunden: {file}", ", PDF background not found: {file}"),
|
||||
(", {count} Elemente übersprungen", ", {count} elements skipped"),
|
||||
(
|
||||
"Platz schaffen nicht möglich: Eine der Seiten konnte nicht geladen werden",
|
||||
"Cannot make space: one of the pages could not be loaded",
|
||||
),
|
||||
(
|
||||
"Nicht gewechselt, Speichern fehlgeschlagen: {err}",
|
||||
"Not switched, saving failed: {err}",
|
||||
),
|
||||
("Nicht importiert: {err}", "Not imported: {err}"),
|
||||
("Importieren", "Import"),
|
||||
("📄 Importieren", "📄 Import"),
|
||||
|
||||
+74
-4
@@ -125,11 +125,24 @@ pub fn page(
|
||||
/// we draw a diameter of `width · (0.5 + p)`, so the widest point gets p = 1 and thinner
|
||||
/// ones less (down to a third of the widest).
|
||||
pub fn fit_widths(widths: &[f32]) -> (f32, Vec<f32>) {
|
||||
let widest = widths.iter().copied().fold(0.0, f32::max).max(0.01);
|
||||
let widest = widths
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|w| w.is_finite())
|
||||
.fold(0.0, f32::max)
|
||||
.max(0.01);
|
||||
let width = widest / 1.5;
|
||||
let pressures = widths
|
||||
.iter()
|
||||
.map(|w| (w / width - 0.5).clamp(0.0, 1.0))
|
||||
.map(|w| {
|
||||
let p = w / width - 0.5;
|
||||
// NaN survives `clamp`, is stored as `null` and makes the page unreadable
|
||||
if p.is_finite() {
|
||||
p.clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.5
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
(width, pressures)
|
||||
}
|
||||
@@ -153,17 +166,29 @@ pub fn ink_stroke(
|
||||
color[3] = 1.0;
|
||||
StrokeTool::Pen
|
||||
};
|
||||
// Broken files may carry NaN or infinite numbers. They would be written as `null` and
|
||||
// the page could not be read again, so such points are left out.
|
||||
let samples = points
|
||||
.iter()
|
||||
.zip(pressures)
|
||||
.filter(|(p, _)| p.is_finite())
|
||||
.enumerate()
|
||||
.map(|(i, (p, &pressure))| SampleData {
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
p: pressure,
|
||||
p: if pressure.is_finite() {
|
||||
pressure.clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.5
|
||||
},
|
||||
t: i as f64 * 0.005,
|
||||
})
|
||||
.collect();
|
||||
let width = if width.is_finite() && width > 0.0 {
|
||||
width
|
||||
} else {
|
||||
5.0
|
||||
};
|
||||
StrokeData {
|
||||
id,
|
||||
color,
|
||||
@@ -246,7 +271,12 @@ pub fn unpremultiply(pixels: &mut [u8]) {
|
||||
/// Rasterizes an SVG for a box of `size` page units (about 250 dpi).
|
||||
pub fn rasterize_svg(svg: &str, size: glam::Vec2) -> Option<(u32, u32, Vec<u8>)> {
|
||||
use resvg::{tiny_skia, usvg};
|
||||
let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).ok()?;
|
||||
// By default usvg loads `<image href="/some/local/file">` from disk. An imported note
|
||||
// must not be able to pull files of this device into the notebook; embedded data URLs
|
||||
// still work.
|
||||
let mut options = usvg::Options::default();
|
||||
options.image_href_resolver.resolve_string = Box::new(|_, _| None);
|
||||
let tree = usvg::Tree::from_str(svg, &options).ok()?;
|
||||
let max = crate::assets::MAX_IMPORT_DIMENSION as f32;
|
||||
let px_per_unit = 250.0 / 254.0;
|
||||
let width = (size.x * px_per_unit).round().clamp(1.0, max) as u32;
|
||||
@@ -288,4 +318,44 @@ mod tests {
|
||||
// Thinner than a third of the widest point is not possible
|
||||
assert!((drawn[3] - 10.0).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_broken_numbers_never_reach_the_page_file() {
|
||||
let (width, pressures) = fit_widths(&[f32::NAN, 10.0, f32::INFINITY]);
|
||||
assert!(width.is_finite() && pressures.iter().all(|p| p.is_finite()));
|
||||
|
||||
let points = [
|
||||
glam::Vec2::new(10.0, 10.0),
|
||||
glam::Vec2::new(f32::NAN, 20.0),
|
||||
glam::Vec2::new(30.0, 30.0),
|
||||
];
|
||||
let stroke = ink_stroke(
|
||||
1,
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
false,
|
||||
&points,
|
||||
f32::NAN,
|
||||
&[f32::NAN, 0.5, 2.0],
|
||||
);
|
||||
assert_eq!(stroke.samples.len(), 2);
|
||||
assert!(stroke.width.is_finite());
|
||||
let json = serde_json::to_string(&stroke).unwrap();
|
||||
assert!(!json.contains("null"), "{json}");
|
||||
assert!(serde_json::from_str::<StrokeData>(&json).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_svg_cannot_embed_local_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let secret = dir.path().join("secret.png");
|
||||
image::RgbaImage::from_pixel(8, 8, image::Rgba([255, 0, 0, 255]))
|
||||
.save(&secret)
|
||||
.unwrap();
|
||||
let svg = format!(
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="8" height="8"><image width="8" height="8" xlink:href="{}"/></svg>"#,
|
||||
secret.display()
|
||||
);
|
||||
let (_, _, pixels) = rasterize_svg(&svg, glam::Vec2::new(80.0, 80.0)).unwrap();
|
||||
assert!(pixels.iter().all(|&b| b == 0), "the local image was drawn");
|
||||
}
|
||||
}
|
||||
+38
-10
@@ -40,8 +40,12 @@ pub const MAX_IMPORT_PAGES: usize = 2000;
|
||||
/// Öffnet ein PDF und liefert die angezeigte Größe jeder Seite in Punkt (Drehung
|
||||
/// berücksichtigt).
|
||||
pub fn page_sizes(bytes: Vec<u8>) -> Result<Vec<(f32, f32)>, String> {
|
||||
let pdf = open(bytes)?;
|
||||
let sizes: Vec<(f32, f32)> = pdf.pages().iter().map(|p| p.render_dimensions()).collect();
|
||||
// Runs on the main thread during import: a panic in the PDF library must not take the
|
||||
// app down
|
||||
let sizes: Vec<(f32, f32)> = guarded(|| {
|
||||
let pdf = open(bytes)?;
|
||||
Ok(pdf.pages().iter().map(|p| p.render_dimensions()).collect())
|
||||
})?;
|
||||
if sizes.is_empty() {
|
||||
return Err(t!("Das PDF enthält keine Seiten").into());
|
||||
}
|
||||
@@ -55,6 +59,13 @@ pub fn page_sizes(bytes: Vec<u8>) -> Result<Vec<(f32, f32)>, String> {
|
||||
Ok(sizes)
|
||||
}
|
||||
|
||||
/// Runs PDF code and turns a panic of the PDF library (malformed files) into an error.
|
||||
fn guarded<T>(work: impl FnOnce() -> Result<T, String>) -> Result<T, String> {
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(work)).unwrap_or_else(|_| {
|
||||
Err(t!("Das PDF kann nicht verarbeitet werden (interner Fehler)").to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn open(bytes: Vec<u8>) -> Result<Pdf, String> {
|
||||
Pdf::new(bytes).map_err(|e| match e {
|
||||
LoadPdfError::Decryption(_) => {
|
||||
@@ -209,21 +220,31 @@ impl PdfRenderService {
|
||||
queue = ready.wait(queue).unwrap_or_else(|e| e.into_inner());
|
||||
}
|
||||
};
|
||||
// A panic here would end the thread and with it every PDF page until
|
||||
// the next start, so it only fails the page at hand
|
||||
if open_pdf.as_ref().map(|(p, _)| p) != Some(&request.path) {
|
||||
open_pdf = std::fs::read(&request.path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(open)
|
||||
.map_err(|e| eprintln!("[PDF] '{}': {e}", request.path.display()))
|
||||
.ok()
|
||||
.map(|pdf| (request.path.clone(), pdf));
|
||||
open_pdf = guarded(|| {
|
||||
std::fs::read(&request.path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(open)
|
||||
})
|
||||
.map_err(|e| eprintln!("[PDF] '{}': {e}", request.path.display()))
|
||||
.ok()
|
||||
.map(|pdf| (request.path.clone(), pdf));
|
||||
}
|
||||
let result = match &open_pdf {
|
||||
// Mipmaps hier statt im Haupt-Thread: sonst stockt das Scrollen bei
|
||||
// jeder neu gerenderten Seite (Issue #28)
|
||||
Some((_, pdf)) => render_page(pdf, request.page, request.max_pixels)
|
||||
.map(crate::image_layer::mip_chain),
|
||||
Some((_, pdf)) => guarded(|| {
|
||||
render_page(pdf, request.page, request.max_pixels)
|
||||
.map(crate::image_layer::mip_chain)
|
||||
}),
|
||||
None => Err(t!("PDF kann nicht geöffnet werden").into()),
|
||||
};
|
||||
if result.is_err() {
|
||||
// The document may be in a broken state after a panic
|
||||
open_pdf = None;
|
||||
}
|
||||
if res_tx.send((request.key, result)).is_err() {
|
||||
return;
|
||||
}
|
||||
@@ -356,6 +377,13 @@ impl Drop for PdfRenderService {
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_panic_in_pdf_code_becomes_an_error() {
|
||||
let result: Result<(), String> = guarded(|| panic!("simulated panic in the PDF library"));
|
||||
assert!(result.is_err());
|
||||
assert_eq!(guarded(|| Ok(7)), Ok(7));
|
||||
}
|
||||
|
||||
/// Minimales gültiges PDF mit Seiten der angegebenen Größen (Punkt); jede Seite zeigt ein
|
||||
/// rotes Rechteck in der linken oberen Ecke. Optional mit `/Rotate`.
|
||||
pub(crate) fn sample_pdf(pages: &[(f32, f32, i32)]) -> Vec<u8> {
|
||||
|
||||
@@ -400,6 +400,10 @@ pub mod clipboard {
|
||||
pub fn set_image(&mut self, _image: ImageData) -> Result<(), &'static str> {
|
||||
Err("not available on Android")
|
||||
}
|
||||
|
||||
pub fn set_text(&mut self, _text: String) -> Result<(), &'static str> {
|
||||
Err("not available on Android")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+18
-1
@@ -561,7 +561,8 @@ fn convert(doc: &RnoteDocument, notebook_dir: &Path) -> Imported {
|
||||
let scale = scale_pt * (72.0 / doc.dpi) as f32;
|
||||
// Rnote grows documents page by page; tolerate rounding in the stored size
|
||||
let count = |length: f64, page: f64| ((length / page - 0.01).ceil() as usize).max(1);
|
||||
let columns = count(doc.size.x, doc.format.x);
|
||||
// A broken document size must not ask for millions of pages
|
||||
let columns = count(doc.size.x, doc.format.x).min(MAX_PAGES);
|
||||
let rows = count(doc.size.y, doc.format.y);
|
||||
let rows = rows.min((MAX_PAGES / columns).max(1));
|
||||
let targets = (0..rows * columns)
|
||||
@@ -889,6 +890,22 @@ mod tests {
|
||||
assert!(second.strokes[1].is_image());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_broken_document_size_is_capped() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let doc = RnoteDocument {
|
||||
format: DVec2::new(800.0, 1100.0),
|
||||
dpi: 96.0,
|
||||
origin: DVec2::ZERO,
|
||||
size: DVec2::new(1e18, 1e18),
|
||||
endless: false,
|
||||
background: PageBackground::Blank,
|
||||
items: Vec::new(),
|
||||
};
|
||||
let imported = convert(&doc, dir.path());
|
||||
assert!(imported.pages.len() <= MAX_PAGES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_infinite_documents_become_endless() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
+144
-13
@@ -100,10 +100,16 @@ impl PenPreset {
|
||||
}
|
||||
}
|
||||
|
||||
/// Anzahl der Stifte in der Werkzeugleiste.
|
||||
pub const PEN_PRESET_COUNT: usize = 4;
|
||||
/// Anzahl der Stifte in der Werkzeugleiste (Issue #56: seven).
|
||||
pub const PEN_PRESET_COUNT: usize = 7;
|
||||
|
||||
/// Standardstifte: Schwarz, Blau und Rot fein, Grün etwas kräftiger.
|
||||
/// Pens stored in `pen_presets`. Versions before 0.44 expect exactly four there and would
|
||||
/// reset all settings for any other length, so the further pens go to `more_pen_presets` and a
|
||||
/// rollback keeps the first four.
|
||||
pub const STORED_PEN_PRESETS: usize = 4;
|
||||
|
||||
/// Standardstifte: Schwarz, Blau und Rot fein, Grün etwas kräftiger, dazu Violett, Orange und
|
||||
/// Grau.
|
||||
pub const DEFAULT_PEN_PRESETS: [PenPreset; PEN_PRESET_COUNT] = [
|
||||
PenPreset {
|
||||
color: [0.12, 0.12, 0.14, 1.0],
|
||||
@@ -121,6 +127,18 @@ pub const DEFAULT_PEN_PRESETS: [PenPreset; PEN_PRESET_COUNT] = [
|
||||
color: [0.18, 0.65, 0.35, 1.0],
|
||||
width_mm: 0.8,
|
||||
},
|
||||
PenPreset {
|
||||
color: [0.55, 0.25, 0.75, 1.0],
|
||||
width_mm: 0.5,
|
||||
},
|
||||
PenPreset {
|
||||
color: [0.95, 0.55, 0.10, 1.0],
|
||||
width_mm: 0.5,
|
||||
},
|
||||
PenPreset {
|
||||
color: [0.45, 0.45, 0.48, 1.0],
|
||||
width_mm: 0.5,
|
||||
},
|
||||
];
|
||||
|
||||
/// Farben des Textmarkers in der Werkzeugleiste (halbtransparent).
|
||||
@@ -199,6 +217,9 @@ pub struct StylusButton {
|
||||
/// Höchstens so viele Stifttasten werden gemerkt.
|
||||
pub const MAX_STYLUS_BUTTONS: usize = 4;
|
||||
|
||||
/// How many own colors fit under the presets of the color popovers (two rows)
|
||||
pub const MAX_SAVED_COLORS: usize = 12;
|
||||
|
||||
/// Benutzereinstellungen der Oberfläche.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
@@ -215,8 +236,10 @@ pub struct UiSettings {
|
||||
/// Frühere globale Einstellung „Seiten durchgehend scrollen“: gilt nur noch für Notizbücher
|
||||
/// ohne eigene Anordnung im Manifest (vor 0.28 angelegt), wird nicht mehr geändert
|
||||
pub continuous_scroll: bool,
|
||||
/// Stifte der Werkzeugleiste und der gewählte davon
|
||||
pub pen_presets: [PenPreset; PEN_PRESET_COUNT],
|
||||
/// Stifte der Werkzeugleiste und der gewählte davon: the first four here, the others in
|
||||
/// `more_pen_presets` (see [`STORED_PEN_PRESETS`]); use [`Self::pens`]
|
||||
pub pen_presets: [PenPreset; STORED_PEN_PRESETS],
|
||||
pub more_pen_presets: Vec<PenPreset>,
|
||||
pub active_pen: usize,
|
||||
/// Eigene Farben von Textmarker und Text-Werkzeug und die gewählte davon
|
||||
pub highlighter_colors: [[f32; 4]; 4],
|
||||
@@ -234,6 +257,11 @@ pub struct UiSettings {
|
||||
pub storage_dir: Option<PathBuf>,
|
||||
/// Compute handwritten math after `=` (Issue #46)
|
||||
pub math_assist: bool,
|
||||
/// One finger draws instead of moving the page. On by default on Android, where phones
|
||||
/// usually have no pen (Issue #50)
|
||||
pub touch_draw: bool,
|
||||
/// Own colors under the presets of the color popovers (Issue #56)
|
||||
pub saved_colors: Vec<[f32; 3]>,
|
||||
}
|
||||
|
||||
impl Default for UiSettings {
|
||||
@@ -245,7 +273,13 @@ impl Default for UiSettings {
|
||||
pattern_color: PatternColor::default(),
|
||||
pressure_curve: PressureCurve::default(),
|
||||
continuous_scroll: true,
|
||||
pen_presets: DEFAULT_PEN_PRESETS,
|
||||
pen_presets: [
|
||||
DEFAULT_PEN_PRESETS[0],
|
||||
DEFAULT_PEN_PRESETS[1],
|
||||
DEFAULT_PEN_PRESETS[2],
|
||||
DEFAULT_PEN_PRESETS[3],
|
||||
],
|
||||
more_pen_presets: DEFAULT_PEN_PRESETS[STORED_PEN_PRESETS..].to_vec(),
|
||||
active_pen: 0,
|
||||
highlighter_colors: DEFAULT_HIGHLIGHTER_COLORS,
|
||||
active_highlighter: 0,
|
||||
@@ -256,6 +290,8 @@ impl Default for UiSettings {
|
||||
eraser_radius_mm: 3.0,
|
||||
storage_dir: None,
|
||||
math_assist: false,
|
||||
touch_draw: cfg!(target_os = "android"),
|
||||
saved_colors: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -266,22 +302,45 @@ impl UiSettings {
|
||||
crate::paths::config_dir().map(|d| d.join("ui.json"))
|
||||
}
|
||||
|
||||
/// Lädt die Einstellungen; bei fehlender oder beschädigter Datei gelten Standardwerte.
|
||||
/// Keeps every setting of a partly invalid file that can still be read; only the
|
||||
/// unreadable ones get their default. One bad value must not reset everything, least of
|
||||
/// all the storage location.
|
||||
fn salvage(content: &str) -> Self {
|
||||
let Ok(serde_json::Value::Object(file)) = serde_json::from_str(content) else {
|
||||
return Self::default();
|
||||
};
|
||||
let mut good = serde_json::Map::new();
|
||||
for (key, value) in file {
|
||||
good.insert(key.clone(), value);
|
||||
let candidate = serde_json::Value::Object(good.clone());
|
||||
if serde_json::from_value::<Self>(candidate).is_err() {
|
||||
eprintln!("[Einstellungen] '{key}' ist ungültig, verwende Standardwert.");
|
||||
good.remove(&key);
|
||||
}
|
||||
}
|
||||
serde_json::from_value(serde_json::Value::Object(good)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Lädt die Einstellungen; bei fehlender Datei gelten Standardwerte, bei einer teilweise
|
||||
/// ungültigen Datei nur für die unlesbaren Werte.
|
||||
pub fn load_from(path: &Path) -> Self {
|
||||
let mut settings: Self = match std::fs::read_to_string(path) {
|
||||
Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
|
||||
eprintln!(
|
||||
"[Einstellungen] '{}' ist ungültig ({e}), verwende Standardwerte.",
|
||||
"[Einstellungen] '{}' ist ungültig ({e}), unlesbare Werte werden ersetzt.",
|
||||
path.display()
|
||||
);
|
||||
Self::default()
|
||||
Self::salvage(&content)
|
||||
}),
|
||||
Err(_) => Self::default(),
|
||||
};
|
||||
settings.pressure_curve = settings.pressure_curve.sanitized();
|
||||
for (preset, fallback) in settings.pen_presets.iter_mut().zip(DEFAULT_PEN_PRESETS) {
|
||||
*preset = preset.sanitized(fallback);
|
||||
let pens = settings.pens();
|
||||
let mut sanitized = DEFAULT_PEN_PRESETS;
|
||||
for ((preset, pen), fallback) in sanitized.iter_mut().zip(pens).zip(DEFAULT_PEN_PRESETS) {
|
||||
*preset = pen.sanitized(fallback);
|
||||
}
|
||||
settings.set_pens(sanitized);
|
||||
settings.active_pen = settings.active_pen.min(PEN_PRESET_COUNT - 1);
|
||||
for (color, fallback) in settings
|
||||
.highlighter_colors
|
||||
@@ -300,9 +359,31 @@ impl UiSettings {
|
||||
.active_text_color
|
||||
.min(settings.text_colors.len() - 1);
|
||||
settings.stylus_buttons.truncate(MAX_STYLUS_BUTTONS);
|
||||
settings.saved_colors.truncate(MAX_SAVED_COLORS);
|
||||
for rgb in &mut settings.saved_colors {
|
||||
let [r, g, b, _] = sanitized_color([rgb[0], rgb[1], rgb[2], 1.0], [0.0, 0.0, 0.0, 1.0]);
|
||||
*rgb = [r, g, b];
|
||||
}
|
||||
settings
|
||||
}
|
||||
|
||||
/// All pens of the toolbar; pens missing in the file (older versions had four) are the
|
||||
/// default pens.
|
||||
pub fn pens(&self) -> [PenPreset; PEN_PRESET_COUNT] {
|
||||
let mut pens = DEFAULT_PEN_PRESETS;
|
||||
let stored = self.pen_presets.iter().chain(&self.more_pen_presets);
|
||||
for (pen, stored) in pens.iter_mut().zip(stored) {
|
||||
*pen = *stored;
|
||||
}
|
||||
pens
|
||||
}
|
||||
|
||||
pub fn set_pens(&mut self, pens: [PenPreset; PEN_PRESET_COUNT]) {
|
||||
self.pen_presets
|
||||
.copy_from_slice(&pens[..STORED_PEN_PRESETS]);
|
||||
self.more_pen_presets = pens[STORED_PEN_PRESETS..].to_vec();
|
||||
}
|
||||
|
||||
/// Speichert die Einstellungen atomar.
|
||||
pub fn save_to(&self, path: &Path) -> Result<(), StorageError> {
|
||||
storage::atomic_write_json(path, self)
|
||||
@@ -334,7 +415,14 @@ mod tests {
|
||||
pen_presets: [PenPreset {
|
||||
color: [0.5, 0.2, 0.7, 1.0],
|
||||
width_mm: 1.5,
|
||||
}; PEN_PRESET_COUNT],
|
||||
}; STORED_PEN_PRESETS],
|
||||
more_pen_presets: vec![
|
||||
PenPreset {
|
||||
color: [0.1, 0.6, 0.7, 1.0],
|
||||
width_mm: 2.0,
|
||||
};
|
||||
PEN_PRESET_COUNT - STORED_PEN_PRESETS
|
||||
],
|
||||
active_pen: 2,
|
||||
highlighter_colors: [[0.9, 0.5, 0.1, 0.35]; 4],
|
||||
active_highlighter: 3,
|
||||
@@ -354,6 +442,8 @@ mod tests {
|
||||
eraser_radius_mm: 6.0,
|
||||
storage_dir: Some(PathBuf::from("/home/pia/Nextcloud/ink-notes")),
|
||||
math_assist: true,
|
||||
touch_draw: true,
|
||||
saved_colors: vec![[0.2, 0.4, 0.6], [1.0, 0.5, 0.0]],
|
||||
};
|
||||
settings.save_to(&path).unwrap();
|
||||
assert_eq!(UiSettings::load_from(&path), settings);
|
||||
@@ -367,6 +457,45 @@ mod tests {
|
||||
assert_eq!(UiSettings::load_from(&path), UiSettings::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_one_invalid_value_does_not_reset_the_other_settings() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("ui.json");
|
||||
std::fs::write(
|
||||
&path,
|
||||
br#"{"storage_dir": "/home/pia/Nextcloud/ink-notes", "eraser_radius_mm": "big", "math_assist": true}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let loaded = UiSettings::load_from(&path);
|
||||
assert_eq!(
|
||||
loaded.storage_dir,
|
||||
Some(PathBuf::from("/home/pia/Nextcloud/ink-notes"))
|
||||
);
|
||||
assert!(loaded.math_assist);
|
||||
assert_eq!(
|
||||
loaded.eraser_radius_mm,
|
||||
UiSettings::default().eraser_radius_mm
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seven_pens_stay_readable_for_older_versions() {
|
||||
// Versions before 0.44 read `pen_presets` as exactly four pens (Issue #56)
|
||||
#[derive(Deserialize)]
|
||||
struct OldSettings {
|
||||
pen_presets: [PenPreset; 4],
|
||||
}
|
||||
let mut settings = UiSettings::default();
|
||||
let mut pens = DEFAULT_PEN_PRESETS;
|
||||
pens[6].color = [0.3, 0.3, 0.9, 1.0];
|
||||
settings.set_pens(pens);
|
||||
let json = serde_json::to_string(&settings).unwrap();
|
||||
let old: OldSettings = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(old.pen_presets[..], DEFAULT_PEN_PRESETS[..4]);
|
||||
let back: UiSettings = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.pens(), pens);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_old_settings_file_keeps_new_defaults() {
|
||||
// ui.json aus v0.3.x kennt nur die Seite der Werkzeugleiste
|
||||
@@ -392,7 +521,7 @@ mod tests {
|
||||
assert_eq!(curve.threshold, 0.0);
|
||||
|
||||
// Ohne Stifte: die Standardstifte; unsinnige Stifte werden begrenzt
|
||||
assert_eq!(settings.pen_presets, DEFAULT_PEN_PRESETS);
|
||||
assert_eq!(settings.pens(), DEFAULT_PEN_PRESETS);
|
||||
assert_eq!(settings.active_pen, 0);
|
||||
std::fs::write(
|
||||
&path,
|
||||
@@ -409,6 +538,8 @@ mod tests {
|
||||
assert_eq!(settings.pen_presets[0].color, [1.0, 0.0, 0.5, 1.0]);
|
||||
assert_eq!(settings.pen_presets[0].width_mm, 5.0);
|
||||
assert_eq!(settings.pen_presets[1].width_mm, 0.15);
|
||||
// Files from before 0.44 have four pens: the others are the default pens
|
||||
assert_eq!(settings.pens()[4..], DEFAULT_PEN_PRESETS[4..]);
|
||||
|
||||
// Eigene Farben: Textmarker bleibt halbtransparent, Text deckend
|
||||
std::fs::write(
|
||||
|
||||
+227
-18
@@ -555,8 +555,12 @@ pub fn atomic_write_file(path: &Path, content: &[u8]) -> Result<(), io::Error> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
file.write_all(content)?;
|
||||
file.sync_all()?;
|
||||
// On a full disk every retry would otherwise leave another partial file behind
|
||||
if let Err(e) = file.write_all(content).and_then(|_| file.sync_all()) {
|
||||
drop(file);
|
||||
let _ = fs::remove_file(&temp_path);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Atomares Rename
|
||||
@@ -630,8 +634,29 @@ fn load_manifest_from_file(path: &Path) -> Result<NotebookManifest, StorageError
|
||||
}
|
||||
|
||||
/// Lädt das Manifest aus `manifest.json`.
|
||||
/// Fällt bei Fehlern/Beschädigung automatisch auf `manifest.json.bak` zurück.
|
||||
/// Fällt bei Beschädigung automatisch auf `manifest.json.bak` zurück.
|
||||
///
|
||||
/// The folder name is the identity of a notebook: everything opens, renames and deletes
|
||||
/// `<base>/<id>`. A copied or renamed folder still carries the id of the original in its
|
||||
/// manifest, so the id is always taken from the folder.
|
||||
pub fn load_manifest(notebook_dir: &Path) -> Result<NotebookManifest, StorageError> {
|
||||
let mut manifest = load_manifest_or_backup(notebook_dir)?;
|
||||
// Canonical, so that a path like `.` still yields the folder name
|
||||
let dir = notebook_dir.canonicalize();
|
||||
let name = dir
|
||||
.as_deref()
|
||||
.unwrap_or(notebook_dir)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str());
|
||||
if let Some(name) = name {
|
||||
if manifest.id != name {
|
||||
manifest.id = name.to_string();
|
||||
}
|
||||
}
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
fn load_manifest_or_backup(notebook_dir: &Path) -> Result<NotebookManifest, StorageError> {
|
||||
let path = notebook_dir.join("manifest.json");
|
||||
if !path.exists() {
|
||||
let bak_path = notebook_dir.join("manifest.json.bak");
|
||||
@@ -647,6 +672,13 @@ pub fn load_manifest(notebook_dir: &Path) -> Result<NotebookManifest, StorageErr
|
||||
|
||||
match load_manifest_from_file(&path) {
|
||||
Ok(manifest) => Ok(manifest),
|
||||
// The backup only stands in for a damaged file. A manifest of a newer app version or
|
||||
// one that cannot be read right now is intact: opening the older backup instead would
|
||||
// let the next save overwrite it with stale content.
|
||||
Err(e @ StorageError::UnsupportedVersion { .. }) => Err(e),
|
||||
Err(StorageError::Io(e)) if e.kind() != io::ErrorKind::InvalidData => {
|
||||
Err(StorageError::Io(e))
|
||||
}
|
||||
Err(e) => {
|
||||
let bak_path = notebook_dir.join("manifest.json.bak");
|
||||
if bak_path.exists() {
|
||||
@@ -817,8 +849,22 @@ pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest)
|
||||
page.persist = PersistState::ReadOnlyAfterLoadError;
|
||||
page
|
||||
}
|
||||
// Listed but never saved (still empty): an empty page, as with lazy loading. Nothing
|
||||
// can be overwritten, there is no file.
|
||||
Err(StorageError::PageNotFound(_)) => {
|
||||
Page::new(first_id, first_bg).with_size(manifest.size_for_page(first_id))
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[Storage] Konnte erste Seite {first_id} nicht laden: {e}. Erstelle leere Ersatzseite.");
|
||||
// Without the warning the page looks writable while nothing on it is saved
|
||||
load_warnings.insert(
|
||||
first_id,
|
||||
PageLoadWarning {
|
||||
page_id: first_id,
|
||||
message: e.to_string(),
|
||||
quarantine_path: None,
|
||||
},
|
||||
);
|
||||
let mut page =
|
||||
Page::new(first_id, first_bg).with_size(manifest.size_for_page(first_id));
|
||||
page.persist = PersistState::ReadOnlyAfterLoadError;
|
||||
@@ -931,6 +977,14 @@ pub fn ensure_page_loaded(
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[Storage] Fehler beim Lazy Loading von Seite {page_id}: {e}");
|
||||
notebook.load_warnings.insert(
|
||||
page_id,
|
||||
PageLoadWarning {
|
||||
page_id,
|
||||
message: e.to_string(),
|
||||
quarantine_path: None,
|
||||
},
|
||||
);
|
||||
notebook.pages[page_idx].is_loaded = true;
|
||||
notebook.pages[page_idx].persist = PersistState::ReadOnlyAfterLoadError;
|
||||
notebook.pages[page_idx].rebuild_cache_if_dirty();
|
||||
@@ -1502,7 +1556,23 @@ pub fn restore_manifest_from_backup(notebook_dir: &Path) -> Result<(), StorageEr
|
||||
}
|
||||
}
|
||||
|
||||
/// Time an entry was moved into `.trash`, read from the `_<unix seconds>` suffix of its name
|
||||
/// (`<id>_<ts>`, `sync_<ts>`).
|
||||
fn trashed_at(name: &str) -> Option<std::time::SystemTime> {
|
||||
let (_, ts) = name.rsplit_once('_')?;
|
||||
if ts.is_empty() || !ts.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
let secs = ts.parse::<u64>().ok()?;
|
||||
std::time::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(secs))
|
||||
}
|
||||
|
||||
/// Entfernt Einträge aus `.trash`, die älter als `max_age` sind.
|
||||
///
|
||||
/// The age counts from the moment of deletion, which is the timestamp in the entry name.
|
||||
/// The modification time of a folder survives the move into `.trash`, so it would purge a
|
||||
/// notebook that had not been edited for a while right after it was deleted. It is only the
|
||||
/// fallback for entries without a timestamp in the name.
|
||||
pub fn cleanup_trash(base_dir: &Path, max_age: std::time::Duration) -> io::Result<usize> {
|
||||
let trash_dir = base_dir.join(".trash");
|
||||
if !trash_dir.exists() {
|
||||
@@ -1513,20 +1583,25 @@ pub fn cleanup_trash(base_dir: &Path, max_age: std::time::Duration) -> io::Resul
|
||||
if let Ok(entries) = fs::read_dir(&trash_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if let Ok(metadata) = entry.metadata() {
|
||||
if let Ok(modified) = metadata.modified() {
|
||||
if let Ok(age) = now.duration_since(modified) {
|
||||
if age >= max_age {
|
||||
if path.is_dir() {
|
||||
if fs::remove_dir_all(&path).is_ok() {
|
||||
removed += 1;
|
||||
}
|
||||
} else if fs::remove_file(&path).is_ok() {
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let since = entry
|
||||
.file_name()
|
||||
.to_str()
|
||||
.and_then(trashed_at)
|
||||
.or_else(|| entry.metadata().ok()?.modified().ok());
|
||||
let expired = since
|
||||
.and_then(|t| now.duration_since(t).ok())
|
||||
.is_some_and(|age| age >= max_age);
|
||||
if !expired {
|
||||
continue;
|
||||
}
|
||||
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
|
||||
let gone = if is_dir {
|
||||
fs::remove_dir_all(&path)
|
||||
} else {
|
||||
fs::remove_file(&path)
|
||||
};
|
||||
if gone.is_ok() {
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1566,6 +1641,77 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[test]
|
||||
fn test_scaled_stroke_keeps_its_width_after_reload() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let color = [0.0, 0.0, 0.0, 1.0];
|
||||
let samples: Vec<StylusSample> = (0..20)
|
||||
.map(|i| StylusSample {
|
||||
position: glam::Vec2::new(200.0 + 10.0 * i as f32, 500.0),
|
||||
pressure: 0.5,
|
||||
timestamp: i as f64 * 0.01,
|
||||
})
|
||||
.collect();
|
||||
let tool = crate::document::StrokeTool::default();
|
||||
let vertices = reconstruct_stroke_vertices(&samples, color, 5.0, tool, true);
|
||||
let mut stroke = Stroke::new(1, samples, vertices, color);
|
||||
stroke.width = 5.0;
|
||||
stroke.finalized = true;
|
||||
let thickness = |s: &Stroke| {
|
||||
let ys = s.vertices.iter().map(|v| v.position[1]);
|
||||
ys.clone().fold(f32::MIN, f32::max) - ys.fold(f32::MAX, f32::min)
|
||||
};
|
||||
let before = thickness(&stroke);
|
||||
|
||||
stroke.scale(glam::Vec2::new(200.0, 500.0), glam::Vec2::splat(3.0));
|
||||
assert!((stroke.width - 15.0).abs() < 1e-4);
|
||||
let scaled = thickness(&stroke);
|
||||
assert!((scaled - 3.0 * before).abs() < 0.01);
|
||||
|
||||
let mut page = Page::new(1, PageBackground::Blank);
|
||||
page.add_stroke(stroke);
|
||||
save_page(temp_dir.path(), &PageData::from(&page)).unwrap();
|
||||
let loaded = load_page(temp_dir.path(), 1).unwrap().to_page();
|
||||
assert!((loaded.strokes[0].width - 15.0).abs() < 1e-4);
|
||||
let reloaded = thickness(&loaded.strokes[0]);
|
||||
assert!(
|
||||
(reloaded - scaled).abs() < 0.05 * scaled,
|
||||
"scaled {scaled}, after reload {reloaded}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_trash_counts_from_deletion_not_from_last_edit() {
|
||||
let base = tempfile::tempdir().unwrap();
|
||||
let trash = base.path().join(".trash");
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
let day = 24 * 3600;
|
||||
let long_ago = std::time::SystemTime::now() - Duration::from_secs(90 * day);
|
||||
|
||||
// Deleted just now, last edited 90 days ago: stays
|
||||
let fresh = trash.join(format!("nb_a_{now}"));
|
||||
// Deleted 40 days ago: goes
|
||||
let expired = trash.join(format!("nb_b_{}", now - 40 * day));
|
||||
// No timestamp in the name: the modification time decides
|
||||
let unnamed = trash.join("stray");
|
||||
for dir in [&fresh, &expired, &unnamed] {
|
||||
fs::create_dir_all(dir).unwrap();
|
||||
fs::write(dir.join("manifest.json"), "{}").unwrap();
|
||||
}
|
||||
for dir in [&fresh, &unnamed] {
|
||||
fs::File::open(dir).unwrap().set_modified(long_ago).unwrap();
|
||||
}
|
||||
|
||||
let removed = cleanup_trash(base.path(), Duration::from_secs(30 * day)).unwrap();
|
||||
assert_eq!(removed, 2);
|
||||
assert!(fresh.exists());
|
||||
assert!(!expired.exists());
|
||||
assert!(!unnamed.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_atomic_save_and_reload() {
|
||||
let temp_dir = tempfile::tempdir().expect("Konnte temporäres Verzeichnis nicht anlegen");
|
||||
@@ -2111,10 +2257,73 @@ mod tests {
|
||||
fs::write(&broken_manifest_path, b"{ broken json ... ").unwrap();
|
||||
|
||||
let loaded = load_manifest(nb_dir).expect("Muss aus .bak geladen werden");
|
||||
assert_eq!(loaded.id, "nb_test");
|
||||
assert_eq!(loaded.title, "Backup Test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_first_page_that_fails_to_load_warns_and_a_missing_one_is_empty() {
|
||||
let base = tempfile::tempdir().unwrap();
|
||||
let (_, _, dir) = create_new_notebook(base.path(), "First page").unwrap();
|
||||
let first = load_manifest(&dir).unwrap().page_ids()[0];
|
||||
let file = dir.join(format!("page_{first}.json"));
|
||||
|
||||
// Never saved: an ordinary empty page
|
||||
let _ = fs::remove_file(&file);
|
||||
let (nb, _) = load_notebook(&dir).unwrap();
|
||||
assert!(!nb.pages[0].is_readonly());
|
||||
assert!(nb.load_warnings.is_empty());
|
||||
|
||||
// Written by a newer version: read-only, and the user is told
|
||||
let mut data = PageData::from(&Page::new(first, PageBackground::Blank));
|
||||
data.format_version = CURRENT_FORMAT_VERSION + 1;
|
||||
fs::write(&file, serde_json::to_vec(&data).unwrap()).unwrap();
|
||||
let (nb, _) = load_notebook(&dir).unwrap();
|
||||
assert!(nb.pages[0].is_readonly());
|
||||
assert!(nb.load_warnings.contains_key(&first));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_newer_manifest_is_not_replaced_by_its_backup() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let nb_dir = temp_dir.path();
|
||||
let old = NotebookManifest::new("nb", "Old state");
|
||||
atomic_write_json(&nb_dir.join("manifest.json.bak"), &old).unwrap();
|
||||
let mut newer = NotebookManifest::new("nb", "New state");
|
||||
newer.format_version = CURRENT_FORMAT_VERSION + 1;
|
||||
atomic_write_json(&nb_dir.join("manifest.json"), &newer).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
load_manifest(nb_dir),
|
||||
Err(StorageError::UnsupportedVersion { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_copied_notebook_folder_is_a_notebook_of_its_own() {
|
||||
let base = tempfile::tempdir().unwrap();
|
||||
let (_, manifest, dir) = create_new_notebook(base.path(), "Original").unwrap();
|
||||
let copy = base.path().join("copy_of_it");
|
||||
fs::create_dir(©).unwrap();
|
||||
fs::copy(dir.join("manifest.json"), copy.join("manifest.json")).unwrap();
|
||||
|
||||
let mut ids: Vec<String> = list_notebooks(base.path())
|
||||
.into_iter()
|
||||
.map(|s| s.id)
|
||||
.collect();
|
||||
ids.sort();
|
||||
let mut expected = vec!["copy_of_it".to_string(), manifest.id.clone()];
|
||||
expected.sort();
|
||||
assert_eq!(ids, expected);
|
||||
|
||||
// Opened by its folder name, and the id stays that way once saved
|
||||
let (_, mut loaded) = load_notebook(©).unwrap();
|
||||
assert_eq!(loaded.id, "copy_of_it");
|
||||
loaded.title = "Copy".to_string();
|
||||
save_manifest(©, &loaded).unwrap();
|
||||
assert_eq!(load_manifest(&dir).unwrap().title, "Original");
|
||||
assert_eq!(load_manifest(©).unwrap().title, "Copy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_notebook_collision_generates_new_id() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::file_picker::{FilePicker, PickerKind};
|
||||
use crate::ruler::{Ruler, RulerHit};
|
||||
use crate::settings::{
|
||||
PenPreset, PressureCurve, SidebarSide, DEFAULT_HIGHLIGHTER_COLORS, DEFAULT_PEN_PRESETS,
|
||||
DEFAULT_TEXT_COLORS, PEN_PRESET_COUNT,
|
||||
DEFAULT_TEXT_COLORS, MAX_SAVED_COLORS, PEN_PRESET_COUNT,
|
||||
};
|
||||
use crate::storage::{NotebookLayout, NotebookSummary};
|
||||
use crate::text::{TextEditing, TextFont, TEXT_SIZES_PT};
|
||||
@@ -319,6 +319,8 @@ pub enum UiAction {
|
||||
SetSidebarSide(SidebarSide),
|
||||
/// Compute handwritten math after `=` on or off (Issue #46)
|
||||
SetMathAssist(bool),
|
||||
/// One finger draws or moves the page (saved, Issue #50)
|
||||
SetTouchDraw(bool),
|
||||
/// Sprache der Oberfläche; `None` = Sprache des Systems
|
||||
SetLanguage(Option<crate::i18n::Language>),
|
||||
/// Papierfarbe der Anzeige und Aufhellen dunkler Tinte (wird gespeichert)
|
||||
@@ -444,6 +446,8 @@ pub struct UiState {
|
||||
pub is_text_color_menu_open: bool,
|
||||
/// Farbwähler in den Farb-Popovern aufgeklappt
|
||||
pub show_color_picker: bool,
|
||||
/// „Meine Farben“ in the color popovers (Issue #56)
|
||||
pub saved_colors: Vec<[f32; 3]>,
|
||||
/// Kontextmenü nach langem Drücken (Einfügen an dieser Stelle)
|
||||
pub context_menu: Option<ContextMenu>,
|
||||
/// Laufendes Platzschaffen (Vorschau)
|
||||
@@ -573,6 +577,7 @@ impl Default for UiState {
|
||||
active_text_color: 0,
|
||||
is_text_color_menu_open: false,
|
||||
show_color_picker: false,
|
||||
saved_colors: Vec::new(),
|
||||
context_menu: None,
|
||||
space_drag: None,
|
||||
stylus_buttons: Vec::new(),
|
||||
@@ -719,7 +724,7 @@ impl UiState {
|
||||
}
|
||||
|
||||
/// Länge der längsten Farbpalette; so viel Platz hält die Leiste immer frei.
|
||||
pub const MAX_PALETTE_LEN: usize = 5;
|
||||
pub const MAX_PALETTE_LEN: usize = PEN_PRESET_COUNT;
|
||||
/// Vertical gap between toolbar buttons and the height of a divider line.
|
||||
pub const SIDEBAR_SPACING: f32 = 2.0;
|
||||
pub const SIDEBAR_DIVIDER: f32 = 3.0;
|
||||
@@ -1302,17 +1307,21 @@ fn autosave_status_style(status: AutosaveStatus) -> (egui::Color32, &'static str
|
||||
fn color_editor(
|
||||
ui: &mut egui::Ui,
|
||||
presets: &[ColorPreset],
|
||||
saved: &mut Vec<[f32; 3]>,
|
||||
color: &mut [f32; 4],
|
||||
picker_open: &mut bool,
|
||||
) -> bool {
|
||||
let same_rgb = |a: &[f32], b: &[f32]| {
|
||||
a[..3]
|
||||
.iter()
|
||||
.zip(&b[..3])
|
||||
.all(|(a, b)| (a - b).abs() < 0.01)
|
||||
};
|
||||
let mut changed = false;
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing = egui::vec2(8.0, 0.0);
|
||||
for &(preset, label) in presets {
|
||||
let is_sel = preset[..3]
|
||||
.iter()
|
||||
.zip(&color[..3])
|
||||
.all(|(a, b)| (a - b).abs() < 0.01);
|
||||
let is_sel = same_rgb(&preset, color);
|
||||
if color_circle_swatch(ui, preset, is_sel, crate::i18n::tr(label), 30.0).clicked() {
|
||||
*color = [preset[0], preset[1], preset[2], color[3]];
|
||||
changed = true;
|
||||
@@ -1329,6 +1338,46 @@ fn color_editor(
|
||||
*picker_open = !*picker_open;
|
||||
}
|
||||
});
|
||||
|
||||
// My colors (Issue #56): kept for pen, highlighter and text alike
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = egui::vec2(8.0, 6.0);
|
||||
let mut remove = None;
|
||||
for (i, rgb) in saved.iter().enumerate() {
|
||||
let swatch = [rgb[0], rgb[1], rgb[2], 1.0];
|
||||
let is_sel = same_rgb(rgb, color);
|
||||
if color_circle_swatch(ui, swatch, is_sel, t!("Meine Farbe"), 30.0).clicked() {
|
||||
*color = [rgb[0], rgb[1], rgb[2], color[3]];
|
||||
changed = true;
|
||||
}
|
||||
if is_sel {
|
||||
remove = Some(i);
|
||||
}
|
||||
}
|
||||
if let Some(i) = remove {
|
||||
if ui
|
||||
.button(egui::RichText::new("🗑").size(16.0))
|
||||
.on_hover_text(t!("Diese Farbe aus „Meine Farben“ entfernen"))
|
||||
.clicked()
|
||||
{
|
||||
saved.remove(i);
|
||||
}
|
||||
} else if saved.len() < MAX_SAVED_COLORS {
|
||||
let label = if saved.is_empty() {
|
||||
t!("➕ Farbe merken")
|
||||
} else {
|
||||
"➕"
|
||||
};
|
||||
if ui
|
||||
.button(label)
|
||||
.on_hover_text(t!("Die aktuelle Farbe zu „Meine Farben“ hinzufügen"))
|
||||
.clicked()
|
||||
{
|
||||
saved.push([color[0], color[1], color[2]]);
|
||||
}
|
||||
}
|
||||
});
|
||||
if *picker_open {
|
||||
ui.add_space(6.0);
|
||||
let [r, g, b] = [0, 1, 2].map(|i| (color[i].clamp(0.0, 1.0) * 255.0).round() as u8);
|
||||
@@ -2940,8 +2989,9 @@ fn file_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Ve
|
||||
),
|
||||
),
|
||||
};
|
||||
let width = dialog_width(ctx, state, 460.0);
|
||||
dialog_window(title, state, false).show(ctx, |ui| {
|
||||
ui.set_width(460.0);
|
||||
ui.set_width(width);
|
||||
touch_friendly_spacing(ui);
|
||||
if dialog_header(ui, header, true) {
|
||||
close = true;
|
||||
@@ -3112,6 +3162,17 @@ fn dialog_window(title: &'static str, state: &UiState, danger: bool) -> egui::Wi
|
||||
.frame(dialog_frame(danger))
|
||||
}
|
||||
|
||||
/// Content width of a dialog: `wanted`, but at most what fits beside the toolbar, so that on a
|
||||
/// phone the dialog is neither hidden under the toolbar nor cut off at the edge (Issue #50).
|
||||
fn dialog_width(ctx: &egui::Context, state: &UiState, wanted: f32) -> f32 {
|
||||
// Frame margins (20 each side) and a gap to the toolbar and the screen edge
|
||||
let free = ctx.screen_rect().width()
|
||||
- state.sidebar_reserved_width()
|
||||
- 2.0 * 20.0
|
||||
- 2.0 * UiState::SIDEBAR_MARGIN;
|
||||
wanted.min(free).max(200.0)
|
||||
}
|
||||
|
||||
/// Kopfzeile eines Dialogs mit Titel und optionalem Schließen-Knopf.
|
||||
/// Gibt `true` zurück, wenn „Schließen“ getippt wurde.
|
||||
fn dialog_header(ui: &mut egui::Ui, title: &str, closable: bool) -> bool {
|
||||
@@ -4079,6 +4140,7 @@ pub fn render_ui(
|
||||
if color_editor(
|
||||
ui,
|
||||
&PEN_COLOR_PRESETS,
|
||||
&mut state.saved_colors,
|
||||
&mut state.pen_color,
|
||||
&mut state.show_color_picker,
|
||||
) {
|
||||
@@ -4260,6 +4322,7 @@ pub fn render_ui(
|
||||
if color_editor(
|
||||
ui,
|
||||
&HIGHLIGHTER_COLOR_PRESETS,
|
||||
&mut state.saved_colors,
|
||||
&mut state.highlighter_color,
|
||||
&mut state.show_color_picker,
|
||||
) {
|
||||
@@ -4366,6 +4429,7 @@ pub fn render_ui(
|
||||
if color_editor(
|
||||
ui,
|
||||
&PEN_COLOR_PRESETS,
|
||||
&mut state.saved_colors,
|
||||
&mut color,
|
||||
&mut state.show_color_picker,
|
||||
) {
|
||||
@@ -4613,13 +4677,18 @@ pub fn render_ui(
|
||||
});
|
||||
|
||||
section(ui, t!("EINGABE"));
|
||||
ui.checkbox(
|
||||
&mut state.touch_draw_enabled,
|
||||
t!("Mit dem Finger zeichnen"),
|
||||
)
|
||||
.on_hover_text(
|
||||
t!("Aus: ein Finger verschiebt, zwei Finger zoomen (empfohlen mit Stift).\nAn: ein Finger zeichnet."),
|
||||
);
|
||||
if ui
|
||||
.checkbox(
|
||||
&mut state.touch_draw_enabled,
|
||||
t!("Mit dem Finger zeichnen"),
|
||||
)
|
||||
.on_hover_text(
|
||||
t!("Aus: ein Finger verschiebt, zwei Finger zoomen (empfohlen mit Stift).\nAn: ein Finger zeichnet."),
|
||||
)
|
||||
.changed()
|
||||
{
|
||||
actions.push(UiAction::SetTouchDraw(state.touch_draw_enabled));
|
||||
}
|
||||
let mut math = state.math.enabled;
|
||||
if ui
|
||||
.checkbox(&mut math, t!("Formeln nach „=“ ausrechnen"))
|
||||
@@ -4930,9 +4999,10 @@ pub fn render_ui(
|
||||
|
||||
// 3b. Dokumenten-Manager (Notizbücher wechseln, anlegen, umbenennen, löschen)
|
||||
if state.is_document_menu_open {
|
||||
let width = dialog_width(ctx, state, 420.0);
|
||||
let doc_win = dialog_window(t!("Dokumenten-Manager"), state, false)
|
||||
.show(ctx, |ui| {
|
||||
ui.set_width(420.0);
|
||||
ui.set_width(width);
|
||||
touch_friendly_spacing(ui);
|
||||
if dialog_header(ui, t!("📚 Notizbücher"), true) {
|
||||
state.is_document_menu_open = false;
|
||||
@@ -4948,13 +5018,13 @@ pub fn render_ui(
|
||||
folder_breadcrumbs(ui, state, &folder);
|
||||
ui.add_space(6.0);
|
||||
|
||||
// Neues Notizbuch oder neuer Ordner hier
|
||||
ui.horizontal(|ui| {
|
||||
// Neues Notizbuch oder neuer Ordner hier (narrow screens: buttons below)
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let text_edit = ui.add(
|
||||
egui::TextEdit::singleline(&mut state.new_notebook_title_input)
|
||||
.hint_text(t!("Name …"))
|
||||
.margin(egui::vec2(8.0, 8.0))
|
||||
.desired_width(190.0),
|
||||
.desired_width(190.0_f32.min(ui.available_width())),
|
||||
);
|
||||
let create_btn = ui
|
||||
.button(egui::RichText::new(t!("➕ Notizbuch")).strong())
|
||||
@@ -5032,7 +5102,7 @@ pub fn render_ui(
|
||||
let edit = ui.add(
|
||||
egui::TextEdit::singleline(&mut state.renaming_title_input)
|
||||
.margin(egui::vec2(8.0, 8.0))
|
||||
.desired_width(260.0),
|
||||
.desired_width(260.0_f32.min(ui.available_width() - 80.0)),
|
||||
);
|
||||
if ui.button("✔").clicked()
|
||||
|| (edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)))
|
||||
@@ -5197,9 +5267,10 @@ pub fn render_ui(
|
||||
|
||||
// 3c. Release- & Update-Center (Gitea Releases)
|
||||
if state.is_update_dialog_open {
|
||||
let width = dialog_width(ctx, state, 460.0);
|
||||
let win = dialog_window(t!("Release- & Update-Center"), state, false)
|
||||
.show(ctx, |ui| {
|
||||
ui.set_width(460.0);
|
||||
ui.set_width(width);
|
||||
touch_friendly_spacing(ui);
|
||||
if dialog_header(ui, t!("🚀 Updates & Versionen"), true) {
|
||||
state.is_update_dialog_open = false;
|
||||
@@ -5421,11 +5492,11 @@ pub fn render_ui(
|
||||
.show(ui, |ui| {
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new(t!("Gitea Server-URL:")).size(11.0));
|
||||
ui.add(egui::TextEdit::singleline(&mut state.updater_server_url_input).desired_width(380.0));
|
||||
ui.add(egui::TextEdit::singleline(&mut state.updater_server_url_input).desired_width(380.0_f32.min(ui.available_width())));
|
||||
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new(t!("Personal Access Token (optional für private Repos):")).size(11.0));
|
||||
ui.add(egui::TextEdit::singleline(&mut state.updater_token_input).password(true).desired_width(380.0));
|
||||
ui.add(egui::TextEdit::singleline(&mut state.updater_token_input).password(true).desired_width(380.0_f32.min(ui.available_width())));
|
||||
|
||||
ui.add_space(6.0);
|
||||
if ui.button(egui::RichText::new(t!("💾 Einstellungen speichern")).size(11.5)).clicked() {
|
||||
@@ -5497,9 +5568,10 @@ pub fn render_ui(
|
||||
|
||||
// 5. Nicht-wegklickbare Warnung bei Ladefehlern / Quarantäne (Block 1)
|
||||
if let Some(warning) = notebook.load_warnings.get(¬ebook.current_page().id) {
|
||||
let width = dialog_width(ctx, state, 420.0);
|
||||
dialog_window(t!("⚠ Datenintegritäts-Warnung"), state, true)
|
||||
.show(ctx, |ui| {
|
||||
ui.set_width(420.0);
|
||||
ui.set_width(width);
|
||||
touch_friendly_spacing(ui);
|
||||
ui.horizontal(|ui| {
|
||||
ui.colored_label(egui::Color32::from_rgb(255, 80, 80), "⚠");
|
||||
@@ -5568,8 +5640,9 @@ pub fn render_ui(
|
||||
// Notfall-/Sicherheits-Dialog bei Fehlschlag des Speicherns vor dem Beenden
|
||||
if let Some(err) = &state.shutdown_error {
|
||||
let mut dismiss = false;
|
||||
let width = dialog_width(ctx, state, 460.0);
|
||||
dialog_window(t!("⚠ Ungespeicherte Änderungen"), state, true).show(ctx, |ui| {
|
||||
ui.set_width(460.0);
|
||||
ui.set_width(width);
|
||||
touch_friendly_spacing(ui);
|
||||
ui.horizontal(|ui| {
|
||||
ui.colored_label(egui::Color32::from_rgb(255, 80, 80), "⚠");
|
||||
@@ -5751,6 +5824,30 @@ mod tests {
|
||||
assert_eq!(PEN_WIDTH_PRESETS[3].0, 1.2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_notebook_dialog_fits_beside_the_toolbar_on_a_phone() {
|
||||
// Phone in portrait (Issue #50): the dialog was wider than the screen
|
||||
let mut state = UiState::new();
|
||||
state.is_document_menu_open = true;
|
||||
state.available_notebooks = vec![NotebookSummary {
|
||||
id: "nb-1".to_string(),
|
||||
title: "Default notebook".to_string(),
|
||||
page_count: 1,
|
||||
modified_at: 100,
|
||||
status: crate::storage::NotebookStatus::Normal,
|
||||
error: None,
|
||||
}];
|
||||
state.current_notebook_id = "nb-1".to_string();
|
||||
let screen = egui::vec2(412.0, 900.0);
|
||||
let ctx = render_frames_sized(&mut state, &Notebook::new(), 8, screen);
|
||||
let dialog = ctx
|
||||
.memory(|m| m.area_rect(egui::Id::new(t!("Dokumenten-Manager"))))
|
||||
.unwrap();
|
||||
let toolbar = state.toolbar_rect.unwrap();
|
||||
assert!(dialog.left() >= toolbar.right(), "{dialog:?} {toolbar:?}");
|
||||
assert!(dialog.right() <= screen.x, "{dialog:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_document_manager_ui_actions() {
|
||||
let mut state = UiState::new();
|
||||
|
||||
@@ -272,6 +272,22 @@ pub fn is_newer_version(remote_tag: &str, current_tag: &str, include_prereleases
|
||||
}
|
||||
}
|
||||
|
||||
/// Version in a release file name (`ink-notes_<version>_<arch>.<ext>`).
|
||||
pub fn asset_version(asset_name: &str) -> Option<&str> {
|
||||
let rest = asset_name.strip_prefix("ink-notes_")?;
|
||||
let (version, _) = rest.split_once('_')?;
|
||||
parse_semver(version).map(|_| version)
|
||||
}
|
||||
|
||||
/// Is the package named `asset_name` newer than the running version `current`?
|
||||
///
|
||||
/// The signature only covers file names and hashes, not the release they are offered in. A
|
||||
/// server could therefore present an old, correctly signed package under a new version
|
||||
/// number. The version in the signed file name closes that gap.
|
||||
pub fn asset_is_upgrade(asset_name: &str, current: &str) -> bool {
|
||||
asset_version(asset_name).is_some_and(|version| is_newer_version(version, current, true))
|
||||
}
|
||||
|
||||
/// Berechnet den hex-kodierten SHA-256 Hash einer Datei.
|
||||
pub fn compute_sha256(path: &Path) -> Result<String, String> {
|
||||
let mut file = std::fs::File::open(path).map_err(|e| {
|
||||
@@ -712,6 +728,14 @@ impl UpdaterController {
|
||||
}
|
||||
};
|
||||
|
||||
if !asset_is_upgrade(&safe_asset_name, env!("CARGO_PKG_VERSION")) {
|
||||
self.status = UpdaterStatus::Error(t!(
|
||||
"'{asset_name}' ist keine neuere Version und wird nicht installiert.",
|
||||
asset_name = safe_asset_name
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
self.status = UpdaterStatus::Downloading {
|
||||
asset_name: safe_asset_name.clone(),
|
||||
percent: 0.0,
|
||||
@@ -1036,6 +1060,23 @@ mod tests {
|
||||
assert!(is_newer_version("1.0.0-rc1", "0.9.0", true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_only_packages_named_as_newer_are_upgrades() {
|
||||
assert_eq!(asset_version("ink-notes_0.46.0_amd64.deb"), Some("0.46.0"));
|
||||
assert_eq!(
|
||||
asset_version("ink-notes_0.46.0_x86_64.AppImage"),
|
||||
Some("0.46.0")
|
||||
);
|
||||
assert_eq!(asset_version("SHA256SUMS.txt"), None);
|
||||
assert_eq!(asset_version("ink-notes_latest_amd64.deb"), None);
|
||||
|
||||
assert!(asset_is_upgrade("ink-notes_0.46.0_arm64.apk", "0.45.0"));
|
||||
// An old release offered again under a new tag
|
||||
assert!(!asset_is_upgrade("ink-notes_0.30.0_amd64.deb", "0.45.0"));
|
||||
assert!(!asset_is_upgrade("ink-notes_0.45.0_x86_64.exe", "0.45.0"));
|
||||
assert!(!asset_is_upgrade("something_else.deb", "0.45.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_asset_name() {
|
||||
assert_eq!(
|
||||
|
||||
Vendored
+8
@@ -18,6 +18,14 @@ docs of the original crate were removed.
|
||||
timestamp of each touch event (Started, Moved, Ended), so ink-notes measures the swipe speed
|
||||
for gliding with the real event times and not with the time it got around to handling them
|
||||
(Issue #28).
|
||||
- `src/platform_impl/linux/wayland/event_loop/mod.rs`: every read from the Wayland socket wakes
|
||||
the app (`AboutToWait`), also when the events belong to another queue on the same connection.
|
||||
octotablet's pen events live on such a queue and only `dispatch_pending` it, so before, pen
|
||||
strokes waited until something else woke the loop (Issue #53).
|
||||
- `src/platform_impl/linux/wayland/seat/touch/mod.rs`: a touch lift (`wl_touch.up`) is handed
|
||||
on right away instead of waiting for the next `wl_touch.frame`. On a GNOME laptop the lift
|
||||
of a swipe only arrived with the next touch, which fits a frame sent before the lift: the page did not glide after a
|
||||
swipe (Issue #28), and a ruler moved with the finger followed the hovering pen (Issue #55).
|
||||
- `Cargo.toml`: `[lints.rust] warnings = "allow"`, because a path dependency is not built with
|
||||
capped lints like a crates.io dependency.
|
||||
|
||||
|
||||
@@ -105,10 +105,11 @@ impl<T: 'static> EventLoop<T> {
|
||||
let wayland_dispatcher =
|
||||
calloop::Dispatcher::new(wayland_source, |_, queue, winit_state: &mut WinitState| {
|
||||
let result = queue.dispatch_pending(winit_state);
|
||||
if result.is_ok()
|
||||
&& (!winit_state.events_sink.is_empty()
|
||||
|| !winit_state.window_compositor_updates.is_empty())
|
||||
{
|
||||
// ink-notes patch: wake the app for every read from the socket, not only for
|
||||
// winit's own events. Events for other queues on the same connection (pen
|
||||
// input from octotablet's queue) would otherwise wait until something else
|
||||
// wakes the loop, so pen strokes appeared late or not at all (Issue #53).
|
||||
if result.is_ok() {
|
||||
winit_state.dispatched_events = true;
|
||||
}
|
||||
result
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
|
||||
use sctk::reexports::client::protocol::wl_seat::WlSeat;
|
||||
use sctk::reexports::client::protocol::wl_surface::WlSurface;
|
||||
use sctk::reexports::client::protocol::wl_touch::WlTouch;
|
||||
use sctk::reexports::client::{Connection, Proxy, QueueHandle};
|
||||
use sctk::reexports::client::protocol::wl_touch::{self, WlTouch};
|
||||
use sctk::reexports::client::{Connection, Dispatch, Proxy, QueueHandle};
|
||||
use sctk::reexports::csd_frame::FrameClick;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use sctk::compositor::SurfaceData;
|
||||
use sctk::seat::touch::{TouchData, TouchHandler};
|
||||
use sctk::seat::SeatState;
|
||||
|
||||
use crate::dpi::LogicalPosition;
|
||||
use crate::event::{Touch, TouchPhase, WindowEvent};
|
||||
@@ -286,4 +287,35 @@ impl TouchDataExt for WlTouch {
|
||||
}
|
||||
}
|
||||
|
||||
sctk::delegate_touch!(WinitState);
|
||||
// ink-notes patch: sctk holds every touch event back until the compositor sends the
|
||||
// `wl_touch.frame` that closes its group. On a GNOME laptop the lift of a swipe only arrived
|
||||
// together with the next touch, which fits a frame sent before the matching `up`: the page could
|
||||
// not glide (Issue #28) and a ruler moved with the finger followed the hovering pen (Issue #55).
|
||||
// A lift is now handed on right away, after the events that came before it.
|
||||
impl Dispatch<WlTouch, TouchData> for WinitState {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
touch: &WlTouch,
|
||||
event: wl_touch::Event,
|
||||
data: &TouchData,
|
||||
conn: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_touch::Event::Up { serial, time, id } = event {
|
||||
// Hand on what sctk still holds, then the lift itself
|
||||
<SeatState as Dispatch<WlTouch, TouchData, Self>>::event(
|
||||
state,
|
||||
touch,
|
||||
wl_touch::Event::Frame,
|
||||
data,
|
||||
conn,
|
||||
qh,
|
||||
);
|
||||
state.up(conn, qh, touch, serial, time, id);
|
||||
} else {
|
||||
<SeatState as Dispatch<WlTouch, TouchData, Self>>::event(
|
||||
state, touch, event, data, conn, qh,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user