Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7e161e70c | ||
|
|
043f487e9c | ||
|
|
3b0b4f51fb | ||
|
|
14abadbd03 | ||
|
|
f279790e00 | ||
|
|
7bd3d9e82f | ||
|
|
ab0f49199b | ||
|
|
38b860bc32 | ||
|
|
055e39c24e | ||
|
|
1b22ffa526 | ||
|
|
0d9e9aa2ea | ||
|
|
298932cb32 | ||
|
|
127bc8d730 | ||
|
|
19fc8dd3ca | ||
|
|
17d0f07b9a | ||
|
|
200f76e32f | ||
|
|
b8748c56d4 | ||
|
|
8c0ccdd394 | ||
|
|
3d6dbf09da | ||
|
|
2a9a67d399 | ||
|
|
307c803246 | ||
|
|
d16cd2db72 | ||
|
|
cd490dd9a7 |
No files matched your search
@@ -19,12 +19,6 @@ jobs:
|
|||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
|
|
||||||
- name: Fetch Android APK
|
|
||||||
uses: actions/download-artifact@v4.3.0
|
|
||||||
with:
|
|
||||||
name: ink-notes-android-apk
|
|
||||||
path: target
|
|
||||||
|
|
||||||
- name: Install System Packaging Tools
|
- name: Install System Packaging Tools
|
||||||
run: |
|
run: |
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
@@ -71,6 +65,14 @@ jobs:
|
|||||||
cargo clippy --all-targets -- -D warnings
|
cargo clippy --all-targets -- -D warnings
|
||||||
cargo test
|
cargo test
|
||||||
|
|
||||||
|
# Only after the Rust cache step: restoring a cache that doesn't match exactly (e.g.
|
||||||
|
# after Cargo.lock changed) cleans target/ and would remove the APK again
|
||||||
|
- name: Fetch Android APK
|
||||||
|
uses: actions/download-artifact@v4.3.0
|
||||||
|
with:
|
||||||
|
name: ink-notes-android-apk
|
||||||
|
path: target
|
||||||
|
|
||||||
- name: Build Release Artifacts (.deb, .AppImage, .exe, .apk, SHA256SUMS.txt + .minisig)
|
- name: Build Release Artifacts (.deb, .AppImage, .exe, .apk, SHA256SUMS.txt + .minisig)
|
||||||
run: |
|
run: |
|
||||||
chmod +x scripts/*.sh
|
chmod +x scripts/*.sh
|
||||||
|
|||||||
Generated
+5
-3
@@ -1540,7 +1540,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ink-notes"
|
name = "ink-notes"
|
||||||
version = "0.40.0"
|
version = "0.44.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ab_glyph",
|
"ab_glyph",
|
||||||
"android_logger",
|
"android_logger",
|
||||||
@@ -1565,6 +1565,7 @@ dependencies = [
|
|||||||
"pollster",
|
"pollster",
|
||||||
"quick-xml",
|
"quick-xml",
|
||||||
"resvg",
|
"resvg",
|
||||||
|
"rustls",
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1576,6 +1577,7 @@ dependencies = [
|
|||||||
"wayland-client",
|
"wayland-client",
|
||||||
"wayland-protocols",
|
"wayland-protocols",
|
||||||
"webbrowser",
|
"webbrowser",
|
||||||
|
"webpki-roots 0.26.11",
|
||||||
"wgpu",
|
"wgpu",
|
||||||
"winit",
|
"winit",
|
||||||
]
|
]
|
||||||
@@ -2578,9 +2580,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.45"
|
version = "0.23.46"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
|||||||
+5
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ink-notes"
|
name = "ink-notes"
|
||||||
version = "0.40.0"
|
version = "0.44.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"
|
||||||
@@ -38,6 +38,10 @@ semver = "1.0"
|
|||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
ureq = { version = "2.10", default-features = false, features = ["tls", "gzip", "json"] }
|
ureq = { version = "2.10", default-features = false, features = ["tls", "gzip", "json"] }
|
||||||
url = "2.5"
|
url = "2.5"
|
||||||
|
# Nextcloud sync (Issue #49): pinning a self-signed certificate needs our own verifier;
|
||||||
|
# same versions and features as ureq uses
|
||||||
|
rustls = { version = "0.23", default-features = false, features = ["logging", "ring", "std", "tls12"] }
|
||||||
|
webpki-roots = "0.26"
|
||||||
|
|
||||||
# Bilder einfügen: Dekodieren (PNG/JPEG/GIF/WebP/BMP) und Systemzwischenablage
|
# Bilder einfügen: Dekodieren (PNG/JPEG/GIF/WebP/BMP) und Systemzwischenablage
|
||||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp"] }
|
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp"] }
|
||||||
|
|||||||
+13
-2
@@ -22,12 +22,12 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
|
|||||||
## Funktionen
|
## Funktionen
|
||||||
|
|
||||||
### Schreiben und Zeichnen
|
### 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.
|
- **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. Am Bildschirm erscheinen sie genauso wie im PDF- und Bildexport.
|
- **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.
|
||||||
- **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; 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.
|
- **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.
|
||||||
@@ -37,6 +37,11 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
|
|||||||
- Erkannt werden Linien, Pfeile, Kreise, Ellipsen, Rechtecke, Dreiecke, Rauten, Vielecke, Koordinatensysteme (L-Form), Achsenkreuze (ein „+“ in einem Zug oder zwei gekreuzte Geraden, Pfeilspitzen an allen vier Enden) und Oktaeder (Skizze als 3D-Körper).
|
- Erkannt werden Linien, Pfeile, Kreise, Ellipsen, Rechtecke, Dreiecke, Rauten, Vielecke, Koordinatensysteme (L-Form), Achsenkreuze (ein „+“ in einem Zug oder zwei gekreuzte Geraden, Pfeilspitzen an allen vier Enden) und Oktaeder (Skizze als 3D-Körper).
|
||||||
- Die Erkennung arbeitet rein geometrisch (Bogenlänge, Douglas-Peucker, Kreis-Fit), ohne Machine Learning. Nicht alles wird erkannt – was nicht passt, bleibt Handschrift.
|
- Die Erkennung arbeitet rein geometrisch (Bogenlänge, Douglas-Peucker, Kreis-Fit), ohne Machine Learning. Nicht alles wird erkannt – was nicht passt, bleibt Handschrift.
|
||||||
|
|
||||||
|
### Formeln ausrechnen (experimentell)
|
||||||
|
- Einschalten im ☰-Menü unter Eingabe („Formeln nach „=“ ausrechnen“). Nach einem handgeschriebenen `=` und einer kurzen Pause erscheint daneben das Ergebnis samt dem Gelesenen, z. B. `2⁴ = 16` oder `(x + 3)² = x² + 6x + 9`; Antippen mit dem Stift oder Klicken fügt es als Textfeld in der Stiftfarbe und in der Höhe der Handschrift ein (Rückgängig entfernt es). Finger und Handballen lösen nichts aus.
|
||||||
|
- Gelesen werden Ziffern, `+ − · : /`, Klammern, Hochzahlen (hochgestellt geschrieben), Bruchstriche, Dezimalkommas und die Variablen `x`, `y`, `a`, `b`. Zahlen werden exakt als Brüche gerechnet, Ausdrücke mit Variablen ausmultipliziert. Vor der Formel etwas Abstand lassen, damit weiter links Geschriebenes nicht mitgelesen wird; in einer Kette wie `x = 2 · 3 =` wird der Teil nach dem vorigen `=` gerechnet.
|
||||||
|
- Die Zeichen werden mit Punktwolken-Vorlagen ($P) erkannt, ohne Machine Learning und offline. Zeigt der Vorschlag ein falsches Zeichen, ihn einfach ignorieren.
|
||||||
|
|
||||||
### Auswählen und Bearbeiten
|
### Auswählen und Bearbeiten
|
||||||
- **Auswahlwerkzeug** (`V`): antippen oder Rahmen aufziehen, dann verschieben, an den Ecken skalieren, am Griff drehen oder mit `R` um 90° drehen. Funktioniert mit Maus, Finger und Stift.
|
- **Auswahlwerkzeug** (`V`): antippen oder Rahmen aufziehen, dann verschieben, an den Ecken skalieren, am Griff drehen oder mit `R` um 90° drehen. Funktioniert mit Maus, Finger und Stift.
|
||||||
- Auswahl über die Palette **umfärben**, **kopieren/einfügen** (`Strg + C/V`), **löschen** (`Entf`).
|
- Auswahl über die Palette **umfärben**, **kopieren/einfügen** (`Strg + C/V`), **löschen** (`Entf`).
|
||||||
@@ -69,6 +74,7 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
|
|||||||
- **Beim Schließen** (Fenster oder `Strg + Q`) und beim Wechsel des Notizbuchs wird alles Ausstehende geschrieben, bevor es weitergeht. Schlägt das fehl, bleibt die App offen und bietet Wiederholen oder eine Notfall-Sicherung an.
|
- **Beim Schließen** (Fenster oder `Strg + Q`) und beim Wechsel des Notizbuchs wird alles Ausstehende geschrieben, bevor es weitergeht. Schlägt das fehl, bleibt die App offen und bietet Wiederholen oder eine Notfall-Sicherung an.
|
||||||
- `Strg + S` speichert sofort. Der Punkt am ☰-Knopf zeigt den Stand: grün = gespeichert, orange = Änderungen warten, gelb = speichert gerade, rot = Fehler.
|
- `Strg + S` speichert sofort. Der Punkt am ☰-Knopf zeigt den Stand: grün = gespeichert, orange = Änderungen warten, gelb = speichert gerade, rot = Fehler.
|
||||||
- **Speicherort** (☰-Menü, „Speicherort“): Liegen die Notizbücher in einem Ordner, den **Nextcloud, Google Drive, Syncthing oder Dropbox** abgleichen, sind sie auf allen Geräten verfügbar. Die Notizbücher liegen im Unterordner `ink-notes`; ist er noch leer, werden die vorhandenen Notizbücher dorthin kopiert (die alten bleiben erhalten). Änderungen von einem anderen Gerät erscheinen nach wenigen Sekunden ohne neu zu öffnen – aber nur, solange lokal alles gespeichert ist, damit nichts überschrieben wird. Legt der Sync-Client eine Konfliktkopie einer Seite an, wird sie als eigene Seite („Konfliktkopie: …“) übernommen statt verloren zu gehen. Ist ein Notizbuch gerade auf einem anderen Gerät geöffnet, warnt die App beim Öffnen.
|
- **Speicherort** (☰-Menü, „Speicherort“): Liegen die Notizbücher in einem Ordner, den **Nextcloud, Google Drive, Syncthing oder Dropbox** abgleichen, sind sie auf allen Geräten verfügbar. Die Notizbücher liegen im Unterordner `ink-notes`; ist er noch leer, werden die vorhandenen Notizbücher dorthin kopiert (die alten bleiben erhalten). Änderungen von einem anderen Gerät erscheinen nach wenigen Sekunden ohne neu zu öffnen – aber nur, solange lokal alles gespeichert ist, damit nichts überschrieben wird. Legt der Sync-Client eine Konfliktkopie einer Seite an, wird sie als eigene Seite („Konfliktkopie: …“) übernommen statt verloren zu gehen. Ist ein Notizbuch gerade auf einem anderen Gerät geöffnet, warnt die App beim Öffnen.
|
||||||
|
- **Nextcloud eingebaut** (☰-Menü, „Nextcloud“; auch unter Android und ohne Sync-Client): Adresse der eigenen Nextcloud eingeben – auch ein privater Server, in einem Unterordner (`example.org/nextcloud`) oder mit eigenem Port. Die Anmeldung läuft im Browser (auch mit Zwei-Faktor-Anmeldung); die App bekommt ein eigenes App-Passwort, das sich in Nextcloud unter Einstellungen → Sicherheit jederzeit widerrufen lässt, und sieht das echte Passwort nie. Die Notizbücher werden alle 30 Sekunden und kurz nach dem Speichern mit dem Ordner `ink-notes` in den Nextcloud-Dateien abgeglichen; offline arbeitet die App normal weiter. Wurde eine Seite auf zwei Geräten geändert, bleiben beide Fassungen erhalten (die andere als eigene Seite); gelöschte Notizbücher und Seiten landen im Papierkorb (lokal `.trash`, auf dem Server dessen Papierkorb). Einem selbst signierten Zertifikat vertraut die App, nachdem du seinen Fingerabdruck bestätigt hast; Adressen mit `http://` gehen nur mit Warnung, weil das App-Passwort dann im Netz lesbar wäre. „Trennen“ beendet den Abgleich und widerruft das App-Passwort; die Notizbücher bleiben erhalten. Bitte entweder den eingebauten Abgleich oder den Ordner eines Sync-Clients als Speicherort verwenden, nicht beides für dieselben Notizbücher.
|
||||||
|
|
||||||
### Export
|
### Export
|
||||||
- **PDF** (`Strg + E`) nach `~/Documents/<Titel>.pdf`: Striche und Formen als Vektoren, Textfelder als echter, durchsuchbarer Text, importierte PDF-Seiten als Vektor-Unterlage, Seitentitel als Lesezeichen.
|
- **PDF** (`Strg + E`) nach `~/Documents/<Titel>.pdf`: Striche und Formen als Vektoren, Textfelder als echter, durchsuchbarer Text, importierte PDF-Seiten als Vektor-Unterlage, Seitentitel als Lesezeichen.
|
||||||
@@ -190,7 +196,12 @@ src/
|
|||||||
├── history.rs # Rückgängig/Wiederholen
|
├── history.rs # Rückgängig/Wiederholen
|
||||||
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb, Speicherort
|
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb, Speicherort
|
||||||
├── disk_watch.rs # Änderungen anderer Geräte übernehmen (Sync-Clients)
|
├── disk_watch.rs # Änderungen anderer Geräte übernehmen (Sync-Clients)
|
||||||
|
├── nextcloud.rs # Nextcloud: Anmeldung im Browser, WebDAV, gemerkte Zertifikate
|
||||||
|
├── cloud_sync.rs # Eingebauter Abgleich mit Nextcloud (Vergleich, Zusammenführen, Hintergrund)
|
||||||
├── library.rs # Ordner der Notizbücher (library.json)
|
├── library.rs # Ordner der Notizbücher (library.json)
|
||||||
|
├── math.rs # Exaktes Ausrechnen handgeschriebener Formeln (Issue #46)
|
||||||
|
├── math_ink.rs # Erkennung handgeschriebener Formeln ($P-Punktwolken)
|
||||||
|
├── math_assist.rs # Ergebnis neben einem handgeschriebenen „=“ (Auslöser, Vorschlag)
|
||||||
├── autosave.rs # Speichern im Hintergrund
|
├── autosave.rs # Speichern im Hintergrund
|
||||||
├── export.rs # PDF-Export
|
├── export.rs # PDF-Export
|
||||||
├── page_export.rs # Seite als PNG/SVG, Auswahl als Bild
|
├── page_export.rs # Seite als PNG/SVG, Auswahl als Bild
|
||||||
|
|||||||
@@ -22,12 +22,12 @@ The interface is available in English and German; ink-notes follows the system l
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Writing and drawing
|
### 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.
|
- **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. They look the same on screen as in PDF and image exports.
|
- **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.
|
||||||
- **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; 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.
|
- **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.
|
||||||
@@ -37,6 +37,11 @@ The interface is available in English and German; ink-notes follows the system l
|
|||||||
- Recognized shapes: lines, arrows, circles, ellipses, rectangles, triangles, rhombuses, polygons, coordinate systems (L shape), axis crosses (a "+" in one stroke or two crossing straight lines, arrowheads on all four ends) and octahedra (sketch as a 3D solid).
|
- Recognized shapes: lines, arrows, circles, ellipses, rectangles, triangles, rhombuses, polygons, coordinate systems (L shape), axis crosses (a "+" in one stroke or two crossing straight lines, arrowheads on all four ends) and octahedra (sketch as a 3D solid).
|
||||||
- Recognition is purely geometric (arc length, Douglas-Peucker, circle fitting), without machine learning. Not everything is recognized – whatever does not fit stays handwriting.
|
- Recognition is purely geometric (arc length, Douglas-Peucker, circle fitting), without machine learning. Not everything is recognized – whatever does not fit stays handwriting.
|
||||||
|
|
||||||
|
### Computing formulas (experimental)
|
||||||
|
- Turn on in the ☰ menu under input ("Compute formulas after “=”"). After a handwritten `=` and a short pause, the result appears next to it together with what was read, e.g. `2⁴ = 16` or `(x + 3)² = x² + 6x + 9`; tapping it with the pen or clicking inserts it as a text box in the ink color, as high as the handwriting (undo removes it). Fingers and palms do not trigger it.
|
||||||
|
- Read are digits, `+ − · : /`, parentheses, exponents (written raised), fraction bars, decimal commas and the variables `x`, `y`, `a`, `b`. Numbers are computed exactly as fractions, expressions with variables are multiplied out. Leave some space before the formula, so that writing further to the left is not read with it; in a chain such as `x = 2 · 3 =` the part after the previous `=` is computed.
|
||||||
|
- The symbols are recognized with point-cloud templates ($P), without machine learning and offline. If the suggestion shows a wrong symbol, just ignore it.
|
||||||
|
|
||||||
### Selecting and editing
|
### Selecting and editing
|
||||||
- **Selection tool** (`V`): tap or drag a frame, then move, scale by the corners, rotate by the handle, or rotate by 90° with `R`. Works with mouse, finger and pen.
|
- **Selection tool** (`V`): tap or drag a frame, then move, scale by the corners, rotate by the handle, or rotate by 90° with `R`. Works with mouse, finger and pen.
|
||||||
- **Recolor** the selection with the palette, **copy/paste** (`Ctrl + C/V`), **delete** (`Del`).
|
- **Recolor** the selection with the palette, **copy/paste** (`Ctrl + C/V`), **delete** (`Del`).
|
||||||
@@ -69,6 +74,7 @@ The interface is available in English and German; ink-notes follows the system l
|
|||||||
- **When closing** (window or `Ctrl + Q`) and when switching notebooks, everything pending is written before continuing. If that fails, the app stays open and offers to retry or to make an emergency backup.
|
- **When closing** (window or `Ctrl + Q`) and when switching notebooks, everything pending is written before continuing. If that fails, the app stays open and offers to retry or to make an emergency backup.
|
||||||
- `Ctrl + S` saves immediately. The dot on the ☰ button shows the state: green = saved, orange = changes pending, yellow = saving, red = error.
|
- `Ctrl + S` saves immediately. The dot on the ☰ button shows the state: green = saved, orange = changes pending, yellow = saving, red = error.
|
||||||
- **Storage location** (☰ menu, "Storage location"): put the notebooks into a folder that **Nextcloud, Google Drive, Syncthing or Dropbox** keep in sync, and they are available on all your devices. The notebooks are kept in the subfolder `ink-notes`; if it is still empty, the existing notebooks are copied there (the old ones stay). Changes arriving from another device show up within a few seconds without reopening – but only while everything local is saved, so nothing gets overwritten. If the sync client makes a conflict copy of a page, it is added as a page of its own („Conflict copy: …“) instead of getting lost. Opening a notebook that is open on another device shows a warning.
|
- **Storage location** (☰ menu, "Storage location"): put the notebooks into a folder that **Nextcloud, Google Drive, Syncthing or Dropbox** keep in sync, and they are available on all your devices. The notebooks are kept in the subfolder `ink-notes`; if it is still empty, the existing notebooks are copied there (the old ones stay). Changes arriving from another device show up within a few seconds without reopening – but only while everything local is saved, so nothing gets overwritten. If the sync client makes a conflict copy of a page, it is added as a page of its own („Conflict copy: …“) instead of getting lost. Opening a notebook that is open on another device shows a warning.
|
||||||
|
- **Nextcloud built in** (☰ menu, "Nextcloud"; also on Android and without a sync client): enter the address of your Nextcloud – also a private server, in a subfolder (`example.org/nextcloud`) or with its own port. Signing in happens in the browser (also with two-factor sign-in); the app gets an app password of its own, which you can revoke in Nextcloud under Settings → Security at any time, and never sees your real password. The notebooks are synced with the folder `ink-notes` in your Nextcloud files every 30 seconds and shortly after saving; the app keeps working offline. If a page was changed on two devices, both versions are kept (the other one as a page of its own); deleted notebooks and pages go into the trash (locally `.trash`, on the server its trash bin). A self-signed certificate is trusted once you have confirmed its fingerprint; addresses with `http://` only work with a warning, since the app password would be readable on the network. "Disconnect" stops syncing and revokes the app password; the notebooks stay. Use either the built-in sync or a sync client's folder as storage location, not both for the same notebooks.
|
||||||
|
|
||||||
### Export
|
### Export
|
||||||
- **PDF** (`Ctrl + E`) to `~/Documents/<title>.pdf`: strokes and shapes as vectors, text boxes as real, searchable text, imported PDF pages as vector background, page titles as bookmarks.
|
- **PDF** (`Ctrl + E`) to `~/Documents/<title>.pdf`: strokes and shapes as vectors, text boxes as real, searchable text, imported PDF pages as vector background, page titles as bookmarks.
|
||||||
@@ -192,7 +198,12 @@ src/
|
|||||||
├── history.rs # Undo/redo
|
├── history.rs # Undo/redo
|
||||||
├── storage.rs # Loading/saving (atomic), notebooks, trash, storage location
|
├── storage.rs # Loading/saving (atomic), notebooks, trash, storage location
|
||||||
├── disk_watch.rs # Taking over changes from other devices (sync clients)
|
├── disk_watch.rs # Taking over changes from other devices (sync clients)
|
||||||
|
├── nextcloud.rs # Nextcloud: sign-in in the browser, WebDAV, pinned certificates
|
||||||
|
├── cloud_sync.rs # Built-in sync with Nextcloud (comparison, merging, background thread)
|
||||||
├── library.rs # Notebook folders (library.json)
|
├── library.rs # Notebook folders (library.json)
|
||||||
|
├── math.rs # Exact evaluation of handwritten math (Issue #46)
|
||||||
|
├── math_ink.rs # Recognition of handwritten math ($P point clouds)
|
||||||
|
├── math_assist.rs # Result next to a handwritten "=" (trigger, suggestion)
|
||||||
├── autosave.rs # Saving in the background
|
├── autosave.rs # Saving in the background
|
||||||
├── export.rs # PDF export
|
├── export.rs # PDF export
|
||||||
├── page_export.rs # Page as PNG/SVG, selection as image
|
├── page_export.rs # Page as PNG/SVG, selection as image
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import android.content.Context;
|
|||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.database.Cursor;
|
import android.database.Cursor;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
|
import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.provider.OpenableColumns;
|
import android.provider.OpenableColumns;
|
||||||
import android.text.InputType;
|
import android.text.InputType;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.ViewGroup;
|
import android.view.ViewGroup;
|
||||||
|
import android.view.WindowInsets;
|
||||||
import android.view.inputmethod.BaseInputConnection;
|
import android.view.inputmethod.BaseInputConnection;
|
||||||
import android.view.inputmethod.EditorInfo;
|
import android.view.inputmethod.EditorInfo;
|
||||||
import android.view.inputmethod.InputConnection;
|
import android.view.inputmethod.InputConnection;
|
||||||
@@ -29,6 +31,9 @@ import java.io.OutputStream;
|
|||||||
* An invisible view with its own input connection passes committed text to Rust.
|
* An invisible view with its own input connection passes committed text to Rust.
|
||||||
* - Picking files: the system file dialog (ACTION_OPEN_DOCUMENT); the chosen file is copied to
|
* - Picking files: the system file dialog (ACTION_OPEN_DOCUMENT); the chosen file is copied to
|
||||||
* the cache folder, as Rust can only open files by path.
|
* the cache folder, as Rust can only open files by path.
|
||||||
|
* - System bars: NativeActivity draws the whole window, also below the status and navigation
|
||||||
|
* bars and display cutouts. Their sizes go to Rust, which keeps its buttons out of them
|
||||||
|
* (Issue #50).
|
||||||
*
|
*
|
||||||
* Rust calls showKeyboard and pickFile through JNI and receives the native* calls
|
* Rust calls showKeyboard and pickFile through JNI and receives the native* calls
|
||||||
* (src/android_bridge.rs).
|
* (src/android_bridge.rs).
|
||||||
@@ -49,6 +54,9 @@ public class InkActivity extends NativeActivity {
|
|||||||
/** Path of the copied file, or null if nothing was chosen or copying failed. */
|
/** Path of the copied file, or null if nothing was chosen or copying failed. */
|
||||||
static native void nativeFilePicked(String path);
|
static native void nativeFilePicked(String path);
|
||||||
|
|
||||||
|
/** Window edges covered by system bars or cutouts, in pixels. */
|
||||||
|
static native void nativeSetInsets(int left, int top, int right, int bottom);
|
||||||
|
|
||||||
private TextSink textSink;
|
private TextSink textSink;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -56,6 +64,26 @@ public class InkActivity extends NativeActivity {
|
|||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
textSink = new TextSink(this);
|
textSink = new TextSink(this);
|
||||||
addContentView(textSink, new ViewGroup.LayoutParams(1, 1));
|
addContentView(textSink, new ViewGroup.LayoutParams(1, 1));
|
||||||
|
View decor = getWindow().getDecorView();
|
||||||
|
decor.setOnApplyWindowInsetsListener((view, insets) -> {
|
||||||
|
reportInsets(insets);
|
||||||
|
return view.onApplyWindowInsets(insets);
|
||||||
|
});
|
||||||
|
decor.requestApplyInsets();
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
|
private static void reportInsets(WindowInsets insets) {
|
||||||
|
if (Build.VERSION.SDK_INT >= 30) {
|
||||||
|
android.graphics.Insets bars = insets.getInsets(
|
||||||
|
WindowInsets.Type.systemBars() | WindowInsets.Type.displayCutout());
|
||||||
|
nativeSetInsets(bars.left, bars.top, bars.right, bars.bottom);
|
||||||
|
} else {
|
||||||
|
// Before Android 11 the system window insets include the bars (and since
|
||||||
|
// Android 9 the cutout)
|
||||||
|
nativeSetInsets(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(),
|
||||||
|
insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetBottom());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shows or hides the soft keyboard; called from Rust on any thread. */
|
/** Shows or hides the soft keyboard; called from Rust on any thread. */
|
||||||
|
|||||||
@@ -84,6 +84,41 @@
|
|||||||
</branding>
|
</branding>
|
||||||
<content_rating type="oars-1.1" />
|
<content_rating type="oars-1.1" />
|
||||||
<releases>
|
<releases>
|
||||||
|
<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>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.41.1" date="2026-10-28">
|
||||||
|
<description>
|
||||||
|
<p>Android: the toolbar no longer hides behind the status bar and the navigation bar.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.41.0" date="2026-10-28">
|
||||||
|
<description>
|
||||||
|
<p>Experimental: computes handwritten formulas after an equals sign and offers the result next to it (turn on in the menu under input).</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
<release version="0.40.0" date="2026-10-28">
|
<release version="0.40.0" date="2026-10-28">
|
||||||
<description>
|
<description>
|
||||||
<p>New Android version: a signed APK for 64-bit ARM devices with pen, touch, soft keyboard, file import and exports to Downloads.</p>
|
<p>New Android version: a signed APK for 64-bit ARM devices with pen, touch, soft keyboard, file import and exports to Downloads.</p>
|
||||||
|
|||||||
@@ -3655,14 +3655,14 @@
|
|||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
"url": "https://static.crates.io/crates/rustls/rustls-0.23.45.crate",
|
"url": "https://static.crates.io/crates/rustls/rustls-0.23.46.crate",
|
||||||
"sha256": "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634",
|
"sha256": "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf",
|
||||||
"dest": "cargo/vendor/rustls-0.23.45"
|
"dest": "cargo/vendor/rustls-0.23.46"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "inline",
|
"type": "inline",
|
||||||
"contents": "{\"package\": \"0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634\", \"files\": {}}",
|
"contents": "{\"package\": \"48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf\", \"files\": {}}",
|
||||||
"dest": "cargo/vendor/rustls-0.23.45",
|
"dest": "cargo/vendor/rustls-0.23.46",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -84,6 +84,41 @@
|
|||||||
</branding>
|
</branding>
|
||||||
<content_rating type="oars-1.1" />
|
<content_rating type="oars-1.1" />
|
||||||
<releases>
|
<releases>
|
||||||
|
<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>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.41.1" date="2026-10-28">
|
||||||
|
<description>
|
||||||
|
<p>Android: the toolbar no longer hides behind the status bar and the navigation bar.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.41.0" date="2026-10-28">
|
||||||
|
<description>
|
||||||
|
<p>Experimental: computes handwritten formulas after an equals sign and offers the result next to it (turn on in the menu under input).</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
<release version="0.40.0" date="2026-10-28">
|
<release version="0.40.0" date="2026-10-28">
|
||||||
<description>
|
<description>
|
||||||
<p>New Android version: a signed APK for 64-bit ARM devices with pen, touch, soft keyboard, file import and exports to Downloads.</p>
|
<p>New Android version: a signed APK for 64-bit ARM devices with pen, touch, soft keyboard, file import and exports to Downloads.</p>
|
||||||
|
|||||||
@@ -3,6 +3,18 @@
|
|||||||
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:
|
||||||
|
- **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)
|
||||||
|
- **Computing formulas (experimental)**: Turn on in ☰ → Input ("Compute formulas after “=”"). After a handwritten `=` and a short pause, the result appears next to it together with what was read, e.g. `2⁴ = 16` or `(x + 3)² = x² + 6x + 9`; tapping it with the pen or clicking inserts it as text in the ink color and as high as the handwriting. Digits, `+ − · : /`, parentheses, exponents, fraction bars, decimal commas and `x`, `y`, `a`, `b` are read; numbers are computed exactly, expressions with variables multiplied out. Recognition runs offline without machine learning – feedback with real handwriting welcome (Issue #46)
|
||||||
- **Android**: `ink-notes_<version>_arm64.apk` brings ink-notes to Android 8 and newer (64-bit ARM) – download it on the device and open it to install. The pen is told apart from fingers (pressure, pen buttons, eraser end, palm rejection), text fields open the soft keyboard, import and inserting images use the system file dialog, and exports are saved to `Download/ink-notes` (Android 10 and newer). Notebooks live in the app's own storage and are removed when uninstalling; updates are installed by opening the new APK. Nothing changes on Linux and Windows
|
- **Android**: `ink-notes_<version>_arm64.apk` brings ink-notes to Android 8 and newer (64-bit ARM) – download it on the device and open it to install. The pen is told apart from fingers (pressure, pen buttons, eraser end, palm rejection), text fields open the soft keyboard, import and inserting images use the system file dialog, and exports are saved to `Download/ink-notes` (Android 10 and newer). Notebooks live in the app's own storage and are removed when uninstalling; updates are installed by opening the new APK. Nothing changes on Linux and Windows
|
||||||
- **Battery drain fixed**: ink-notes redrew its window at full frame rate even when nothing happened, which kept the app and the desktop (e.g. gnome-shell) busy and drained the battery. It now only draws when something changes; idle CPU load drops to practically zero. Also applies to Windows. Internally, the Android version now follows the app lifecycle and can be packed into an APK (not part of the release yet)
|
- **Battery drain fixed**: ink-notes redrew its window at full frame rate even when nothing happened, which kept the app and the desktop (e.g. gnome-shell) busy and drained the battery. It now only draws when something changes; idle CPU load drops to practically zero. Also applies to Windows. Internally, the Android version now follows the app lifecycle and can be packed into an APK (not part of the release yet)
|
||||||
- **PDF export on current lopdf**: The PDF export uses its own small PDF writer on top of lopdf 0.46 instead of the outdated printpdf 0.7 (which pinned lopdf 0.31). Exported PDFs look the same – layers, searchable text, images, highlighters and bookmarks stay as before
|
- **PDF export on current lopdf**: The PDF export uses its own small PDF writer on top of lopdf 0.46 instead of the outdated printpdf 0.7 (which pinned lopdf 0.31). Exported PDFs look the same – layers, searchable text, images, highlighters and bookmarks stay as before
|
||||||
|
|||||||
+40
-8
@@ -6,6 +6,7 @@
|
|||||||
//! below, possibly on another thread, so they only queue messages and wake the event loop.
|
//! below, possibly on another thread, so they only queue messages and wake the event loop.
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use jni::objects::{JObject, JString, JValue};
|
use jni::objects::{JObject, JString, JValue};
|
||||||
@@ -24,15 +25,23 @@ enum Message {
|
|||||||
static MESSAGES: Mutex<Vec<Message>> = Mutex::new(Vec::new());
|
static MESSAGES: Mutex<Vec<Message>> = Mutex::new(Vec::new());
|
||||||
/// What the open file dialog is for
|
/// What the open file dialog is for
|
||||||
static PICKING: Mutex<Option<PickerKind>> = Mutex::new(None);
|
static PICKING: Mutex<Option<PickerKind>> = Mutex::new(None);
|
||||||
|
/// Window edges covered by system bars or cutouts (left, top, right, bottom; pixels)
|
||||||
|
static INSETS: Mutex<[f32; 4]> = Mutex::new([0.0; 4]);
|
||||||
|
/// The insets changed since the last frame
|
||||||
|
static INSETS_CHANGED: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
fn wake() {
|
||||||
|
if let Some(app) = crate::platform::android_app() {
|
||||||
|
app.create_waker().wake();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn push(message: Message) {
|
fn push(message: Message) {
|
||||||
MESSAGES
|
MESSAGES
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(|e| e.into_inner())
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
.push(message);
|
.push(message);
|
||||||
if let Some(app) = crate::platform::android_app() {
|
wake();
|
||||||
app.create_waker().wake();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
@@ -75,16 +84,39 @@ pub extern "system" fn Java_eu_pansi_inknotes_InkActivity_nativeFilePicked(
|
|||||||
push(Message::Picked(path));
|
push(Message::Picked(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Is a message waiting (then the app draws a new frame to take it)?
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_eu_pansi_inknotes_InkActivity_nativeSetInsets(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_class: JObject,
|
||||||
|
left: jint,
|
||||||
|
top: jint,
|
||||||
|
right: jint,
|
||||||
|
bottom: jint,
|
||||||
|
) {
|
||||||
|
let insets = [left, top, right, bottom].map(|v| v.max(0) as f32);
|
||||||
|
*INSETS.lock().unwrap_or_else(|e| e.into_inner()) = insets;
|
||||||
|
INSETS_CHANGED.store(true, Ordering::Relaxed);
|
||||||
|
wake();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Window edges covered by the status bar, the navigation bar or display cutouts (left, top,
|
||||||
|
/// right, bottom; physical pixels)
|
||||||
|
pub fn insets() -> [f32; 4] {
|
||||||
|
*INSETS.lock().unwrap_or_else(|e| e.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is a message waiting or did the insets change (then the app draws a new frame)?
|
||||||
pub fn has_messages() -> bool {
|
pub fn has_messages() -> bool {
|
||||||
!MESSAGES
|
INSETS_CHANGED.load(Ordering::Relaxed)
|
||||||
.lock()
|
|| !MESSAGES
|
||||||
.unwrap_or_else(|e| e.into_inner())
|
.lock()
|
||||||
.is_empty()
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Typed text and deletions as egui input, and the action for a picked file.
|
/// Typed text and deletions as egui input, and the action for a picked file.
|
||||||
pub fn take_messages() -> (Vec<egui::Event>, Vec<UiAction>) {
|
pub fn take_messages() -> (Vec<egui::Event>, Vec<UiAction>) {
|
||||||
|
INSETS_CHANGED.store(false, Ordering::Relaxed);
|
||||||
let messages = std::mem::take(&mut *MESSAGES.lock().unwrap_or_else(|e| e.into_inner()));
|
let messages = std::mem::take(&mut *MESSAGES.lock().unwrap_or_else(|e| e.into_inner()));
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
let mut actions = Vec::new();
|
let mut actions = Vec::new();
|
||||||
|
|||||||
+192
-9
@@ -335,7 +335,7 @@ fn commit_stroke_to_notebook(
|
|||||||
notebook: &mut Notebook,
|
notebook: &mut Notebook,
|
||||||
manifest: &mut NotebookManifest,
|
manifest: &mut NotebookManifest,
|
||||||
autosave: &mut AutosaveController,
|
autosave: &mut AutosaveController,
|
||||||
ui_state: &UiState,
|
ui_state: &mut UiState,
|
||||||
window: &Window,
|
window: &Window,
|
||||||
snapped_shape: Option<RecognizedShape>,
|
snapped_shape: Option<RecognizedShape>,
|
||||||
) {
|
) {
|
||||||
@@ -357,6 +357,7 @@ fn commit_stroke_to_notebook(
|
|||||||
let page = notebook.current_page_mut();
|
let page = notebook.current_page_mut();
|
||||||
page.replace_stroke(previous_id, stroke);
|
page.replace_stroke(previous_id, stroke);
|
||||||
canvas.sync_page(page);
|
canvas.sync_page(page);
|
||||||
|
ui_state.math.stroke_committed(page, Instant::now());
|
||||||
autosave.mark_dirty(notebook, manifest);
|
autosave.mark_dirty(notebook, manifest);
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
return;
|
return;
|
||||||
@@ -383,6 +384,8 @@ fn commit_stroke_to_notebook(
|
|||||||
let page = notebook.current_page_mut();
|
let page = notebook.current_page_mut();
|
||||||
page.add_stroke(stroke);
|
page.add_stroke(stroke);
|
||||||
canvas.sync_page(page);
|
canvas.sync_page(page);
|
||||||
|
// An `=` may have been completed (Issue #46)
|
||||||
|
ui_state.math.stroke_committed(page, Instant::now());
|
||||||
manifest.next_stroke_id = notebook.next_stroke_id;
|
manifest.next_stroke_id = notebook.next_stroke_id;
|
||||||
autosave.mark_dirty(notebook, manifest);
|
autosave.mark_dirty(notebook, manifest);
|
||||||
// Durchgehende Ansicht: unten auf der letzten Seite geht es auf einer neuen weiter
|
// Durchgehende Ansicht: unten auf der letzten Seite geht es auf einer neuen weiter
|
||||||
@@ -915,6 +918,54 @@ fn insert_text_on_page(
|
|||||||
ui_state.set_export_message(t!("Text eingefügt – mit dem Text-Werkzeug (T) änderbar"));
|
ui_state.set_export_message(t!("Text eingefügt – mit dem Text-Werkzeug (T) änderbar"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Inserts the result of a math suggestion as a text box next to its `=`, in the ink color
|
||||||
|
/// and as high as the handwriting, as one undo step (Issue #46)
|
||||||
|
fn insert_math_result(
|
||||||
|
suggestion: &crate::math_assist::Suggestion,
|
||||||
|
canvas: &mut CanvasState,
|
||||||
|
notebook: &mut Notebook,
|
||||||
|
manifest: &mut NotebookManifest,
|
||||||
|
autosave: &mut AutosaveController,
|
||||||
|
) {
|
||||||
|
let page = notebook.current_page();
|
||||||
|
if page.id != suggestion.page_id || page.is_readonly() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let font = crate::text::TextFont::Sans;
|
||||||
|
let ascent_per_em = crate::text::TextBox::new(String::new(), 1.0, font, 1.0)
|
||||||
|
.layout()
|
||||||
|
.ascent;
|
||||||
|
let (origin, size) = suggestion.text_placement(ascent_per_em);
|
||||||
|
let mut edit = crate::text::TextEditing::new_at(
|
||||||
|
page.id,
|
||||||
|
origin,
|
||||||
|
page.text_right_edge(origin.x),
|
||||||
|
size,
|
||||||
|
font,
|
||||||
|
suggestion.color,
|
||||||
|
);
|
||||||
|
edit.origin = origin;
|
||||||
|
edit.text.content = suggestion.result.clone();
|
||||||
|
// As wide as the result, so that it does not wrap near the edge of the page
|
||||||
|
edit.text.wrap_width = edit
|
||||||
|
.text
|
||||||
|
.layout()
|
||||||
|
.lines
|
||||||
|
.iter()
|
||||||
|
.map(|line| line.width)
|
||||||
|
.fold(0.0, f32::max)
|
||||||
|
+ size;
|
||||||
|
let id = notebook.next_stroke_id();
|
||||||
|
let Some(stroke) = edit.to_stroke(id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let page = notebook.current_page_mut();
|
||||||
|
page.add_stroke(stroke);
|
||||||
|
canvas.sync_page(page);
|
||||||
|
manifest.next_stroke_id = notebook.next_stroke_id;
|
||||||
|
autosave.mark_dirty(notebook, manifest);
|
||||||
|
}
|
||||||
|
|
||||||
/// Beendet eine laufende Textbearbeitung: übernimmt das Feld (neu, geändert oder – wenn
|
/// Beendet eine laufende Textbearbeitung: übernimmt das Feld (neu, geändert oder – wenn
|
||||||
/// geleert – entfernt) als einen Undo-Schritt auf seiner Seite.
|
/// geleert – entfernt) als einen Undo-Schritt auf seiner Seite.
|
||||||
fn finish_text_edit(
|
fn finish_text_edit(
|
||||||
@@ -1416,9 +1467,11 @@ pub fn run_with(
|
|||||||
ui_state.pattern_color = ui_settings.pattern_color;
|
ui_state.pattern_color = ui_settings.pattern_color;
|
||||||
ui_state.pressure_curve = ui_settings.pressure_curve;
|
ui_state.pressure_curve = ui_settings.pressure_curve;
|
||||||
ui_state.continuous_scroll = ui_settings.continuous_scroll;
|
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.stylus_buttons = ui_settings.stylus_buttons.clone();
|
||||||
ui_state.set_eraser_radius_mm(ui_settings.eraser_radius_mm);
|
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.select_pen(ui_settings.active_pen);
|
ui_state.select_pen(ui_settings.active_pen);
|
||||||
ui_state.highlighter_colors = ui_settings.highlighter_colors;
|
ui_state.highlighter_colors = ui_settings.highlighter_colors;
|
||||||
ui_state.select_highlighter_color(ui_settings.active_highlighter);
|
ui_state.select_highlighter_color(ui_settings.active_highlighter);
|
||||||
@@ -1431,6 +1484,8 @@ pub fn run_with(
|
|||||||
ui_state.library = Library::load(&storage_base_dir);
|
ui_state.library = Library::load(&storage_base_dir);
|
||||||
ui_state.storage_dir = storage_base_dir.clone();
|
ui_state.storage_dir = storage_base_dir.clone();
|
||||||
ui_state.storage_dir_custom = ui_settings.storage_dir.is_some();
|
ui_state.storage_dir_custom = ui_settings.storage_dir.is_some();
|
||||||
|
// Built-in Nextcloud sync of the storage folder (Issue #49, stage 2)
|
||||||
|
let mut cloud = crate::cloud_sync::CloudSync::new(storage_base_dir.clone());
|
||||||
if let Some(warning) = startup_warning {
|
if let Some(warning) = startup_warning {
|
||||||
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||||
ui_state.set_export_message(warning);
|
ui_state.set_export_message(warning);
|
||||||
@@ -1680,6 +1735,21 @@ pub fn run_with(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Built-in sync: changes from the server to the open notebook wait while it
|
||||||
|
// has unsaved changes; a pass soon after saving
|
||||||
|
let unsaved = new_status != AutosaveStatus::Saved
|
||||||
|
|| is_drawing
|
||||||
|
|| is_stylus_drawing
|
||||||
|
|| ui_state.text_edit.is_some();
|
||||||
|
if cloud.update(&manifest.id, unsaved) {
|
||||||
|
disk_watch.check_soon(Instant::now());
|
||||||
|
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
if prev_status != AutosaveStatus::Saved && new_status == AutosaveStatus::Saved {
|
||||||
|
cloud.sync_soon();
|
||||||
|
}
|
||||||
|
|
||||||
// Take over changes from other devices (Issue #49), but only while everything
|
// Take over changes from other devices (Issue #49), but only while everything
|
||||||
// local is saved and nothing is being drawn or typed
|
// local is saved and nothing is being drawn or typed
|
||||||
if disk_watch.dir != storage_dir {
|
if disk_watch.dir != storage_dir {
|
||||||
@@ -1831,6 +1901,10 @@ pub fn run_with(
|
|||||||
PenEvent::In => {
|
PenEvent::In => {
|
||||||
is_stylus_in_proximity = true;
|
is_stylus_in_proximity = true;
|
||||||
stylus_tool_id = Some(tool);
|
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 => {
|
PenEvent::Out => {
|
||||||
if is_stylus_in_proximity {
|
if is_stylus_in_proximity {
|
||||||
@@ -1864,7 +1938,7 @@ pub fn run_with(
|
|||||||
&mut notebook,
|
&mut notebook,
|
||||||
&mut manifest,
|
&mut manifest,
|
||||||
&mut autosave,
|
&mut autosave,
|
||||||
&ui_state,
|
&mut ui_state,
|
||||||
&window,
|
&window,
|
||||||
stroke_snapped_shape.take(),
|
stroke_snapped_shape.take(),
|
||||||
);
|
);
|
||||||
@@ -2048,6 +2122,26 @@ pub fn run_with(
|
|||||||
stylus_tool_id = Some(tool);
|
stylus_tool_id = Some(tool);
|
||||||
let (sx, sy) = stylus_screen_pos;
|
let (sx, sy) = stylus_screen_pos;
|
||||||
|
|
||||||
|
// Tapping a math suggestion inserts its result (Issue #46)
|
||||||
|
if let Some(suggestion) = (!ui_state.view_mode)
|
||||||
|
.then(|| {
|
||||||
|
ui_state.math.take_hit(egui::pos2(sx / scale, sy / scale))
|
||||||
|
})
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
|
insert_math_result(
|
||||||
|
&suggestion,
|
||||||
|
&mut canvas,
|
||||||
|
&mut notebook,
|
||||||
|
&mut manifest,
|
||||||
|
&mut autosave,
|
||||||
|
);
|
||||||
|
// The rest of this tap goes nowhere
|
||||||
|
stylus_ui_capture = true;
|
||||||
|
window.request_redraw();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if ui::is_screen_point_in_ui(&egui_ctx, &ui_state, (sx, sy), scale)
|
if ui::is_screen_point_in_ui(&egui_ctx, &ui_state, (sx, sy), scale)
|
||||||
{
|
{
|
||||||
// In die Eingabe-Warteschlange des NÄCHSTEN egui-Frames
|
// In die Eingabe-Warteschlange des NÄCHSTEN egui-Frames
|
||||||
@@ -2275,7 +2369,7 @@ pub fn run_with(
|
|||||||
&mut notebook,
|
&mut notebook,
|
||||||
&mut manifest,
|
&mut manifest,
|
||||||
&mut autosave,
|
&mut autosave,
|
||||||
&ui_state,
|
&mut ui_state,
|
||||||
&window,
|
&window,
|
||||||
snapped_shape,
|
snapped_shape,
|
||||||
);
|
);
|
||||||
@@ -2351,6 +2445,16 @@ pub fn run_with(
|
|||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pause after an `=` is over: compute the expression (Issue #46)
|
||||||
|
if ui_state.math.update(
|
||||||
|
notebook.current_page(),
|
||||||
|
Instant::now(),
|
||||||
|
is_drawing || is_stylus_drawing,
|
||||||
|
crate::i18n::language() == crate::i18n::Language::German,
|
||||||
|
) {
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
|
||||||
// Nachgleiten nach dem Wischen
|
// Nachgleiten nach dem Wischen
|
||||||
if let Some(delta) = fling.step(Instant::now()) {
|
if let Some(delta) = fling.step(Instant::now()) {
|
||||||
canvas.pan(delta.x, delta.y);
|
canvas.pan(delta.x, delta.y);
|
||||||
@@ -2372,12 +2476,19 @@ pub fn run_with(
|
|||||||
|| is_stylus_drawing
|
|| is_stylus_drawing
|
||||||
|| is_stylus_in_proximity
|
|| is_stylus_in_proximity
|
||||||
|| fling.is_active()
|
|| 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)));
|
target.set_control_flow(ControlFlow::wait_duration(Duration::from_millis(8)));
|
||||||
} else if let Some(at) = [
|
} else if let Some(at) = [
|
||||||
egui_repaint_at,
|
egui_repaint_at,
|
||||||
long_press.deadline(),
|
long_press.deadline(),
|
||||||
|
ui_state.math.deadline(),
|
||||||
Some(disk_watch.deadline()),
|
Some(disk_watch.deadline()),
|
||||||
|
cloud.deadline(),
|
||||||
// The updater thread does not wake the loop: look for its results now and then
|
// The updater thread does not wake the loop: look for its results now and then
|
||||||
matches!(
|
matches!(
|
||||||
updater.status(),
|
updater.status(),
|
||||||
@@ -2495,6 +2606,7 @@ pub fn run_with(
|
|||||||
WindowEvent::Focused(true) => {
|
WindowEvent::Focused(true) => {
|
||||||
// Coming back from another app: look for changes right away
|
// Coming back from another app: look for changes right away
|
||||||
disk_watch.check_soon(Instant::now());
|
disk_watch.check_soon(Instant::now());
|
||||||
|
cloud.sync_soon();
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3176,6 +3288,25 @@ pub fn run_with(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clicking a math suggestion inserts its result (Issue #46)
|
||||||
|
if button == MouseButton::Left && !ui_state.view_mode {
|
||||||
|
let (sx, sy) = cursor_screen_pos;
|
||||||
|
let scale = window.scale_factor() as f32;
|
||||||
|
if let Some(suggestion) =
|
||||||
|
ui_state.math.take_hit(egui::pos2(sx / scale, sy / scale))
|
||||||
|
{
|
||||||
|
insert_math_result(
|
||||||
|
&suggestion,
|
||||||
|
&mut canvas,
|
||||||
|
&mut notebook,
|
||||||
|
&mut manifest,
|
||||||
|
&mut autosave,
|
||||||
|
);
|
||||||
|
window.request_redraw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fling.stop();
|
fling.stop();
|
||||||
// 1. Pan via mittlere Maustaste oder Leertaste + Linksklick
|
// 1. Pan via mittlere Maustaste oder Leertaste + Linksklick
|
||||||
// In view-only mode the left and right buttons move the page too (Issue #35)
|
// In view-only mode the left and right buttons move the page too (Issue #35)
|
||||||
@@ -3380,7 +3511,7 @@ pub fn run_with(
|
|||||||
&mut notebook,
|
&mut notebook,
|
||||||
&mut manifest,
|
&mut manifest,
|
||||||
&mut autosave,
|
&mut autosave,
|
||||||
&ui_state,
|
&mut ui_state,
|
||||||
&window,
|
&window,
|
||||||
snapped_shape,
|
snapped_shape,
|
||||||
);
|
);
|
||||||
@@ -3589,6 +3720,9 @@ pub fn run_with(
|
|||||||
match touch.phase {
|
match touch.phase {
|
||||||
TouchPhase::Started => {
|
TouchPhase::Started => {
|
||||||
// Jede neue Berührung hält ein gleitendes Blatt an
|
// 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();
|
fling.stop();
|
||||||
// Event-Isolation für Touch-Eingaben
|
// Event-Isolation für Touch-Eingaben
|
||||||
let is_touch_in_ui = ui::is_screen_point_in_ui(
|
let is_touch_in_ui = ui::is_screen_point_in_ui(
|
||||||
@@ -3958,7 +4092,7 @@ pub fn run_with(
|
|||||||
&mut notebook,
|
&mut notebook,
|
||||||
&mut manifest,
|
&mut manifest,
|
||||||
&mut autosave,
|
&mut autosave,
|
||||||
&ui_state,
|
&mut ui_state,
|
||||||
&window,
|
&window,
|
||||||
snapped_shape,
|
snapped_shape,
|
||||||
);
|
);
|
||||||
@@ -4048,6 +4182,17 @@ pub fn run_with(
|
|||||||
raw_input.events.extend(events);
|
raw_input.events.extend(events);
|
||||||
ui_state.queued_actions.extend(actions);
|
ui_state.queued_actions.extend(actions);
|
||||||
}
|
}
|
||||||
|
// Keep the UI out of system bars and cutouts (Android, Issue #50)
|
||||||
|
let safe_area = platform::safe_area_insets();
|
||||||
|
if let Some(rect) = raw_input.screen_rect.filter(|_| safe_area != [0.0; 4])
|
||||||
|
{
|
||||||
|
let ppp = egui_ctx.pixels_per_point();
|
||||||
|
let [left, top, right, bottom] = safe_area.map(|v| v / ppp);
|
||||||
|
raw_input.screen_rect = Some(egui::Rect::from_min_max(
|
||||||
|
rect.min + egui::vec2(left, top),
|
||||||
|
rect.max - egui::vec2(right, bottom),
|
||||||
|
));
|
||||||
|
}
|
||||||
egui_ctx.begin_frame(raw_input);
|
egui_ctx.begin_frame(raw_input);
|
||||||
|
|
||||||
// 2. Auswahl-Overlay rendern (Bounding Box, Griffe, Lasso)
|
// 2. Auswahl-Overlay rendern (Bounding Box, Griffe, Lasso)
|
||||||
@@ -4067,6 +4212,7 @@ pub fn run_with(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.filter(|(id, _, _)| *id == manifest.id)
|
.filter(|(id, _, _)| *id == manifest.id)
|
||||||
.map(|(_, index, _)| index + 1);
|
.map(|(_, index, _)| index + 1);
|
||||||
|
ui_state.cloud = cloud.view();
|
||||||
let actions = ui::render_ui(
|
let actions = ui::render_ui(
|
||||||
&egui_ctx,
|
&egui_ctx,
|
||||||
&mut ui_state,
|
&mut ui_state,
|
||||||
@@ -4080,8 +4226,8 @@ pub fn run_with(
|
|||||||
let reserved =
|
let reserved =
|
||||||
ui_state.sidebar_reserved_width() * window.scale_factor() as f32;
|
ui_state.sidebar_reserved_width() * window.scale_factor() as f32;
|
||||||
let (inset_left, inset_right) = match ui_state.sidebar_side {
|
let (inset_left, inset_right) = match ui_state.sidebar_side {
|
||||||
SidebarSide::Left => (reserved, 0.0),
|
SidebarSide::Left => (reserved + safe_area[0], safe_area[2]),
|
||||||
SidebarSide::Right => (0.0, reserved),
|
SidebarSide::Right => (safe_area[0], reserved + safe_area[2]),
|
||||||
};
|
};
|
||||||
if canvas.set_ui_insets(inset_left, inset_right) {
|
if canvas.set_ui_insets(inset_left, inset_right) {
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
@@ -5128,6 +5274,26 @@ pub fn run_with(
|
|||||||
}
|
}
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
|
UiAction::CloudConnect { server, trusted } => {
|
||||||
|
cloud.connect(&server, trusted);
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
UiAction::CloudCancel => {
|
||||||
|
cloud.cancel_login();
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
UiAction::CloudSyncNow => {
|
||||||
|
let _ = autosave.flush();
|
||||||
|
cloud.sync_now();
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
UiAction::CloudDisconnect => {
|
||||||
|
cloud.disconnect();
|
||||||
|
ui_state.set_export_message(
|
||||||
|
t!("Nextcloud getrennt – die Notizbücher bleiben auf diesem Gerät").to_string(),
|
||||||
|
);
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
UiAction::OpenUrlInBrowser(url) => {
|
UiAction::OpenUrlInBrowser(url) => {
|
||||||
let _ = webbrowser::open(&url);
|
let _ = webbrowser::open(&url);
|
||||||
}
|
}
|
||||||
@@ -5187,6 +5353,22 @@ pub fn run_with(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
UiAction::SetMathAssist(on) => {
|
||||||
|
ui_settings.math_assist = on;
|
||||||
|
save_ui_settings(
|
||||||
|
&ui_settings,
|
||||||
|
ui_settings_path.as_deref(),
|
||||||
|
&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) => {
|
UiAction::SetSidebarSide(side) => {
|
||||||
ui_settings.sidebar_side = side;
|
ui_settings.sidebar_side = side;
|
||||||
save_ui_settings(
|
save_ui_settings(
|
||||||
@@ -5222,6 +5404,7 @@ pub fn run_with(
|
|||||||
ui_state.storage_dir = storage_base_dir.clone();
|
ui_state.storage_dir = storage_base_dir.clone();
|
||||||
ui_state.storage_dir_custom =
|
ui_state.storage_dir_custom =
|
||||||
ui_settings.storage_dir.is_some();
|
ui_settings.storage_dir.is_some();
|
||||||
|
cloud.set_local_dir(storage_base_dir.clone());
|
||||||
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||||
// Same notebook if it exists there, otherwise the first one
|
// Same notebook if it exists there, otherwise the first one
|
||||||
let open_id = ui_state
|
let open_id = ui_state
|
||||||
@@ -5411,7 +5594,7 @@ pub fn run_with(
|
|||||||
ui_state.sync_tool_colors();
|
ui_state.sync_tool_colors();
|
||||||
if !egui_ctx.is_using_pointer() {
|
if !egui_ctx.is_using_pointer() {
|
||||||
let mut updated = ui_settings.clone();
|
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.active_pen = ui_state.active_pen;
|
||||||
updated.highlighter_colors = ui_state.highlighter_colors;
|
updated.highlighter_colors = ui_state.highlighter_colors;
|
||||||
updated.active_highlighter = ui_state.active_highlighter;
|
updated.active_highlighter = ui_state.active_highlighter;
|
||||||
|
|||||||
+1945
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -87,7 +87,7 @@ fn scan(dir: &Path) -> HashMap<String, Fingerprint> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Page id of a regular page file (`page_<id>.json`).
|
/// Page id of a regular page file (`page_<id>.json`).
|
||||||
fn page_file_id(name: &str) -> Option<u64> {
|
pub(crate) fn page_file_id(name: &str) -> Option<u64> {
|
||||||
name.strip_prefix("page_")?
|
name.strip_prefix("page_")?
|
||||||
.strip_suffix(".json")?
|
.strip_suffix(".json")?
|
||||||
.parse()
|
.parse()
|
||||||
|
|||||||
+76
-6
@@ -275,7 +275,7 @@ pub enum EraserMode {
|
|||||||
/// Nur Textmarker – die Handschrift darunter bleibt erhalten
|
/// Nur Textmarker – die Handschrift darunter bleibt erhalten
|
||||||
HighlighterOnly,
|
HighlighterOnly,
|
||||||
/// Nur den überstrichenen Teil eines Strichs entfernen; der Rest bleibt in Stücken
|
/// 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,
|
Partial,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,6 +578,12 @@ impl Stroke {
|
|||||||
min_dist
|
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.
|
/// Prüft, ob dieser Strich von einem Radierer an Position `eraser_pos` mit Radius `eraser_radius` getroffen wird.
|
||||||
pub fn intersects_eraser(
|
pub fn intersects_eraser(
|
||||||
&self,
|
&self,
|
||||||
@@ -942,10 +948,15 @@ impl Page {
|
|||||||
let mut hit_indices = Vec::new();
|
let mut hit_indices = Vec::new();
|
||||||
|
|
||||||
for (i, stroke) in self.strokes.iter().enumerate() {
|
for (i, stroke) in self.strokes.iter().enumerate() {
|
||||||
if !stroke.is_object()
|
let hit = if stroke.is_text() {
|
||||||
&& mode.erases(stroke.tool)
|
mode != EraserMode::HighlighterOnly
|
||||||
&& stroke.intersects_eraser(eraser_pos, eraser_radius, max_stroke_radius)
|
&& 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);
|
hit_indices.push(i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -970,7 +981,7 @@ impl Page {
|
|||||||
|
|
||||||
/// Teilradierer: entfernt den Teil aller Striche im Kreis um `eraser_pos`. Getroffene
|
/// 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`),
|
/// 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.
|
/// eines Radierzugs bilden einen gemeinsamen Undo-Schritt. Gibt `true` zurück, wenn sich etwas geändert hat.
|
||||||
pub fn erase_partial_at(
|
pub fn erase_partial_at(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -982,6 +993,14 @@ impl Page {
|
|||||||
let mut added = Vec::new();
|
let mut added = Vec::new();
|
||||||
let mut result = Vec::with_capacity(self.strokes.len());
|
let mut result = Vec::with_capacity(self.strokes.len());
|
||||||
for stroke in std::mem::take(&mut self.strokes) {
|
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;
|
let reach = eraser_radius + stroke.width / 2.0;
|
||||||
if stroke.is_object() || !stroke.aabb.expanded(reach).contains_point(eraser_pos) {
|
if stroke.is_object() || !stroke.aabb.expanded(reach).contains_point(eraser_pos) {
|
||||||
result.push(stroke);
|
result.push(stroke);
|
||||||
@@ -2177,6 +2196,57 @@ mod tests {
|
|||||||
assert!(page.strokes.is_empty());
|
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]
|
#[test]
|
||||||
fn test_pdf_page_size_follows_orientation() {
|
fn test_pdf_page_size_follows_orientation() {
|
||||||
// Hochformat (A4, Letter) bleibt A4 hoch
|
// Hochformat (A4, Letter) bleibt A4 hoch
|
||||||
|
|||||||
@@ -172,6 +172,8 @@ mod tests {
|
|||||||
("rnote.rs", include_str!("rnote.rs")),
|
("rnote.rs", include_str!("rnote.rs")),
|
||||||
("saber.rs", include_str!("saber.rs")),
|
("saber.rs", include_str!("saber.rs")),
|
||||||
("xopp.rs", include_str!("xopp.rs")),
|
("xopp.rs", include_str!("xopp.rs")),
|
||||||
|
("nextcloud.rs", include_str!("nextcloud.rs")),
|
||||||
|
("cloud_sync.rs", include_str!("cloud_sync.rs")),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Alle Texte in `t!("…")` und `tn!("…")` eines Quelltexts, auch über mehrere Zeilen
|
/// Alle Texte in `t!("…")` und `tn!("…")` eines Quelltexts, auch über mehrere Zeilen
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("1 Objekt | [R] Drehen [Entf] Löschen", "1 object | [R] Rotate [Del] Delete"),
|
("1 Objekt | [R] Drehen [Entf] Löschen", "1 object | [R] Rotate [Del] Delete"),
|
||||||
("Abbrechen", "Cancel"),
|
("Abbrechen", "Cancel"),
|
||||||
("Abfrage fehlgeschlagen:", "Request failed:"),
|
("Abfrage fehlgeschlagen:", "Request failed:"),
|
||||||
|
("Abgleich beenden und das App-Passwort widerrufen; die Notizbücher bleiben auf diesem Gerät und auf dem Server", "Stop syncing and revoke the app password; the notebooks stay on this device and on the server"),
|
||||||
("Achsenkreuz", "Axis cross"),
|
("Achsenkreuz", "Axis cross"),
|
||||||
("Achteck (Oktagon)", "Octagon"),
|
("Achteck (Oktagon)", "Octagon"),
|
||||||
("Aktuelle Version:", "Current version:"),
|
("Aktuelle Version:", "Current version:"),
|
||||||
@@ -20,6 +21,9 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("Alle", "All"),
|
("Alle", "All"),
|
||||||
("Alles gespeichert", "All saved"),
|
("Alles gespeichert", "All saved"),
|
||||||
("Als leere Seite bearbeiten (Schreibschutz aufheben)", "Edit as blank page (remove write protection)"),
|
("Als leere Seite bearbeiten (Schreibschutz aufheben)", "Edit as blank page (remove write protection)"),
|
||||||
|
("Anmeldung abgelaufen – bitte erneut verbinden", "Sign-in expired – please connect again"),
|
||||||
|
("Anmeldung abgelehnt – bitte neu verbinden", "Sign-in rejected – please connect again"),
|
||||||
|
("Anmeldung im Browser; die App bekommt ein eigenes App-Passwort, das sich in Nextcloud jederzeit widerrufen lässt", "Sign in in the browser; the app gets an app password of its own, which can be revoked in Nextcloud at any time"),
|
||||||
("AppImage (Portabel)", "AppImage (portable)"),
|
("AppImage (Portabel)", "AppImage (portable)"),
|
||||||
("Asset-Name darf nicht leer sein.", "Asset name must not be empty."),
|
("Asset-Name darf nicht leer sein.", "Asset name must not be empty."),
|
||||||
("Auch per Strg+V aus der Zwischenablage oder durch Ziehen einer Bilddatei ins Fenster", "Also with Ctrl+V from the clipboard or by dragging an image file into the window"),
|
("Auch per Strg+V aus der Zwischenablage oder durch Ziehen einer Bilddatei ins Fenster", "Also with Ctrl+V from the clipboard or by dragging an image file into the window"),
|
||||||
@@ -44,6 +48,8 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("Bild mit transparentem Hintergrund, nur Inhalt", "Image with transparent background, content only"),
|
("Bild mit transparentem Hintergrund, nur Inhalt", "Image with transparent background, content only"),
|
||||||
("Bild nicht eingefügt: {err}", "Image not inserted: {err}"),
|
("Bild nicht eingefügt: {err}", "Image not inserted: {err}"),
|
||||||
("Bild, Text oder kopierte Objekte aus der Zwischenablage hier einfügen", "Paste an image, text or copied objects from the clipboard here"),
|
("Bild, Text oder kopierte Objekte aus der Zwischenablage hier einfügen", "Paste an image, text or copied objects from the clipboard here"),
|
||||||
|
("Bitte die Adresse des Nextcloud-Servers eingeben", "Please enter the address of the Nextcloud server"),
|
||||||
|
("Bitte im Browser anmelden und den Zugriff erlauben …", "Please sign in in the browser and grant access …"),
|
||||||
("Blanko", "Blank"),
|
("Blanko", "Blank"),
|
||||||
("Blau", "Blue"),
|
("Blau", "Blue"),
|
||||||
("Code", "Code"),
|
("Code", "Code"),
|
||||||
@@ -56,15 +62,34 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("Den Ordner gibt es nicht mehr", "The folder no longer exists"),
|
("Den Ordner gibt es nicht mehr", "The folder no longer exists"),
|
||||||
("Den Ordner „{name}“ gibt es hier schon", "A folder “{name}” already exists here"),
|
("Den Ordner „{name}“ gibt es hier schon", "A folder “{name}” already exists here"),
|
||||||
("Den Zielordner gibt es nicht mehr", "The target folder no longer exists"),
|
("Den Zielordner gibt es nicht mehr", "The target folder no longer exists"),
|
||||||
|
("Der Ordner „ink-notes“ auf dem Server fehlt. Zur Sicherheit wird nichts gelöscht – zum erneuten Hochladen die Verbindung trennen und neu verbinden.", "The folder “ink-notes” is missing on the server. To be safe, nothing is deleted – to upload again, disconnect and connect again."),
|
||||||
("Der Ordnername ist leer", "The folder name is empty"),
|
("Der Ordnername ist leer", "The folder name is empty"),
|
||||||
("Der Ordnername ist zu lang (höchstens {max} Zeichen)", "The folder name is too long (at most {max} characters)"),
|
("Der Ordnername ist zu lang (höchstens {max} Zeichen)", "The folder name is too long (at most {max} characters)"),
|
||||||
|
("Der Server leitet nach {to} weiter – bitte diese Adresse verwenden", "The server redirects to {to} – please use that address"),
|
||||||
|
("Der Speicherort {dir} fehlt. Zur Sicherheit wird nichts gelöscht.", "The storage location {dir} is missing. To be safe, nothing is deleted."),
|
||||||
("Der übergeordnete Ordner existiert nicht mehr", "The parent folder no longer exists"),
|
("Der übergeordnete Ordner existiert nicht mehr", "The parent folder no longer exists"),
|
||||||
("DICKEN-PRESETS", "WIDTH PRESETS"),
|
("DICKEN-PRESETS", "WIDTH PRESETS"),
|
||||||
|
("Die Adresse muss mit https:// beginnen", "The address must start with https://"),
|
||||||
("Die beschädigte Originaldatei wurde in Quarantäne gesichert:", "The damaged original file was saved to quarantine:"),
|
("Die beschädigte Originaldatei wurde in Quarantäne gesichert:", "The damaged original file was saved to quarantine:"),
|
||||||
("Die Datei ist kein lesbares PDF", "The file is not a readable PDF"),
|
("Die Datei ist kein lesbares PDF", "The file is not a readable PDF"),
|
||||||
("Die einzige Seite eines Notizbuchs kann nicht gelöscht werden", "The only page of a notebook cannot be deleted"),
|
("Die einzige Seite eines Notizbuchs kann nicht gelöscht werden", "The only page of a notebook cannot be deleted"),
|
||||||
("Die Flatpak-Version wird über die Software-Verwaltung aktualisiert.", "The Flatpak version is updated through the software manager."),
|
("Die Flatpak-Version wird über die Software-Verwaltung aktualisiert.", "The Flatpak version is updated through the software manager."),
|
||||||
("Die heruntergeladene Datei existiert nicht mehr.", "The downloaded file no longer exists."),
|
("Die heruntergeladene Datei existiert nicht mehr.", "The downloaded file no longer exists."),
|
||||||
|
("Inzwischen geändert", "Changed meanwhile"),
|
||||||
|
("Kein Datenordner", "No data folder"),
|
||||||
|
("NEXTCLOUD", "NEXTCLOUD"),
|
||||||
|
("Nextcloud getrennt – die Notizbücher bleiben auf diesem Gerät", "Nextcloud disconnected – the notebooks stay on this device"),
|
||||||
|
("Nicht gefunden", "Not found"),
|
||||||
|
("Server nicht erreichbar: {msg}", "Server not reachable: {msg}"),
|
||||||
|
("Server-Fehler {code}", "Server error {code}"),
|
||||||
|
("Sofort mit dem Server abgleichen", "Sync with the server right away"),
|
||||||
|
("Trennen", "Disconnect"),
|
||||||
|
("Unerwartete Antwort des Servers", "Unexpected answer from the server"),
|
||||||
|
("Ungültige Adresse: {e}", "Invalid address: {e}"),
|
||||||
|
("Unter dieser Adresse antwortet kein Nextcloud-Server", "No Nextcloud server answers at this address"),
|
||||||
|
("Vertrauen", "Trust"),
|
||||||
|
("Zertifikat nicht vertrauenswürdig (SHA-256 {fingerprint})", "Certificate not trusted (SHA-256 {fingerprint})"),
|
||||||
|
("Zugang nicht gespeichert: {err}", "Sign-in not saved: {err}"),
|
||||||
("die oberste Ebene", "the top level"),
|
("die oberste Ebene", "the top level"),
|
||||||
("Die Zwischenablage ist leer", "The clipboard is empty"),
|
("Die Zwischenablage ist leer", "The clipboard is empty"),
|
||||||
("Diese Version wird über die Software-Verwaltung aktualisiert (z. B. GNOME Software oder „flatpak update“).", "This version is updated through the software manager (e.g. GNOME Software or “flatpak update”)."),
|
("Diese Version wird über die Software-Verwaltung aktualisiert (z. B. GNOME Software oder „flatpak update“).", "This version is updated through the software manager (e.g. GNOME Software or “flatpak update”)."),
|
||||||
@@ -107,6 +132,7 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("Fehler beim Öffnen: {err}", "Error while opening: {err}"),
|
("Fehler beim Öffnen: {err}", "Error while opening: {err}"),
|
||||||
("Fehler: {err}", "Error: {err}"),
|
("Fehler: {err}", "Error: {err}"),
|
||||||
("Fehlermeldung: {message}", "Error message: {message}"),
|
("Fehlermeldung: {message}", "Error message: {message}"),
|
||||||
|
("Formeln nach „=“ ausrechnen", "Compute formulas after “=”"),
|
||||||
("Formen (Shape)", "Shapes"),
|
("Formen (Shape)", "Shapes"),
|
||||||
("Formen: Striche werden zu Linien, Rechtecken, Kreisen … (S/F)", "Shapes: strokes become lines, rectangles, circles … (S/F)"),
|
("Formen: Striche werden zu Linien, Rechtecken, Kreisen … (S/F)", "Shapes: strokes become lines, rectangles, circles … (S/F)"),
|
||||||
("Fünfeck (Pentagon)", "Pentagon"),
|
("Fünfeck (Pentagon)", "Pentagon"),
|
||||||
@@ -123,6 +149,7 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("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"),
|
||||||
|
("gerade eben", "just now"),
|
||||||
("hart", "hard"),
|
("hart", "hard"),
|
||||||
("Hell", "Light"),
|
("Hell", "Light"),
|
||||||
("Hellgrün", "Light green"),
|
("Hellgrün", "Light green"),
|
||||||
@@ -186,6 +213,7 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("Mindestdruck", "Minimum pressure"),
|
("Mindestdruck", "Minimum pressure"),
|
||||||
("Mit dem Finger zeichnen", "Draw with finger"),
|
("Mit dem Finger zeichnen", "Draw with finger"),
|
||||||
("Mittel", "Medium"),
|
("Mittel", "Medium"),
|
||||||
|
("Nach einem handgeschriebenen „=“ erscheint das Ergebnis daneben, z. B. 2⁴ = 16. Antippen mit dem Stift oder Klicken fügt es als Text ein.\nGelesen werden Ziffern, + − · : /, Klammern, Hochzahlen, Brüche und x, y, a, b.", "After a handwritten “=” the result appears next to it, e.g. 2⁴ = 16. Tapping it with the pen or clicking inserts it as text.\nDigits, + − · : /, parentheses, exponents, fractions and x, y, a, b are read."),
|
||||||
("Name …", "Name …"),
|
("Name …", "Name …"),
|
||||||
("Neon-Gelb", "Neon yellow"),
|
("Neon-Gelb", "Neon yellow"),
|
||||||
("Netzwerk-Transportfehler: {t}", "Network transport error: {t}"),
|
("Netzwerk-Transportfehler: {t}", "Network transport error: {t}"),
|
||||||
@@ -364,6 +392,8 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("Verzeichnis '{path}' enthält kein manifest.json", "Folder '{path}' contains no manifest.json"),
|
("Verzeichnis '{path}' enthält kein manifest.json", "Folder '{path}' contains no manifest.json"),
|
||||||
("Verzeichnis '{path}' existiert nicht", "Folder '{path}' does not exist"),
|
("Verzeichnis '{path}' existiert nicht", "Folder '{path}' does not exist"),
|
||||||
("Vorherige Seite (Bild auf)", "Previous page (Page Up)"),
|
("Vorherige Seite (Bild auf)", "Previous page (Page Up)"),
|
||||||
|
("vor {hours} h", "{hours} h ago"),
|
||||||
|
("vor {minutes} min", "{minutes} min ago"),
|
||||||
("weich", "soft"),
|
("weich", "soft"),
|
||||||
("Weich: schon leichter Druck ergibt kräftige Linien.\nHart: erst fester Druck ergibt kräftige Linien.", "Soft: even light pressure gives bold lines.\nHard: only firm pressure gives bold lines."),
|
("Weich: schon leichter Druck ergibt kräftige Linien.\nHart: erst fester Druck ergibt kräftige Linien.", "Soft: even light pressure gives bold lines.\nHard: only firm pressure gives bold lines."),
|
||||||
("Weitere Funktionen – Update verfügbar", "More – update available"),
|
("Weitere Funktionen – Update verfügbar", "More – update available"),
|
||||||
@@ -383,6 +413,7 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("Zugriffsfehler (I/O). Die Datei existiert, kann aber nicht gelesen werden.\nDie Seite bleibt schreibgeschützt, um ein Überschreiben zu verhindern.", "Access error (I/O). The file exists but cannot be read.\nThe page stays read-only to prevent overwriting it."),
|
("Zugriffsfehler (I/O). Die Datei existiert, kann aber nicht gelesen werden.\nDie Seite bleibt schreibgeschützt, um ein Überschreiben zu verhindern.", "Access error (I/O). The file exists but cannot be read.\nThe page stays read-only to prevent overwriting it."),
|
||||||
("Zurück zu Seiten geht nur, solange alles im Startausschnitt liegt", "Back to pages only works while everything lies in the starting area"),
|
("Zurück zu Seiten geht nur, solange alles im Startausschnitt liegt", "Back to pages only works while everything lies in the starting area"),
|
||||||
("Zurücksetzen (Strg+0)", "Reset (Ctrl+0)"),
|
("Zurücksetzen (Strg+0)", "Reset (Ctrl+0)"),
|
||||||
|
("z. B. cloud.example.org", "e.g. cloud.example.org"),
|
||||||
("{count} Objekt(e) eingefügt", "{count} object(s) pasted"),
|
("{count} Objekt(e) eingefügt", "{count} object(s) pasted"),
|
||||||
("{count} Objekt(e) gelöscht", "{count} object(s) deleted"),
|
("{count} Objekt(e) gelöscht", "{count} object(s) deleted"),
|
||||||
("{count} Objekt(e) kopiert – auch als Bild für andere Programme", "{count} object(s) copied – also as an image for other apps"),
|
("{count} Objekt(e) kopiert – auch als Bild für andere Programme", "{count} object(s) copied – also as an image for other apps"),
|
||||||
@@ -403,21 +434,28 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("„{title}“ verschieben nach:", "Move “{title}” to:"),
|
("„{title}“ verschieben nach:", "Move “{title}” to:"),
|
||||||
("↩ Rückgängig", "↩ Undo"),
|
("↩ Rückgängig", "↩ Undo"),
|
||||||
("⏳ Update lädt … {percent} %", "⏳ Downloading update … {percent} %"),
|
("⏳ Update lädt … {percent} %", "⏳ Downloading update … {percent} %"),
|
||||||
|
("⏳ Verbinde …", "⏳ Connecting …"),
|
||||||
("⏷ {mm} mm Platz", "⏷ {mm} mm space"),
|
("⏷ {mm} mm Platz", "⏷ {mm} mm space"),
|
||||||
("♾ Endlos", "♾ Endless"),
|
("♾ Endlos", "♾ Endless"),
|
||||||
("⚙ Erweiterte Server-Einstellungen", "⚙ Advanced server settings"),
|
("⚙ Erweiterte Server-Einstellungen", "⚙ Advanced server settings"),
|
||||||
("⚠ Beschädigt", "⚠ Damaged"),
|
("⚠ Beschädigt", "⚠ Damaged"),
|
||||||
|
("⚠ Das Zertifikat von {server} ist von keiner bekannten Stelle signiert. Nur vertrauen, wenn dieser Fingerabdruck mit dem des Servers übereinstimmt:", "⚠ The certificate of {server} isn't signed by a known authority. Only trust it if this fingerprint matches the server's:"),
|
||||||
("⚠ Datenintegritäts-Warnung", "⚠ Data integrity warning"),
|
("⚠ Datenintegritäts-Warnung", "⚠ Data integrity warning"),
|
||||||
("⚠ In anderer Instanz geöffnet (Schreibschutz)", "⚠ Opened in another instance (read-only)"),
|
("⚠ In anderer Instanz geöffnet (Schreibschutz)", "⚠ Opened in another instance (read-only)"),
|
||||||
|
("⚠ Ohne https ist das App-Passwort im Netz lesbar – nur im eigenen Heimnetz verwenden", "⚠ Without https the app password can be read on the network – only use this in your own home network"),
|
||||||
("⚠ Ungespeicherte Änderungen", "⚠ Unsaved changes"),
|
("⚠ Ungespeicherte Änderungen", "⚠ Unsaved changes"),
|
||||||
|
("⚠ Unverschlüsselt (http)", "⚠ Unencrypted (http)"),
|
||||||
("⚠\u{fe0f} {name} (Beschädigt - kein Manifest)", "⚠\u{fe0f} {name} (damaged - no manifest)"),
|
("⚠\u{fe0f} {name} (Beschädigt - kein Manifest)", "⚠\u{fe0f} {name} (damaged - no manifest)"),
|
||||||
("⚠\u{fe0f} {name} (Beschädigt)", "⚠\u{fe0f} {name} (damaged)"),
|
("⚠\u{fe0f} {name} (Beschädigt)", "⚠\u{fe0f} {name} (damaged)"),
|
||||||
|
("⚪ Offline – neuer Versuch in Kürze ({msg})", "⚪ Offline – trying again shortly ({msg})"),
|
||||||
("⚫ Aktiv", "⚫ Active"),
|
("⚫ Aktiv", "⚫ Active"),
|
||||||
|
("✔ Synchronisiert {when}", "✔ Synced {when}"),
|
||||||
("✔ Update bereit", "✔ Update ready"),
|
("✔ Update bereit", "✔ Update ready"),
|
||||||
("➕ Neue Seite einfügen", "➕ Insert new page"),
|
("➕ Neue Seite einfügen", "➕ Insert new page"),
|
||||||
("➕ Notizbuch", "➕ Notebook"),
|
("➕ Notizbuch", "➕ Notebook"),
|
||||||
("⟲ Gelöschte Seite {number} wiederherstellen", "⟲ Restore deleted page {number}"),
|
("⟲ Gelöschte Seite {number} wiederherstellen", "⟲ Restore deleted page {number}"),
|
||||||
("⬇ Downloads", "⬇ Downloads"),
|
("⬇ Downloads", "⬇ Downloads"),
|
||||||
|
("🌐 Browser öffnen", "🌐 Open browser"),
|
||||||
("🌐 Release auf Gitea öffnen", "🌐 Open release on Gitea"),
|
("🌐 Release auf Gitea öffnen", "🌐 Open release on Gitea"),
|
||||||
("🏠 Persönlicher Ordner", "🏠 Home folder"),
|
("🏠 Persönlicher Ordner", "🏠 Home folder"),
|
||||||
("💾 Einstellungen speichern", "💾 Save settings"),
|
("💾 Einstellungen speichern", "💾 Save settings"),
|
||||||
@@ -433,10 +471,13 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("📚 Oberste Ebene", "📚 Top level"),
|
("📚 Oberste Ebene", "📚 Top level"),
|
||||||
("📜 Durchgehend", "📜 Continuous"),
|
("📜 Durchgehend", "📜 Continuous"),
|
||||||
("📦 Paket mit Software-Center öffnen / installieren", "📦 Open / install package with the software center"),
|
("📦 Paket mit Software-Center öffnen / installieren", "📦 Open / install package with the software center"),
|
||||||
|
("🔄 Jetzt", "🔄 Now"),
|
||||||
("🔄 Suche nach Updates …", "🔄 Checking for updates …"),
|
("🔄 Suche nach Updates …", "🔄 Checking for updates …"),
|
||||||
|
("🔄 Synchronisiere …", "🔄 Syncing …"),
|
||||||
("🔄 Updates & Versionen", "🔄 Updates & versions"),
|
("🔄 Updates & Versionen", "🔄 Updates & versions"),
|
||||||
("🔄 Backup", "🔄 Backup"),
|
("🔄 Backup", "🔄 Backup"),
|
||||||
("🔄 Nach Updates suchen", "🔄 Check for updates"),
|
("🔄 Nach Updates suchen", "🔄 Check for updates"),
|
||||||
|
("🔗 Verbinden …", "🔗 Connect …"),
|
||||||
("🖥 Schreibtisch", "🖥 Desktop"),
|
("🖥 Schreibtisch", "🖥 Desktop"),
|
||||||
("🖼 Bild aus Datei …", "🖼 Image from file …"),
|
("🖼 Bild aus Datei …", "🖼 Image from file …"),
|
||||||
("🖼 Bild einfügen … (Strg+I)", "🖼 Insert image … (Ctrl+I)"),
|
("🖼 Bild einfügen … (Strg+I)", "🖼 Insert image … (Ctrl+I)"),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ pub mod assets;
|
|||||||
pub mod autosave;
|
pub mod autosave;
|
||||||
pub mod background;
|
pub mod background;
|
||||||
pub mod canvas;
|
pub mod canvas;
|
||||||
|
pub mod cloud_sync;
|
||||||
pub mod disk_watch;
|
pub mod disk_watch;
|
||||||
pub mod document;
|
pub mod document;
|
||||||
pub mod export;
|
pub mod export;
|
||||||
@@ -18,6 +19,10 @@ pub mod i18n;
|
|||||||
pub mod image_layer;
|
pub mod image_layer;
|
||||||
pub mod import;
|
pub mod import;
|
||||||
pub mod library;
|
pub mod library;
|
||||||
|
pub mod math;
|
||||||
|
pub mod math_assist;
|
||||||
|
pub mod math_ink;
|
||||||
|
pub mod nextcloud;
|
||||||
pub mod osk;
|
pub mod osk;
|
||||||
pub mod page_export;
|
pub mod page_export;
|
||||||
pub mod paths;
|
pub mod paths;
|
||||||
|
|||||||
+757
@@ -0,0 +1,757 @@
|
|||||||
|
//! Exact evaluation of handwritten math (Issue #46, stage 1).
|
||||||
|
//!
|
||||||
|
//! Takes the text of a recognised expression such as `2^4`, `2,5 · 3` or `(x + 3)²`
|
||||||
|
//! (optionally ending with `=`) and returns its result as text: `16`, `7,5` and
|
||||||
|
//! `x² + 6x + 9`. Numbers are exact fractions, so `1 : 3 · 3` gives `1`, and expressions
|
||||||
|
//! with variables are expanded into polynomials.
|
||||||
|
//!
|
||||||
|
//! The input is lenient towards how math is written by hand: decimal comma or point,
|
||||||
|
//! `·`, `×`, `*` and `:`, `÷`, `/`, superscript exponents, Unicode minus signs and implicit
|
||||||
|
//! multiplication (`2x`, `3(x + 1)`, `(a + b)(a − b)`).
|
||||||
|
|
||||||
|
use std::cmp::Ordering;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
|
/// Largest exponent for a polynomial that is not a plain number
|
||||||
|
const MAX_POLYNOMIAL_EXPONENT: u32 = 32;
|
||||||
|
/// Largest exponent for a plain number
|
||||||
|
const MAX_NUMBER_EXPONENT: u32 = 1000;
|
||||||
|
/// Results with more terms are not shown (they would not fit next to the `=` anyway)
|
||||||
|
const MAX_TERMS: usize = 64;
|
||||||
|
/// Terminating decimals with more digits after the separator are shown as fractions
|
||||||
|
const MAX_DECIMALS: u32 = 12;
|
||||||
|
|
||||||
|
/// Computes the result of `expression`, written for display after its `=` sign.
|
||||||
|
///
|
||||||
|
/// `decimal_comma` selects the decimal separator of the result. Returns `None` if the
|
||||||
|
/// expression cannot be read or computed (e.g. division by zero, a variable in a
|
||||||
|
/// denominator or overflow), and also if there is nothing to compute, i.e. the result would
|
||||||
|
/// just repeat the input (`5`, `x`, `2x`).
|
||||||
|
pub fn solve(expression: &str, decimal_comma: bool) -> Option<String> {
|
||||||
|
let tokens = tokenize(expression)?;
|
||||||
|
let ast = Parser::new(&tokens).parse()?;
|
||||||
|
if matches!(ast, Expr::Number(_) | Expr::Variable(_)) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let result = ast.eval()?;
|
||||||
|
if result.terms.len() > MAX_TERMS {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let text = result.format(decimal_comma);
|
||||||
|
let compact = |s: &str| -> String {
|
||||||
|
s.chars()
|
||||||
|
.filter(|c| !c.is_whitespace())
|
||||||
|
.map(|c| if c == '−' { '-' } else { c })
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
if compact(&text) == compact(expression.trim().trim_end_matches('=')) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `expression` written neatly for showing it, e.g. `2⁴` for `2^(4)`, `(x + 1)/2` for
|
||||||
|
/// `((x+1)/(2))` or `2,5 · 3` for `2,5*3`. `None` if it cannot be read.
|
||||||
|
pub fn display(expression: &str, decimal_comma: bool) -> Option<String> {
|
||||||
|
let tokens = tokenize(expression)?;
|
||||||
|
let ast = Parser::new(&tokens).parse()?;
|
||||||
|
Some(ast.display(decimal_comma))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Rational numbers ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Exact fraction `num / den` with `den > 0`, always reduced
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
struct Rational {
|
||||||
|
num: i128,
|
||||||
|
den: i128,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gcd(mut a: i128, mut b: i128) -> i128 {
|
||||||
|
a = a.abs();
|
||||||
|
b = b.abs();
|
||||||
|
while b != 0 {
|
||||||
|
(a, b) = (b, a % b);
|
||||||
|
}
|
||||||
|
a
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rational {
|
||||||
|
const ZERO: Rational = Rational { num: 0, den: 1 };
|
||||||
|
const ONE: Rational = Rational { num: 1, den: 1 };
|
||||||
|
|
||||||
|
fn new(num: i128, den: i128) -> Option<Rational> {
|
||||||
|
// i128::MIN has no positive counterpart, which `gcd` and `neg` need
|
||||||
|
if den == 0 || num == i128::MIN || den == i128::MIN {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let g = gcd(num, den).max(1);
|
||||||
|
let (mut num, mut den) = (num / g, den / g);
|
||||||
|
if den < 0 {
|
||||||
|
num = num.checked_neg()?;
|
||||||
|
den = den.checked_neg()?;
|
||||||
|
}
|
||||||
|
Some(Rational { num, den })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn integer(n: i128) -> Rational {
|
||||||
|
Rational { num: n, den: 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_zero(self) -> bool {
|
||||||
|
self.num == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add(self, other: Rational) -> Option<Rational> {
|
||||||
|
let g = gcd(self.den, other.den);
|
||||||
|
let den = (self.den / g).checked_mul(other.den)?;
|
||||||
|
let num = self
|
||||||
|
.num
|
||||||
|
.checked_mul(other.den / g)?
|
||||||
|
.checked_add(other.num.checked_mul(self.den / g)?)?;
|
||||||
|
Rational::new(num, den)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn neg(self) -> Option<Rational> {
|
||||||
|
Some(Rational {
|
||||||
|
num: self.num.checked_neg()?,
|
||||||
|
den: self.den,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mul(self, other: Rational) -> Option<Rational> {
|
||||||
|
// Cross-reducing first keeps the intermediate products small
|
||||||
|
let g1 = gcd(self.num, other.den).max(1);
|
||||||
|
let g2 = gcd(other.num, self.den).max(1);
|
||||||
|
let num = (self.num / g1).checked_mul(other.num / g2)?;
|
||||||
|
let den = (self.den / g2).checked_mul(other.den / g1)?;
|
||||||
|
Rational::new(num, den)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recip(self) -> Option<Rational> {
|
||||||
|
Rational::new(self.den, self.num)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pow(self, exponent: i128) -> Option<Rational> {
|
||||||
|
if exponent.unsigned_abs() > MAX_NUMBER_EXPONENT as u128 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let base = if exponent < 0 { self.recip()? } else { self };
|
||||||
|
let mut result = Rational::ONE;
|
||||||
|
for _ in 0..exponent.unsigned_abs() {
|
||||||
|
result = result.mul(base)?;
|
||||||
|
}
|
||||||
|
Some(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_integer(self) -> Option<i128> {
|
||||||
|
(self.den == 1).then_some(self.num)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The number without its sign: as a terminating decimal if it has one with at most
|
||||||
|
/// [`MAX_DECIMALS`] digits, otherwise as a fraction `p/q`.
|
||||||
|
fn format_abs(self, decimal_comma: bool) -> String {
|
||||||
|
let num = self.num.unsigned_abs();
|
||||||
|
let den = self.den.unsigned_abs();
|
||||||
|
let whole = num / den;
|
||||||
|
let mut rest = num % den;
|
||||||
|
if rest == 0 {
|
||||||
|
return whole.to_string();
|
||||||
|
}
|
||||||
|
let mut reduced = den;
|
||||||
|
let mut decimals = 0;
|
||||||
|
for factor in [2, 5] {
|
||||||
|
while reduced.is_multiple_of(factor) {
|
||||||
|
reduced /= factor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if reduced == 1 {
|
||||||
|
let mut digits = String::new();
|
||||||
|
while rest != 0 && decimals <= MAX_DECIMALS {
|
||||||
|
let Some(shifted) = rest.checked_mul(10) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
rest = shifted;
|
||||||
|
digits.push(char::from(b'0' + (rest / den) as u8));
|
||||||
|
rest %= den;
|
||||||
|
decimals += 1;
|
||||||
|
}
|
||||||
|
if rest == 0 {
|
||||||
|
let separator = if decimal_comma { ',' } else { '.' };
|
||||||
|
return format!("{whole}{separator}{digits}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
format!("{num}/{den}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Polynomials --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Product of variables with their exponents, e.g. `a²b` = `{a: 2, b: 1}`
|
||||||
|
type Monomial = BTreeMap<char, u32>;
|
||||||
|
|
||||||
|
/// Sum of monomials with exact coefficients; a plain number is the empty monomial
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct Polynomial {
|
||||||
|
terms: BTreeMap<Monomial, Rational>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Polynomial {
|
||||||
|
fn constant(value: Rational) -> Polynomial {
|
||||||
|
let mut terms = BTreeMap::new();
|
||||||
|
if !value.is_zero() {
|
||||||
|
terms.insert(Monomial::new(), value);
|
||||||
|
}
|
||||||
|
Polynomial { terms }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn variable(name: char) -> Polynomial {
|
||||||
|
let mut terms = BTreeMap::new();
|
||||||
|
terms.insert(Monomial::from([(name, 1)]), Rational::ONE);
|
||||||
|
Polynomial { terms }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The value if this is a plain number
|
||||||
|
fn as_constant(&self) -> Option<Rational> {
|
||||||
|
match self.terms.len() {
|
||||||
|
0 => Some(Rational::ZERO),
|
||||||
|
1 => self.terms.get(&Monomial::new()).copied(),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_term(&mut self, monomial: Monomial, coefficient: Rational) -> Option<()> {
|
||||||
|
let sum = match self.terms.get(&monomial) {
|
||||||
|
Some(existing) => existing.add(coefficient)?,
|
||||||
|
None => coefficient,
|
||||||
|
};
|
||||||
|
if sum.is_zero() {
|
||||||
|
self.terms.remove(&monomial);
|
||||||
|
} else {
|
||||||
|
self.terms.insert(monomial, sum);
|
||||||
|
}
|
||||||
|
Some(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add(mut self, other: &Polynomial) -> Option<Polynomial> {
|
||||||
|
for (monomial, coefficient) in &other.terms {
|
||||||
|
self.add_term(monomial.clone(), *coefficient)?;
|
||||||
|
}
|
||||||
|
Some(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn neg(&self) -> Option<Polynomial> {
|
||||||
|
let mut terms = BTreeMap::new();
|
||||||
|
for (monomial, coefficient) in &self.terms {
|
||||||
|
terms.insert(monomial.clone(), coefficient.neg()?);
|
||||||
|
}
|
||||||
|
Some(Polynomial { terms })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mul(&self, other: &Polynomial) -> Option<Polynomial> {
|
||||||
|
let mut product = Polynomial::constant(Rational::ZERO);
|
||||||
|
for (m1, c1) in &self.terms {
|
||||||
|
for (m2, c2) in &other.terms {
|
||||||
|
let mut monomial = m1.clone();
|
||||||
|
for (&name, &exponent) in m2 {
|
||||||
|
let e = monomial.entry(name).or_insert(0);
|
||||||
|
*e = e.checked_add(exponent)?;
|
||||||
|
}
|
||||||
|
product.add_term(monomial, c1.mul(*c2)?)?;
|
||||||
|
}
|
||||||
|
if product.terms.len() > MAX_TERMS * 4 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(product)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn div(&self, divisor: &Polynomial) -> Option<Polynomial> {
|
||||||
|
let factor = divisor.as_constant()?.recip()?;
|
||||||
|
self.mul(&Polynomial::constant(factor))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pow(&self, exponent: &Polynomial) -> Option<Polynomial> {
|
||||||
|
let exponent = exponent.as_constant()?.as_integer()?;
|
||||||
|
if let Some(base) = self.as_constant() {
|
||||||
|
return Some(Polynomial::constant(base.pow(exponent)?));
|
||||||
|
}
|
||||||
|
if !(0..=MAX_POLYNOMIAL_EXPONENT as i128).contains(&exponent) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut result = Polynomial::constant(Rational::ONE);
|
||||||
|
for _ in 0..exponent {
|
||||||
|
result = result.mul(self)?;
|
||||||
|
}
|
||||||
|
Some(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// E.g. `x² + 6x + 9`, `a² − b²`, `7,5`: highest degree first, then in alphabetical
|
||||||
|
/// order of the variables (`a² + 2ab + b²`).
|
||||||
|
fn format(&self, decimal_comma: bool) -> String {
|
||||||
|
if self.terms.is_empty() {
|
||||||
|
return "0".to_owned();
|
||||||
|
}
|
||||||
|
let degree = |m: &Monomial| m.values().sum::<u32>();
|
||||||
|
let mut terms: Vec<_> = self.terms.iter().collect();
|
||||||
|
terms.sort_by(|(a, _), (b, _)| degree(b).cmp(°ree(a)).then_with(|| graded_order(a, b)));
|
||||||
|
let mut out = String::new();
|
||||||
|
for (index, (monomial, coefficient)) in terms.into_iter().enumerate() {
|
||||||
|
let negative = coefficient.num < 0;
|
||||||
|
match (index, negative) {
|
||||||
|
(0, true) => out.push('−'),
|
||||||
|
(0, false) => {}
|
||||||
|
(_, true) => out.push_str(" − "),
|
||||||
|
(_, false) => out.push_str(" + "),
|
||||||
|
}
|
||||||
|
let magnitude = coefficient.format_abs(decimal_comma);
|
||||||
|
if monomial.is_empty() {
|
||||||
|
out.push_str(&magnitude);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if magnitude.contains('/') {
|
||||||
|
let _ = write!(out, "({magnitude})");
|
||||||
|
} else if magnitude != "1" {
|
||||||
|
out.push_str(&magnitude);
|
||||||
|
}
|
||||||
|
for (&name, &exponent) in monomial {
|
||||||
|
out.push(name);
|
||||||
|
if exponent > 1 {
|
||||||
|
out.push_str(&superscript(exponent));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Order within the same degree: the monomial with the higher power of the alphabetically
|
||||||
|
/// first variable comes first (`a²`, `ab`, `b²`).
|
||||||
|
fn graded_order(a: &Monomial, b: &Monomial) -> Ordering {
|
||||||
|
let names: std::collections::BTreeSet<char> = a.keys().chain(b.keys()).copied().collect();
|
||||||
|
for name in names {
|
||||||
|
let ea = a.get(&name).copied().unwrap_or(0);
|
||||||
|
let eb = b.get(&name).copied().unwrap_or(0);
|
||||||
|
match eb.cmp(&ea) {
|
||||||
|
Ordering::Equal => continue,
|
||||||
|
other => return other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ordering::Equal
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUPERSCRIPT_DIGITS: [char; 10] = ['⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹'];
|
||||||
|
|
||||||
|
fn superscript(n: u32) -> String {
|
||||||
|
n.to_string()
|
||||||
|
.chars()
|
||||||
|
.map(|d| SUPERSCRIPT_DIGITS[d.to_digit(10).unwrap_or(0) as usize])
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Reading the expression ---------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
enum Token {
|
||||||
|
Number(Rational),
|
||||||
|
Variable(char),
|
||||||
|
Plus,
|
||||||
|
Minus,
|
||||||
|
Times,
|
||||||
|
/// `/` for a fraction, `:` for a division sign (`:` and `÷`)
|
||||||
|
Divide(char),
|
||||||
|
Power,
|
||||||
|
Open,
|
||||||
|
Close,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tokenize(input: &str) -> Option<Vec<Token>> {
|
||||||
|
let input = input.trim().trim_end_matches('=').trim_end();
|
||||||
|
let chars: Vec<char> = input.chars().collect();
|
||||||
|
let mut tokens = Vec::new();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < chars.len() {
|
||||||
|
let c = chars[i];
|
||||||
|
if c.is_ascii_digit() {
|
||||||
|
let (value, next) = read_number(&chars, i)?;
|
||||||
|
tokens.push(Token::Number(value));
|
||||||
|
i = next;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if superscript_value(c).is_some() || c == '⁻' {
|
||||||
|
// A run of superscript characters is the exponent: x²³ = x^23, 2⁻¹ = 2^(-1)
|
||||||
|
let negative = c == '⁻';
|
||||||
|
let start = if negative { i + 1 } else { i };
|
||||||
|
let mut end = start;
|
||||||
|
let mut value: i128 = 0;
|
||||||
|
while let Some(digit) = chars.get(end).and_then(|&d| superscript_value(d)) {
|
||||||
|
value = value.checked_mul(10)?.checked_add(digit)?;
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
if end == start {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
tokens.push(Token::Power);
|
||||||
|
if negative {
|
||||||
|
tokens.push(Token::Minus);
|
||||||
|
}
|
||||||
|
tokens.push(Token::Number(Rational::integer(value)));
|
||||||
|
i = end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let token = match c {
|
||||||
|
_ if c.is_whitespace() => None,
|
||||||
|
'+' => Some(Token::Plus),
|
||||||
|
'-' | '−' | '–' => Some(Token::Minus),
|
||||||
|
'*' | '·' | '⋅' | '∙' | '×' => Some(Token::Times),
|
||||||
|
'/' => Some(Token::Divide('/')),
|
||||||
|
':' | '÷' => Some(Token::Divide(':')),
|
||||||
|
'^' => Some(Token::Power),
|
||||||
|
'(' | '[' | '{' => Some(Token::Open),
|
||||||
|
')' | ']' | '}' => Some(Token::Close),
|
||||||
|
_ if c.is_ascii_alphabetic() => Some(Token::Variable(c)),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
tokens.extend(token);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
Some(tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn superscript_value(c: char) -> Option<i128> {
|
||||||
|
SUPERSCRIPT_DIGITS
|
||||||
|
.iter()
|
||||||
|
.position(|&d| d == c)
|
||||||
|
.map(|p| p as i128)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads `12`, `2,5` or `0.125` from `start`; returns the value and the index after it
|
||||||
|
fn read_number(chars: &[char], start: usize) -> Option<(Rational, usize)> {
|
||||||
|
let mut i = start;
|
||||||
|
let mut num: i128 = 0;
|
||||||
|
let mut den: i128 = 1;
|
||||||
|
let mut in_fraction = false;
|
||||||
|
while i < chars.len() {
|
||||||
|
let c = chars[i];
|
||||||
|
if let Some(digit) = c.to_digit(10) {
|
||||||
|
num = num.checked_mul(10)?.checked_add(digit as i128)?;
|
||||||
|
if in_fraction {
|
||||||
|
den = den.checked_mul(10)?;
|
||||||
|
}
|
||||||
|
} else if (c == ',' || c == '.')
|
||||||
|
&& !in_fraction
|
||||||
|
&& chars.get(i + 1).is_some_and(|d| d.is_ascii_digit())
|
||||||
|
{
|
||||||
|
in_fraction = true;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
Some((Rational::new(num, den)?, i))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
enum Expr {
|
||||||
|
Number(Rational),
|
||||||
|
Variable(char),
|
||||||
|
Neg(Box<Expr>),
|
||||||
|
Add(Box<Expr>, Box<Expr>),
|
||||||
|
Sub(Box<Expr>, Box<Expr>),
|
||||||
|
Mul(Box<Expr>, Box<Expr>),
|
||||||
|
/// With the sign it was written with, for [`display`]
|
||||||
|
Div(Box<Expr>, Box<Expr>, char),
|
||||||
|
Pow(Box<Expr>, Box<Expr>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Expr {
|
||||||
|
fn eval(&self) -> Option<Polynomial> {
|
||||||
|
Some(match self {
|
||||||
|
Expr::Number(value) => Polynomial::constant(*value),
|
||||||
|
Expr::Variable(name) => Polynomial::variable(*name),
|
||||||
|
Expr::Neg(e) => e.eval()?.neg()?,
|
||||||
|
Expr::Add(a, b) => a.eval()?.add(&b.eval()?)?,
|
||||||
|
Expr::Sub(a, b) => a.eval()?.add(&b.eval()?.neg()?)?,
|
||||||
|
Expr::Mul(a, b) => a.eval()?.mul(&b.eval()?)?,
|
||||||
|
Expr::Div(a, b, _) => a.eval()?.div(&b.eval()?)?,
|
||||||
|
Expr::Pow(a, b) => a.eval()?.pow(&b.eval()?)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Expr {
|
||||||
|
/// Binding strength for deciding on parentheses: sums < products < signs < powers < atoms
|
||||||
|
fn precedence(&self) -> u8 {
|
||||||
|
match self {
|
||||||
|
Expr::Add(..) | Expr::Sub(..) => 1,
|
||||||
|
Expr::Mul(..) | Expr::Div(..) => 2,
|
||||||
|
Expr::Neg(_) => 3,
|
||||||
|
Expr::Pow(..) => 4,
|
||||||
|
Expr::Number(_) | Expr::Variable(_) => 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The text of this expression, in parentheses if it binds weaker than `min`
|
||||||
|
fn display_at(&self, min: u8, decimal_comma: bool) -> String {
|
||||||
|
let text = self.display(decimal_comma);
|
||||||
|
if self.precedence() < min {
|
||||||
|
format!("({text})")
|
||||||
|
} else {
|
||||||
|
text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display(&self, decimal_comma: bool) -> String {
|
||||||
|
let dc = decimal_comma;
|
||||||
|
match self {
|
||||||
|
Expr::Number(value) => value.format_abs(dc),
|
||||||
|
Expr::Variable(name) => name.to_string(),
|
||||||
|
Expr::Neg(e) => format!("−{}", e.display_at(3, dc)),
|
||||||
|
Expr::Add(a, b) => format!("{} + {}", a.display_at(1, dc), b.display_at(1, dc)),
|
||||||
|
Expr::Sub(a, b) => format!("{} − {}", a.display_at(1, dc), b.display_at(2, dc)),
|
||||||
|
Expr::Mul(a, b) => {
|
||||||
|
let left = a.display_at(2, dc);
|
||||||
|
let right = b.display_at(3, dc);
|
||||||
|
// 2x, 3(x + 1), x², but 2 · 3 and x · 2
|
||||||
|
let implicit = !matches!(**a, Expr::Neg(_))
|
||||||
|
&& (matches!(**b, Expr::Variable(_))
|
||||||
|
|| matches!(&**b, Expr::Pow(base, _) if matches!(**base, Expr::Variable(_)))
|
||||||
|
|| right.starts_with('('));
|
||||||
|
if implicit {
|
||||||
|
format!("{left}{right}")
|
||||||
|
} else {
|
||||||
|
format!("{left} · {right}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::Div(a, b, sign) => match sign {
|
||||||
|
'/' => format!("{}/{}", a.display_at(4, dc), b.display_at(4, dc)),
|
||||||
|
_ => format!("{} : {}", a.display_at(2, dc), b.display_at(3, dc)),
|
||||||
|
},
|
||||||
|
Expr::Pow(base, exponent) => {
|
||||||
|
let base = base.display_at(5, dc);
|
||||||
|
let integer = |e: &Expr| match e {
|
||||||
|
Expr::Number(v) => v.as_integer().map(|n| superscript(n as u32)),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let small = match &**exponent {
|
||||||
|
Expr::Neg(e) => integer(e).map(|s| format!("⁻{s}")),
|
||||||
|
e => integer(e),
|
||||||
|
};
|
||||||
|
match small {
|
||||||
|
Some(small) => format!("{base}{small}"),
|
||||||
|
None => format!("{base}^({})", exponent.display(dc)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursive descent with the usual precedence: `+ −` < `· :` and implicit
|
||||||
|
/// multiplication < unary minus < `^` (right-associative, so `−2² = −4` and `2^3^2 = 2^9`).
|
||||||
|
struct Parser<'a> {
|
||||||
|
tokens: &'a [Token],
|
||||||
|
pos: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Parser<'a> {
|
||||||
|
fn new(tokens: &'a [Token]) -> Self {
|
||||||
|
Parser { tokens, pos: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(mut self) -> Option<Expr> {
|
||||||
|
let expr = self.sum()?;
|
||||||
|
(self.pos == self.tokens.len()).then_some(expr)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peek(&self) -> Option<&Token> {
|
||||||
|
self.tokens.get(self.pos)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn eat(&mut self, token: &Token) -> bool {
|
||||||
|
let found = self.peek() == Some(token);
|
||||||
|
if found {
|
||||||
|
self.pos += 1;
|
||||||
|
}
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sum(&mut self) -> Option<Expr> {
|
||||||
|
let mut left = self.product()?;
|
||||||
|
loop {
|
||||||
|
if self.eat(&Token::Plus) {
|
||||||
|
left = Expr::Add(Box::new(left), Box::new(self.product()?));
|
||||||
|
} else if self.eat(&Token::Minus) {
|
||||||
|
left = Expr::Sub(Box::new(left), Box::new(self.product()?));
|
||||||
|
} else {
|
||||||
|
return Some(left);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn product(&mut self) -> Option<Expr> {
|
||||||
|
let mut left = self.unary()?;
|
||||||
|
loop {
|
||||||
|
if self.eat(&Token::Times) {
|
||||||
|
left = Expr::Mul(Box::new(left), Box::new(self.unary()?));
|
||||||
|
} else if let Some(&Token::Divide(sign)) = self.peek() {
|
||||||
|
self.pos += 1;
|
||||||
|
left = Expr::Div(Box::new(left), Box::new(self.unary()?), sign);
|
||||||
|
} else if matches!(self.peek(), Some(Token::Variable(_) | Token::Open)) {
|
||||||
|
// Implicit multiplication: 2x, 3(x + 1), (a + b)(a − b). Two numbers in a
|
||||||
|
// row are not multiplied, they are more likely a misread.
|
||||||
|
left = Expr::Mul(Box::new(left), Box::new(self.power()?));
|
||||||
|
} else {
|
||||||
|
return Some(left);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unary(&mut self) -> Option<Expr> {
|
||||||
|
if self.eat(&Token::Minus) {
|
||||||
|
return Some(Expr::Neg(Box::new(self.unary()?)));
|
||||||
|
}
|
||||||
|
if self.eat(&Token::Plus) {
|
||||||
|
return self.unary();
|
||||||
|
}
|
||||||
|
self.power()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn power(&mut self) -> Option<Expr> {
|
||||||
|
let base = self.atom()?;
|
||||||
|
if self.eat(&Token::Power) {
|
||||||
|
return Some(Expr::Pow(Box::new(base), Box::new(self.unary()?)));
|
||||||
|
}
|
||||||
|
Some(base)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn atom(&mut self) -> Option<Expr> {
|
||||||
|
let token = self.peek()?.clone();
|
||||||
|
self.pos += 1;
|
||||||
|
match token {
|
||||||
|
Token::Number(value) => Some(Expr::Number(value)),
|
||||||
|
Token::Variable(name) => Some(Expr::Variable(name)),
|
||||||
|
Token::Open => {
|
||||||
|
let inner = self.sum()?;
|
||||||
|
self.eat(&Token::Close).then_some(inner)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn de(expression: &str) -> Option<String> {
|
||||||
|
solve(expression, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arithmetic() {
|
||||||
|
assert_eq!(de("2^4 ="), Some("16".into()));
|
||||||
|
assert_eq!(de("2⁴="), Some("16".into()));
|
||||||
|
assert_eq!(de("1 + 2 · 3"), Some("7".into()));
|
||||||
|
assert_eq!(de("(1 + 2) × 3"), Some("9".into()));
|
||||||
|
assert_eq!(de("7 − 10"), Some("−3".into()));
|
||||||
|
assert_eq!(de("−2^2"), Some("−4".into()));
|
||||||
|
assert_eq!(de("(−2)²"), Some("4".into()));
|
||||||
|
assert_eq!(de("2^3^2"), Some("512".into()));
|
||||||
|
assert_eq!(de("10 : 4"), Some("2,5".into()));
|
||||||
|
assert_eq!(de("10 ÷ 4"), Some("2,5".into()));
|
||||||
|
assert_eq!(de("3 * [2 + 1]"), Some("9".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decimals_and_fractions() {
|
||||||
|
assert_eq!(de("2,5 · 3"), Some("7,5".into()));
|
||||||
|
assert_eq!(solve("2.5 * 3", false), Some("7.5".into()));
|
||||||
|
assert_eq!(de("0,1 + 0,2"), Some("0,3".into()));
|
||||||
|
assert_eq!(de("1 : 3"), Some("1/3".into()));
|
||||||
|
assert_eq!(de("1 : 3 · 3"), Some("1".into()));
|
||||||
|
assert_eq!(de("2^-2"), Some("0,25".into()));
|
||||||
|
assert_eq!(de("2⁻¹"), Some("0,5".into()));
|
||||||
|
assert_eq!(de("1 : 8"), Some("0,125".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn polynomials() {
|
||||||
|
assert_eq!(de("(x + 3)^2 ="), Some("x² + 6x + 9".into()));
|
||||||
|
assert_eq!(de("(x+3)²"), Some("x² + 6x + 9".into()));
|
||||||
|
assert_eq!(de("(a + b)²"), Some("a² + 2ab + b²".into()));
|
||||||
|
assert_eq!(de("(a + b)(a − b)"), Some("a² − b²".into()));
|
||||||
|
assert_eq!(de("(a − b)²"), Some("a² − 2ab + b²".into()));
|
||||||
|
assert_eq!(de("2x + 3x"), Some("5x".into()));
|
||||||
|
assert_eq!(de("x · x"), Some("x²".into()));
|
||||||
|
assert_eq!(de("3(x + 1)"), Some("3x + 3".into()));
|
||||||
|
assert_eq!(de("(x + 1)^3"), Some("x³ + 3x² + 3x + 1".into()));
|
||||||
|
assert_eq!(de("(2x − 1)²"), Some("4x² − 4x + 1".into()));
|
||||||
|
assert_eq!(de("x − x"), Some("0".into()));
|
||||||
|
assert_eq!(de("(x + 1) : 2"), Some("0,5x + 0,5".into()));
|
||||||
|
assert_eq!(de("x : 3"), Some("(1/3)x".into()));
|
||||||
|
assert_eq!(de("−(x + 1)"), Some("−x − 1".into()));
|
||||||
|
assert_eq!(
|
||||||
|
de("(x+1)^10").map(|s| s.starts_with("x¹⁰ + 10x⁹")),
|
||||||
|
Some(true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_to_compute() {
|
||||||
|
assert_eq!(de("5"), None);
|
||||||
|
assert_eq!(de("x ="), None);
|
||||||
|
assert_eq!(de("2x"), None);
|
||||||
|
assert_eq!(de("x² + 1"), None);
|
||||||
|
assert_eq!(de("2,5"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unreadable_or_not_computable() {
|
||||||
|
assert_eq!(de(""), None);
|
||||||
|
assert_eq!(de("="), None);
|
||||||
|
assert_eq!(de("1 +"), None);
|
||||||
|
assert_eq!(de("(1 + 2"), None);
|
||||||
|
assert_eq!(de("1 + 2)"), None);
|
||||||
|
assert_eq!(de("2 3"), None);
|
||||||
|
assert_eq!(de("1 : 0"), None);
|
||||||
|
assert_eq!(de("0^-1"), None);
|
||||||
|
assert_eq!(de("1 : x"), None);
|
||||||
|
assert_eq!(de("x^y"), None);
|
||||||
|
assert_eq!(de("x^0,5"), None);
|
||||||
|
assert_eq!(de("2 ? 3"), None);
|
||||||
|
assert_eq!(de("1,2,3"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn displays_expressions() {
|
||||||
|
let show = |e: &str| display(e, true).unwrap();
|
||||||
|
assert_eq!(show("2^(4)"), "2⁴");
|
||||||
|
assert_eq!(show("(x+3)^(2)"), "(x + 3)²");
|
||||||
|
assert_eq!(show("2,5·3"), "2,5 · 3");
|
||||||
|
assert_eq!(show("9:3"), "9 : 3");
|
||||||
|
assert_eq!(show("((x+1)/(2))"), "(x + 1)/2");
|
||||||
|
assert_eq!(show("((1)/(2))+((1)/(2·3))"), "1/2 + 1/(2 · 3)");
|
||||||
|
assert_eq!(show("2^(-1)"), "2⁻¹");
|
||||||
|
assert_eq!(show("2^(x+1)"), "2^(x + 1)");
|
||||||
|
assert_eq!(show("3(x+1)"), "3(x + 1)");
|
||||||
|
assert_eq!(show("2x^(2)"), "2x²");
|
||||||
|
assert_eq!(show("x·2"), "x · 2");
|
||||||
|
assert_eq!(show("(a+b)(a-b)"), "(a + b)(a − b)");
|
||||||
|
assert_eq!(show("7-(5-2)"), "7 − (5 − 2)");
|
||||||
|
assert_eq!(show("-2^(2)"), "−2²");
|
||||||
|
assert_eq!(show("(-2)^(2)"), "(−2)²");
|
||||||
|
assert_eq!(show("0.5"), "0,5");
|
||||||
|
assert!(display("1 +", true).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overflow_and_limits() {
|
||||||
|
assert_eq!(de("10^100"), None);
|
||||||
|
assert_eq!(de("2^100000"), None);
|
||||||
|
assert_eq!(de("(x + 1)^33"), None);
|
||||||
|
assert_eq!(de("(a+b+c+d+e)^8"), None);
|
||||||
|
assert_eq!(de("99999999999999999999999999999999999999999999 + 1"), None);
|
||||||
|
assert_eq!(de("2^64"), Some("18446744073709551616".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,465 @@
|
|||||||
|
//! Computes handwritten math after an `=` (Issue #46, stage 3).
|
||||||
|
//!
|
||||||
|
//! When the last two strokes on a page form an `=` and the pen then rests for
|
||||||
|
//! [`TRIGGER_DELAY`], the strokes left of it are recognized ([`crate::math_ink`]) and computed
|
||||||
|
//! ([`crate::math::solve`]). The result is offered next to the `=` as a suggestion that also
|
||||||
|
//! shows what was read (`2⁴ = 16`), so that a misread symbol is noticed. Tapping it with the
|
||||||
|
//! pen or clicking it inserts the result as a text box; fingers and palms are ignored. Any new
|
||||||
|
//! stroke drops the suggestion. Off by default (☰ → Input).
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use glam::Vec2;
|
||||||
|
|
||||||
|
use crate::document::{Page, Stroke, StrokeTool};
|
||||||
|
use crate::math_ink::Recognizer;
|
||||||
|
|
||||||
|
/// Pause after the `=` before its expression is computed; a new stroke cancels it
|
||||||
|
pub const TRIGGER_DELAY: Duration = Duration::from_millis(500);
|
||||||
|
/// Estimated symbol height relative to the width of the `=`
|
||||||
|
const SYMBOL_PER_EQUALS: f32 = 1.3;
|
||||||
|
/// Strokes left of the `=` belong to the expression up to a gap of this many symbol heights
|
||||||
|
const MAX_GAP: f32 = 2.0;
|
||||||
|
/// At most this many strokes are read as the expression
|
||||||
|
const MAX_STROKES: usize = 80;
|
||||||
|
/// Cap height of the text font relative to its size (Ubuntu: 0.69 em)
|
||||||
|
const CAP_HEIGHT_PER_EM: f32 = 0.7;
|
||||||
|
|
||||||
|
/// A result offered next to an `=`
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Suggestion {
|
||||||
|
pub page_id: u64,
|
||||||
|
/// The two strokes of the `=`
|
||||||
|
pub equals: [u64; 2],
|
||||||
|
/// What was read, written neatly (`2⁴`)
|
||||||
|
pub expression: String,
|
||||||
|
/// The result (`16`)
|
||||||
|
pub result: String,
|
||||||
|
/// Where the result goes: left end of the text on the vertical middle of the `=`
|
||||||
|
pub anchor: Vec2,
|
||||||
|
/// Height of the handwritten digits in page units
|
||||||
|
pub symbol_height: f32,
|
||||||
|
/// Ink color of the `=`
|
||||||
|
pub color: [f32; 4],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Suggestion {
|
||||||
|
/// Top left corner and size (em) of a text box that puts the result next to the `=`, as
|
||||||
|
/// high as the handwriting
|
||||||
|
pub fn text_placement(&self, ascent_per_em: f32) -> (Vec2, f32) {
|
||||||
|
let size = self.symbol_height / CAP_HEIGHT_PER_EM;
|
||||||
|
// Baseline at the bottom of digits centered on the `=`
|
||||||
|
let baseline = self.anchor.y + self.symbol_height / 2.0;
|
||||||
|
(
|
||||||
|
Vec2::new(self.anchor.x, baseline - ascent_per_em * size),
|
||||||
|
size,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct Pending {
|
||||||
|
page_id: u64,
|
||||||
|
equals: [u64; 2],
|
||||||
|
due: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State of the math assistant: waiting after an `=`, and the suggestion shown
|
||||||
|
#[derive(Default, Clone)]
|
||||||
|
pub struct MathAssist {
|
||||||
|
pub enabled: bool,
|
||||||
|
/// Built on first use
|
||||||
|
recognizer: Option<Arc<Recognizer>>,
|
||||||
|
pending: Option<Pending>,
|
||||||
|
suggestion: Option<Suggestion>,
|
||||||
|
/// Where the UI drew the suggestion (egui points), for [`Self::take_hit`]
|
||||||
|
pub chip_rect: Option<egui::Rect>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for MathAssist {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("MathAssist")
|
||||||
|
.field("enabled", &self.enabled)
|
||||||
|
.field("pending", &self.pending)
|
||||||
|
.field("suggestion", &self.suggestion)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MathAssist {
|
||||||
|
/// After a stroke was added to `page`: drops the suggestion and waits if the stroke
|
||||||
|
/// completed an `=`
|
||||||
|
pub fn stroke_committed(&mut self, page: &Page, now: Instant) {
|
||||||
|
self.suggestion = None;
|
||||||
|
self.chip_rect = None;
|
||||||
|
self.pending = None;
|
||||||
|
if !self.enabled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some((a, b)) = equals_at_end(&page.strokes) {
|
||||||
|
self.pending = Some(Pending {
|
||||||
|
page_id: page.id,
|
||||||
|
equals: [page.strokes[a].id, page.strokes[b].id],
|
||||||
|
due: now + TRIGGER_DELAY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When [`Self::update`] has something to do
|
||||||
|
pub fn deadline(&self) -> Option<Instant> {
|
||||||
|
self.pending.as_ref().map(|p| p.due)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes the expression once the pause after the `=` is over (not while a stroke is
|
||||||
|
/// being drawn). True if a suggestion appeared.
|
||||||
|
pub fn update(
|
||||||
|
&mut self,
|
||||||
|
page: &Page,
|
||||||
|
now: Instant,
|
||||||
|
drawing: bool,
|
||||||
|
decimal_comma: bool,
|
||||||
|
) -> bool {
|
||||||
|
let Some(pending) = self.pending.as_ref() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if drawing || now < pending.due {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let pending = self.pending.take().expect("checked above");
|
||||||
|
if pending.page_id != page.id || !self.enabled {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let recognizer = self
|
||||||
|
.recognizer
|
||||||
|
.get_or_insert_with(|| Arc::new(Recognizer::new()));
|
||||||
|
self.suggestion = suggest(recognizer, page, pending.equals, decimal_comma);
|
||||||
|
self.suggestion.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The suggestion if it belongs to `page` and its `=` is still there
|
||||||
|
pub fn suggestion_for(&self, page: &Page) -> Option<&Suggestion> {
|
||||||
|
self.suggestion.as_ref().filter(|s| {
|
||||||
|
s.page_id == page.id
|
||||||
|
&& s.equals
|
||||||
|
.iter()
|
||||||
|
.all(|id| page.strokes.iter().any(|stroke| stroke.id == *id))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The suggestion if `pos` (egui points) is on it; it is then taken away
|
||||||
|
pub fn take_hit(&mut self, pos: egui::Pos2) -> Option<Suggestion> {
|
||||||
|
if !self.chip_rect.is_some_and(|r| r.contains(pos)) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.chip_rect = None;
|
||||||
|
self.suggestion.take()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_enabled(&mut self, enabled: bool) {
|
||||||
|
self.enabled = enabled;
|
||||||
|
if !enabled {
|
||||||
|
self.pending = None;
|
||||||
|
self.suggestion = None;
|
||||||
|
self.chip_rect = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handwriting that can be read: pen strokes (also snapped lines), no highlighter, text or
|
||||||
|
/// images
|
||||||
|
fn is_ink(stroke: &Stroke) -> bool {
|
||||||
|
!stroke.is_object() && stroke.tool != StrokeTool::Highlighter && !stroke.points.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn positions(stroke: &Stroke) -> Vec<Vec2> {
|
||||||
|
stroke.points.iter().map(|s| s.position).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A straight, roughly horizontal stroke: one bar of an `=`
|
||||||
|
fn is_bar(stroke: &Stroke) -> bool {
|
||||||
|
let size = stroke.aabb.max - stroke.aabb.min;
|
||||||
|
let length: f32 = positions(stroke)
|
||||||
|
.windows(2)
|
||||||
|
.map(|w| w[0].distance(w[1]))
|
||||||
|
.sum();
|
||||||
|
size.x > 0.0 && size.x >= 2.5 * size.y && length <= 1.3 * size.x
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Indices of the last two ink strokes if they form an `=`: two bars of similar length, one
|
||||||
|
/// above the other
|
||||||
|
fn equals_at_end(strokes: &[Stroke]) -> Option<(usize, usize)> {
|
||||||
|
let mut ink = strokes.iter().enumerate().rev().filter(|(_, s)| is_ink(s));
|
||||||
|
let (b, second) = ink.next()?;
|
||||||
|
let (a, first) = ink.next()?;
|
||||||
|
if !is_bar(first) || !is_bar(second) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (w1, w2) = (
|
||||||
|
first.aabb.max.x - first.aabb.min.x,
|
||||||
|
second.aabb.max.x - second.aabb.min.x,
|
||||||
|
);
|
||||||
|
let (short, long) = (w1.min(w2), w1.max(w2));
|
||||||
|
let overlap = first.aabb.max.x.min(second.aabb.max.x) - first.aabb.min.x.max(second.aabb.min.x);
|
||||||
|
let dy = ((first.aabb.min.y + first.aabb.max.y) - (second.aabb.min.y + second.aabb.max.y))
|
||||||
|
.abs()
|
||||||
|
/ 2.0;
|
||||||
|
(overlap >= 0.5 * short && long <= 2.0 * short && dy >= 0.1 * long && dy <= long)
|
||||||
|
.then_some((a, b))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Indices of the strokes left of the `=` that make up its expression, in the order they were
|
||||||
|
/// written: ink in a band around the `=`, going left until a wide gap
|
||||||
|
fn expression_strokes(strokes: &[Stroke], equals: (usize, usize)) -> Vec<usize> {
|
||||||
|
let (e1, e2) = (&strokes[equals.0], &strokes[equals.1]);
|
||||||
|
let min = e1.aabb.min.min(e2.aabb.min);
|
||||||
|
let max = e1.aabb.max.max(e2.aabb.max);
|
||||||
|
let height = (max.x - min.x) * SYMBOL_PER_EQUALS;
|
||||||
|
let middle = (min.y + max.y) / 2.0;
|
||||||
|
let mut candidates: Vec<usize> = (0..strokes.len())
|
||||||
|
.filter(|&i| i != equals.0 && i != equals.1 && is_ink(&strokes[i]))
|
||||||
|
.filter(|&i| {
|
||||||
|
let s = &strokes[i].aabb;
|
||||||
|
let center = (s.min.y + s.max.y) / 2.0;
|
||||||
|
(center - middle).abs() <= 2.0 * height
|
||||||
|
&& s.max.y - s.min.y <= 3.0 * height
|
||||||
|
&& s.max.x <= min.x + 0.2 * height
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
candidates.sort_by(|&a, &b| strokes[b].aabb.max.x.total_cmp(&strokes[a].aabb.max.x));
|
||||||
|
let mut left = min.x;
|
||||||
|
let mut chosen = Vec::new();
|
||||||
|
for i in candidates {
|
||||||
|
let s = &strokes[i].aabb;
|
||||||
|
if s.max.x < left - MAX_GAP * height || chosen.len() >= MAX_STROKES {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
left = left.min(s.min.x);
|
||||||
|
chosen.push(i);
|
||||||
|
}
|
||||||
|
chosen.sort_unstable();
|
||||||
|
chosen
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads and computes the expression in front of the `=` made of the strokes `equals`
|
||||||
|
fn suggest(
|
||||||
|
recognizer: &Recognizer,
|
||||||
|
page: &Page,
|
||||||
|
equals: [u64; 2],
|
||||||
|
decimal_comma: bool,
|
||||||
|
) -> Option<Suggestion> {
|
||||||
|
let strokes = &page.strokes;
|
||||||
|
let index = |id: u64| strokes.iter().position(|s| s.id == id);
|
||||||
|
let pair = (index(equals[0])?, index(equals[1])?);
|
||||||
|
let mut chosen = expression_strokes(strokes, pair);
|
||||||
|
if chosen.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
chosen.extend([pair.0, pair.1]);
|
||||||
|
let points: Vec<Vec<Vec2>> = chosen.iter().map(|&i| positions(&strokes[i])).collect();
|
||||||
|
let recognition = recognizer.recognize(&points)?;
|
||||||
|
// The `=` must be read as the last symbol, from exactly its two strokes
|
||||||
|
let last = recognition.symbols.last()?;
|
||||||
|
let equals_strokes = [points.len() - 2, points.len() - 1];
|
||||||
|
let mut last_strokes = last.strokes.clone();
|
||||||
|
last_strokes.sort_unstable();
|
||||||
|
if last.ch != '=' || last_strokes != equals_strokes {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// In a chain like `x = 2 + 3 =` only the part after the previous `=` is computed
|
||||||
|
let text = recognition.text.strip_suffix('=')?;
|
||||||
|
let expression = text.rsplit('=').next()?;
|
||||||
|
// A dot or comma that was written decides the separator of the result
|
||||||
|
let decimal_comma = if expression.contains(',') {
|
||||||
|
true
|
||||||
|
} else if expression.contains('.') {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
decimal_comma
|
||||||
|
};
|
||||||
|
let result = crate::math::solve(expression, decimal_comma)?;
|
||||||
|
let shown = crate::math::display(expression, decimal_comma)?;
|
||||||
|
|
||||||
|
let (e1, e2) = (&strokes[pair.0], &strokes[pair.1]);
|
||||||
|
let right = e1.aabb.max.x.max(e2.aabb.max.x);
|
||||||
|
let middle = (e1.aabb.min.y.min(e2.aabb.min.y) + e1.aabb.max.y.max(e2.aabb.max.y)) / 2.0;
|
||||||
|
let mut heights: Vec<f32> = recognition
|
||||||
|
.symbols
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.ch.is_ascii_digit() || s.ch.is_ascii_alphabetic())
|
||||||
|
.map(|s| s.max.y - s.min.y)
|
||||||
|
.collect();
|
||||||
|
heights.sort_by(f32::total_cmp);
|
||||||
|
let width = right - e1.aabb.min.x.min(e2.aabb.min.x);
|
||||||
|
let symbol_height = heights
|
||||||
|
.last()
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(width * SYMBOL_PER_EQUALS)
|
||||||
|
.max(1.0);
|
||||||
|
Some(Suggestion {
|
||||||
|
page_id: page.id,
|
||||||
|
equals,
|
||||||
|
expression: shown,
|
||||||
|
result,
|
||||||
|
anchor: Vec2::new(right + 0.4 * symbol_height, middle),
|
||||||
|
symbol_height,
|
||||||
|
color: e2.color,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::document::StylusSample;
|
||||||
|
use crate::math_ink::tests::{write_line, Jitter};
|
||||||
|
|
||||||
|
const BLACK: [f32; 4] = [0.0, 0.0, 0.0, 1.0];
|
||||||
|
|
||||||
|
fn stroke(id: u64, points: &[Vec2]) -> Stroke {
|
||||||
|
let samples = points
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, &position)| StylusSample {
|
||||||
|
position,
|
||||||
|
pressure: 0.5,
|
||||||
|
timestamp: i as f64 * 0.01,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Stroke::new(id, samples, Vec::new(), BLACK)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A page with `text` written at `origin`, scaled by `scale`
|
||||||
|
fn page_with(lines: &[(&str, Vec2)], scale: f32) -> Page {
|
||||||
|
let mut page = Page::new(7, crate::document::PageBackground::Blank);
|
||||||
|
let mut id = 1;
|
||||||
|
for (seed, (text, origin)) in lines.iter().enumerate() {
|
||||||
|
for points in write_line(text, &mut Jitter(seed as u64 + 3)) {
|
||||||
|
let points: Vec<Vec2> = points.iter().map(|p| *origin + *p * scale).collect();
|
||||||
|
page.add_stroke(stroke(id, &points));
|
||||||
|
id += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
page
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(page: &Page) -> Option<Suggestion> {
|
||||||
|
let mut assist = MathAssist {
|
||||||
|
enabled: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let start = Instant::now();
|
||||||
|
assist.stroke_committed(page, start);
|
||||||
|
assert!(!assist.update(page, start, false, true), "too early");
|
||||||
|
assist.update(page, start + TRIGGER_DELAY, false, true);
|
||||||
|
assist.suggestion_for(page).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suggests_result_after_equals() {
|
||||||
|
let page = page_with(&[("(x+3)^2=", Vec2::new(100.0, 200.0))], 3.0);
|
||||||
|
let suggestion = run(&page).unwrap();
|
||||||
|
assert_eq!(suggestion.expression, "(x + 3)²");
|
||||||
|
assert_eq!(suggestion.result, "x² + 6x + 9");
|
||||||
|
let equals_right = page
|
||||||
|
.strokes
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.take(2)
|
||||||
|
.map(|s| s.aabb.max.x)
|
||||||
|
.fold(f32::MIN, f32::max);
|
||||||
|
assert!(suggestion.anchor.x > equals_right);
|
||||||
|
// The handwriting is 14 units high, scaled by 3
|
||||||
|
assert!(
|
||||||
|
(suggestion.symbol_height - 42.0).abs() < 6.0,
|
||||||
|
"{suggestion:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_writing_far_to_the_left_and_on_other_lines() {
|
||||||
|
let page = page_with(
|
||||||
|
&[
|
||||||
|
("7", Vec2::new(0.0, 200.0)),
|
||||||
|
("5·5", Vec2::new(0.0, 300.0)),
|
||||||
|
("2^4=", Vec2::new(150.0, 200.0)),
|
||||||
|
],
|
||||||
|
2.0,
|
||||||
|
);
|
||||||
|
let suggestion = run(&page).unwrap();
|
||||||
|
assert_eq!(suggestion.expression, "2⁴");
|
||||||
|
assert_eq!(suggestion.result, "16");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn computes_the_last_part_of_a_chain() {
|
||||||
|
let page = page_with(&[("x=2·3=", Vec2::new(0.0, 0.0))], 2.0);
|
||||||
|
assert_eq!(run(&page).unwrap().result, "6");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_without_equals_or_when_disabled() {
|
||||||
|
let page = page_with(&[("2+3", Vec2::ZERO)], 2.0);
|
||||||
|
assert!(run(&page).is_none());
|
||||||
|
let page = page_with(&[("2+3=", Vec2::ZERO)], 2.0);
|
||||||
|
let mut assist = MathAssist::default();
|
||||||
|
assist.stroke_committed(&page, Instant::now());
|
||||||
|
assert!(assist.deadline().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_stroke_or_removed_equals_drops_the_suggestion() {
|
||||||
|
let mut page = page_with(&[("1+1=", Vec2::ZERO)], 2.0);
|
||||||
|
let mut assist = MathAssist {
|
||||||
|
enabled: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let start = Instant::now();
|
||||||
|
assist.stroke_committed(&page, start);
|
||||||
|
// A stroke still being drawn when the time is up: nothing yet
|
||||||
|
assert!(!assist.update(&page, start + TRIGGER_DELAY, true, true));
|
||||||
|
assert!(assist.update(&page, start + TRIGGER_DELAY, false, true));
|
||||||
|
assert_eq!(assist.suggestion_for(&page).unwrap().result, "2");
|
||||||
|
let last = page.strokes.last().unwrap().id;
|
||||||
|
page.remove_strokes_by_ids(&[last]);
|
||||||
|
assert!(assist.suggestion_for(&page).is_none());
|
||||||
|
assist.stroke_committed(&page, start);
|
||||||
|
assert!(assist.suggestion.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hit_takes_the_suggestion() {
|
||||||
|
let page = page_with(&[("1+1=", Vec2::ZERO)], 2.0);
|
||||||
|
let mut assist = MathAssist {
|
||||||
|
enabled: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let start = Instant::now();
|
||||||
|
assist.stroke_committed(&page, start);
|
||||||
|
assist.update(&page, start + TRIGGER_DELAY, false, true);
|
||||||
|
assist.chip_rect = Some(egui::Rect::from_min_size(
|
||||||
|
egui::pos2(10.0, 10.0),
|
||||||
|
egui::vec2(50.0, 20.0),
|
||||||
|
));
|
||||||
|
assert!(assist.take_hit(egui::pos2(5.0, 5.0)).is_none());
|
||||||
|
assert_eq!(assist.take_hit(egui::pos2(20.0, 20.0)).unwrap().result, "2");
|
||||||
|
assert!(assist.take_hit(egui::pos2(20.0, 20.0)).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn result_text_sits_on_the_baseline() {
|
||||||
|
let suggestion = Suggestion {
|
||||||
|
page_id: 1,
|
||||||
|
equals: [1, 2],
|
||||||
|
expression: "1 + 1".into(),
|
||||||
|
result: "2".into(),
|
||||||
|
anchor: Vec2::new(100.0, 50.0),
|
||||||
|
symbol_height: 14.0,
|
||||||
|
color: BLACK,
|
||||||
|
};
|
||||||
|
let (top_left, size) = suggestion.text_placement(0.9);
|
||||||
|
assert!((size - 20.0).abs() < 0.01);
|
||||||
|
// Baseline (top + ascent) at the bottom of digits centered on the `=`
|
||||||
|
assert!((top_left.y + 0.9 * size - 57.0).abs() < 0.01);
|
||||||
|
assert_eq!(top_left.x, 100.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1259
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,853 @@
|
|||||||
|
//! Talking to a Nextcloud server (Issue #49, stage 2): finding the server, signing in through
|
||||||
|
//! the browser (Login Flow v2, the app gets its own app password), and WebDAV access to the
|
||||||
|
//! folder `ink-notes` in the user's files. The sync itself is in `cloud_sync.rs`.
|
||||||
|
//!
|
||||||
|
//! Private servers often use a self-signed certificate. Such a certificate is trusted once the
|
||||||
|
//! user has confirmed its SHA-256 fingerprint; it is then pinned for this server.
|
||||||
|
|
||||||
|
use std::io::Read;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
|
||||||
|
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
|
||||||
|
use rustls::{DigitallySignedStruct, SignatureScheme};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
use crate::t;
|
||||||
|
|
||||||
|
/// Name of the synced folder in the user's files.
|
||||||
|
pub const REMOTE_FOLDER: &str = "ink-notes";
|
||||||
|
|
||||||
|
/// Largest file the sync downloads (bigger ones are surely not ours).
|
||||||
|
const MAX_DOWNLOAD: u64 = 512 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// A connected Nextcloud account, stored in `nextcloud.json` next to the settings.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Account {
|
||||||
|
/// Server address including a subfolder, without trailing slash
|
||||||
|
pub server: String,
|
||||||
|
/// Login name for the app password
|
||||||
|
pub login_name: String,
|
||||||
|
/// User id in WebDAV paths (differs from the login name e.g. with e-mail logins)
|
||||||
|
pub user_id: String,
|
||||||
|
pub app_password: String,
|
||||||
|
/// SHA-256 fingerprint of a certificate the user trusts although it isn't signed by a
|
||||||
|
/// known authority
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub pinned_cert: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Account {
|
||||||
|
pub fn file_path() -> Option<PathBuf> {
|
||||||
|
crate::paths::config_dir().map(|d| d.join("nextcloud.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_from(path: &Path) -> Option<Self> {
|
||||||
|
serde_json::from_slice(&std::fs::read(path).ok()?).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores the account; the file is only readable by the user.
|
||||||
|
pub fn save_to(&self, path: &Path) -> Result<(), String> {
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
crate::storage::create_secure_dir_all(dir).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
crate::storage::atomic_write_json(path, self).map_err(|e| e.to_string())?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `user@server` for the interface.
|
||||||
|
pub fn label(&self) -> String {
|
||||||
|
let host = url::Url::parse(&self.server)
|
||||||
|
.ok()
|
||||||
|
.and_then(|u| {
|
||||||
|
let port = u.port().map(|p| format!(":{p}")).unwrap_or_default();
|
||||||
|
u.host_str()
|
||||||
|
.map(|h| format!("{h}{port}{}", u.path().trim_end_matches('/')))
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| self.server.clone());
|
||||||
|
format!("{}@{host}", self.login_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_insecure(&self) -> bool {
|
||||||
|
self.server.starts_with("http://")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why talking to the server failed.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum Error {
|
||||||
|
/// The certificate isn't signed by a known authority (or doesn't match the pinned one)
|
||||||
|
Untrusted {
|
||||||
|
fingerprint: String,
|
||||||
|
},
|
||||||
|
/// Server not reachable (offline, wrong address)
|
||||||
|
Offline(String),
|
||||||
|
/// App password revoked or wrong
|
||||||
|
Unauthorized,
|
||||||
|
NotFound,
|
||||||
|
/// The file changed on the server since we last saw it
|
||||||
|
PreconditionFailed,
|
||||||
|
Other(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Error {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Error::Untrusted { fingerprint } => write!(
|
||||||
|
f,
|
||||||
|
"{}",
|
||||||
|
t!(
|
||||||
|
"Zertifikat nicht vertrauenswürdig (SHA-256 {fingerprint})",
|
||||||
|
fingerprint = fingerprint
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Error::Offline(msg) => write!(f, "{}", t!("Server nicht erreichbar: {msg}", msg = msg)),
|
||||||
|
Error::Unauthorized => write!(f, "{}", t!("Anmeldung abgelehnt – bitte neu verbinden")),
|
||||||
|
Error::NotFound => write!(f, "{}", t!("Nicht gefunden")),
|
||||||
|
Error::PreconditionFailed => write!(f, "{}", t!("Inzwischen geändert")),
|
||||||
|
Error::Other(msg) => write!(f, "{msg}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server address from what the user typed: `https://` is added when missing, a pasted link
|
||||||
|
/// into the web interface is cut back to the server (a subfolder such as `/nextcloud` stays).
|
||||||
|
pub fn normalize_server(input: &str) -> Result<String, String> {
|
||||||
|
let input = input.trim();
|
||||||
|
if input.is_empty() {
|
||||||
|
return Err(t!("Bitte die Adresse des Nextcloud-Servers eingeben").to_string());
|
||||||
|
}
|
||||||
|
let with_scheme = if input.contains("://") {
|
||||||
|
input.to_string()
|
||||||
|
} else {
|
||||||
|
format!("https://{input}")
|
||||||
|
};
|
||||||
|
let mut url = url::Url::parse(&with_scheme).map_err(|e| t!("Ungültige Adresse: {e}", e = e))?;
|
||||||
|
if !matches!(url.scheme(), "https" | "http") || url.host_str().is_none() {
|
||||||
|
return Err(t!("Die Adresse muss mit https:// beginnen").to_string());
|
||||||
|
}
|
||||||
|
url.set_query(None);
|
||||||
|
url.set_fragment(None);
|
||||||
|
let _ = url.set_username("");
|
||||||
|
let _ = url.set_password(None);
|
||||||
|
let mut path = url.path().to_string();
|
||||||
|
for marker in [
|
||||||
|
"/index.php",
|
||||||
|
"/apps/",
|
||||||
|
"/login",
|
||||||
|
"/remote.php",
|
||||||
|
"/status.php",
|
||||||
|
"/s/",
|
||||||
|
] {
|
||||||
|
if let Some(pos) = path.find(marker) {
|
||||||
|
path.truncate(pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
url.set_path(path.trim_end_matches('/'));
|
||||||
|
Ok(url.as_str().trim_end_matches('/').to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SHA-256 fingerprint as colon-separated hex pairs, as browsers show it.
|
||||||
|
pub fn fingerprint(cert: &[u8]) -> String {
|
||||||
|
Sha256::digest(cert)
|
||||||
|
.iter()
|
||||||
|
.map(|b| format!("{b:02X}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(":")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accepts what the usual authorities sign and, besides, the one pinned certificate. Remembers
|
||||||
|
/// the fingerprint of a rejected certificate so the user can be asked about it.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct Verifier {
|
||||||
|
webpki: Arc<rustls::client::WebPkiServerVerifier>,
|
||||||
|
provider: Arc<rustls::crypto::CryptoProvider>,
|
||||||
|
pinned: Option<String>,
|
||||||
|
rejected: Arc<Mutex<Option<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerCertVerifier for Verifier {
|
||||||
|
fn verify_server_cert(
|
||||||
|
&self,
|
||||||
|
end_entity: &CertificateDer<'_>,
|
||||||
|
intermediates: &[CertificateDer<'_>],
|
||||||
|
server_name: &ServerName<'_>,
|
||||||
|
ocsp_response: &[u8],
|
||||||
|
now: UnixTime,
|
||||||
|
) -> Result<ServerCertVerified, rustls::Error> {
|
||||||
|
match self.webpki.verify_server_cert(
|
||||||
|
end_entity,
|
||||||
|
intermediates,
|
||||||
|
server_name,
|
||||||
|
ocsp_response,
|
||||||
|
now,
|
||||||
|
) {
|
||||||
|
Ok(ok) => Ok(ok),
|
||||||
|
Err(err) => {
|
||||||
|
let print = fingerprint(end_entity);
|
||||||
|
if self.pinned.as_deref() == Some(print.as_str()) {
|
||||||
|
return Ok(ServerCertVerified::assertion());
|
||||||
|
}
|
||||||
|
if let Ok(mut slot) = self.rejected.lock() {
|
||||||
|
*slot = Some(print);
|
||||||
|
}
|
||||||
|
Err(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls12_signature(
|
||||||
|
&self,
|
||||||
|
message: &[u8],
|
||||||
|
cert: &CertificateDer<'_>,
|
||||||
|
dss: &DigitallySignedStruct,
|
||||||
|
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||||
|
rustls::crypto::verify_tls12_signature(
|
||||||
|
message,
|
||||||
|
cert,
|
||||||
|
dss,
|
||||||
|
&self.provider.signature_verification_algorithms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls13_signature(
|
||||||
|
&self,
|
||||||
|
message: &[u8],
|
||||||
|
cert: &CertificateDer<'_>,
|
||||||
|
dss: &DigitallySignedStruct,
|
||||||
|
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||||
|
rustls::crypto::verify_tls13_signature(
|
||||||
|
message,
|
||||||
|
cert,
|
||||||
|
dss,
|
||||||
|
&self.provider.signature_verification_algorithms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
||||||
|
self.provider
|
||||||
|
.signature_verification_algorithms
|
||||||
|
.supported_schemes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTP client for one server, with the pinned certificate if any.
|
||||||
|
pub struct Client {
|
||||||
|
agent: ureq::Agent,
|
||||||
|
rejected: Arc<Mutex<Option<String>>>,
|
||||||
|
user_agent: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
pub fn new(pinned: Option<String>) -> Self {
|
||||||
|
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||||
|
let mut roots = rustls::RootCertStore::empty();
|
||||||
|
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||||
|
let rejected = Arc::new(Mutex::new(None));
|
||||||
|
let mut agent = ureq::AgentBuilder::new()
|
||||||
|
.timeout_connect(Duration::from_secs(10))
|
||||||
|
.timeout_read(Duration::from_secs(120))
|
||||||
|
.timeout_write(Duration::from_secs(120))
|
||||||
|
.redirects(0);
|
||||||
|
let webpki = rustls::client::WebPkiServerVerifier::builder_with_provider(
|
||||||
|
Arc::new(roots),
|
||||||
|
provider.clone(),
|
||||||
|
)
|
||||||
|
.build();
|
||||||
|
let config = webpki.map_err(|e| e.to_string()).and_then(|webpki| {
|
||||||
|
let verifier = Verifier {
|
||||||
|
webpki,
|
||||||
|
provider: provider.clone(),
|
||||||
|
pinned,
|
||||||
|
rejected: rejected.clone(),
|
||||||
|
};
|
||||||
|
rustls::ClientConfig::builder_with_provider(provider)
|
||||||
|
.with_safe_default_protocol_versions()
|
||||||
|
.map(|b| {
|
||||||
|
b.dangerous()
|
||||||
|
.with_custom_certificate_verifier(Arc::new(verifier))
|
||||||
|
.with_no_client_auth()
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
});
|
||||||
|
match config {
|
||||||
|
Ok(config) => agent = agent.tls_config(Arc::new(config)),
|
||||||
|
Err(err) => eprintln!("[Nextcloud] TLS-Einrichtung fehlgeschlagen: {err}"),
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
agent: agent.build(),
|
||||||
|
rejected,
|
||||||
|
user_agent: format!("ink-notes ({})", crate::storage::device_name()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(&self, method: &str, url: &str) -> ureq::Request {
|
||||||
|
if let Ok(mut slot) = self.rejected.lock() {
|
||||||
|
*slot = None;
|
||||||
|
}
|
||||||
|
self.agent
|
||||||
|
.request(method, url)
|
||||||
|
.set("User-Agent", &self.user_agent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turns a failed request into an [`Error`].
|
||||||
|
fn error(&self, err: ureq::Error) -> Error {
|
||||||
|
match err {
|
||||||
|
ureq::Error::Status(401, _) => Error::Unauthorized,
|
||||||
|
ureq::Error::Status(404, _) => Error::NotFound,
|
||||||
|
ureq::Error::Status(412, _) => Error::PreconditionFailed,
|
||||||
|
ureq::Error::Status(code, resp) => {
|
||||||
|
let location = resp.header("Location").map(str::to_string);
|
||||||
|
match location {
|
||||||
|
Some(to) if (300..400).contains(&code) => Error::Other(t!(
|
||||||
|
"Der Server leitet nach {to} weiter – bitte diese Adresse verwenden",
|
||||||
|
to = to
|
||||||
|
)),
|
||||||
|
_ => Error::Other(t!("Server-Fehler {code}", code = code)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ureq::Error::Transport(transport) => {
|
||||||
|
if let Some(print) = self.rejected.lock().ok().and_then(|s| s.clone()) {
|
||||||
|
return Error::Untrusted { fingerprint: print };
|
||||||
|
}
|
||||||
|
Error::Offline(transport.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks that a Nextcloud server answers at `server`.
|
||||||
|
pub fn check_server(client: &Client, server: &str) -> Result<(), Error> {
|
||||||
|
let resp = client
|
||||||
|
.request("GET", &format!("{server}/status.php"))
|
||||||
|
.call()
|
||||||
|
.map_err(|e| client.error(e))?;
|
||||||
|
let status: serde_json::Value = resp.into_json().map_err(|_| not_nextcloud())?;
|
||||||
|
if status.get("installed").and_then(|v| v.as_bool()) == Some(true) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(not_nextcloud())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn not_nextcloud() -> Error {
|
||||||
|
Error::Other(t!("Unter dieser Adresse antwortet kein Nextcloud-Server").to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A started sign-in: the user approves it in the browser, the app polls for the result.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct LoginFlow {
|
||||||
|
pub login_url: String,
|
||||||
|
poll_url: String,
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts a link the server built with its own idea of its address (wrong behind a proxy) onto
|
||||||
|
/// the address the user gave.
|
||||||
|
fn onto_server(link: &str, server: &str, marker: &str) -> String {
|
||||||
|
let same_origin = match (url::Url::parse(link), url::Url::parse(server)) {
|
||||||
|
(Ok(a), Ok(b)) => a.origin() == b.origin(),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
match link.find(marker) {
|
||||||
|
Some(pos) if !same_origin => format!("{server}/index.php{}", &link[pos..]),
|
||||||
|
_ => link.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts Login Flow v2; the app password gets the device name as its name.
|
||||||
|
pub fn start_login(client: &Client, server: &str) -> Result<LoginFlow, Error> {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Poll {
|
||||||
|
token: String,
|
||||||
|
endpoint: String,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Start {
|
||||||
|
poll: Poll,
|
||||||
|
login: String,
|
||||||
|
}
|
||||||
|
let resp = client
|
||||||
|
.request("POST", &format!("{server}/index.php/login/v2"))
|
||||||
|
.call()
|
||||||
|
.map_err(|e| client.error(e))?;
|
||||||
|
let start: Start = resp.into_json().map_err(|_| not_nextcloud())?;
|
||||||
|
Ok(LoginFlow {
|
||||||
|
login_url: onto_server(&start.login, server, "/login/v2"),
|
||||||
|
poll_url: onto_server(&start.poll.endpoint, server, "/login/v2"),
|
||||||
|
token: start.poll.token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Login name and app password once the user has approved the sign-in.
|
||||||
|
pub fn poll_login(client: &Client, flow: &LoginFlow) -> Result<Option<(String, String)>, Error> {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct Done {
|
||||||
|
login_name: String,
|
||||||
|
app_password: String,
|
||||||
|
}
|
||||||
|
match client
|
||||||
|
.request("POST", &flow.poll_url)
|
||||||
|
.send_form(&[("token", flow.token.as_str())])
|
||||||
|
{
|
||||||
|
Ok(resp) => {
|
||||||
|
let done: Done = resp.into_json().map_err(|e| Error::Other(e.to_string()))?;
|
||||||
|
Ok(Some((done.login_name, done.app_password)))
|
||||||
|
}
|
||||||
|
// Not approved yet
|
||||||
|
Err(ureq::Error::Status(404, _)) => Ok(None),
|
||||||
|
Err(err) => Err(client.error(err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn basic_auth(login_name: &str, password: &str) -> String {
|
||||||
|
let raw = format!("{login_name}:{password}");
|
||||||
|
format!(
|
||||||
|
"Basic {}",
|
||||||
|
base64::engine::general_purpose::STANDARD.encode(raw)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User id of the signed-in user (the name in WebDAV paths).
|
||||||
|
pub fn user_id(
|
||||||
|
client: &Client,
|
||||||
|
server: &str,
|
||||||
|
login_name: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<String, Error> {
|
||||||
|
let resp = client
|
||||||
|
.request(
|
||||||
|
"GET",
|
||||||
|
&format!("{server}/ocs/v2.php/cloud/user?format=json"),
|
||||||
|
)
|
||||||
|
.set("OCS-APIRequest", "true")
|
||||||
|
.set("Authorization", &basic_auth(login_name, password))
|
||||||
|
.call()
|
||||||
|
.map_err(|e| client.error(e))?;
|
||||||
|
let value: serde_json::Value = resp.into_json().map_err(|e| Error::Other(e.to_string()))?;
|
||||||
|
value["ocs"]["data"]["id"]
|
||||||
|
.as_str()
|
||||||
|
.map(str::to_string)
|
||||||
|
.ok_or_else(|| Error::Other(t!("Unerwartete Antwort des Servers").to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revokes the app password on the server (when disconnecting).
|
||||||
|
pub fn revoke(account: &Account) -> Result<(), Error> {
|
||||||
|
let client = Client::new(account.pinned_cert.clone());
|
||||||
|
client
|
||||||
|
.request(
|
||||||
|
"DELETE",
|
||||||
|
&format!("{}/ocs/v2.php/core/apppassword", account.server),
|
||||||
|
)
|
||||||
|
.set("OCS-APIRequest", "true")
|
||||||
|
.set(
|
||||||
|
"Authorization",
|
||||||
|
&basic_auth(&account.login_name, &account.app_password),
|
||||||
|
)
|
||||||
|
.call()
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| client.error(e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One entry of a folder listing on the server.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Entry {
|
||||||
|
/// Path relative to the synced folder, `/`-separated
|
||||||
|
pub path: String,
|
||||||
|
pub is_dir: bool,
|
||||||
|
pub etag: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How an upload must find the file on the server.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Expect<'a> {
|
||||||
|
/// Must not exist yet
|
||||||
|
New,
|
||||||
|
/// Must still have this ETag
|
||||||
|
Etag(&'a str),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The folder `ink-notes` on the server, as the sync sees it.
|
||||||
|
pub trait Remote {
|
||||||
|
/// Entries directly in `dir` (`""` is the synced folder); `None` if it doesn't exist.
|
||||||
|
fn list(&mut self, dir: &str) -> Result<Option<Vec<Entry>>, Error>;
|
||||||
|
/// Content and ETag of a file.
|
||||||
|
fn get(&mut self, path: &str) -> Result<(Vec<u8>, String), Error>;
|
||||||
|
/// Uploads a file and returns its new ETag.
|
||||||
|
fn put(&mut self, path: &str, data: &[u8], expect: Expect<'_>) -> Result<String, Error>;
|
||||||
|
/// Creates a folder (fine if it exists).
|
||||||
|
fn mkdir(&mut self, path: &str) -> Result<(), Error>;
|
||||||
|
/// Deletes a file or folder (into the server's trash bin); with an ETag only if unchanged.
|
||||||
|
fn delete(&mut self, path: &str, etag: Option<&str>) -> Result<(), Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Remote`] over WebDAV.
|
||||||
|
pub struct WebDav {
|
||||||
|
client: Client,
|
||||||
|
/// `…/remote.php/dav/files/<user>/ink-notes/`
|
||||||
|
root: url::Url,
|
||||||
|
auth: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebDav {
|
||||||
|
pub fn new(account: &Account) -> Result<Self, Error> {
|
||||||
|
Self::with_folder(account, REMOTE_FOLDER)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`Self::new`], with another folder (for tests against a real server).
|
||||||
|
pub fn with_folder(account: &Account, folder: &str) -> Result<Self, Error> {
|
||||||
|
let mut root = url::Url::parse(&format!("{}/", account.server))
|
||||||
|
.map_err(|e| Error::Other(e.to_string()))?;
|
||||||
|
root.path_segments_mut()
|
||||||
|
.map_err(|_| Error::Other(account.server.clone()))?
|
||||||
|
.pop_if_empty()
|
||||||
|
.extend(["remote.php", "dav", "files", &account.user_id, folder, ""]);
|
||||||
|
Ok(Self {
|
||||||
|
client: Client::new(account.pinned_cert.clone()),
|
||||||
|
root,
|
||||||
|
auth: basic_auth(&account.login_name, &account.app_password),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn url(&self, path: &str, dir: bool) -> String {
|
||||||
|
let mut url = self.root.clone();
|
||||||
|
if let Ok(mut segments) = url.path_segments_mut() {
|
||||||
|
segments.pop_if_empty();
|
||||||
|
if !path.is_empty() {
|
||||||
|
segments.extend(path.split('/'));
|
||||||
|
}
|
||||||
|
if dir {
|
||||||
|
segments.push("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
url.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(&self, method: &str, url: &str) -> ureq::Request {
|
||||||
|
self.client
|
||||||
|
.request(method, url)
|
||||||
|
.set("Authorization", &self.auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes `%xx` escapes of a URL path.
|
||||||
|
fn percent_decode(s: &str) -> String {
|
||||||
|
let bytes = s.as_bytes();
|
||||||
|
let mut out = Vec::with_capacity(bytes.len());
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||||
|
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or("");
|
||||||
|
if let Ok(b) = u8::from_str_radix(hex, 16) {
|
||||||
|
out.push(b);
|
||||||
|
i += 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(bytes[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&out).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entries of a PROPFIND answer: (decoded href path, is folder, ETag).
|
||||||
|
pub fn parse_multistatus(xml: &str) -> Result<Vec<(String, bool, String)>, String> {
|
||||||
|
use quick_xml::events::Event;
|
||||||
|
let mut reader = quick_xml::Reader::from_str(xml);
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
let mut href = String::new();
|
||||||
|
let mut etag = String::new();
|
||||||
|
let mut is_dir = false;
|
||||||
|
let mut ok = false;
|
||||||
|
let mut in_tag: Option<&'static str> = None;
|
||||||
|
loop {
|
||||||
|
match reader.read_event().map_err(|e| e.to_string())? {
|
||||||
|
Event::Start(e) => {
|
||||||
|
let name = e.local_name();
|
||||||
|
match name.as_ref() {
|
||||||
|
b"response" => {
|
||||||
|
href.clear();
|
||||||
|
etag.clear();
|
||||||
|
is_dir = false;
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
b"href" => in_tag = Some("href"),
|
||||||
|
b"getetag" => in_tag = Some("etag"),
|
||||||
|
b"status" => in_tag = Some("status"),
|
||||||
|
b"collection" => is_dir = true,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Empty(e) => {
|
||||||
|
if e.local_name().as_ref() == b"collection" {
|
||||||
|
is_dir = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event @ (Event::Text(_) | Event::GeneralRef(_)) => {
|
||||||
|
let text = match event {
|
||||||
|
Event::Text(t) => t.decode().map_err(|e| e.to_string())?.into_owned(),
|
||||||
|
Event::GeneralRef(r) => match r.resolve_char_ref() {
|
||||||
|
Ok(Some(c)) => c.to_string(),
|
||||||
|
_ => r
|
||||||
|
.decode()
|
||||||
|
.ok()
|
||||||
|
.and_then(|name| quick_xml::escape::resolve_predefined_entity(&name))
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string(),
|
||||||
|
},
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
match in_tag {
|
||||||
|
Some("href") => href.push_str(&text),
|
||||||
|
Some("etag") => etag.push_str(&text),
|
||||||
|
Some("status") => ok |= text.contains(" 200 "),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::End(e) => {
|
||||||
|
let name = e.local_name();
|
||||||
|
match name.as_ref() {
|
||||||
|
b"href" | b"getetag" | b"status" => in_tag = None,
|
||||||
|
b"response" if ok => {
|
||||||
|
entries.push((percent_decode(href.trim()), is_dir, etag.trim().to_string()))
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Eof => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ETag of a file in an answer, as PROPFIND lists it. Nextcloud's own `OC-ETag` is left alone
|
||||||
|
/// by the web server; a plain `ETag` may carry a mark of the compression.
|
||||||
|
fn etag_of(resp: &ureq::Response) -> Option<String> {
|
||||||
|
if let Some(etag) = resp.header("OC-ETag") {
|
||||||
|
return Some(etag.to_string());
|
||||||
|
}
|
||||||
|
let etag = resp.header("ETag")?;
|
||||||
|
let plain = etag.strip_suffix('"').and_then(|unquoted| {
|
||||||
|
["-gzip", "-br", "-zstd", "-df"]
|
||||||
|
.iter()
|
||||||
|
.find_map(|mark| unquoted.strip_suffix(mark))
|
||||||
|
});
|
||||||
|
Some(plain.map_or(etag.to_string(), |tag| format!("{tag}\"")))
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROPFIND_BODY: &str = r#"<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:getetag/><d:resourcetype/></d:prop></d:propfind>"#;
|
||||||
|
|
||||||
|
impl Remote for WebDav {
|
||||||
|
fn list(&mut self, dir: &str) -> Result<Option<Vec<Entry>>, Error> {
|
||||||
|
let url = self.url(dir, true);
|
||||||
|
let resp = match self
|
||||||
|
.request("PROPFIND", &url)
|
||||||
|
.set("Depth", "1")
|
||||||
|
.set("Content-Type", "application/xml; charset=utf-8")
|
||||||
|
.send_string(PROPFIND_BODY)
|
||||||
|
{
|
||||||
|
Ok(resp) => resp,
|
||||||
|
Err(ureq::Error::Status(404, _)) => return Ok(None),
|
||||||
|
Err(err) => return Err(self.client.error(err)),
|
||||||
|
};
|
||||||
|
let body = resp
|
||||||
|
.into_string()
|
||||||
|
.map_err(|e| Error::Offline(e.to_string()))?;
|
||||||
|
let root_path = percent_decode(self.root.path());
|
||||||
|
let entries = parse_multistatus(&body).map_err(Error::Other)?;
|
||||||
|
Ok(Some(
|
||||||
|
entries
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(href, is_dir, etag)| {
|
||||||
|
// Hrefs are paths, sometimes full URLs
|
||||||
|
let path = match href.find("/remote.php/") {
|
||||||
|
Some(pos) if href.contains("://") => href[pos..].to_string(),
|
||||||
|
_ => href,
|
||||||
|
};
|
||||||
|
let path = path.strip_prefix(&root_path)?.trim_end_matches('/');
|
||||||
|
(path != dir && !path.is_empty()).then(|| Entry {
|
||||||
|
path: path.to_string(),
|
||||||
|
is_dir,
|
||||||
|
etag,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&mut self, path: &str) -> Result<(Vec<u8>, String), Error> {
|
||||||
|
// Uncompressed: Apache marks the ETag of a compressed answer (`"…-gzip"`)
|
||||||
|
let resp = self
|
||||||
|
.request("GET", &self.url(path, false))
|
||||||
|
.set("Accept-Encoding", "identity")
|
||||||
|
.call()
|
||||||
|
.map_err(|e| self.client.error(e))?;
|
||||||
|
let etag = etag_of(&resp).unwrap_or_default();
|
||||||
|
let mut data = Vec::new();
|
||||||
|
resp.into_reader()
|
||||||
|
.take(MAX_DOWNLOAD)
|
||||||
|
.read_to_end(&mut data)
|
||||||
|
.map_err(|e| Error::Offline(e.to_string()))?;
|
||||||
|
Ok((data, etag))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put(&mut self, path: &str, data: &[u8], expect: Expect<'_>) -> Result<String, Error> {
|
||||||
|
let mut req = self.request("PUT", &self.url(path, false));
|
||||||
|
req = match expect {
|
||||||
|
Expect::New => req.set("If-None-Match", "*"),
|
||||||
|
Expect::Etag(etag) => req.set("If-Match", etag),
|
||||||
|
};
|
||||||
|
let resp = req.send_bytes(data).map_err(|e| self.client.error(e))?;
|
||||||
|
match etag_of(&resp) {
|
||||||
|
Some(etag) => Ok(etag),
|
||||||
|
None => {
|
||||||
|
// Look it up when the server didn't say
|
||||||
|
let parent = path.rsplit_once('/').map_or("", |(p, _)| p);
|
||||||
|
self.list(parent)?
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.find(|e| e.path == path)
|
||||||
|
.map(|e| e.etag)
|
||||||
|
.ok_or(Error::NotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mkdir(&mut self, path: &str) -> Result<(), Error> {
|
||||||
|
match self.request("MKCOL", &self.url(path, true)).call() {
|
||||||
|
Ok(_) | Err(ureq::Error::Status(405, _)) => Ok(()),
|
||||||
|
Err(err) => Err(self.client.error(err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&mut self, path: &str, etag: Option<&str>) -> Result<(), Error> {
|
||||||
|
let mut req = self.request("DELETE", &self.url(path, false));
|
||||||
|
if let Some(etag) = etag {
|
||||||
|
req = req.set("If-Match", etag);
|
||||||
|
}
|
||||||
|
match req.call() {
|
||||||
|
Ok(_) | Err(ureq::Error::Status(404, _)) => Ok(()),
|
||||||
|
Err(err) => Err(self.client.error(err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalizes_server_addresses() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_server("cloud.example.org").unwrap(),
|
||||||
|
"https://cloud.example.org"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_server(" https://example.org/nextcloud/ ").unwrap(),
|
||||||
|
"https://example.org/nextcloud"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_server("https://example.org/nextcloud/index.php/apps/files/?dir=/").unwrap(),
|
||||||
|
"https://example.org/nextcloud"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_server("http://192.168.1.5:8080/apps/dashboard/").unwrap(),
|
||||||
|
"http://192.168.1.5:8080"
|
||||||
|
);
|
||||||
|
assert!(normalize_server("").is_err());
|
||||||
|
assert!(normalize_server("ftp://example.org").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn moves_links_onto_the_given_server() {
|
||||||
|
let server = "https://cloud.example.org/nc";
|
||||||
|
assert_eq!(
|
||||||
|
onto_server("http://localhost/login/v2/poll", server, "/login/v2"),
|
||||||
|
"https://cloud.example.org/nc/index.php/login/v2/poll"
|
||||||
|
);
|
||||||
|
let same = "https://cloud.example.org/nc/login/v2/flow/abc";
|
||||||
|
assert_eq!(onto_server(same, server, "/login/v2"), same);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_propfind_answers() {
|
||||||
|
let xml = r#"<?xml version="1.0"?>
|
||||||
|
<d:multistatus xmlns:d="DAV:"><d:response><d:href>/remote.php/dav/files/pia/ink-notes/</d:href><d:propstat><d:prop><d:getetag>"abc"</d:getetag><d:resourcetype><d:collection/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
|
||||||
|
<d:response><d:href>/remote.php/dav/files/pia/ink-notes/library.json</d:href><d:propstat><d:prop><d:getetag>"e1"</d:getetag><d:resourcetype/></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
|
||||||
|
<d:response><d:href>/remote.php/dav/files/pia/ink-notes/Mathe%20%C3%BC/</d:href><d:propstat><d:prop><d:getetag>"e2"</d:getetag><d:resourcetype><d:collection/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>"#;
|
||||||
|
let entries = parse_multistatus(xml).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
entries,
|
||||||
|
vec![
|
||||||
|
(
|
||||||
|
"/remote.php/dav/files/pia/ink-notes/".to_string(),
|
||||||
|
true,
|
||||||
|
"\"abc\"".to_string()
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/remote.php/dav/files/pia/ink-notes/library.json".to_string(),
|
||||||
|
false,
|
||||||
|
"\"e1\"".to_string()
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/remote.php/dav/files/pia/ink-notes/Mathe ü/".to_string(),
|
||||||
|
true,
|
||||||
|
"\"e2\"".to_string()
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builds_webdav_urls() {
|
||||||
|
let account = Account {
|
||||||
|
server: "https://example.org/nc".into(),
|
||||||
|
login_name: "pia@example.org".into(),
|
||||||
|
user_id: "pia".into(),
|
||||||
|
app_password: "x".into(),
|
||||||
|
pinned_cert: None,
|
||||||
|
};
|
||||||
|
let dav = WebDav::new(&account).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
dav.url("nb 1/page_1.json", false),
|
||||||
|
"https://example.org/nc/remote.php/dav/files/pia/ink-notes/nb%201/page_1.json"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
dav.url("", true),
|
||||||
|
"https://example.org/nc/remote.php/dav/files/pia/ink-notes/"
|
||||||
|
);
|
||||||
|
assert_eq!(account.label(), "pia@example.org@example.org/nc");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn account_round_trip() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("cfg").join("nextcloud.json");
|
||||||
|
let account = Account {
|
||||||
|
server: "https://example.org".into(),
|
||||||
|
login_name: "pia".into(),
|
||||||
|
user_id: "pia".into(),
|
||||||
|
app_password: "secret".into(),
|
||||||
|
pinned_cert: Some("AA:BB".into()),
|
||||||
|
};
|
||||||
|
account.save_to(&path).unwrap();
|
||||||
|
assert_eq!(Account::load_from(&path), Some(account));
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
|
||||||
|
assert_eq!(mode & 0o777, 0o600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -143,6 +143,20 @@ pub fn android_app() -> Option<winit::platform::android::activity::AndroidApp> {
|
|||||||
ANDROID_APP.get().cloned()
|
ANDROID_APP.get().cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Window edges (left, top, right, bottom; physical pixels) covered by system bars or display
|
||||||
|
/// cutouts. Android: the app draws below the status and navigation bars, so its buttons must
|
||||||
|
/// keep out of them (Issue #50). Desktop: none.
|
||||||
|
pub fn safe_area_insets() -> [f32; 4] {
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
crate::android_bridge::insets()
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
{
|
||||||
|
[0.0; 4]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Makes an exported file available to the user. Desktop: it already is (the path is
|
/// Makes an exported file available to the user. Desktop: it already is (the path is
|
||||||
/// returned). Android: copied to `Download/ink-notes` through the MediaStore (Android 10 and
|
/// returned). Android: copied to `Download/ink-notes` through the MediaStore (Android 10 and
|
||||||
/// newer), as other apps can't see the app's own folder; older versions keep it there.
|
/// newer), as other apps can't see the app's own folder; older versions keep it there.
|
||||||
|
|||||||
+92
-10
@@ -100,10 +100,16 @@ impl PenPreset {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Anzahl der Stifte in der Werkzeugleiste.
|
/// Anzahl der Stifte in der Werkzeugleiste (Issue #56: seven).
|
||||||
pub const PEN_PRESET_COUNT: usize = 4;
|
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] = [
|
pub const DEFAULT_PEN_PRESETS: [PenPreset; PEN_PRESET_COUNT] = [
|
||||||
PenPreset {
|
PenPreset {
|
||||||
color: [0.12, 0.12, 0.14, 1.0],
|
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],
|
color: [0.18, 0.65, 0.35, 1.0],
|
||||||
width_mm: 0.8,
|
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).
|
/// Farben des Textmarkers in der Werkzeugleiste (halbtransparent).
|
||||||
@@ -215,8 +233,10 @@ pub struct UiSettings {
|
|||||||
/// Frühere globale Einstellung „Seiten durchgehend scrollen“: gilt nur noch für Notizbücher
|
/// 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
|
/// ohne eigene Anordnung im Manifest (vor 0.28 angelegt), wird nicht mehr geändert
|
||||||
pub continuous_scroll: bool,
|
pub continuous_scroll: bool,
|
||||||
/// Stifte der Werkzeugleiste und der gewählte davon
|
/// Stifte der Werkzeugleiste und der gewählte davon: the first four here, the others in
|
||||||
pub pen_presets: [PenPreset; PEN_PRESET_COUNT],
|
/// `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,
|
pub active_pen: usize,
|
||||||
/// Eigene Farben von Textmarker und Text-Werkzeug und die gewählte davon
|
/// Eigene Farben von Textmarker und Text-Werkzeug und die gewählte davon
|
||||||
pub highlighter_colors: [[f32; 4]; 4],
|
pub highlighter_colors: [[f32; 4]; 4],
|
||||||
@@ -232,6 +252,11 @@ pub struct UiSettings {
|
|||||||
/// Folder of the notebooks, e.g. inside a Nextcloud folder; default location if unset
|
/// Folder of the notebooks, e.g. inside a Nextcloud folder; default location if unset
|
||||||
/// (Issue #49)
|
/// (Issue #49)
|
||||||
pub storage_dir: Option<PathBuf>,
|
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for UiSettings {
|
impl Default for UiSettings {
|
||||||
@@ -243,7 +268,13 @@ impl Default for UiSettings {
|
|||||||
pattern_color: PatternColor::default(),
|
pattern_color: PatternColor::default(),
|
||||||
pressure_curve: PressureCurve::default(),
|
pressure_curve: PressureCurve::default(),
|
||||||
continuous_scroll: true,
|
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,
|
active_pen: 0,
|
||||||
highlighter_colors: DEFAULT_HIGHLIGHTER_COLORS,
|
highlighter_colors: DEFAULT_HIGHLIGHTER_COLORS,
|
||||||
active_highlighter: 0,
|
active_highlighter: 0,
|
||||||
@@ -253,6 +284,8 @@ impl Default for UiSettings {
|
|||||||
language: None,
|
language: None,
|
||||||
eraser_radius_mm: 3.0,
|
eraser_radius_mm: 3.0,
|
||||||
storage_dir: None,
|
storage_dir: None,
|
||||||
|
math_assist: false,
|
||||||
|
touch_draw: cfg!(target_os = "android"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -276,9 +309,12 @@ impl UiSettings {
|
|||||||
Err(_) => Self::default(),
|
Err(_) => Self::default(),
|
||||||
};
|
};
|
||||||
settings.pressure_curve = settings.pressure_curve.sanitized();
|
settings.pressure_curve = settings.pressure_curve.sanitized();
|
||||||
for (preset, fallback) in settings.pen_presets.iter_mut().zip(DEFAULT_PEN_PRESETS) {
|
let pens = settings.pens();
|
||||||
*preset = preset.sanitized(fallback);
|
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);
|
settings.active_pen = settings.active_pen.min(PEN_PRESET_COUNT - 1);
|
||||||
for (color, fallback) in settings
|
for (color, fallback) in settings
|
||||||
.highlighter_colors
|
.highlighter_colors
|
||||||
@@ -300,6 +336,23 @@ impl UiSettings {
|
|||||||
settings
|
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.
|
/// Speichert die Einstellungen atomar.
|
||||||
pub fn save_to(&self, path: &Path) -> Result<(), StorageError> {
|
pub fn save_to(&self, path: &Path) -> Result<(), StorageError> {
|
||||||
storage::atomic_write_json(path, self)
|
storage::atomic_write_json(path, self)
|
||||||
@@ -331,7 +384,14 @@ mod tests {
|
|||||||
pen_presets: [PenPreset {
|
pen_presets: [PenPreset {
|
||||||
color: [0.5, 0.2, 0.7, 1.0],
|
color: [0.5, 0.2, 0.7, 1.0],
|
||||||
width_mm: 1.5,
|
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,
|
active_pen: 2,
|
||||||
highlighter_colors: [[0.9, 0.5, 0.1, 0.35]; 4],
|
highlighter_colors: [[0.9, 0.5, 0.1, 0.35]; 4],
|
||||||
active_highlighter: 3,
|
active_highlighter: 3,
|
||||||
@@ -350,6 +410,8 @@ mod tests {
|
|||||||
language: Some(Language::English),
|
language: Some(Language::English),
|
||||||
eraser_radius_mm: 6.0,
|
eraser_radius_mm: 6.0,
|
||||||
storage_dir: Some(PathBuf::from("/home/pia/Nextcloud/ink-notes")),
|
storage_dir: Some(PathBuf::from("/home/pia/Nextcloud/ink-notes")),
|
||||||
|
math_assist: true,
|
||||||
|
touch_draw: true,
|
||||||
};
|
};
|
||||||
settings.save_to(&path).unwrap();
|
settings.save_to(&path).unwrap();
|
||||||
assert_eq!(UiSettings::load_from(&path), settings);
|
assert_eq!(UiSettings::load_from(&path), settings);
|
||||||
@@ -363,6 +425,24 @@ mod tests {
|
|||||||
assert_eq!(UiSettings::load_from(&path), UiSettings::default());
|
assert_eq!(UiSettings::load_from(&path), UiSettings::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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]
|
#[test]
|
||||||
fn test_old_settings_file_keeps_new_defaults() {
|
fn test_old_settings_file_keeps_new_defaults() {
|
||||||
// ui.json aus v0.3.x kennt nur die Seite der Werkzeugleiste
|
// ui.json aus v0.3.x kennt nur die Seite der Werkzeugleiste
|
||||||
@@ -388,7 +468,7 @@ mod tests {
|
|||||||
assert_eq!(curve.threshold, 0.0);
|
assert_eq!(curve.threshold, 0.0);
|
||||||
|
|
||||||
// Ohne Stifte: die Standardstifte; unsinnige Stifte werden begrenzt
|
// 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);
|
assert_eq!(settings.active_pen, 0);
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
&path,
|
&path,
|
||||||
@@ -405,6 +485,8 @@ mod tests {
|
|||||||
assert_eq!(settings.pen_presets[0].color, [1.0, 0.0, 0.5, 1.0]);
|
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[0].width_mm, 5.0);
|
||||||
assert_eq!(settings.pen_presets[1].width_mm, 0.15);
|
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
|
// Eigene Farben: Textmarker bleibt halbtransparent, Text deckend
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
|
|||||||
@@ -317,6 +317,10 @@ pub enum UiAction {
|
|||||||
UnlockCorruptedPage(u64),
|
UnlockCorruptedPage(u64),
|
||||||
/// Werkzeugleiste an die andere Fensterseite verlegen (wird gespeichert)
|
/// Werkzeugleiste an die andere Fensterseite verlegen (wird gespeichert)
|
||||||
SetSidebarSide(SidebarSide),
|
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
|
/// Sprache der Oberfläche; `None` = Sprache des Systems
|
||||||
SetLanguage(Option<crate::i18n::Language>),
|
SetLanguage(Option<crate::i18n::Language>),
|
||||||
/// Papierfarbe der Anzeige und Aufhellen dunkler Tinte (wird gespeichert)
|
/// Papierfarbe der Anzeige und Aufhellen dunkler Tinte (wird gespeichert)
|
||||||
@@ -341,6 +345,15 @@ pub enum UiAction {
|
|||||||
ImportFile(std::path::PathBuf),
|
ImportFile(std::path::PathBuf),
|
||||||
/// Store the notebooks in this folder, `None` for the default location (Issue #49)
|
/// Store the notebooks in this folder, `None` for the default location (Issue #49)
|
||||||
SetStorageDir(Option<std::path::PathBuf>),
|
SetStorageDir(Option<std::path::PathBuf>),
|
||||||
|
/// Sign in to a Nextcloud server through the browser; with the fingerprint of a
|
||||||
|
/// certificate the user trusts (Issue #49, stage 2)
|
||||||
|
CloudConnect {
|
||||||
|
server: String,
|
||||||
|
trusted: Option<String>,
|
||||||
|
},
|
||||||
|
CloudCancel,
|
||||||
|
CloudSyncNow,
|
||||||
|
CloudDisconnect,
|
||||||
/// Aktuelle Seite als Bild oder SVG exportieren
|
/// Aktuelle Seite als Bild oder SVG exportieren
|
||||||
ExportPage(PageExportKind),
|
ExportPage(PageExportKind),
|
||||||
/// Titel einer Seite setzen (leer = entfernen)
|
/// Titel einer Seite setzen (leer = entfernen)
|
||||||
@@ -413,6 +426,8 @@ pub struct UiState {
|
|||||||
pub update_modal_rect: Option<egui::Rect>,
|
pub update_modal_rect: Option<egui::Rect>,
|
||||||
/// Steuert, ob Touch-Eingaben mit einem Finger zeichnen oder das Dokument verschieben (Default: false = reine Gesten-Navigation & Zoom)
|
/// Steuert, ob Touch-Eingaben mit einem Finger zeichnen oder das Dokument verschieben (Default: false = reine Gesten-Navigation & Zoom)
|
||||||
pub touch_draw_enabled: bool,
|
pub touch_draw_enabled: bool,
|
||||||
|
/// Computes handwritten math after an `=` (Issue #46)
|
||||||
|
pub math: crate::math_assist::MathAssist,
|
||||||
/// Zuletzt gelöschtes Notizbuch für Rückgängig während der Sitzung (Papierkorb-Pfad, Titel)
|
/// Zuletzt gelöschtes Notizbuch für Rückgängig während der Sitzung (Papierkorb-Pfad, Titel)
|
||||||
pub last_deleted_notebook: Option<(std::path::PathBuf, String)>,
|
pub last_deleted_notebook: Option<(std::path::PathBuf, String)>,
|
||||||
/// Stifte der Werkzeugleiste (Farbe und Stärke) und der gewählte davon; `pen_color` und
|
/// Stifte der Werkzeugleiste (Farbe und Stärke) und der gewählte davon; `pen_color` und
|
||||||
@@ -493,8 +508,9 @@ pub struct UiState {
|
|||||||
pub scrollbar_grab: Option<f32>,
|
pub scrollbar_grab: Option<f32>,
|
||||||
/// Aktuelle Abbildung Seite ↔ Bildschirm (für Lineal und Treffertests)
|
/// Aktuelle Abbildung Seite ↔ Bildschirm (für Lineal und Treffertests)
|
||||||
pub page_view: Option<PageView>,
|
pub page_view: Option<PageView>,
|
||||||
/// Fenstergröße in logischen Punkten (vom letzten Bild)
|
/// Usable part of the window in logical points (from the last frame; on Android without the
|
||||||
pub screen_size: egui::Vec2,
|
/// system bars)
|
||||||
|
pub screen_rect: egui::Rect,
|
||||||
/// Eingabefeld „Titel dieser Seite“ in der Seitenübersicht und die Seite, zu der es gehört
|
/// Eingabefeld „Titel dieser Seite“ in der Seitenübersicht und die Seite, zu der es gehört
|
||||||
pub page_title_input: String,
|
pub page_title_input: String,
|
||||||
pub page_title_for: Option<u64>,
|
pub page_title_for: Option<u64>,
|
||||||
@@ -508,6 +524,9 @@ pub struct UiState {
|
|||||||
/// Folder holding the notebooks and whether the user picked it (Issue #49)
|
/// Folder holding the notebooks and whether the user picked it (Issue #49)
|
||||||
pub storage_dir: std::path::PathBuf,
|
pub storage_dir: std::path::PathBuf,
|
||||||
pub storage_dir_custom: bool,
|
pub storage_dir_custom: bool,
|
||||||
|
/// Nextcloud sync as the app last saw it, and the server address being typed
|
||||||
|
pub cloud: crate::cloud_sync::View,
|
||||||
|
pub cloud_server_input: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for UiState {
|
impl Default for UiState {
|
||||||
@@ -546,6 +565,7 @@ impl Default for UiState {
|
|||||||
show_updater_config: false,
|
show_updater_config: false,
|
||||||
update_modal_rect: None,
|
update_modal_rect: None,
|
||||||
touch_draw_enabled: false,
|
touch_draw_enabled: false,
|
||||||
|
math: crate::math_assist::MathAssist::default(),
|
||||||
last_deleted_notebook: None,
|
last_deleted_notebook: None,
|
||||||
pen_presets: DEFAULT_PEN_PRESETS,
|
pen_presets: DEFAULT_PEN_PRESETS,
|
||||||
active_pen: 0,
|
active_pen: 0,
|
||||||
@@ -588,13 +608,15 @@ impl Default for UiState {
|
|||||||
ruler_drag: None,
|
ruler_drag: None,
|
||||||
scrollbar_grab: None,
|
scrollbar_grab: None,
|
||||||
page_view: None,
|
page_view: None,
|
||||||
screen_size: egui::Vec2::ZERO,
|
screen_rect: egui::Rect::ZERO,
|
||||||
page_title_input: String::new(),
|
page_title_input: String::new(),
|
||||||
page_title_for: None,
|
page_title_for: None,
|
||||||
page_picker_list: false,
|
page_picker_list: false,
|
||||||
last_picker_dir: None,
|
last_picker_dir: None,
|
||||||
storage_dir: std::path::PathBuf::new(),
|
storage_dir: std::path::PathBuf::new(),
|
||||||
storage_dir_custom: false,
|
storage_dir_custom: false,
|
||||||
|
cloud: crate::cloud_sync::View::default(),
|
||||||
|
cloud_server_input: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -661,7 +683,7 @@ impl UiState {
|
|||||||
|
|
||||||
/// Mitte des Bereichs neben der Werkzeugleiste (logische Punkte).
|
/// Mitte des Bereichs neben der Werkzeugleiste (logische Punkte).
|
||||||
fn visible_canvas_center(&self) -> egui::Pos2 {
|
fn visible_canvas_center(&self) -> egui::Pos2 {
|
||||||
(self.screen_size / 2.0).to_pos2()
|
self.screen_rect.center()
|
||||||
+ egui::vec2(
|
+ egui::vec2(
|
||||||
match self.sidebar_side {
|
match self.sidebar_side {
|
||||||
SidebarSide::Left => self.sidebar_reserved_width() / 2.0,
|
SidebarSide::Left => self.sidebar_reserved_width() / 2.0,
|
||||||
@@ -699,7 +721,7 @@ impl UiState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Länge der längsten Farbpalette; so viel Platz hält die Leiste immer frei.
|
/// 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.
|
/// Vertical gap between toolbar buttons and the height of a divider line.
|
||||||
pub const SIDEBAR_SPACING: f32 = 2.0;
|
pub const SIDEBAR_SPACING: f32 = 2.0;
|
||||||
pub const SIDEBAR_DIVIDER: f32 = 3.0;
|
pub const SIDEBAR_DIVIDER: f32 = 3.0;
|
||||||
@@ -1652,6 +1674,57 @@ fn text_style_buttons(ui: &mut egui::Ui, btn: f32, state: &mut UiState) {
|
|||||||
|
|
||||||
/// Bearbeitung eines Textfelds: egui-Textfeld an der Stelle und in der Größe des Felds auf
|
/// Bearbeitung eines Textfelds: egui-Textfeld an der Stelle und in der Größe des Felds auf
|
||||||
/// dem Blatt (ungedreht), mit derselben Schrift. Esc beendet die Bearbeitung.
|
/// dem Blatt (ungedreht), mit derselben Schrift. Esc beendet die Bearbeitung.
|
||||||
|
/// The result offered next to a handwritten `=` (Issue #46): what was read and the result,
|
||||||
|
/// e.g. `2⁴ = 16`. Not interactive for egui, so that fingers and palms pass through to the
|
||||||
|
/// page; the pen and the mouse take it via [`crate::math_assist::MathAssist::take_hit`].
|
||||||
|
fn math_suggestion(ctx: &egui::Context, state: &mut UiState, notebook: &Notebook) {
|
||||||
|
state.math.chip_rect = None;
|
||||||
|
if state.view_mode {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (Some(view), Some(suggestion)) = (
|
||||||
|
state.page_view,
|
||||||
|
state.math.suggestion_for(notebook.current_page()).cloned(),
|
||||||
|
) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let size = (suggestion.symbol_height * view.scale * 0.8).clamp(14.0, 48.0);
|
||||||
|
let response = egui::Area::new(egui::Id::new("math_suggestion"))
|
||||||
|
.fixed_pos(view.to_screen(suggestion.anchor))
|
||||||
|
.pivot(egui::Align2::LEFT_CENTER)
|
||||||
|
.order(egui::Order::Foreground)
|
||||||
|
.interactable(false)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
egui::Frame::none()
|
||||||
|
.fill(egui::Color32::from_rgba_unmultiplied(30, 34, 44, 235))
|
||||||
|
.stroke(egui::Stroke::new(
|
||||||
|
1.5_f32,
|
||||||
|
egui::Color32::from_rgb(70, 130, 230),
|
||||||
|
))
|
||||||
|
.rounding(size * 0.4)
|
||||||
|
.inner_margin(egui::vec2(size * 0.45, size * 0.2))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.spacing_mut().item_spacing.x = size * 0.25;
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(format!("{} =", suggestion.expression))
|
||||||
|
.size(size * 0.7)
|
||||||
|
.color(egui::Color32::from_gray(170)),
|
||||||
|
);
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(&suggestion.result)
|
||||||
|
.size(size)
|
||||||
|
.strong()
|
||||||
|
.color(egui::Color32::WHITE),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.response;
|
||||||
|
// A little larger than drawn, as the pen does not hit exactly
|
||||||
|
state.math.chip_rect = Some(response.rect.expand(6.0));
|
||||||
|
}
|
||||||
|
|
||||||
fn text_edit_overlay(ctx: &egui::Context, state: &mut UiState) {
|
fn text_edit_overlay(ctx: &egui::Context, state: &mut UiState) {
|
||||||
let adapt = state.adapt_ink_colors && state.paper_theme.is_dark();
|
let adapt = state.adapt_ink_colors && state.paper_theme.is_dark();
|
||||||
let (Some(view), Some(edit)) = (state.page_view, state.text_edit.as_mut()) else {
|
let (Some(view), Some(edit)) = (state.page_view, state.text_edit.as_mut()) else {
|
||||||
@@ -2869,8 +2942,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| {
|
dialog_window(title, state, false).show(ctx, |ui| {
|
||||||
ui.set_width(460.0);
|
ui.set_width(width);
|
||||||
touch_friendly_spacing(ui);
|
touch_friendly_spacing(ui);
|
||||||
if dialog_header(ui, header, true) {
|
if dialog_header(ui, header, true) {
|
||||||
close = true;
|
close = true;
|
||||||
@@ -3041,6 +3115,17 @@ fn dialog_window(title: &'static str, state: &UiState, danger: bool) -> egui::Wi
|
|||||||
.frame(dialog_frame(danger))
|
.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.
|
/// Kopfzeile eines Dialogs mit Titel und optionalem Schließen-Knopf.
|
||||||
/// Gibt `true` zurück, wenn „Schließen“ getippt wurde.
|
/// Gibt `true` zurück, wenn „Schließen“ getippt wurde.
|
||||||
fn dialog_header(ui: &mut egui::Ui, title: &str, closable: bool) -> bool {
|
fn dialog_header(ui: &mut egui::Ui, title: &str, closable: bool) -> bool {
|
||||||
@@ -3133,6 +3218,171 @@ pub fn key_allowed_in_view_mode(key: &winit::keyboard::Key, ctrl: bool) -> bool
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "How long ago" for the last sync.
|
||||||
|
fn elapsed_label(at: std::time::SystemTime) -> String {
|
||||||
|
let secs = at.elapsed().map_or(0, |d| d.as_secs());
|
||||||
|
if secs < 60 {
|
||||||
|
t!("gerade eben").to_string()
|
||||||
|
} else if secs < 3600 {
|
||||||
|
t!("vor {minutes} min", minutes = secs / 60)
|
||||||
|
} else {
|
||||||
|
t!("vor {hours} h", hours = secs / 3600)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nextcloud sync in the menu (Issue #49, stage 2).
|
||||||
|
fn cloud_section(ui: &mut egui::Ui, state: &mut UiState, actions: &mut Vec<UiAction>) {
|
||||||
|
use crate::cloud_sync::Status;
|
||||||
|
let small = |text: String, color: egui::Color32| {
|
||||||
|
egui::Label::new(egui::RichText::new(text).size(11.5).color(color)).wrap(true)
|
||||||
|
};
|
||||||
|
let grey = egui::Color32::from_gray(190);
|
||||||
|
let warn = egui::Color32::from_rgb(240, 180, 80);
|
||||||
|
let button = |text: String| egui::Button::new(text).min_size(egui::vec2(115.0, 28.0));
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(t!("NEXTCLOUD"))
|
||||||
|
.size(10.5)
|
||||||
|
.strong()
|
||||||
|
.color(egui::Color32::from_gray(160)),
|
||||||
|
);
|
||||||
|
let status = state.cloud.status.clone();
|
||||||
|
if matches!(
|
||||||
|
status,
|
||||||
|
Status::Connecting | Status::WaitingForLogin { .. } | Status::Syncing
|
||||||
|
) {
|
||||||
|
ui.ctx()
|
||||||
|
.request_repaint_after(std::time::Duration::from_millis(500));
|
||||||
|
}
|
||||||
|
match status {
|
||||||
|
Status::Off | Status::Failed(_) if state.cloud.account.is_none() => {
|
||||||
|
if let Status::Failed(msg) = &status {
|
||||||
|
ui.add(small(format!("⚠ {msg}"), warn));
|
||||||
|
}
|
||||||
|
let field = ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut state.cloud_server_input)
|
||||||
|
.hint_text(t!("z. B. cloud.example.org"))
|
||||||
|
.margin(egui::vec2(8.0, 6.0))
|
||||||
|
.desired_width(f32::INFINITY),
|
||||||
|
);
|
||||||
|
if state.cloud_server_input.trim().starts_with("http://") {
|
||||||
|
ui.add(small(
|
||||||
|
t!("⚠ Ohne https ist das App-Passwort im Netz lesbar – nur im eigenen Heimnetz verwenden").to_string(),
|
||||||
|
warn,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let enter = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||||
|
if ui
|
||||||
|
.add_enabled(
|
||||||
|
!state.cloud_server_input.trim().is_empty(),
|
||||||
|
button(t!("🔗 Verbinden …").to_string()),
|
||||||
|
)
|
||||||
|
.on_hover_text(t!("Anmeldung im Browser; die App bekommt ein eigenes App-Passwort, das sich in Nextcloud jederzeit widerrufen lässt"))
|
||||||
|
.clicked()
|
||||||
|
|| enter && !state.cloud_server_input.trim().is_empty()
|
||||||
|
{
|
||||||
|
actions.push(UiAction::CloudConnect {
|
||||||
|
server: state.cloud_server_input.clone(),
|
||||||
|
trusted: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Status::Off | Status::Connecting => {
|
||||||
|
ui.add(small(t!("⏳ Verbinde …").to_string(), grey));
|
||||||
|
if ui.add(button(t!("Abbrechen").to_string())).clicked() {
|
||||||
|
actions.push(UiAction::CloudCancel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Status::Untrusted {
|
||||||
|
server,
|
||||||
|
fingerprint,
|
||||||
|
} => {
|
||||||
|
ui.add(small(
|
||||||
|
t!("⚠ Das Zertifikat von {server} ist von keiner bekannten Stelle signiert. Nur vertrauen, wenn dieser Fingerabdruck mit dem des Servers übereinstimmt:", server = server),
|
||||||
|
warn,
|
||||||
|
));
|
||||||
|
ui.add(
|
||||||
|
egui::Label::new(
|
||||||
|
egui::RichText::new(&fingerprint)
|
||||||
|
.monospace()
|
||||||
|
.size(10.0)
|
||||||
|
.color(grey),
|
||||||
|
)
|
||||||
|
.wrap(true),
|
||||||
|
);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui.add(button(t!("Vertrauen").to_string())).clicked() {
|
||||||
|
actions.push(UiAction::CloudConnect {
|
||||||
|
server: server.clone(),
|
||||||
|
trusted: Some(fingerprint.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if ui.add(button(t!("Abbrechen").to_string())).clicked() {
|
||||||
|
actions.push(UiAction::CloudCancel);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Status::WaitingForLogin { url } => {
|
||||||
|
ui.add(small(
|
||||||
|
t!("Bitte im Browser anmelden und den Zugriff erlauben …").to_string(),
|
||||||
|
grey,
|
||||||
|
));
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui
|
||||||
|
.add(button(t!("🌐 Browser öffnen").to_string()))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(UiAction::OpenUrlInBrowser(url.clone()));
|
||||||
|
}
|
||||||
|
if ui.add(button(t!("Abbrechen").to_string())).clicked() {
|
||||||
|
actions.push(UiAction::CloudCancel);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Status::Idle { .. } | Status::Syncing | Status::Offline(_) | Status::Failed(_) => {
|
||||||
|
if let Some(account) = &state.cloud.account {
|
||||||
|
ui.add(small(format!("👤 {account}"), grey).truncate(true))
|
||||||
|
.on_hover_text(account);
|
||||||
|
}
|
||||||
|
let (text, color) = match &status {
|
||||||
|
Status::Idle { last: Some(at) } => (
|
||||||
|
t!("✔ Synchronisiert {when}", when = elapsed_label(*at)),
|
||||||
|
egui::Color32::from_rgb(120, 200, 120),
|
||||||
|
),
|
||||||
|
Status::Offline(msg) => (
|
||||||
|
t!("⚪ Offline – neuer Versuch in Kürze ({msg})", msg = msg),
|
||||||
|
grey,
|
||||||
|
),
|
||||||
|
Status::Failed(msg) => (format!("⚠ {msg}"), warn),
|
||||||
|
_ => (t!("🔄 Synchronisiere …").to_string(), grey),
|
||||||
|
};
|
||||||
|
ui.add(small(text, color));
|
||||||
|
if state.cloud.insecure {
|
||||||
|
ui.add(small(t!("⚠ Unverschlüsselt (http)").to_string(), warn));
|
||||||
|
}
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui
|
||||||
|
.add_enabled(
|
||||||
|
!matches!(status, Status::Syncing),
|
||||||
|
button(t!("🔄 Jetzt").to_string()),
|
||||||
|
)
|
||||||
|
.on_hover_text(t!("Sofort mit dem Server abgleichen"))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(UiAction::CloudSyncNow);
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.add(button(t!("Trennen").to_string()))
|
||||||
|
.on_hover_text(t!("Abgleich beenden und das App-Passwort widerrufen; die Notizbücher bleiben auf diesem Gerät und auf dem Server"))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(UiAction::CloudDisconnect);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Rendert die schwebende Toolbar und alle zugehörigen UI-Komponenten mit `egui`.
|
/// Rendert die schwebende Toolbar und alle zugehörigen UI-Komponenten mit `egui`.
|
||||||
///
|
///
|
||||||
/// Gibt eine Liste von `UiAction`-Events zurück, die vom Hauptprogramm verarbeitet werden.
|
/// Gibt eine Liste von `UiAction`-Events zurück, die vom Hauptprogramm verarbeitet werden.
|
||||||
@@ -3144,12 +3394,13 @@ pub fn render_ui(
|
|||||||
updater: &crate::updater::UpdaterController,
|
updater: &crate::updater::UpdaterController,
|
||||||
) -> Vec<UiAction> {
|
) -> Vec<UiAction> {
|
||||||
let mut actions = std::mem::take(&mut state.queued_actions);
|
let mut actions = std::mem::take(&mut state.queued_actions);
|
||||||
state.screen_size = ctx.screen_rect().size();
|
state.screen_rect = ctx.screen_rect();
|
||||||
page_separators(ctx, state, notebook);
|
page_separators(ctx, state, notebook);
|
||||||
page_scrollbar(ctx, state, notebook, &mut actions);
|
page_scrollbar(ctx, state, notebook, &mut actions);
|
||||||
space_preview(ctx, state, notebook);
|
space_preview(ctx, state, notebook);
|
||||||
ruler_overlay(ctx, state);
|
ruler_overlay(ctx, state);
|
||||||
text_edit_overlay(ctx, state);
|
text_edit_overlay(ctx, state);
|
||||||
|
math_suggestion(ctx, state, notebook);
|
||||||
context_menu(ctx, state, &mut actions);
|
context_menu(ctx, state, &mut actions);
|
||||||
|
|
||||||
let mut pen_btn_rect = None;
|
let mut pen_btn_rect = None;
|
||||||
@@ -4376,13 +4627,27 @@ pub fn render_ui(
|
|||||||
});
|
});
|
||||||
|
|
||||||
section(ui, t!("EINGABE"));
|
section(ui, t!("EINGABE"));
|
||||||
ui.checkbox(
|
if ui
|
||||||
&mut state.touch_draw_enabled,
|
.checkbox(
|
||||||
t!("Mit dem Finger zeichnen"),
|
&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."),
|
.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"))
|
||||||
|
.on_hover_text(t!("Nach einem handgeschriebenen „=“ erscheint das Ergebnis daneben, z. B. 2⁴ = 16. Antippen mit dem Stift oder Klicken fügt es als Text ein.\nGelesen werden Ziffern, + − · : /, Klammern, Hochzahlen, Brüche und x, y, a, b."))
|
||||||
|
.changed()
|
||||||
|
{
|
||||||
|
state.math.set_enabled(math);
|
||||||
|
actions.push(UiAction::SetMathAssist(math));
|
||||||
|
}
|
||||||
if let Some(curve) = pressure_curve_editor(ui, state) {
|
if let Some(curve) = pressure_curve_editor(ui, state) {
|
||||||
actions.push(UiAction::SavePressureCurve(curve));
|
actions.push(UiAction::SavePressureCurve(curve));
|
||||||
}
|
}
|
||||||
@@ -4468,6 +4733,7 @@ pub fn render_ui(
|
|||||||
actions.push(UiAction::SetStorageDir(None));
|
actions.push(UiAction::SetStorageDir(None));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
cloud_section(ui, state, &mut actions);
|
||||||
|
|
||||||
section(ui, t!("STATUS"));
|
section(ui, t!("STATUS"));
|
||||||
let (dot_color, status_text) = autosave_status_style(autosave_status);
|
let (dot_color, status_text) = autosave_status_style(autosave_status);
|
||||||
@@ -4683,9 +4949,10 @@ pub fn render_ui(
|
|||||||
|
|
||||||
// 3b. Dokumenten-Manager (Notizbücher wechseln, anlegen, umbenennen, löschen)
|
// 3b. Dokumenten-Manager (Notizbücher wechseln, anlegen, umbenennen, löschen)
|
||||||
if state.is_document_menu_open {
|
if state.is_document_menu_open {
|
||||||
|
let width = dialog_width(ctx, state, 420.0);
|
||||||
let doc_win = dialog_window(t!("Dokumenten-Manager"), state, false)
|
let doc_win = dialog_window(t!("Dokumenten-Manager"), state, false)
|
||||||
.show(ctx, |ui| {
|
.show(ctx, |ui| {
|
||||||
ui.set_width(420.0);
|
ui.set_width(width);
|
||||||
touch_friendly_spacing(ui);
|
touch_friendly_spacing(ui);
|
||||||
if dialog_header(ui, t!("📚 Notizbücher"), true) {
|
if dialog_header(ui, t!("📚 Notizbücher"), true) {
|
||||||
state.is_document_menu_open = false;
|
state.is_document_menu_open = false;
|
||||||
@@ -4701,13 +4968,13 @@ pub fn render_ui(
|
|||||||
folder_breadcrumbs(ui, state, &folder);
|
folder_breadcrumbs(ui, state, &folder);
|
||||||
ui.add_space(6.0);
|
ui.add_space(6.0);
|
||||||
|
|
||||||
// Neues Notizbuch oder neuer Ordner hier
|
// Neues Notizbuch oder neuer Ordner hier (narrow screens: buttons below)
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
let text_edit = ui.add(
|
let text_edit = ui.add(
|
||||||
egui::TextEdit::singleline(&mut state.new_notebook_title_input)
|
egui::TextEdit::singleline(&mut state.new_notebook_title_input)
|
||||||
.hint_text(t!("Name …"))
|
.hint_text(t!("Name …"))
|
||||||
.margin(egui::vec2(8.0, 8.0))
|
.margin(egui::vec2(8.0, 8.0))
|
||||||
.desired_width(190.0),
|
.desired_width(190.0_f32.min(ui.available_width())),
|
||||||
);
|
);
|
||||||
let create_btn = ui
|
let create_btn = ui
|
||||||
.button(egui::RichText::new(t!("➕ Notizbuch")).strong())
|
.button(egui::RichText::new(t!("➕ Notizbuch")).strong())
|
||||||
@@ -4785,7 +5052,7 @@ pub fn render_ui(
|
|||||||
let edit = ui.add(
|
let edit = ui.add(
|
||||||
egui::TextEdit::singleline(&mut state.renaming_title_input)
|
egui::TextEdit::singleline(&mut state.renaming_title_input)
|
||||||
.margin(egui::vec2(8.0, 8.0))
|
.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()
|
if ui.button("✔").clicked()
|
||||||
|| (edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)))
|
|| (edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)))
|
||||||
@@ -4950,9 +5217,10 @@ pub fn render_ui(
|
|||||||
|
|
||||||
// 3c. Release- & Update-Center (Gitea Releases)
|
// 3c. Release- & Update-Center (Gitea Releases)
|
||||||
if state.is_update_dialog_open {
|
if state.is_update_dialog_open {
|
||||||
|
let width = dialog_width(ctx, state, 460.0);
|
||||||
let win = dialog_window(t!("Release- & Update-Center"), state, false)
|
let win = dialog_window(t!("Release- & Update-Center"), state, false)
|
||||||
.show(ctx, |ui| {
|
.show(ctx, |ui| {
|
||||||
ui.set_width(460.0);
|
ui.set_width(width);
|
||||||
touch_friendly_spacing(ui);
|
touch_friendly_spacing(ui);
|
||||||
if dialog_header(ui, t!("🚀 Updates & Versionen"), true) {
|
if dialog_header(ui, t!("🚀 Updates & Versionen"), true) {
|
||||||
state.is_update_dialog_open = false;
|
state.is_update_dialog_open = false;
|
||||||
@@ -5174,11 +5442,11 @@ pub fn render_ui(
|
|||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
ui.label(egui::RichText::new(t!("Gitea Server-URL:")).size(11.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.add_space(4.0);
|
||||||
ui.label(egui::RichText::new(t!("Personal Access Token (optional für private Repos):")).size(11.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);
|
ui.add_space(6.0);
|
||||||
if ui.button(egui::RichText::new(t!("💾 Einstellungen speichern")).size(11.5)).clicked() {
|
if ui.button(egui::RichText::new(t!("💾 Einstellungen speichern")).size(11.5)).clicked() {
|
||||||
@@ -5250,9 +5518,10 @@ pub fn render_ui(
|
|||||||
|
|
||||||
// 5. Nicht-wegklickbare Warnung bei Ladefehlern / Quarantäne (Block 1)
|
// 5. Nicht-wegklickbare Warnung bei Ladefehlern / Quarantäne (Block 1)
|
||||||
if let Some(warning) = notebook.load_warnings.get(¬ebook.current_page().id) {
|
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)
|
dialog_window(t!("⚠ Datenintegritäts-Warnung"), state, true)
|
||||||
.show(ctx, |ui| {
|
.show(ctx, |ui| {
|
||||||
ui.set_width(420.0);
|
ui.set_width(width);
|
||||||
touch_friendly_spacing(ui);
|
touch_friendly_spacing(ui);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.colored_label(egui::Color32::from_rgb(255, 80, 80), "⚠");
|
ui.colored_label(egui::Color32::from_rgb(255, 80, 80), "⚠");
|
||||||
@@ -5321,8 +5590,9 @@ pub fn render_ui(
|
|||||||
// Notfall-/Sicherheits-Dialog bei Fehlschlag des Speicherns vor dem Beenden
|
// Notfall-/Sicherheits-Dialog bei Fehlschlag des Speicherns vor dem Beenden
|
||||||
if let Some(err) = &state.shutdown_error {
|
if let Some(err) = &state.shutdown_error {
|
||||||
let mut dismiss = false;
|
let mut dismiss = false;
|
||||||
|
let width = dialog_width(ctx, state, 460.0);
|
||||||
dialog_window(t!("⚠ Ungespeicherte Änderungen"), state, true).show(ctx, |ui| {
|
dialog_window(t!("⚠ Ungespeicherte Änderungen"), state, true).show(ctx, |ui| {
|
||||||
ui.set_width(460.0);
|
ui.set_width(width);
|
||||||
touch_friendly_spacing(ui);
|
touch_friendly_spacing(ui);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.colored_label(egui::Color32::from_rgb(255, 80, 80), "⚠");
|
ui.colored_label(egui::Color32::from_rgb(255, 80, 80), "⚠");
|
||||||
@@ -5504,6 +5774,30 @@ mod tests {
|
|||||||
assert_eq!(PEN_WIDTH_PRESETS[3].0, 1.2);
|
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]
|
#[test]
|
||||||
fn test_document_manager_ui_actions() {
|
fn test_document_manager_ui_actions() {
|
||||||
let mut state = UiState::new();
|
let mut state = UiState::new();
|
||||||
|
|||||||
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
|
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
|
for gliding with the real event times and not with the time it got around to handling them
|
||||||
(Issue #28).
|
(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
|
- `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.
|
||||||
|
|
||||||
|
|||||||
@@ -105,10 +105,11 @@ impl<T: 'static> EventLoop<T> {
|
|||||||
let wayland_dispatcher =
|
let wayland_dispatcher =
|
||||||
calloop::Dispatcher::new(wayland_source, |_, queue, winit_state: &mut WinitState| {
|
calloop::Dispatcher::new(wayland_source, |_, queue, winit_state: &mut WinitState| {
|
||||||
let result = queue.dispatch_pending(winit_state);
|
let result = queue.dispatch_pending(winit_state);
|
||||||
if result.is_ok()
|
// ink-notes patch: wake the app for every read from the socket, not only for
|
||||||
&& (!winit_state.events_sink.is_empty()
|
// winit's own events. Events for other queues on the same connection (pen
|
||||||
|| !winit_state.window_compositor_updates.is_empty())
|
// 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;
|
winit_state.dispatched_events = true;
|
||||||
}
|
}
|
||||||
result
|
result
|
||||||
|
|||||||
@@ -2,14 +2,15 @@
|
|||||||
|
|
||||||
use sctk::reexports::client::protocol::wl_seat::WlSeat;
|
use sctk::reexports::client::protocol::wl_seat::WlSeat;
|
||||||
use sctk::reexports::client::protocol::wl_surface::WlSurface;
|
use sctk::reexports::client::protocol::wl_surface::WlSurface;
|
||||||
use sctk::reexports::client::protocol::wl_touch::WlTouch;
|
use sctk::reexports::client::protocol::wl_touch::{self, WlTouch};
|
||||||
use sctk::reexports::client::{Connection, Proxy, QueueHandle};
|
use sctk::reexports::client::{Connection, Dispatch, Proxy, QueueHandle};
|
||||||
use sctk::reexports::csd_frame::FrameClick;
|
use sctk::reexports::csd_frame::FrameClick;
|
||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use sctk::compositor::SurfaceData;
|
use sctk::compositor::SurfaceData;
|
||||||
use sctk::seat::touch::{TouchData, TouchHandler};
|
use sctk::seat::touch::{TouchData, TouchHandler};
|
||||||
|
use sctk::seat::SeatState;
|
||||||
|
|
||||||
use crate::dpi::LogicalPosition;
|
use crate::dpi::LogicalPosition;
|
||||||
use crate::event::{Touch, TouchPhase, WindowEvent};
|
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