Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5d9d72e79 | ||
|
|
955eaa4095 | ||
|
|
330c29193f | ||
|
|
dd515787ec | ||
|
|
b11fb2d762 | ||
|
|
e8a7675f8d | ||
|
|
0992b9bc0e | ||
|
|
78554c77ee | ||
|
|
1e34c01ad2 |
No files matched your search
Generated
+1
-1
@@ -1393,7 +1393,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ink-notes"
|
name = "ink-notes"
|
||||||
version = "0.33.0"
|
version = "0.35.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ab_glyph",
|
"ab_glyph",
|
||||||
"arboard",
|
"arboard",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ink-notes"
|
name = "ink-notes"
|
||||||
version = "0.33.0"
|
version = "0.35.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
||||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||||
|
|||||||
+4
-3
@@ -27,7 +27,7 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
|
|||||||
- **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.
|
- **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).
|
- **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.
|
- **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, in vier Größen (wird gemerkt). Die Stiftrückseite radiert ebenfalls.
|
- **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.
|
||||||
- **Virtuelles Lineal** (`L`): verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden gerade.
|
- **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.
|
- **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.
|
- **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.
|
||||||
@@ -152,8 +152,9 @@ Die CI prüft zusätzlich `cargo fmt --check` und `cargo clippy --all-targets --
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
src/
|
src/
|
||||||
├── main.rs # Event-Loop (winit), Eingaben von Maus, Touch und Stift, Ablaufsteuerung
|
├── main.rs # Einstieg für den Desktop, startet app::run
|
||||||
├── lib.rs # Bibliotheks-Exporte (für Tests)
|
├── app.rs # Event-Loop (winit), Eingaben von Maus, Touch und Stift, Ablaufsteuerung
|
||||||
|
├── lib.rs # Bibliotheks-Exporte (App und Module, auch für Tests)
|
||||||
├── canvas.rs # wgpu-Rendering: Papier, Muster, Bilder, Tinte, Nachbarseiten
|
├── canvas.rs # wgpu-Rendering: Papier, Muster, Bilder, Tinte, Nachbarseiten
|
||||||
├── shader.wgsl # Vertex- und Fragment-Shader
|
├── shader.wgsl # Vertex- und Fragment-Shader
|
||||||
├── viewport.rs # Kamera (Zoom, Verschieben), Seitenstapel der durchgehenden Ansicht
|
├── viewport.rs # Kamera (Zoom, Verschieben), Seitenstapel der durchgehenden Ansicht
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ The interface is available in English and German; ink-notes follows the system l
|
|||||||
- **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.
|
- **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).
|
- **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.
|
- **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, in four sizes (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; 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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
@@ -154,8 +154,9 @@ New code comments and commit messages are written in English; older code and his
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
src/
|
src/
|
||||||
├── main.rs # Event loop (winit), mouse, touch and pen input, control flow
|
├── main.rs # Desktop entry point, starts app::run
|
||||||
├── lib.rs # Library exports (for tests)
|
├── app.rs # Event loop (winit), mouse, touch and pen input, control flow
|
||||||
|
├── lib.rs # Library exports (app and modules, also for tests)
|
||||||
├── canvas.rs # wgpu rendering: paper, patterns, images, ink, neighboring pages
|
├── canvas.rs # wgpu rendering: paper, patterns, images, ink, neighboring pages
|
||||||
├── shader.wgsl # Vertex and fragment shaders
|
├── shader.wgsl # Vertex and fragment shaders
|
||||||
├── viewport.rs # Camera (zoom, pan), page stack of the continuous view, swipe momentum
|
├── viewport.rs # Camera (zoom, pan), page stack of the continuous view, swipe momentum
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ ink-notes ist ein einzelnes Programm mit einem Event-Loop (`winit`) im Haupt-Thr
|
|||||||
| Haupt-Thread | Eingaben, Datenmodell, Rendering (`wgpu`), Oberfläche (`egui`) |
|
| Haupt-Thread | Eingaben, Datenmodell, Rendering (`wgpu`), Oberfläche (`egui`) |
|
||||||
| Autosave | Seiten und Manifest auf die Festplatte schreiben (`autosave.rs`) |
|
| Autosave | Seiten und Manifest auf die Festplatte schreiben (`autosave.rs`) |
|
||||||
| PDF-Rendering | Importierte PDF-Seiten zu Texturen rendern (`pdf_doc.rs`, `hayro`) |
|
| PDF-Rendering | Importierte PDF-Seiten zu Texturen rendern (`pdf_doc.rs`, `hayro`) |
|
||||||
| Export | PDF/PNG/SVG-Export, damit die Oberfläche nicht hängt (`main.rs`) |
|
| Export | PDF/PNG/SVG-Export, damit die Oberfläche nicht hängt (`app.rs`) |
|
||||||
|
|
||||||
Stifteingaben kommen über `octotablet` (Tablet-Protokoll von Wayland bzw. X11), Maus und Touch über `winit`. Die Oberfläche (Werkzeugleiste, Menüs, Dialoge, Textbearbeitung) ist mit `egui` gebaut und wird im selben Render-Durchgang über das Blatt gezeichnet.
|
Stifteingaben kommen über `octotablet` (Tablet-Protokoll von Wayland bzw. X11), Maus und Touch über `winit`. Die Oberfläche (Werkzeugleiste, Menüs, Dialoge, Textbearbeitung) ist mit `egui` gebaut und wird im selben Render-Durchgang über das Blatt gezeichnet.
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@ Jede Seite hat einen eigenen Verlauf (`history.rs`): Hinzufügen, Entfernen, Ers
|
|||||||
2. **PDF** (nur im Notfall): importierte PDF-Seite als Bild, falls sie sich nicht als Vektor übernehmen lässt.
|
2. **PDF** (nur im Notfall): importierte PDF-Seite als Bild, falls sie sich nicht als Vektor übernehmen lässt.
|
||||||
3. **Images:** eingefügte Bilder (höchstens 300 dpi).
|
3. **Images:** eingefügte Bilder (höchstens 300 dpi).
|
||||||
4. **Text:** Textfelder als echter Text mit eingebetteter Schrift (durchsuchbar, kopierbar). Jedes Feld liegt in einem eigenen Koordinatensystem, das eine Transformationsmatrix auf seine Ecken abbildet.
|
4. **Text:** Textfelder als echter Text mit eingebetteter Schrift (durchsuchbar, kopierbar). Jedes Feld liegt in einem eigenen Koordinatensystem, das eine Transformationsmatrix auf seine Ecken abbildet.
|
||||||
5. **Ink:** Striche als gefüllte Polygone, Formen als Pfade. Halbtransparente Striche (Textmarker) bekommen einen Grafikzustand mit Deckkraft (`ca`/`CA`); printpdf kann solche Zustände nicht anlegen, deshalb trägt `add_alpha_states` sie nach dem Speichern mit `lopdf` in die Seitenressourcen ein. Jeder Strich ist ein einziger Pfad und deckt sich daher nicht selbst doppelt.
|
5. **Ink:** Striche als gefüllte Polygone, Formen als Pfade. Halbtransparente Striche (Textmarker) bekommen einen Grafikzustand mit Deckkraft (`ca`/`CA`); printpdf kann solche Zustände nicht anlegen, deshalb trägt `add_alpha_states` sie nach dem Speichern mit `lopdf` in die Seitenressourcen ein. Jeder Strich ist ein einziger Pfad und deckt sich daher nicht selbst doppelt. Der Umriss eines Strichs wird vor dem Export vereinfacht (`geometry::simplify_closed_outline`, höchstens 0,01 mm vom gezeichneten entfernt): Das Netz hat etwa 16 Punkte je Stiftpunkt, und mit allen dauerte der Export einer vollen Seite Sekunden. `INK_NOTES_EXACT_OUTLINES=1` exportiert wie bisher jeden Punkt.
|
||||||
|
|
||||||
Importierte PDF-Seiten werden anschließend als Form-XObject unter die Seite gelegt (`pdf_merge.rs`), bleiben also Vektorgrafik. Seitentitel werden zu Lesezeichen.
|
Importierte PDF-Seiten werden anschließend als Form-XObject unter die Seite gelegt (`pdf_merge.rs`), bleiben also Vektorgrafik. Seitentitel werden zu Lesezeichen.
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ ink-notes is a single program with an event loop (`winit`) on the main thread, p
|
|||||||
| Main thread | Input, data model, rendering (`wgpu`), interface (`egui`) |
|
| Main thread | Input, data model, rendering (`wgpu`), interface (`egui`) |
|
||||||
| Autosave | Writes pages and manifest to disk (`autosave.rs`) |
|
| Autosave | Writes pages and manifest to disk (`autosave.rs`) |
|
||||||
| PDF rendering | Renders imported PDF pages to textures (`pdf_doc.rs`, `hayro`) |
|
| PDF rendering | Renders imported PDF pages to textures (`pdf_doc.rs`, `hayro`) |
|
||||||
| Export | PDF/PNG/SVG export, so the interface does not freeze (`main.rs`) |
|
| Export | PDF/PNG/SVG export, so the interface does not freeze (`app.rs`) |
|
||||||
|
|
||||||
Pen input arrives via `octotablet` (the Wayland or X11 tablet protocol), mouse and touch via `winit`. The interface (toolbar, menus, dialogs, text editing) is built with `egui` and drawn over the page in the same render pass.
|
Pen input arrives via `octotablet` (the Wayland or X11 tablet protocol), mouse and touch via `winit`. The interface (toolbar, menus, dialogs, text editing) is built with `egui` and drawn over the page in the same render pass.
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@ Each page has its own history (`history.rs`): adding, removing, replacing and mo
|
|||||||
2. **PDF** (fallback only): imported PDF page as an image, if it cannot be taken over as vectors.
|
2. **PDF** (fallback only): imported PDF page as an image, if it cannot be taken over as vectors.
|
||||||
3. **Images:** inserted images (at most 300 dpi).
|
3. **Images:** inserted images (at most 300 dpi).
|
||||||
4. **Text:** text boxes as real text with embedded font (searchable, copyable). Each box lives in its own coordinate system that a transformation matrix maps onto its corners.
|
4. **Text:** text boxes as real text with embedded font (searchable, copyable). Each box lives in its own coordinate system that a transformation matrix maps onto its corners.
|
||||||
5. **Ink:** strokes as filled polygons, shapes as paths. Semi-transparent strokes (highlighter) get a graphics state with opacity (`ca`/`CA`); printpdf cannot create such states, so `add_alpha_states` adds them to the page resources with `lopdf` after saving. Each stroke is a single path and therefore does not cover itself twice.
|
5. **Ink:** strokes as filled polygons, shapes as paths. Semi-transparent strokes (highlighter) get a graphics state with opacity (`ca`/`CA`); printpdf cannot create such states, so `add_alpha_states` adds them to the page resources with `lopdf` after saving. Each stroke is a single path and therefore does not cover itself twice. The outline of a stroke is simplified before export (`geometry::simplify_closed_outline`, at most 0.01 mm away from the drawn one): the mesh has about 16 points per pen sample, and with all of them a full page took seconds to export. `INK_NOTES_EXACT_OUTLINES=1` exports every point as before.
|
||||||
|
|
||||||
Imported PDF pages are then placed under the page as a form XObject (`pdf_merge.rs`), so they stay vector graphics. Page titles become bookmarks.
|
Imported PDF pages are then placed under the page as a form XObject (`pdf_merge.rs`), so they stay vector graphics. Page titles become bookmarks.
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,26 @@
|
|||||||
</branding>
|
</branding>
|
||||||
<content_rating type="oars-1.1" />
|
<content_rating type="oars-1.1" />
|
||||||
<releases>
|
<releases>
|
||||||
|
<release version="0.35.0" date="2026-10-27">
|
||||||
|
<description>
|
||||||
|
<p>PDF and PNG export and copying a selection as an image are about six times faster, and exported PDFs are much smaller, because stroke outlines are simplified invisibly before export.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.34.2" date="2026-10-26">
|
||||||
|
<description>
|
||||||
|
<p>The swipe speed for gliding is measured with the touch screen's own event times, so a swipe still glides when the app handles the lift a little late.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.34.1" date="2026-10-25">
|
||||||
|
<description>
|
||||||
|
<p>A finger swipe is measured reliably again for gliding, also when the view touches the edge of the notebook. The fling diagnostics log more details.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.34.0" date="2026-10-23">
|
||||||
|
<description>
|
||||||
|
<p>The eraser size is set in a menu like the pens, with presets and a slider. Corners of snapped shapes stay pointed. Slides get flat tiles in the page overview.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
<release version="0.33.0" date="2026-10-21">
|
<release version="0.33.0" date="2026-10-21">
|
||||||
<description>
|
<description>
|
||||||
<p>The hand is ignored while the pen is near the screen, also on the title bar. The eraser comes in four sizes. The page overview shows imported PDF pages. Snapped triangles and other shapes have the same width on every side. A selection made with the pen's select button can be moved with the pen.</p>
|
<p>The hand is ignored while the pen is near the screen, also on the title bar. The eraser comes in four sizes. The page overview shows imported PDF pages. Snapped triangles and other shapes have the same width on every side. A selection made with the pen's select button can be moved with the pen.</p>
|
||||||
|
|||||||
@@ -84,6 +84,26 @@
|
|||||||
</branding>
|
</branding>
|
||||||
<content_rating type="oars-1.1" />
|
<content_rating type="oars-1.1" />
|
||||||
<releases>
|
<releases>
|
||||||
|
<release version="0.35.0" date="2026-10-27">
|
||||||
|
<description>
|
||||||
|
<p>PDF and PNG export and copying a selection as an image are about six times faster, and exported PDFs are much smaller, because stroke outlines are simplified invisibly before export.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.34.2" date="2026-10-26">
|
||||||
|
<description>
|
||||||
|
<p>The swipe speed for gliding is measured with the touch screen's own event times, so a swipe still glides when the app handles the lift a little late.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.34.1" date="2026-10-25">
|
||||||
|
<description>
|
||||||
|
<p>A finger swipe is measured reliably again for gliding, also when the view touches the edge of the notebook. The fling diagnostics log more details.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.34.0" date="2026-10-23">
|
||||||
|
<description>
|
||||||
|
<p>The eraser size is set in a menu like the pens, with presets and a slider. Corners of snapped shapes stay pointed. Slides get flat tiles in the page overview.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
<release version="0.33.0" date="2026-10-21">
|
<release version="0.33.0" date="2026-10-21">
|
||||||
<description>
|
<description>
|
||||||
<p>The hand is ignored while the pen is near the screen, also on the title bar. The eraser comes in four sizes. The page overview shows imported PDF pages. Snapped triangles and other shapes have the same width on every side. A selection made with the pen's select button can be moved with the pen.</p>
|
<p>The hand is ignored while the pen is near the screen, also on the title bar. The eraser comes in four sizes. The page overview shows imported PDF pages. Snapped triangles and other shapes have the same width on every side. A selection made with the pen's select button can be moved with the pen.</p>
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
Notebook app for pen, touch and mouse on Linux (tested on Zorin OS 18 / Ubuntu 24.04).
|
Notebook app for pen, touch and mouse on Linux (tested on Zorin OS 18 / Ubuntu 24.04).
|
||||||
|
|
||||||
### Highlights:
|
### Highlights:
|
||||||
|
- **Faster export**: PDF and PNG export and copying a selection as an image are about six times faster and PDFs are up to eight times smaller, because stroke outlines are simplified before export (at most 0.01 mm off; `INK_NOTES_EXACT_OUTLINES=1` exports every point as before)
|
||||||
|
- **Fling timing**: The swipe speed for gliding is measured with the touch screen's own event times (Wayland), so a fast swipe glides even when the lift is handled late; the fling diagnostics also report slow event handling
|
||||||
|
- **Fling fix**: A finger swipe is no longer lost when the view touches the edge of the notebook, so the page glides on after a fast swipe; the fling diagnostics (`INK_NOTES_DEBUG_FLING=1`) log moves, distance and timing
|
||||||
|
- **Feedback round**: The eraser size is set in a menu like the pens (four presets and a slider); corners of snapped triangles and polygons stay pointed; slides (landscape PDFs) get flat tiles in the page overview without an empty strip
|
||||||
- **Palm rejection & more feedback**: A hand resting on the screen while the pen is near is ignored (also on the title bar, no more accidental paste menu or window moving); the eraser has four sizes (button below the eraser modes, remembered); the page overview shows imported PDF pages; snapped triangles, rectangles and polygons have the same width on every side; a selection made with the pen's select button can be moved with the bare pen
|
- **Palm rejection & more feedback**: A hand resting on the screen while the pen is near is ignored (also on the title bar, no more accidental paste menu or window moving); the eraser has four sizes (button below the eraser modes, remembered); the page overview shows imported PDF pages; snapped triangles, rectangles and polygons have the same width on every side; a selection made with the pen's select button can be moved with the bare pen
|
||||||
- **Feedback round**: The endless canvas starts at the top left and only grows to the right and down; new text fields start short and grow with the text; toolbar buttons are larger again on small screens; on Wayland the title bar buttons (close, minimize, maximize) and moving the window work with a finger
|
- **Feedback round**: The endless canvas starts at the top left and only grows to the right and down; new text fields start short and grow with the text; toolbar buttons are larger again on small screens; on Wayland the title bar buttons (close, minimize, maximize) and moving the window work with a finger
|
||||||
- **View only**: 👁 in the toolbar hides the tools – pen, finger and mouse only scroll and zoom (the pen glides on like a finger), nothing can be changed by accident; tap 👁 again to write
|
- **View only**: 👁 in the toolbar hides the tools – pen, finger and mouse only scroll and zoom (the pen glides on like a finger), nothing can be changed by accident; tap 👁 again to write
|
||||||
|
|||||||
+4352
File diff suppressed because it is too large.
Load diff
+30
-15
@@ -1222,21 +1222,25 @@ fn render_stroke_polygon(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
|
|||||||
|
|
||||||
// Geschlossener Pfad um den Ribbon-Rand:
|
// Geschlossener Pfad um den Ribbon-Rand:
|
||||||
// Left[0] -> Left[1] -> ... -> Left[n-1] -> Right[n-1] -> Right[n-2] -> ... -> Right[0]
|
// Left[0] -> Left[1] -> ... -> Left[n-1] -> Right[n-1] -> Right[n-2] -> ... -> Right[0]
|
||||||
let mut ring = Vec::with_capacity(n * 2);
|
let outline: Vec<glam::Vec2> = (0..n)
|
||||||
|
.map(|i| stroke.vertices[i * 2].position)
|
||||||
// 1. Linke Hüllkurve vorwärts
|
.chain((0..n).rev().map(|i| stroke.vertices[i * 2 + 1].position))
|
||||||
for i in 0..n {
|
.map(glam::Vec2::from)
|
||||||
let v = stroke.vertices[i * 2];
|
.collect();
|
||||||
let (x, y) = page_to_pdf_mm(v.position[0], v.position[1]);
|
// The mesh has about 16 points per pen sample, far more than the eye (or a printer) can
|
||||||
ring.push((Point::new(Mm(x), Mm(y)), false));
|
// tell apart; fewer points make the PDF, PNG export and copying as an image much faster
|
||||||
}
|
let outline = if exact_outlines() {
|
||||||
|
outline
|
||||||
// 2. Rechte Hüllkurve rückwärts
|
} else {
|
||||||
for i in (0..n).rev() {
|
crate::geometry::simplify_closed_outline(&outline, OUTLINE_TOLERANCE)
|
||||||
let v = stroke.vertices[i * 2 + 1];
|
};
|
||||||
let (x, y) = page_to_pdf_mm(v.position[0], v.position[1]);
|
let ring: Vec<(Point, bool)> = outline
|
||||||
ring.push((Point::new(Mm(x), Mm(y)), false));
|
.iter()
|
||||||
}
|
.map(|p| {
|
||||||
|
let (x, y) = page_to_pdf_mm(p.x, p.y);
|
||||||
|
(Point::new(Mm(x), Mm(y)), false)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
let fill_color = Color::Rgb(Rgb::new(
|
let fill_color = Color::Rgb(Rgb::new(
|
||||||
stroke.color[0],
|
stroke.color[0],
|
||||||
@@ -1254,6 +1258,17 @@ fn render_stroke_polygon(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
|
|||||||
layer.add_polygon(poly);
|
layer.add_polygon(poly);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Largest deviation of an exported stroke outline from the drawn one, in page units
|
||||||
|
/// (0.01 mm: a tenth of a pixel at 200 dpi).
|
||||||
|
pub const OUTLINE_TOLERANCE: f32 = 0.1;
|
||||||
|
|
||||||
|
/// `INK_NOTES_EXACT_OUTLINES=1` exports every point of the stroke outlines, as before this change
|
||||||
|
/// (fallback in case the simplified outlines ever look wrong).
|
||||||
|
fn exact_outlines() -> bool {
|
||||||
|
static EXACT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||||
|
*EXACT.get_or_init(|| std::env::var_os("INK_NOTES_EXACT_OUTLINES").is_some_and(|v| v != "0"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Strategie B: Kubische Bézier-Kurven aus den Abtastpunkten via printpdf::Line.
|
/// Strategie B: Kubische Bézier-Kurven aus den Abtastpunkten via printpdf::Line.
|
||||||
fn render_stroke_bezier(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
|
fn render_stroke_bezier(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
|
||||||
if stroke.points.is_empty() {
|
if stroke.points.is_empty() {
|
||||||
|
|||||||
+132
-2
@@ -352,6 +352,63 @@ fn rdp_recursive(points: &[Vec2], epsilon: f32) -> Vec<Vec2> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Simplifies a closed outline (the last point connects back to the first) so that no point
|
||||||
|
/// of the original lies farther than `tolerance` from the result. Unlike
|
||||||
|
/// [`ramer_douglas_peucker`], it measures the distance to the segment, not to the infinite
|
||||||
|
/// line, so stroke tips that turn back are kept. Works without recursion, so long strokes
|
||||||
|
/// cannot overflow the stack.
|
||||||
|
pub fn simplify_closed_outline(points: &[Vec2], tolerance: f32) -> Vec<Vec2> {
|
||||||
|
if points.len() <= 3 {
|
||||||
|
return points.to_vec();
|
||||||
|
}
|
||||||
|
// Two chains between the first point and the point farthest from it: the start of an
|
||||||
|
// outline often lies on top of its end (round cap), which would leave no chord to measure
|
||||||
|
let far = (1..points.len())
|
||||||
|
.max_by(|&i, &j| {
|
||||||
|
points[i]
|
||||||
|
.distance_squared(points[0])
|
||||||
|
.total_cmp(&points[j].distance_squared(points[0]))
|
||||||
|
})
|
||||||
|
.unwrap_or(1);
|
||||||
|
let mut keep = vec![false; points.len()];
|
||||||
|
keep[0] = true;
|
||||||
|
keep[far] = true;
|
||||||
|
let mut stack = vec![(0, far), (far, points.len())];
|
||||||
|
while let Some((start, end)) = stack.pop() {
|
||||||
|
// `end == points.len()` closes the ring back to the first point
|
||||||
|
let (a, b) = (points[start], points[end % points.len()]);
|
||||||
|
let mut worst = (0.0_f32, start);
|
||||||
|
for (i, &p) in points.iter().enumerate().take(end).skip(start + 1) {
|
||||||
|
let d = point_to_segment_dist(p, a, b);
|
||||||
|
if d > worst.0 {
|
||||||
|
worst = (d, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if worst.0 > tolerance {
|
||||||
|
keep[worst.1] = true;
|
||||||
|
stack.push((start, worst.1));
|
||||||
|
stack.push((worst.1, end));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
points
|
||||||
|
.iter()
|
||||||
|
.zip(keep)
|
||||||
|
.filter_map(|(&p, keep)| keep.then_some(p))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abstand eines Punktes `p` zur Strecke von `a` nach `b`.
|
||||||
|
#[inline]
|
||||||
|
pub fn point_to_segment_dist(p: Vec2, a: Vec2, b: Vec2) -> f32 {
|
||||||
|
let ab = b - a;
|
||||||
|
let len_sq = ab.length_squared();
|
||||||
|
if len_sq < 1e-12 {
|
||||||
|
return p.distance(a);
|
||||||
|
}
|
||||||
|
let t = ((p - a).dot(ab) / len_sq).clamp(0.0, 1.0);
|
||||||
|
p.distance(a + ab * t)
|
||||||
|
}
|
||||||
|
|
||||||
/// Abstand eines Punktes `p` zur Geraden durch `a` und `b`.
|
/// Abstand eines Punktes `p` zur Geraden durch `a` und `b`.
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn point_to_line_dist(p: Vec2, a: Vec2, b: Vec2) -> f32 {
|
pub fn point_to_line_dist(p: Vec2, a: Vec2, b: Vec2) -> f32 {
|
||||||
@@ -1801,8 +1858,9 @@ pub fn contour_rms(points: &[Vec2], shape: &RecognizedShape) -> Option<f32> {
|
|||||||
Some((sum_sq / points.len() as f32).sqrt())
|
Some((sum_sq / points.len() as f32).sqrt())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Longest miter of a polygon corner, in half stroke widths; sharper corners are bevelled.
|
/// Longest miter of a polygon corner, in half stroke widths: corners down to about 14° stay
|
||||||
const MITER_LIMIT: f32 = 2.0;
|
/// pointed, only sharper ones are bevelled (Issue #41).
|
||||||
|
const MITER_LIMIT: f32 = 8.0;
|
||||||
|
|
||||||
/// Vertex pairs (left, right of the outline direction) of a triangle strip at corner `curr`
|
/// Vertex pairs (left, right of the outline direction) of a triangle strip at corner `curr`
|
||||||
/// between the edges from `prev` and to `next` with left normals `n_prev` and `n_next`. Both
|
/// between the edges from `prev` and to `next` with left normals `n_prev` and `n_next`. Both
|
||||||
@@ -2326,6 +2384,78 @@ pub fn shape_to_stroke(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Largest distance of any point of `original` from the closed outline `simplified`.
|
||||||
|
fn outline_deviation(original: &[Vec2], simplified: &[Vec2]) -> f32 {
|
||||||
|
original
|
||||||
|
.iter()
|
||||||
|
.map(|&p| {
|
||||||
|
(0..simplified.len())
|
||||||
|
.map(|i| {
|
||||||
|
point_to_segment_dist(
|
||||||
|
p,
|
||||||
|
simplified[i],
|
||||||
|
simplified[(i + 1) % simplified.len()],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.fold(f32::INFINITY, f32::min)
|
||||||
|
})
|
||||||
|
.fold(0.0, f32::max)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_simplified_stroke_outline_stays_within_tolerance() {
|
||||||
|
// A handwritten loop like a letter, as the pen delivers it
|
||||||
|
let samples: Vec<StylusSample> = (0..30)
|
||||||
|
.map(|i| {
|
||||||
|
let t = i as f32 / 29.0 * std::f32::consts::TAU * 1.3;
|
||||||
|
StylusSample {
|
||||||
|
position: Vec2::new(500.0 + 12.0 * t.cos() + t * 4.0, 800.0 + 20.0 * t.sin()),
|
||||||
|
pressure: 0.4 + 0.3 * (t * 0.7).sin().abs(),
|
||||||
|
timestamp: i as f64 * 0.008,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let vertices = crate::storage::reconstruct_stroke_vertices(
|
||||||
|
&samples,
|
||||||
|
[0.0, 0.0, 0.0, 1.0],
|
||||||
|
5.0,
|
||||||
|
Default::default(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
let n = vertices.len() / 2;
|
||||||
|
let outline: Vec<Vec2> = (0..n)
|
||||||
|
.map(|i| vertices[i * 2].position)
|
||||||
|
.chain((0..n).rev().map(|i| vertices[i * 2 + 1].position))
|
||||||
|
.map(Vec2::from)
|
||||||
|
.collect();
|
||||||
|
let tolerance = 0.1;
|
||||||
|
let simplified = simplify_closed_outline(&outline, tolerance);
|
||||||
|
let deviation = outline_deviation(&outline, &simplified);
|
||||||
|
assert!(deviation <= tolerance + 1e-4, "{deviation}");
|
||||||
|
assert!(
|
||||||
|
simplified.len() * 4 < outline.len(),
|
||||||
|
"{} -> {} points",
|
||||||
|
outline.len(),
|
||||||
|
simplified.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_simplified_outline_keeps_tips_that_turn_back() {
|
||||||
|
// Thin hairpin: out along y = 0 to x = 20, back along y = 0.05. Measured against the
|
||||||
|
// infinite line, the tip would vanish.
|
||||||
|
let mut outline: Vec<Vec2> = (0..=20).map(|x| Vec2::new(x as f32, 0.0)).collect();
|
||||||
|
outline.extend((0..20).rev().map(|x| Vec2::new(x as f32, 0.05)));
|
||||||
|
let simplified = simplify_closed_outline(&outline, 0.1);
|
||||||
|
let reach = simplified.iter().map(|p| p.x).fold(f32::MIN, f32::max);
|
||||||
|
assert_eq!(reach, 20.0, "{simplified:?}");
|
||||||
|
assert!(outline_deviation(&outline, &simplified) <= 0.1);
|
||||||
|
assert!(simplified.len() <= 4, "{simplified:?}");
|
||||||
|
// Tiny outlines stay as they are
|
||||||
|
let dot = [Vec2::ZERO, Vec2::X, Vec2::Y];
|
||||||
|
assert_eq!(simplify_closed_outline(&dot, 0.1), dot.to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_detect_line() {
|
fn test_detect_line() {
|
||||||
// Leicht zittrige diagonale Punktfolge
|
// Leicht zittrige diagonale Punktfolge
|
||||||
|
|||||||
+1
-1
@@ -156,7 +156,7 @@ mod tests {
|
|||||||
("file_picker.rs", include_str!("file_picker.rs")),
|
("file_picker.rs", include_str!("file_picker.rs")),
|
||||||
("geometry.rs", include_str!("geometry.rs")),
|
("geometry.rs", include_str!("geometry.rs")),
|
||||||
("library.rs", include_str!("library.rs")),
|
("library.rs", include_str!("library.rs")),
|
||||||
("main.rs", include_str!("main.rs")),
|
("app.rs", include_str!("app.rs")),
|
||||||
("page_export.rs", include_str!("page_export.rs")),
|
("page_export.rs", include_str!("page_export.rs")),
|
||||||
("pdf_doc.rs", include_str!("pdf_doc.rs")),
|
("pdf_doc.rs", include_str!("pdf_doc.rs")),
|
||||||
("pdf_merge.rs", include_str!("pdf_merge.rs")),
|
("pdf_merge.rs", include_str!("pdf_merge.rs")),
|
||||||
|
|||||||
+5
-2
@@ -120,6 +120,7 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("Gitea Release-JSON konnte nicht verarbeitet werden: {e}", "Could not process the Gitea release JSON: {e}"),
|
("Gitea Release-JSON konnte nicht verarbeitet werden: {e}", "Could not process the Gitea release JSON: {e}"),
|
||||||
("Gitea Server-URL:", "Gitea server URL:"),
|
("Gitea Server-URL:", "Gitea server URL:"),
|
||||||
("Gitea-Server antwortete mit HTTP {code}: {body}", "Gitea server responded with HTTP {code}: {body}"),
|
("Gitea-Server antwortete mit HTTP {code}: {body}", "Gitea server responded with HTTP {code}: {body}"),
|
||||||
|
("GRÖSSE", "SIZE"),
|
||||||
("Graphit-Grau", "Graphite gray"),
|
("Graphit-Grau", "Graphite gray"),
|
||||||
("Größere Schrift (jetzt {size} pt)", "Larger font (now {size} pt)"),
|
("Größere Schrift (jetzt {size} pt)", "Larger font (now {size} pt)"),
|
||||||
("Grün", "Green"),
|
("Grün", "Green"),
|
||||||
@@ -256,9 +257,10 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("Quell-PDF ist verschlüsselt", "Source PDF is encrypted"),
|
("Quell-PDF ist verschlüsselt", "Source PDF is encrypted"),
|
||||||
("Radieren", "Erase"),
|
("Radieren", "Erase"),
|
||||||
("Radierer (E oder rechte Maustaste) – darunter: ganze Striche, Teilstrich, nur Textmarker und die Größe", "Eraser (E or right mouse button) – below: whole strokes, partial stroke, highlighter only and the size"),
|
("Radierer (E oder rechte Maustaste) – darunter: ganze Striche, Teilstrich, nur Textmarker und die Größe", "Eraser (E or right mouse button) – below: whole strokes, partial stroke, highlighter only and the size"),
|
||||||
|
("Radierer", "Eraser"),
|
||||||
("Radierer (Eraser)", "Eraser"),
|
("Radierer (Eraser)", "Eraser"),
|
||||||
("Radiergröße {size} mm", "Eraser size {size} mm"),
|
("Radiergröße {size} mm – tippen zum Ändern", "Eraser size {size} mm – tap to change"),
|
||||||
("Radiergröße {size} mm – tippen für die nächste Größe", "Eraser size {size} mm – tap for the next size"),
|
("Radiergröße {size} setzen", "Set eraser size to {size}"),
|
||||||
("Raute (Rhombus)", "Rhombus"),
|
("Raute (Rhombus)", "Rhombus"),
|
||||||
("Rechteck", "Rectangle"),
|
("Rechteck", "Rectangle"),
|
||||||
("Rechts", "Right"),
|
("Rechts", "Right"),
|
||||||
@@ -314,6 +316,7 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("Stift {number} ({width} mm)", "Pen {number} ({width} mm)"),
|
("Stift {number} ({width} mm)", "Pen {number} ({width} mm)"),
|
||||||
("Stift {number}", "Pen {number}"),
|
("Stift {number}", "Pen {number}"),
|
||||||
("Stift", "Pen"),
|
("Stift", "Pen"),
|
||||||
|
("STUFENLOSE GRÖSSE", "STEPLESS SIZE"),
|
||||||
("STUFENLOSE STÄRKE", "STEPLESS WIDTH"),
|
("STUFENLOSE STÄRKE", "STEPLESS WIDTH"),
|
||||||
("Stärke auf {width} setzen", "Set width to {width}"),
|
("Stärke auf {width} setzen", "Set width to {width}"),
|
||||||
("System", "System"),
|
("System", "System"),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! ink-notes: Latenzoptimierte Inking-App für Linux.
|
//! ink-notes: Latenzoptimierte Inking-App für Linux.
|
||||||
|
|
||||||
|
pub mod app;
|
||||||
pub mod assets;
|
pub mod assets;
|
||||||
pub mod autosave;
|
pub mod autosave;
|
||||||
pub mod background;
|
pub mod background;
|
||||||
|
|||||||
+3
-4252
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -2385,7 +2385,7 @@ mod tests {
|
|||||||
create_new_notebook(temp_dir.path(), "Neue Seiten").unwrap();
|
create_new_notebook(temp_dir.path(), "Neue Seiten").unwrap();
|
||||||
let mut autosave = AutosaveController::new(dir.clone(), Duration::from_secs(60));
|
let mut autosave = AutosaveController::new(dir.clone(), Duration::from_secs(60));
|
||||||
|
|
||||||
// Wie Taste N in main.rs: neue Seite anhängen, Strich darauf, speichern
|
// Wie Taste N in app.rs: neue Seite anhängen, Strich darauf, speichern
|
||||||
notebook.next_page();
|
notebook.next_page();
|
||||||
let idx = notebook.current_page_index;
|
let idx = notebook.current_page_index;
|
||||||
notebook.pages[idx] = page_with_one_stroke(notebook.pages[idx].id, PageBackground::Lined);
|
notebook.pages[idx] = page_with_one_stroke(notebook.pages[idx].id, PageBackground::Lined);
|
||||||
|
|||||||
@@ -110,8 +110,10 @@ pub const PEN_WIDTH_PRESETS: [(f32, &str); 4] = [
|
|||||||
pub const HIGHLIGHTER_WIDTH_PRESETS: [(f32, &str); 3] =
|
pub const HIGHLIGHTER_WIDTH_PRESETS: [(f32, &str); 3] =
|
||||||
[(4.0, "4.0 mm"), (6.0, "6.0 mm"), (8.0, "8.0 mm")];
|
[(4.0, "4.0 mm"), (6.0, "6.0 mm"), (8.0, "8.0 mm")];
|
||||||
|
|
||||||
/// Eraser radii in mm, small to large; the toolbar button cycles through them (Issue #38).
|
/// Eraser size presets as radius in mm, small to large (Issue #38).
|
||||||
pub const ERASER_RADII_MM: [f32; 4] = [1.5, 3.0, 6.0, 12.0];
|
pub const ERASER_RADII_MM: [f32; 4] = [1.5, 3.0, 6.0, 12.0];
|
||||||
|
/// Range of the eraser size slider (diameter in mm).
|
||||||
|
pub const ERASER_DIAMETER_RANGE_MM: (f32, f32) = (2.0, 30.0);
|
||||||
|
|
||||||
/// Abbildung zwischen Seitenkoordinaten und logischen Bildschirmpunkten (egui), vom
|
/// Abbildung zwischen Seitenkoordinaten und logischen Bildschirmpunkten (egui), vom
|
||||||
/// Hauptprogramm je Bild aus Ansicht und Zoom gesetzt.
|
/// Hauptprogramm je Bild aus Ansicht und Zoom gesetzt.
|
||||||
@@ -376,6 +378,8 @@ pub struct UiState {
|
|||||||
pub toolbar_rect: Option<egui::Rect>,
|
pub toolbar_rect: Option<egui::Rect>,
|
||||||
/// Öffnungszustand des Einstellungs-Popovers für den Stift
|
/// Öffnungszustand des Einstellungs-Popovers für den Stift
|
||||||
pub is_pen_menu_open: bool,
|
pub is_pen_menu_open: bool,
|
||||||
|
/// Eraser size menu next to the size button (Issue #38)
|
||||||
|
pub is_eraser_menu_open: bool,
|
||||||
/// Öffnungszustand des Einstellungs-Popovers für den Textmarker
|
/// Öffnungszustand des Einstellungs-Popovers für den Textmarker
|
||||||
pub is_highlighter_menu_open: bool,
|
pub is_highlighter_menu_open: bool,
|
||||||
/// Bildschirm-Rechteck des geöffneten Einstellungs-Popovers
|
/// Bildschirm-Rechteck des geöffneten Einstellungs-Popovers
|
||||||
@@ -514,6 +518,7 @@ impl Default for UiState {
|
|||||||
current_zoom: 1.0,
|
current_zoom: 1.0,
|
||||||
toolbar_rect: None,
|
toolbar_rect: None,
|
||||||
is_pen_menu_open: false,
|
is_pen_menu_open: false,
|
||||||
|
is_eraser_menu_open: false,
|
||||||
is_highlighter_menu_open: false,
|
is_highlighter_menu_open: false,
|
||||||
popover_rect: None,
|
popover_rect: None,
|
||||||
is_document_menu_open: false,
|
is_document_menu_open: false,
|
||||||
@@ -595,21 +600,14 @@ impl UiState {
|
|||||||
/// Abstand der Werkzeugleiste zum Fensterrand.
|
/// Abstand der Werkzeugleiste zum Fensterrand.
|
||||||
pub const SIDEBAR_MARGIN: f32 = 10.0;
|
pub const SIDEBAR_MARGIN: f32 = 10.0;
|
||||||
|
|
||||||
/// Index of the eraser size in [`ERASER_RADII_MM`] closest to the current radius.
|
/// Sets the eraser radius within the slider range (e.g. from the settings file).
|
||||||
pub fn eraser_size_index(&self) -> usize {
|
|
||||||
(0..ERASER_RADII_MM.len())
|
|
||||||
.min_by(|&a, &b| {
|
|
||||||
let da = (ERASER_RADII_MM[a] - self.eraser_radius_mm).abs();
|
|
||||||
let db = (ERASER_RADII_MM[b] - self.eraser_radius_mm).abs();
|
|
||||||
da.total_cmp(&db)
|
|
||||||
})
|
|
||||||
.unwrap_or(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the eraser radius to the closest available size (e.g. from the settings file).
|
|
||||||
pub fn set_eraser_radius_mm(&mut self, mm: f32) {
|
pub fn set_eraser_radius_mm(&mut self, mm: f32) {
|
||||||
self.eraser_radius_mm = mm;
|
let (min, max) = ERASER_DIAMETER_RANGE_MM;
|
||||||
self.eraser_radius_mm = ERASER_RADII_MM[self.eraser_size_index()];
|
self.eraser_radius_mm = if mm.is_finite() {
|
||||||
|
mm.clamp(min / 2.0, max / 2.0)
|
||||||
|
} else {
|
||||||
|
ERASER_RADII_MM[1]
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Eraser radius in page units.
|
/// Eraser radius in page units.
|
||||||
@@ -624,6 +622,7 @@ impl UiState {
|
|||||||
self.context_menu = None;
|
self.context_menu = None;
|
||||||
self.is_text_color_menu_open = false;
|
self.is_text_color_menu_open = false;
|
||||||
self.is_pen_menu_open = false;
|
self.is_pen_menu_open = false;
|
||||||
|
self.is_eraser_menu_open = false;
|
||||||
self.is_highlighter_menu_open = false;
|
self.is_highlighter_menu_open = false;
|
||||||
self.is_more_menu_open = false;
|
self.is_more_menu_open = false;
|
||||||
self.show_page_jump_popup = false;
|
self.show_page_jump_popup = false;
|
||||||
@@ -964,6 +963,61 @@ fn stroke_preview_button(
|
|||||||
response.on_hover_text(t!("Stärke auf {width} setzen", width = label_text))
|
response.on_hover_text(t!("Stärke auf {width} setzen", width = label_text))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Eraser size button of the eraser menu: a circle in proportion to the size and its diameter.
|
||||||
|
fn eraser_size_button(ui: &mut egui::Ui, is_active: bool, radius_mm: f32) -> egui::Response {
|
||||||
|
let (rect, response) = ui.allocate_exact_size(egui::vec2(66.0, 40.0), egui::Sense::click());
|
||||||
|
let label = format!("{} mm", format_mm(radius_mm * 2.0));
|
||||||
|
if ui.is_rect_visible(rect) {
|
||||||
|
let painter = ui.painter();
|
||||||
|
let (bg, border) = if is_active {
|
||||||
|
(
|
||||||
|
egui::Color32::from_rgba_premultiplied(50, 85, 150, 220),
|
||||||
|
egui::Color32::from_rgba_unmultiplied(120, 175, 255, 200),
|
||||||
|
)
|
||||||
|
} else if response.hovered() {
|
||||||
|
(
|
||||||
|
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 16),
|
||||||
|
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 25),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 8),
|
||||||
|
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 14),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
painter.rect_filled(rect, egui::Rounding::same(6.0), bg);
|
||||||
|
painter.rect_stroke(
|
||||||
|
rect,
|
||||||
|
egui::Rounding::same(6.0),
|
||||||
|
egui::Stroke::new(1.0_f32, border),
|
||||||
|
);
|
||||||
|
let text_color = if is_active {
|
||||||
|
egui::Color32::WHITE
|
||||||
|
} else {
|
||||||
|
egui::Color32::from_gray(190)
|
||||||
|
};
|
||||||
|
painter.circle_stroke(
|
||||||
|
egui::pos2(rect.center().x, rect.center().y - 5.0),
|
||||||
|
(radius_mm * 0.9).clamp(1.5, 11.0),
|
||||||
|
egui::Stroke::new(1.5_f32, text_color),
|
||||||
|
);
|
||||||
|
painter.text(
|
||||||
|
egui::pos2(rect.center().x, rect.max.y - 7.5),
|
||||||
|
egui::Align2::CENTER_CENTER,
|
||||||
|
&label,
|
||||||
|
egui::FontId::proportional(10.0),
|
||||||
|
text_color,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
response.on_hover_text(t!("Radiergröße {size} setzen", size = label))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Millimeters without trailing zeros: 3, 1.5, 2.25.
|
||||||
|
fn format_mm(mm: f32) -> String {
|
||||||
|
let text = format!("{:.2}", mm);
|
||||||
|
text.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// Inhalt eines Symbol-Knopfs der Werkzeugleiste.
|
/// Inhalt eines Symbol-Knopfs der Werkzeugleiste.
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
enum SidebarIcon<'a> {
|
enum SidebarIcon<'a> {
|
||||||
@@ -1379,8 +1433,36 @@ fn color_circle_swatch(
|
|||||||
|
|
||||||
/// Spalten der Seitenauswahl.
|
/// Spalten der Seitenauswahl.
|
||||||
const PAGE_PICKER_COLUMNS: usize = 4;
|
const PAGE_PICKER_COLUMNS: usize = 4;
|
||||||
/// Größe einer Kachel der Seitenauswahl: A4-Vorschau plus Zeile für die Seitenzahl.
|
/// Breite einer Kachel der Seitenauswahl; darunter kommt eine Zeile für die Seitenzahl.
|
||||||
const PAGE_TILE: egui::Vec2 = egui::vec2(66.0, 66.0 * PAGE_HEIGHT / PAGE_WIDTH + 18.0);
|
const PAGE_TILE_WIDTH: f32 = 66.0;
|
||||||
|
const PAGE_TILE_LABEL: f32 = 18.0;
|
||||||
|
|
||||||
|
/// Size of a page picker tile: as high as the tallest page of the notebook needs, so that
|
||||||
|
/// slides (landscape PDFs) get flat tiles without an empty strip (Issue #40).
|
||||||
|
fn page_tile_size(notebook: &Notebook) -> egui::Vec2 {
|
||||||
|
let aspect = notebook
|
||||||
|
.pages
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.size.y / p.size.x.max(1.0))
|
||||||
|
.fold(0.0_f32, f32::max);
|
||||||
|
let aspect = if aspect > 0.0 {
|
||||||
|
aspect.clamp(0.3, 2.0)
|
||||||
|
} else {
|
||||||
|
PAGE_HEIGHT / PAGE_WIDTH
|
||||||
|
};
|
||||||
|
egui::vec2(PAGE_TILE_WIDTH, PAGE_TILE_WIDTH * aspect + PAGE_TILE_LABEL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rectangle of a page of `page_size` in a thumbnail area: as large as possible, centered
|
||||||
|
/// horizontally and at the top.
|
||||||
|
fn thumbnail_sheet(page_size: glam::Vec2, rect: egui::Rect) -> egui::Rect {
|
||||||
|
let scale = (rect.width() / page_size.x).min(rect.height() / page_size.y);
|
||||||
|
let size = egui::vec2(page_size.x, page_size.y) * scale;
|
||||||
|
egui::Rect::from_min_size(
|
||||||
|
rect.min + egui::vec2((rect.width() - size.x) / 2.0, 0.0),
|
||||||
|
size,
|
||||||
|
)
|
||||||
|
}
|
||||||
/// Höchstzahl gezeichneter Punkte pro Vorschau (dünnt volle Seiten aus).
|
/// Höchstzahl gezeichneter Punkte pro Vorschau (dünnt volle Seiten aus).
|
||||||
const THUMBNAIL_POINT_BUDGET: usize = 1500;
|
const THUMBNAIL_POINT_BUDGET: usize = 1500;
|
||||||
|
|
||||||
@@ -1396,12 +1478,8 @@ pub fn page_thumbnail_shapes(
|
|||||||
adapt_ink: bool,
|
adapt_ink: bool,
|
||||||
pdf_texture: Option<egui::TextureId>,
|
pdf_texture: Option<egui::TextureId>,
|
||||||
) -> Vec<egui::Shape> {
|
) -> Vec<egui::Shape> {
|
||||||
let scale = (rect.width() / page.size.x).min(rect.height() / page.size.y);
|
let sheet = thumbnail_sheet(page.size, rect);
|
||||||
let size = egui::vec2(page.size.x, page.size.y) * scale;
|
let scale = sheet.width() / page.size.x;
|
||||||
let sheet = egui::Rect::from_min_size(
|
|
||||||
rect.min + egui::vec2((rect.width() - size.x) / 2.0, 0.0),
|
|
||||||
size,
|
|
||||||
);
|
|
||||||
let mut shapes = vec![egui::Shape::rect_filled(
|
let mut shapes = vec![egui::Shape::rect_filled(
|
||||||
sheet,
|
sheet,
|
||||||
egui::Rounding::same(3.0),
|
egui::Rounding::same(3.0),
|
||||||
@@ -1632,14 +1710,13 @@ fn page_thumbnail(
|
|||||||
page: &Page,
|
page: &Page,
|
||||||
number: usize,
|
number: usize,
|
||||||
is_current: bool,
|
is_current: bool,
|
||||||
|
tile: egui::Vec2,
|
||||||
wanted_pdfs: &mut Vec<crate::document::PdfBackground>,
|
wanted_pdfs: &mut Vec<crate::document::PdfBackground>,
|
||||||
) -> egui::Response {
|
) -> egui::Response {
|
||||||
let (rect, response) = ui.allocate_exact_size(PAGE_TILE, egui::Sense::click());
|
let (rect, response) = ui.allocate_exact_size(tile, egui::Sense::click());
|
||||||
if ui.is_rect_visible(rect) {
|
if ui.is_rect_visible(rect) {
|
||||||
let thumb = egui::Rect::from_min_size(
|
let thumb =
|
||||||
rect.min,
|
egui::Rect::from_min_size(rect.min, egui::vec2(tile.x, tile.y - PAGE_TILE_LABEL));
|
||||||
egui::vec2(PAGE_TILE.x, PAGE_TILE.x * PAGE_HEIGHT / PAGE_WIDTH),
|
|
||||||
);
|
|
||||||
let pdf_texture = match &page.pdf_background {
|
let pdf_texture = match &page.pdf_background {
|
||||||
Some(pdf) => match state.pdf_thumbnails.get(&pdf.key()) {
|
Some(pdf) => match state.pdf_thumbnails.get(&pdf.key()) {
|
||||||
Some(texture) => texture.as_ref().map(|t| t.0.id()),
|
Some(texture) => texture.as_ref().map(|t| t.0.id()),
|
||||||
@@ -1658,9 +1735,11 @@ fn page_thumbnail(
|
|||||||
state.adapt_ink_colors,
|
state.adapt_ink_colors,
|
||||||
pdf_texture,
|
pdf_texture,
|
||||||
));
|
));
|
||||||
|
// Frame and marks follow the page itself, not the whole tile
|
||||||
|
let sheet = thumbnail_sheet(page.size, thumb);
|
||||||
if !page.is_loaded {
|
if !page.is_loaded {
|
||||||
painter.text(
|
painter.text(
|
||||||
thumb.center(),
|
sheet.center(),
|
||||||
egui::Align2::CENTER_CENTER,
|
egui::Align2::CENTER_CENTER,
|
||||||
"…",
|
"…",
|
||||||
egui::FontId::proportional(16.0),
|
egui::FontId::proportional(16.0),
|
||||||
@@ -1669,7 +1748,7 @@ fn page_thumbnail(
|
|||||||
}
|
}
|
||||||
// Lesezeichen-Ecke für benannte Seiten
|
// Lesezeichen-Ecke für benannte Seiten
|
||||||
if page.title.is_some() {
|
if page.title.is_some() {
|
||||||
let corner = thumb.right_top();
|
let corner = sheet.right_top();
|
||||||
painter.add(egui::Shape::convex_polygon(
|
painter.add(egui::Shape::convex_polygon(
|
||||||
vec![
|
vec![
|
||||||
corner + egui::vec2(-14.0, 0.0),
|
corner + egui::vec2(-14.0, 0.0),
|
||||||
@@ -1687,7 +1766,7 @@ fn page_thumbnail(
|
|||||||
} else {
|
} else {
|
||||||
egui::Stroke::new(1.0_f32, egui::Color32::from_white_alpha(60))
|
egui::Stroke::new(1.0_f32, egui::Color32::from_white_alpha(60))
|
||||||
};
|
};
|
||||||
painter.rect_stroke(thumb, egui::Rounding::same(3.0), border);
|
painter.rect_stroke(sheet, egui::Rounding::same(3.0), border);
|
||||||
painter.text(
|
painter.text(
|
||||||
egui::pos2(rect.center().x, rect.max.y - 8.0),
|
egui::pos2(rect.center().x, rect.max.y - 8.0),
|
||||||
egui::Align2::CENTER_CENTER,
|
egui::Align2::CENTER_CENTER,
|
||||||
@@ -3030,6 +3109,7 @@ pub fn render_ui(
|
|||||||
context_menu(ctx, state, &mut actions);
|
context_menu(ctx, state, &mut actions);
|
||||||
|
|
||||||
let mut pen_btn_rect = None;
|
let mut pen_btn_rect = None;
|
||||||
|
let mut eraser_btn_rect = None;
|
||||||
let mut high_btn_rect = None;
|
let mut high_btn_rect = None;
|
||||||
let mut text_color_btn_rect = None;
|
let mut text_color_btn_rect = None;
|
||||||
let mut more_btn_rect = None;
|
let mut more_btn_rect = None;
|
||||||
@@ -3430,31 +3510,27 @@ pub fn render_ui(
|
|||||||
}
|
}
|
||||||
used += swatch + spacing;
|
used += swatch + spacing;
|
||||||
}
|
}
|
||||||
// Eraser size: tapping cycles small → large (Issue #38)
|
// Eraser size: opens a menu like the pens (Issue #38)
|
||||||
let index = state.eraser_size_index();
|
let diameter = format_mm(state.eraser_radius_mm * 2.0);
|
||||||
let diameter = format!("{}", ERASER_RADII_MM[index] * 2.0);
|
|
||||||
let tooltip = t!(
|
let tooltip = t!(
|
||||||
"Radiergröße {size} mm – tippen für die nächste Größe",
|
"Radiergröße {size} mm – tippen zum Ändern",
|
||||||
size = diameter
|
size = diameter
|
||||||
);
|
);
|
||||||
let dot = [2.5_f32, 4.0, 6.0, 8.5][index].min(swatch * 0.45);
|
let dot = (state.eraser_radius_mm * 0.7).clamp(2.0, swatch * 0.45);
|
||||||
if sidebar_button(
|
let response = sidebar_button(
|
||||||
ui,
|
ui,
|
||||||
swatch,
|
swatch,
|
||||||
SidebarIcon::Dot(dot),
|
SidebarIcon::Dot(dot),
|
||||||
false,
|
state.is_eraser_menu_open,
|
||||||
true,
|
true,
|
||||||
None,
|
None,
|
||||||
&tooltip,
|
&tooltip,
|
||||||
)
|
);
|
||||||
.clicked()
|
eraser_btn_rect = Some(response.rect);
|
||||||
{
|
if response.clicked() {
|
||||||
let next = (index + 1) % ERASER_RADII_MM.len();
|
let open = !state.is_eraser_menu_open;
|
||||||
state.eraser_radius_mm = ERASER_RADII_MM[next];
|
state.close_overlays();
|
||||||
state.set_export_message(t!(
|
state.is_eraser_menu_open = open;
|
||||||
"Radiergröße {size} mm",
|
|
||||||
size = format!("{}", ERASER_RADII_MM[next] * 2.0)
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
used += swatch + spacing;
|
used += swatch + spacing;
|
||||||
}
|
}
|
||||||
@@ -3776,6 +3852,84 @@ pub fn render_ui(
|
|||||||
frame.response.rect
|
frame.response.rect
|
||||||
});
|
});
|
||||||
|
|
||||||
|
popover_rect = Some(pop_response.inner);
|
||||||
|
}
|
||||||
|
} else if state.is_eraser_menu_open && state.active_tool == ActiveTool::Eraser {
|
||||||
|
if let Some(btn_rect) = eraser_btn_rect {
|
||||||
|
let (popover_pos, pivot) =
|
||||||
|
popover_placement(state.sidebar_side, area_response.inner, btn_rect);
|
||||||
|
let pop_response = egui::Area::new(egui::Id::new("eraser_settings_popover"))
|
||||||
|
.fixed_pos(popover_pos)
|
||||||
|
.pivot(pivot)
|
||||||
|
.constrain(true)
|
||||||
|
.order(egui::Order::Foreground)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
let frame = popover_frame().show(ui, |ui| {
|
||||||
|
ui.set_width(320.0);
|
||||||
|
touch_friendly_spacing(ui);
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(t!("Radierer"))
|
||||||
|
.strong()
|
||||||
|
.color(egui::Color32::WHITE)
|
||||||
|
.size(12.5),
|
||||||
|
);
|
||||||
|
ui.with_layout(
|
||||||
|
egui::Layout::right_to_left(egui::Align::Center),
|
||||||
|
|ui| {
|
||||||
|
if close_button(ui).clicked() {
|
||||||
|
state.is_eraser_menu_open = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(t!("GRÖSSE"))
|
||||||
|
.size(10.5)
|
||||||
|
.strong()
|
||||||
|
.color(egui::Color32::from_gray(160)),
|
||||||
|
);
|
||||||
|
ui.add_space(3.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.spacing_mut().item_spacing = egui::vec2(6.0, 0.0);
|
||||||
|
for radius in ERASER_RADII_MM {
|
||||||
|
let is_sel = (state.eraser_radius_mm - radius).abs() < 0.05;
|
||||||
|
if eraser_size_button(ui, is_sel, radius).clicked() {
|
||||||
|
state.eraser_radius_mm = radius;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(10.0);
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(t!("STUFENLOSE GRÖSSE"))
|
||||||
|
.size(10.5)
|
||||||
|
.strong()
|
||||||
|
.color(egui::Color32::from_gray(160)),
|
||||||
|
);
|
||||||
|
ui.add_space(2.0);
|
||||||
|
let mut diameter = state.eraser_radius_mm * 2.0;
|
||||||
|
if ui
|
||||||
|
.add(
|
||||||
|
egui::Slider::new(
|
||||||
|
&mut diameter,
|
||||||
|
ERASER_DIAMETER_RANGE_MM.0..=ERASER_DIAMETER_RANGE_MM.1,
|
||||||
|
)
|
||||||
|
.suffix(" mm")
|
||||||
|
.step_by(0.5),
|
||||||
|
)
|
||||||
|
.changed()
|
||||||
|
{
|
||||||
|
state.set_eraser_radius_mm(diameter / 2.0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
frame.response.rect
|
||||||
|
});
|
||||||
|
|
||||||
popover_rect = Some(pop_response.inner);
|
popover_rect = Some(pop_response.inner);
|
||||||
}
|
}
|
||||||
} else if state.is_highlighter_menu_open {
|
} else if state.is_highlighter_menu_open {
|
||||||
@@ -4295,7 +4449,9 @@ pub fn render_ui(
|
|||||||
popover_frame()
|
popover_frame()
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
// Breite = Kachelraster; sonst nimmt die Kopfzeile die ganze Fensterbreite ein
|
// Breite = Kachelraster; sonst nimmt die Kopfzeile die ganze Fensterbreite ein
|
||||||
ui.set_width(PAGE_PICKER_COLUMNS as f32 * (PAGE_TILE.x + 8.0) - 8.0);
|
ui.set_width(
|
||||||
|
PAGE_PICKER_COLUMNS as f32 * (PAGE_TILE_WIDTH + 8.0) - 8.0,
|
||||||
|
);
|
||||||
touch_friendly_spacing(ui);
|
touch_friendly_spacing(ui);
|
||||||
let title = t!("Seiten ({count})", count = notebook.pages.len());
|
let title = t!("Seiten ({count})", count = notebook.pages.len());
|
||||||
if dialog_header(ui, &title, true) {
|
if dialog_header(ui, &title, true) {
|
||||||
@@ -4370,6 +4526,7 @@ pub fn render_ui(
|
|||||||
.max_height(420.0)
|
.max_height(420.0)
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
let mut wanted_pdfs = Vec::new();
|
let mut wanted_pdfs = Vec::new();
|
||||||
|
let tile = page_tile_size(notebook);
|
||||||
egui::Grid::new("page_picker_grid")
|
egui::Grid::new("page_picker_grid")
|
||||||
.spacing([8.0, 8.0])
|
.spacing([8.0, 8.0])
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
@@ -4383,6 +4540,7 @@ pub fn render_ui(
|
|||||||
page,
|
page,
|
||||||
idx + 1,
|
idx + 1,
|
||||||
is_current,
|
is_current,
|
||||||
|
tile,
|
||||||
&mut wanted_pdfs,
|
&mut wanted_pdfs,
|
||||||
)
|
)
|
||||||
.clicked()
|
.clicked()
|
||||||
@@ -4428,6 +4586,7 @@ pub fn render_ui(
|
|||||||
|
|
||||||
// Automatisches Schließen der Popover bei Klick außerhalb von Leiste und Popover
|
// Automatisches Schließen der Popover bei Klick außerhalb von Leiste und Popover
|
||||||
if (state.is_pen_menu_open
|
if (state.is_pen_menu_open
|
||||||
|
|| state.is_eraser_menu_open
|
||||||
|| state.is_highlighter_menu_open
|
|| state.is_highlighter_menu_open
|
||||||
|| state.is_text_color_menu_open
|
|| state.is_text_color_menu_open
|
||||||
|| state.is_more_menu_open
|
|| state.is_more_menu_open
|
||||||
@@ -4439,6 +4598,7 @@ pub fn render_ui(
|
|||||||
let in_popover = state.popover_rect.is_some_and(|r| r.contains(pos));
|
let in_popover = state.popover_rect.is_some_and(|r| r.contains(pos));
|
||||||
if !in_toolbar && !in_popover {
|
if !in_toolbar && !in_popover {
|
||||||
state.is_pen_menu_open = false;
|
state.is_pen_menu_open = false;
|
||||||
|
state.is_eraser_menu_open = false;
|
||||||
state.is_highlighter_menu_open = false;
|
state.is_highlighter_menu_open = false;
|
||||||
state.is_text_color_menu_open = false;
|
state.is_text_color_menu_open = false;
|
||||||
state.is_more_menu_open = false;
|
state.is_more_menu_open = false;
|
||||||
@@ -5818,17 +5978,20 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_eraser_size_snaps_to_the_available_sizes() {
|
fn test_eraser_size_stays_in_the_slider_range() {
|
||||||
let mut state = UiState::new();
|
let mut state = UiState::new();
|
||||||
assert_eq!(state.eraser_radius(), 30.0);
|
assert_eq!(state.eraser_radius(), 30.0);
|
||||||
// Values from the settings file snap to the closest size
|
// Values from the settings file stay within the slider range
|
||||||
state.set_eraser_radius_mm(7.0);
|
state.set_eraser_radius_mm(7.0);
|
||||||
assert_eq!(state.eraser_radius_mm, 6.0);
|
assert_eq!(state.eraser_radius_mm, 7.0);
|
||||||
assert_eq!(state.eraser_size_index(), 2);
|
|
||||||
state.set_eraser_radius_mm(100.0);
|
state.set_eraser_radius_mm(100.0);
|
||||||
assert_eq!(state.eraser_radius_mm, 12.0);
|
assert_eq!(state.eraser_radius_mm, ERASER_DIAMETER_RANGE_MM.1 / 2.0);
|
||||||
|
state.set_eraser_radius_mm(0.0);
|
||||||
|
assert_eq!(state.eraser_radius_mm, ERASER_DIAMETER_RANGE_MM.0 / 2.0);
|
||||||
state.set_eraser_radius_mm(f32::NAN);
|
state.set_eraser_radius_mm(f32::NAN);
|
||||||
assert!(ERASER_RADII_MM.contains(&state.eraser_radius_mm));
|
assert_eq!(state.eraser_radius_mm, 3.0);
|
||||||
|
assert_eq!(format_mm(3.0), "3");
|
||||||
|
assert_eq!(format_mm(2.5), "2.5");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -5970,7 +6133,7 @@ mod tests {
|
|||||||
const UI_SOURCES: [(&str, &str); 4] = [
|
const UI_SOURCES: [(&str, &str); 4] = [
|
||||||
("ui.rs", include_str!("ui.rs")),
|
("ui.rs", include_str!("ui.rs")),
|
||||||
("selection.rs", include_str!("selection.rs")),
|
("selection.rs", include_str!("selection.rs")),
|
||||||
("main.rs", include_str!("main.rs")),
|
("app.rs", include_str!("app.rs")),
|
||||||
("file_picker.rs", include_str!("file_picker.rs")),
|
("file_picker.rs", include_str!("file_picker.rs")),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -6504,6 +6667,25 @@ mod tests {
|
|||||||
assert!(rect.contains_rect(shapes[1].visual_bounding_rect()));
|
assert!(rect.contains_rect(shapes[1].visual_bounding_rect()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_page_tiles_follow_the_page_shape() {
|
||||||
|
// Slides only: flat tiles without an empty strip below the page (Issue #40)
|
||||||
|
let mut notebook = Notebook::new();
|
||||||
|
notebook.pages[0].size = glam::Vec2::new(2970.0, 1671.0);
|
||||||
|
let flat = page_tile_size(¬ebook);
|
||||||
|
assert!((flat.y - PAGE_TILE_LABEL - flat.x * 1671.0 / 2970.0).abs() < 0.01);
|
||||||
|
let thumb =
|
||||||
|
egui::Rect::from_min_size(egui::Pos2::ZERO, flat - egui::vec2(0.0, PAGE_TILE_LABEL));
|
||||||
|
let sheet = thumbnail_sheet(notebook.pages[0].size, thumb);
|
||||||
|
assert!((sheet.height() - thumb.height()).abs() < 0.01);
|
||||||
|
|
||||||
|
// A portrait page among them: tiles as high as A4, slides at the top
|
||||||
|
let index = notebook.add_page(PageBackground::Blank);
|
||||||
|
notebook.pages[index].size = crate::document::A4_SIZE;
|
||||||
|
let tall = page_tile_size(¬ebook);
|
||||||
|
assert!((tall.y - PAGE_TILE_LABEL - tall.x * PAGE_HEIGHT / PAGE_WIDTH).abs() < 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_page_picker_requests_loading_of_unloaded_pages() {
|
fn test_page_picker_requests_loading_of_unloaded_pages() {
|
||||||
let updater = crate::updater::UpdaterController::new();
|
let updater = crate::updater::UpdaterController::new();
|
||||||
|
|||||||
@@ -389,6 +389,14 @@ impl Fling {
|
|||||||
velocity
|
velocity
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The glide continues from `now` (after [`Self::release`] with the time the finger was
|
||||||
|
/// lifted, which may lie a little in the past).
|
||||||
|
pub fn restart_clock(&mut self, now: std::time::Instant) {
|
||||||
|
if self.active {
|
||||||
|
self.last = Some(now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Hält an (neue Berührung, Mausklick, Mausrad).
|
/// Hält an (neue Berührung, Mausklick, Mausrad).
|
||||||
pub fn stop(&mut self) {
|
pub fn stop(&mut self) {
|
||||||
*self = Self::default();
|
*self = Self::default();
|
||||||
@@ -430,6 +438,25 @@ mod tests {
|
|||||||
PageStack::new(vec![A4; count], current)
|
PageStack::new(vec![A4; count], current)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fling_measures_with_event_times_handled_late() {
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
let t0 = Instant::now();
|
||||||
|
let ms = |n: u64| t0 + Duration::from_millis(n);
|
||||||
|
let mut fling = Fling::default();
|
||||||
|
// The finger moved until 100 ms and was lifted at 105 ms; the app only got to the
|
||||||
|
// lift 300 ms later, so the glide starts from there
|
||||||
|
for i in 0..=10 {
|
||||||
|
fling.track(Vec2::new(0.0, -30.0), ms(i * 10));
|
||||||
|
}
|
||||||
|
let velocity = fling.release(ms(105));
|
||||||
|
assert!(velocity.y < -2000.0, "{velocity:?}");
|
||||||
|
assert!(fling.is_active());
|
||||||
|
fling.restart_clock(ms(405));
|
||||||
|
let first = fling.step(ms(421)).unwrap();
|
||||||
|
assert!(first.y < -20.0 && first.y > -60.0, "{first:?}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_fling_glides_and_slows_down() {
|
fn test_fling_glides_and_slows_down() {
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|||||||
Vendored
+4
@@ -14,6 +14,10 @@ docs of the original crate were removed.
|
|||||||
- `src/platform/wayland.rs`: `ignore_decoration_touches_until` lets ink-notes ignore touches on
|
- `src/platform/wayland.rs`: `ignore_decoration_touches_until` lets ink-notes ignore touches on
|
||||||
the title bar while the pen is near the screen, so a resting palm does not move the window
|
the title bar while the pen is near the screen, so a resting palm does not move the window
|
||||||
(Issue #39).
|
(Issue #39).
|
||||||
|
- `src/platform/wayland.rs` and the touch handler: `take_touch_time` returns the compositor
|
||||||
|
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).
|
||||||
- `Cargo.toml`: `[lints.rust] warnings = "allow"`, because a path dependency is not built with
|
- `Cargo.toml`: `[lints.rust] warnings = "allow"`, because a path dependency is not built with
|
||||||
capped lints like a crates.io dependency.
|
capped lints like a crates.io dependency.
|
||||||
|
|
||||||
|
|||||||
Vendored
+30
@@ -28,6 +28,36 @@ pub(crate) fn decoration_touches_ignored() -> bool {
|
|||||||
.is_some_and(|until| std::time::Instant::now() < until)
|
.is_some_and(|until| std::time::Instant::now() < until)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ink-notes patch: compositor timestamps (ms) of the touch events not yet taken, in event order.
|
||||||
|
static TOUCH_TIMES: std::sync::Mutex<
|
||||||
|
std::collections::VecDeque<(u64, crate::event::TouchPhase, u32)>,
|
||||||
|
> = std::sync::Mutex::new(std::collections::VecDeque::new());
|
||||||
|
|
||||||
|
/// ink-notes patch: remembers the compositor time of a touch event that is about to be sent.
|
||||||
|
pub(crate) fn push_touch_time(id: u64, phase: crate::event::TouchPhase, time: u32) {
|
||||||
|
let mut times = TOUCH_TIMES.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
// Nobody takes them (e.g. an app that never asks): keep only the latest
|
||||||
|
if times.len() >= 256 {
|
||||||
|
times.pop_front();
|
||||||
|
}
|
||||||
|
times.push_back((id, phase, time));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ink-notes patch: compositor time in milliseconds (arbitrary origin, wraps around) of the
|
||||||
|
/// touch event with this id and phase (`Started`, `Moved` or `Ended`; `Cancelled` has none),
|
||||||
|
/// for the event currently being handled. Call it once for every such `WindowEvent::Touch`.
|
||||||
|
/// `None` on X11 or if the time is unknown.
|
||||||
|
pub fn take_touch_time(id: u64, phase: crate::event::TouchPhase) -> Option<u32> {
|
||||||
|
let mut times = TOUCH_TIMES.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
// Older entries that were never taken are skipped
|
||||||
|
while let Some((i, p, time)) = times.pop_front() {
|
||||||
|
if i == id && p == phase {
|
||||||
|
return Some(time);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Additional methods on [`EventLoopWindowTarget`] that are specific to Wayland.
|
/// Additional methods on [`EventLoopWindowTarget`] that are specific to Wayland.
|
||||||
pub trait EventLoopWindowTargetExtWayland {
|
pub trait EventLoopWindowTargetExtWayland {
|
||||||
/// True if the [`EventLoopWindowTarget`] uses Wayland.
|
/// True if the [`EventLoopWindowTarget`] uses Wayland.
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ impl TouchHandler for WinitState {
|
|||||||
.touch_map
|
.touch_map
|
||||||
.insert(id, TouchPoint { surface, location, frame_of: None });
|
.insert(id, TouchPoint { surface, location, frame_of: None });
|
||||||
|
|
||||||
|
// ink-notes patch: keep the event time for `take_touch_time`
|
||||||
|
crate::platform::wayland::push_touch_time(id as u64, TouchPhase::Started, time);
|
||||||
|
|
||||||
self.events_sink.push_window_event(
|
self.events_sink.push_window_event(
|
||||||
WindowEvent::Touch(Touch {
|
WindowEvent::Touch(Touch {
|
||||||
device_id: crate::event::DeviceId(crate::platform_impl::DeviceId::Wayland(
|
device_id: crate::event::DeviceId(crate::platform_impl::DeviceId::Wayland(
|
||||||
@@ -138,6 +141,8 @@ impl TouchHandler for WinitState {
|
|||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ink-notes patch: keep the event time for `take_touch_time`
|
||||||
|
crate::platform::wayland::push_touch_time(id as u64, TouchPhase::Ended, time);
|
||||||
self.events_sink.push_window_event(
|
self.events_sink.push_window_event(
|
||||||
WindowEvent::Touch(Touch {
|
WindowEvent::Touch(Touch {
|
||||||
device_id: crate::event::DeviceId(crate::platform_impl::DeviceId::Wayland(
|
device_id: crate::event::DeviceId(crate::platform_impl::DeviceId::Wayland(
|
||||||
@@ -157,7 +162,7 @@ impl TouchHandler for WinitState {
|
|||||||
_: &Connection,
|
_: &Connection,
|
||||||
_: &QueueHandle<Self>,
|
_: &QueueHandle<Self>,
|
||||||
touch: &WlTouch,
|
touch: &WlTouch,
|
||||||
_: u32,
|
time: u32,
|
||||||
id: i32,
|
id: i32,
|
||||||
position: (f64, f64),
|
position: (f64, f64),
|
||||||
) {
|
) {
|
||||||
@@ -192,6 +197,9 @@ impl TouchHandler for WinitState {
|
|||||||
|
|
||||||
touch_point.location = LogicalPosition::<f64>::from(position);
|
touch_point.location = LogicalPosition::<f64>::from(position);
|
||||||
|
|
||||||
|
// ink-notes patch: keep the event time for `take_touch_time`
|
||||||
|
crate::platform::wayland::push_touch_time(id as u64, TouchPhase::Moved, time);
|
||||||
|
|
||||||
self.events_sink.push_window_event(
|
self.events_sink.push_window_event(
|
||||||
WindowEvent::Touch(Touch {
|
WindowEvent::Touch(Touch {
|
||||||
device_id: crate::event::DeviceId(crate::platform_impl::DeviceId::Wayland(
|
device_id: crate::event::DeviceId(crate::platform_impl::DeviceId::Wayland(
|
||||||
|
|||||||
Reference in new issue
Block a user