Compare commits

...
23 Commits
Author SHA1 Message Date
haraldandClaude Opus 5.5 4761624fbe docs: write commit messages in English from now on
CONTRIBUTING (en/de) and README now state that new code comments and commit messages are
written in English.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:20:08 +02:00
haraldandClaude Opus 5.5 86f1fae02a docs: Release-Notes auf Englisch, neue Code-Kommentare künftig englisch
Die Vorlage der Release-Notes ist vollständig übersetzt. CONTRIBUTING (en/de) und README
vermerken, dass neue Code-Kommentare englisch geschrieben werden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:15:26 +02:00
haraldandClaude Opus 5.5 7cf315967a docs: Dokumentation auf Englisch, deutsche Fassungen als *.de.md
CONTRIBUTING, SECURITY, docs/ARCHITECTURE und docs/FILE_FORMAT sind jetzt englisch; die
deutschen Fassungen bleiben als *.de.md erhalten und verlinken sich gegenseitig. Inhaltlich
nachgezogen: Live-Strich mit gleicher Tessellierung, runde Ecken, Nachgleiten, eingerasteter
Textmarker, Farben im Dateiformat sind sRGB. Kommentare im Flatpak-Manifest auf Englisch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:12:56 +02:00
haraldandClaude Opus 5.5 023991d0a3 docs: README auf Englisch, deutsche Fassung als README.de.md
GitHub und Flathub erwarten englische Texte. Das bisherige README bleibt als README.de.md
erhalten; beide verlinken sich gegenseitig. Das englische README nutzt ein englisches Banner
(assets/ink-notes-banner-en.svg, ohne veraltete Versionsnummer). Links in CONTRIBUTING.md und
SECURITY.md zeigen auf die deutschen Abschnitte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:09:19 +02:00
haraldandClaude Opus 5.5 0ddd0bc50e chore(release): bump version to 0.30.2
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 6m13s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:51:33 +02:00
haraldandClaude Opus 5.5 476d0e628f fix: Strg+Plus/Minus vergrößert nicht mehr die Oberfläche (Issue #34)
egui zoomte bei Strg+Plus/Minus zusätzlich zum Blatt die ganze Oberfläche. Danach lagen
das bearbeitete Textfeld (und andere Overlays) um den Zoomfaktor versetzt und vergrößert
über dem Blatt; beim Abwählen sprang der Text an seine echte Stelle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:49:11 +02:00
haraldandClaude Opus 5.5 a5692e5430 chore(release): bump version to 0.30.1
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 6m52s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:15:37 +02:00
haraldandClaude Opus 5.5 6a043802d7 fix: Eingerasteter Textmarker behält Breite (Issue #32), runde Spitzen und ruhiger Strich beim Absetzen (Issue #33), zuverlässigeres Nachgleiten (Issue #28)
- Halten am Strichende rastet Formen in der Breite des gewählten Werkzeugs ein; ein
  eingerasteter Textmarker bleibt Textmarker und bildet kein Achsenkreuz
- Ecken (Spitze einer „1“, Hochzahlen) werden rund verbunden statt stumpf abgeschnitten
- Der Live-Strich nutzt dieselbe Tessellierung wie der fertige Strich, damit er beim
  Abheben nicht springt
- Nachgleiten misst die Geschwindigkeit über die letzten 100 ms statt über einzelne
  Ereignisse; INK_NOTES_DEBUG_FLING gibt die Messung im Terminal aus

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:13:09 +02:00
haraldandClaude Opus 5.5 76968f9e58 chore(release): bump version to 0.30.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 7m18s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:16:57 +02:00
haraldandClaude Opus 5.5 b75858b43a feat: Schneller durch lange Notizbücher scrollen (Issue #28), neue Notizbücher durchgehend (Issue #30)
- Nachgleiten: Nach schnellem Wischen mit dem Finger gleitet das Blatt
  weiter und bremst ab; eine neue Berührung, Klick oder Mausrad hält an
- Bildlaufleiste in der durchgehenden Ansicht gegenüber der
  Werkzeugleiste: Griff ziehen oder auf die Leiste tippen springt durch
  das Notizbuch, beim Ziehen steht die Seitenzahl daneben
- Mausrad scrollt doppelt so weit je Raste
- Neue Notizbücher (auch PDF-Importe) sind durchgehend; beim Anlegen
  stehen nur noch Durchgehend und Endlos zur Wahl, feste Seiten gibt es
  weiter im ☰-Menü

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:12:51 +02:00
haraldandClaude Opus 5.5 5fafff5eaf style: cargo fmt
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 6m29s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:19:10 +02:00
haraldandClaude Opus 5.5 d21fa91315 chore(release): bump version to 0.29.1
Build & Publish Release / Build Packages & Publish to Gitea (push) Failing after 1m47s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:28:51 +02:00
haraldandClaude Opus 5.5 b00767b457 perf: Große Auswahlen flüssig verschieben, drehen und skalieren
Seit 0.27 kopierte die Prüfung des Seitenverhältnis-Schlosses bei jedem
Bild alle ausgewählten Striche und suchte jeden davon quadratisch in der
Seite. Zusammen mit dem Kopieren beim Ziehen lastete das bei großen
Auswahlen den Prozessor aus, das Verschieben kam kaum noch hinterher.

- Schloss und Auswahlrahmen ohne Kopien bestimmen
- Striche beim Ziehen an ihrer Stelle aus dem Ausgangszustand umformen
  statt die Auswahl zweimal zu kopieren; Suche über eine ID-Tabelle
- Stiftbewegungen der Auswahl nur einmal je Bild anwenden
- Endlose Fläche: Mausrad scrollt (Strg+Mausrad zoomt)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:28:51 +02:00
haraldandClaude Opus 5.5 63ab6a3e5b chore(release): bump version to 0.29.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 8m46s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:17:17 +02:00
haraldandClaude Opus 5.5 3c793fe659 feat: Endlose Fläche als Seitenanordnung (Issue #30, Schritt 2)
Ein endloses Notizbuch hat eine einzige Seite von 10 × 10 m; Eingabe,
Treffertests, Undo und Speichern bleiben dadurch unverändert. Die
Ansicht zeigt bei 100 % einen DIN-A4-Rahmen in der Mitte, Bilder und
Textfelder richten sich nach A4. Wählbar beim Anlegen und im ☰-Menü,
umschalten nur bei einer Seite ohne PDF bzw. zurück, solange alles im
Startausschnitt liegt. Seiten-Knöpfe sind auf der endlosen Fläche aus.

Der Export enthält nur den beschriebenen Bereich mit 10 mm Rand, auf
das Musterraster gelegt; Bilder einer Auswahl rastern nur noch den
Ausschnitt statt der ganzen Seite. Formatversion 9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:14:45 +02:00
haraldandClaude Opus 5.5 8bc29faddc chore(release): bump version to 0.28.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 8m1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:01:18 +02:00
haraldandClaude Opus 5.5 8ea20aa783 feat: Seitenanordnung je Notizbuch (Issue #30, Schritt 1)
Ob ein Notizbuch Seite für Seite oder durchgehend angezeigt wird, steht
jetzt im Manifest (`layout`). Beim Anlegen ist „Feste Seiten“
vorgewählt; im ☰-Menü lässt sich die Anordnung des offenen Notizbuchs
umschalten. Notizbücher ohne Angabe behalten die frühere globale
Einstellung, ältere App-Versionen ignorieren das Feld.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:58:55 +02:00
haraldandClaude Opus 5.5 1b30f2bd26 chore(release): bump version to 0.27.1
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 13m58s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:48:20 +02:00
haraldandClaude Opus 5.5 657a5ff16e feat: Stifte der Werkzeugleiste in ihrer Farbe (Issue #31)
Statt eines dünnen Strichs auf grauem Papier zeigt jeder Stift jetzt
einen schräg liegenden Stift, dessen Schaft und Mine in seiner Farbe
gefüllt sind. Eine helle Kontur hält dunkle Farben auf der dunklen
Leiste sichtbar; die Stärke steht weiter im Tooltip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:44:51 +02:00
haraldandClaude Opus 5.5 8fddd9e2c3 chore(release): bump version to 0.27.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 21m31s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:18:34 +02:00
haraldandClaude Opus 5.5 1a98c9b8bb feat: Knopf für das Seitenverhältnis-Schloss der Auswahl (Issue #29)
Im Tablet-Betrieb gibt es keine Umschalttaste. Beim Auswahlwerkzeug
steht unter den Stiftfarben jetzt ein Schloss: 🔒 skaliert gleichmäßig,
🔓 frei. Vorgabe ist gesperrt, sobald ein Bild ausgewählt ist, sonst
frei. Das Umschalten gilt für die aktuelle Auswahl; eine neue Auswahl
beginnt wieder mit der Vorgabe. Shift sperrt am Rechner zusätzlich.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:13:15 +02:00
haraldandClaude Opus 5.5 98b78d29e6 feat: Bilder beim Skalieren nicht verzerren (Issue #29)
Enthält die Auswahl ein Bild, skaliert der Eckgriff gleichmäßig, das
Seitenverhältnis bleibt erhalten. Mit gedrückter Umschalttaste gilt das
auch für Striche und Formen. Der Faktor ist die Projektion der
Zugposition auf die Diagonale der Auswahl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:02:56 +02:00
haraldandClaude Opus 5.5 81ccd93438 docs: Code of Conduct, Mitmach-Anleitung, Sicherheitsrichtlinie und Vorlagen
Für die Community-Standards auf GitHub (Spiegel) und für Gitea:

- CODE_OF_CONDUCT.md: Contributor Covenant 2.1, Kontakt harald@pansi.eu
- CONTRIBUTING.md (Deutsch/Englisch): Gitea als Hauptrepository,
  Prüfungen wie in der CI, Tests, Dateiformat, Übersetzungen
- SECURITY.md: unterstützt wird die neueste Version, Meldungen
  vertraulich per E-Mail
- Issue-Vorlagen für Fehler und Ideen, Kontakt-Links (auf GitHub mit
  Verweis auf Gitea), Pull-Request-Vorlage
- README: Abschnitt „Mitmachen“

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:40:36 +02:00
35 changed files with 3093 additions and 567 deletions

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
# Gitea liest die Vorlagen aus .github/ISSUE_TEMPLATE; diese Datei ersetzt dort nur die
# Kontakt-Links (der Verweis auf Gitea selbst entfällt).
blank_issues_enabled: true
contact_links:
- name: "Sicherheitslücke melden / Report a security issue"
url: https://gitea.pansi.eu/harald/ink-notes/src/branch/main/SECURITY.md
about: "Bitte vertraulich per E-Mail, nicht als öffentliches Issue. / Please report privately by e-mail, not as a public issue."
+28
View File
@@ -0,0 +1,28 @@
---
name: "Fehler melden / Bug report"
about: "Etwas funktioniert nicht wie erwartet / Something does not work as expected"
title: ""
---
<!-- Sicherheitslücken bitte nicht hier melden, siehe SECURITY.md. / Please report security issues privately, see SECURITY.md. -->
**Was passiert? / What happens?**
**Was hättest du erwartet? / What did you expect?**
**Schritte zum Nachstellen / Steps to reproduce**
1.
2.
3.
**Umgebung / Environment**
- ink-notes-Version / version (☰ → Updates):
- Installiert als / installed as (.deb, AppImage, Flatpak, selbst gebaut / built from source):
- Distribution:
- Wayland oder / or X11:
- Eingabegerät / input device (Stift / pen, Finger / touch, Maus / mouse, Touchpad):
**Bildschirmfoto, Aufnahme oder Ausgabe im Terminal / Screenshot, recording or terminal output**
<!-- Wenn möglich: ink-notes im Terminal starten und die Ausgabe hier einfügen. / If possible, start ink-notes in a terminal and paste its output here. -->
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: true
contact_links:
- name: "Issues auf Gitea (Hauptrepository) / Issues on Gitea (main repository)"
url: https://gitea.pansi.eu/harald/ink-notes/issues
about: "GitHub ist ein Spiegel; Issues werden bevorzugt auf Gitea bearbeitet. / GitHub is a mirror; issues are preferably handled on Gitea."
- name: "Sicherheitslücke melden / Report a security issue"
url: https://gitea.pansi.eu/harald/ink-notes/src/branch/main/SECURITY.md
about: "Bitte vertraulich per E-Mail, nicht als öffentliches Issue. / Please report privately by e-mail, not as a public issue."
+14
View File
@@ -0,0 +1,14 @@
---
name: "Idee vorschlagen / Feature request"
about: "Eine neue Funktion oder Verbesserung / A new feature or improvement"
title: ""
---
**Was möchtest du tun können? / What would you like to be able to do?**
**Wie machst du es bisher, und was stört dich daran? / How do you do it today, and what bothers you about it?**
**Wie könnte es aussehen? / How could it work?**
<!-- Skizzen, Bildschirmfotos oder Beispiele aus anderen Apps helfen. / Sketches, screenshots or examples from other apps help. -->
+13
View File
@@ -0,0 +1,13 @@
<!-- GitHub ist ein Spiegel von https://gitea.pansi.eu/harald/ink-notes; Pull Requests werden dort von Hand übernommen. / GitHub is a mirror; pull requests are applied on Gitea by hand. -->
**Was ändert sich und warum? / What changes and why?**
**Zugehöriges Issue / Related issue:** #
**Checkliste / Checklist**
- [ ] `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings` und / and `cargo test` laufen durch / pass
- [ ] Tests für neue Funktionen oder behobene Fehler / tests for new features or fixes
- [ ] Neue Texte der Oberfläche auch auf Englisch (`src/i18n/`) / new UI texts translated to English
- [ ] Änderungen am Dateiformat in `docs/FILE_FORMAT.md` beschrieben / file format changes documented (falls nötig / if needed)
- [ ] Ich habe CONTRIBUTING.md gelesen / I have read CONTRIBUTING.md
+85
View File
@@ -0,0 +1,85 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at harald@pansi.eu. All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of actions.
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
+34
View File
@@ -0,0 +1,34 @@
# Mitmachen bei ink-notes
[English](CONTRIBUTING.md) · **Deutsch**
Danke, dass du ink-notes verbessern willst!
## Wo?
Das Hauptrepository liegt auf **Gitea**: <https://gitea.pansi.eu/harald/ink-notes>.
[GitHub](https://github.com/haraldpansi-gif/ink-notes) ist ein Spiegel davon – Issues und Pull Requests sind dort auch willkommen, Pull Requests werden aber von Hand nach Gitea übernommen.
## Fehler melden und Ideen vorschlagen
- Bitte zuerst nachsehen, ob es schon ein [Issue](https://gitea.pansi.eu/harald/ink-notes/issues) dazu gibt.
- Für Fehler die Vorlage „Fehler melden“ verwenden: Version (☰ → Updates), Distribution, Wayland oder X11, Eingabegerät (Stift, Finger, Maus, Touchpad) und die Schritte, mit denen der Fehler auftritt. Ein Bildschirmfoto oder eine kurze Aufnahme hilft oft sehr.
- Deutsch und Englisch sind beide in Ordnung.
- **Sicherheitslücken bitte nicht öffentlich melden**, sondern wie in [SECURITY.de.md](SECURITY.de.md) beschrieben.
## Code beitragen
1. Größere Änderungen am besten vorher in einem Issue absprechen.
2. Bauen und testen, wie im [README](README.de.md#entwicklung) beschrieben. Vor dem Einreichen müssen diese Prüfungen durchlaufen (die CI prüft sie ebenfalls):
```bash
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test
```
3. Neue Funktionen und behobene Fehler bekommen einen Test, wo das sinnvoll geht.
4. Änderungen am Dateiformat bitte in [docs/FILE_FORMAT.de.md](docs/FILE_FORMAT.de.md) nachtragen; ältere Notizbücher müssen weiter lesbar bleiben.
5. Commit-Nachrichten im Stil von [Conventional Commits](https://www.conventionalcommits.org/), z. B. `fix: …`, `feat: …`, `perf: …`, `docs: …`; ein Bezug auf ein Issue als `(Issue #28)`. Neue Code-Kommentare und Commit-Nachrichten werden auf Englisch geschrieben (älterer Code und die Historie enthalten noch Deutsch).
6. Texte der Oberfläche gibt es auf Deutsch und Englisch: Neue Texte mit `t!("…")` schreiben und die englische Übersetzung unter `src/i18n/` ergänzen.
Mit dem Einreichen stimmst du zu, dass dein Beitrag unter der [MIT-Lizenz](LICENSE) des Projekts steht. Für den Umgang miteinander gilt der [Code of Conduct](CODE_OF_CONDUCT.md).
+33
View File
@@ -0,0 +1,33 @@
# Contributing to ink-notes
**English** · [Deutsch](CONTRIBUTING.de.md)
Thank you for wanting to improve ink-notes!
## Where?
The main repository is on **Gitea**: <https://gitea.pansi.eu/harald/ink-notes>.
[GitHub](https://github.com/haraldpansi-gif/ink-notes) is a mirror – issues and pull requests are welcome there too, but pull requests are applied to Gitea by hand.
## Reporting bugs and suggesting ideas
- Please check first whether there is already an [issue](https://gitea.pansi.eu/harald/ink-notes/issues) for it.
- For bugs, use the "Bug report" template: version (☰ → Updates), distribution, Wayland or X11, input device (pen, finger, mouse, touchpad) and the steps that trigger the bug. A screenshot or a short recording often helps a lot.
- English and German are both fine.
- **Please do not report security vulnerabilities publicly**; follow [SECURITY.md](SECURITY.md) instead.
## Contributing code
1. Discuss larger changes in an issue first.
2. Build and test as described in the [README](README.md#development). These checks must pass before submitting (CI checks them too):
```bash
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test
```
3. New features and bug fixes get a test where that makes sense.
4. Document changes to the file format in [docs/FILE_FORMAT.md](docs/FILE_FORMAT.md) (and its German version); older notebooks must remain readable.
5. Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/), e.g. `fix: …`, `feat: …`, `perf: …`, `docs: …`; refer to an issue as `(Issue #28)`. New code comments and commit messages are written in English (older code and history still contain German).
6. The interface is available in German and English: write new texts with `t!("…")` (the German text is the key) and add the English translation in `src/i18n/`.
By contributing you agree that your contribution is licensed under the project's [MIT License](LICENSE). Everyone taking part is expected to follow the [Code of Conduct](CODE_OF_CONDUCT.md).
Generated
+1 -1
View File
@@ -1393,7 +1393,7 @@ dependencies = [
[[package]] [[package]]
name = "ink-notes" name = "ink-notes"
version = "0.26.2" version = "0.30.2"
dependencies = [ dependencies = [
"ab_glyph", "ab_glyph",
"arboard", "arboard",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "ink-notes" name = "ink-notes"
version = "0.26.2" version = "0.30.2"
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"
+253
View File
@@ -0,0 +1,253 @@
<p align="center">
<img src="assets/ink-notes-banner.svg" alt="ink-notes Banner" width="100%">
</p>
<p align="center">
<strong>Eine moderne Notizbuch-App für Linux zum Schreiben mit Stift, Touch und Maus</strong><br>
Geschrieben in <strong>Rust</strong> mit <strong>wgpu</strong>, <strong>winit</strong> und <strong>egui</strong>.
</p>
<p align="center"><a href="README.md">English</a> · <strong>Deutsch</strong></p>
---
ink-notes speichert Handschrift als Vektoren, erkennt auf Wunsch Formen, kann PDFs zum Beschriften importieren, Bilder und Textfelder einfügen und alles wieder als PDF, PNG oder SVG exportieren. Gespeichert wird automatisch im Hintergrund.
Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemsprache, umstellen lässt sie sich im ☰-Menü unter „Sprache / Language“.
---
## Funktionen
### Schreiben und Zeichnen
- **Vier Stifte** in der Werkzeugleiste, jeder mit eigener Farbe und Stärke (0,15–5 mm, mit Druckstärke). Ändert man Farbe oder Stärke, merkt sich der gewählte Stift das – auch über einen Neustart hinweg. **Textmarker** (halbtransparent, 4–8 mm) in vier Farben; innerhalb eines Strichs wird er nicht dunkler, erst ein neuer Strich deckt stärker.
- **Eigene Farben:** Jede Farbe in der Werkzeugleiste (Stifte, Textmarker, Text) lässt sich anpassen – gewählte Farbe erneut antippen, eine Vorgabe oder mit 🎨 einen freien Farbton wählen. Die Leiste merkt sich die Farben. Am Bildschirm erscheinen sie genauso wie im PDF- und Bildexport.
- **Glättung in zwei Stufen:** Während des Schreibens glättet ein 1€-Filter das Zittern, die Linie erscheint sofort. Beim Absetzen wird der Strich nachbearbeitet: Ecken (Knicke über 65°) bleiben erhalten und werden rund verbunden, der Druckverlauf wird geglättet, überflüssige Punkte entfallen, die Enden werden rund.
- **Druckkurve** im ☰-Menü an den eigenen Stift anpassen (weicher/härter, Schwelle gegen Grundrauschen).
- **Tasten am Stift** im ☰-Menü belegen: Radieren (Standard), Auswählen, Textmarker, Stift oder nichts – gilt, solange die Taste gedrückt ist. Jede Taste erscheint dort nach dem ersten Drücken.
- **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker. Die Stiftrückseite radiert ebenfalls.
- **Virtuelles Lineal** (`L`): verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden gerade.
- **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.
### Formerkennung
- **Formen-Werkzeug** (`S`/`F`) oder **Halten** am Strichende (über 400 ms stillhalten): Die Skizze wird durch eine saubere Form in der Breite des gewählten Stifts bzw. Textmarkers ersetzt.
- 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.
### 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.
- Auswahl über die Palette **umfärben**, **kopieren/einfügen** (`Strg + C/V`), **löschen** (`Entf`).
- `Strg + C` legt die Auswahl zusätzlich als Bild in die Zwischenablage, z. B. für Chat oder Mail.
- **Rückgängig/Wiederholen** (`Strg + Z/Y`) für alle Änderungen, je Seite.
### Text, Bilder und PDFs
- **Text-Werkzeug** (`T`): ins Blatt tippen und schreiben – normale oder Code-Schrift (feste Zeichenbreite, Tab rückt ein), 8–36 pt, Zeilenumbruch am Feldrand. Vorhandenen Text antippen zum Ändern, `Esc` beendet. Textfelder lassen sich wie Bilder verschieben, drehen und skalieren. In anderen Programmen kopierter Text wird mit `Strg + V` zum Textfeld.
- **Bilder** (PNG, JPEG, GIF, WebP, BMP) über `Strg + I`, aus der Zwischenablage oder per Ziehen ins Fenster einfügen.
- **Einfügen per Finger:** Lange (½ s) mit dem Finger aufs Blatt drücken öffnet ein Menü „Einfügen“ – Screenshot, kopierter Text oder kopierte Objekte landen genau an dieser Stelle.
- **PDF importieren** (☰-Menü): Jede PDF-Seite wird eine Notizbuchseite mit der PDF-Seite als Hintergrund. Hochformat-Seiten werden DIN A4, Querformat-Seiten (z. B. Folien) bekommen eine Querformat-Seite ohne leeren Streifen darunter. Beim Hineinzoomen werden PDF-Seiten schärfer nachgerendert.
### Seiten und Ansicht
- **Hintergründe:** blanko, kariert (5 mm), liniert (7,5 mm), Punktraster (5 mm). Die **Farbe von Karos, Linien und Punkten** ist wählbar (hell, mittel, kräftig, blau, grün) und gilt auch im Export.
- **Papierfarbe** der Anzeige: weiß, creme, dunkelgrau, schwarz; auf dunklem Papier werden dunkle Stiftfarben auf Wunsch aufgehellt. Gespeichert und exportiert wird immer auf weißem Papier.
- **Durchgehendes Scrollen, endlose Fläche oder feste Seiten, je Notizbuch:** Beim Anlegen wählbar (Vorgabe: durchgehend; oder endlos), im ☰-Menü umschaltbar – dort auch auf feste Seiten (Seite für Seite blättern mit Pfeilen in der Werkzeugleiste). Durchgehend hängen die Seiten lückenlos untereinander, eine dunkle Linie markiert die Grenze; schnelles Wischen gleitet nach, und mit der Bildlaufleiste am Rand gegenüber der Werkzeugleiste springt man samt Seitenzahl durch lange PDFs; wer im unteren Fünftel der letzten Seite schreibt, bekommt automatisch eine neue Seite. Endlos ist eine einzige Fläche ohne Rand, auf der man in alle Richtungen schreibt; der PDF-Export enthält nur den beschriebenen Bereich.
- **Seiten einfügen und löschen:** ➕ in der Werkzeugleiste fügt nach der aktuellen Seite eine leere Seite ein. Löschen im ☰-Menü (mit Rückfrage); die zuletzt gelöschte Seite lässt sich dort wiederherstellen, solange die App läuft.
- **Zoom** von 25 % bis 1000 % (`Strg + Mausrad`, zwei Finger, `Strg + +/-/0`), Verschieben mit mittlerer Maustaste, `Leertaste + Ziehen` oder einem Finger.
- **Seiten** anhängen oder direkt nach der aktuellen Seite einfügen, **Seitentitel** vergeben; die Seitenübersicht zeigt Vorschaubilder oder ein Inhaltsverzeichnis. Seitentitel werden im PDF zu Lesezeichen.
- **Werkzeugleiste** links oder rechts andocken (für Links- und Rechtshänder).
### Notizbücher und Speichern
- Mehrere **Notizbücher** anlegen, umbenennen, wechseln und löschen (gelöschte landen zunächst im Papierkorb und lassen sich wiederherstellen).
- **Ordner** für Notizbücher, beliebig verschachtelt (z. B. 3. Semester › Mathe): anlegen, umbenennen, löschen (der Inhalt rückt eine Ebene nach oben) und Notizbücher wie Ordner (samt Inhalt) mit 📂 verschieben. Neue Notizbücher landen im gerade geöffneten Ordner.
- **Automatisches Speichern** im Hintergrund: 1,5 Sekunden nach der letzten Änderung, bei ununterbrochenem Schreiben spätestens alle 10 Sekunden. Zeichnen wird dabei nicht unterbrochen.
- Jede Datei wird erst vollständig in eine temporäre Datei geschrieben und dann ersetzt – ein Absturz beim Speichern hinterlässt keine halbe Datei.
- **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.
### 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.
- **Einzelne Seite** als PNG (mit oder ohne Hintergrund) oder SVG über das ☰-Menü.
### Eingabegeräte
- Stifte über das Tablet-Protokoll von Wayland bzw. X11 (Druck, Stiftrückseite, Seitentaste).
- Solange der Stift aufliegt oder in der Nähe ist, werden Berührungen mit der Hand ignoriert.
- Finger: standardmäßig scrollen und zoomen; Zeichnen mit dem Finger lässt sich im ☰-Menü einschalten.
### Updates
- Die App prüft auf neue Versionen auf Gitea und installiert sie auf Wunsch. Installiert wird nur, wenn die Minisign-Signatur und die SHA-256-Prüfsumme stimmen (Details unten).
- Als Flatpak sucht ink-notes nicht selbst nach Updates; die kommen dann über die Software-Verwaltung (`flatpak update`).
---
## Installation
Pakete gibt es unter [Releases](https://gitea.pansi.eu/harald/ink-notes/releases) als `.deb` und `.AppImage` (x86_64). `<version>` steht für die Versionsnummer, z. B. `0.11.0`.
```bash
# Debian-Paket (Zorin OS 18 / Ubuntu 24.04)
sudo apt install ./ink-notes_<version>_amd64.deb
# oder AppImage
chmod +x ink-notes_<version>_x86_64.AppImage
./ink-notes_<version>_x86_64.AppImage
```
Hinweise:
- Benötigt `glibc >= 2.39` (Ubuntu 24.04, Zorin OS 18) und einen Grafiktreiber mit Vulkan oder OpenGL.
- Meldet das AppImage einen Fehler zu `libfuse.so.2`, entweder `sudo apt install libfuse2t64` installieren oder mit `--appimage-extract-and-run` starten.
---
## Tastenkürzel
| Aktion | Taste / Geste |
| :--- | :--- |
| Zeichnen | Stiftspitze, linke Maustaste (Finger, wenn eingeschaltet) |
| Radieren | `E` umschalten, rechte Maustaste, Stiftrückseite oder Seitentaste |
| Stift in Schwarz / Blau / Rot | `B` / `U` / `R` |
| Formen-Werkzeug | `S` oder `F` |
| Auswahlwerkzeug | `V` |
| Text-Werkzeug | `T`, dann ins Blatt klicken; `Esc` beendet die Eingabe |
| Lineal ein/aus | `L` |
| Hintergrund wechseln | `G` |
| Auswahl um 90° drehen | `R` (bei Auswahl) |
| Kopieren / Einfügen | `Strg + C` / `Strg + V` |
| Auswahl löschen | `Entf` oder `Rücktaste` |
| Rückgängig / Wiederholen | `Strg + Z` / `Strg + Y` oder `Strg + Umschalt + Z` |
| Bild einfügen | `Strg + I` |
| Scrollen | Mausrad (`Umschalt` = waagerecht), ein Finger |
| Zoomen | `Strg + Mausrad`, zwei Finger, `Strg + +` / `Strg + -` |
| Zoom zurücksetzen | `Strg + 0` |
| Verschieben | mittlere Maustaste oder `Leertaste + Ziehen` |
| Nächste / vorherige Seite | `Bild ab` oder `N` / `Bild auf` |
| Erste / letzte Seite | `Pos 1` / `Ende` |
| Jetzt speichern | `Strg + S` |
| PDF exportieren | `Strg + E` |
| Menüs schließen, Auswahl aufheben | `Esc` |
| Beenden (speichert vorher) | `Strg + Q` oder Fenster schließen |
---
## Entwicklung
### Voraussetzungen
- Rust 1.92 oder neuer (`rustup update stable`)
- Vulkan- oder OpenGL-Treiber (z. B. `mesa-vulkan-drivers`)
- X11-/Wayland-Bibliotheken: `libx11-dev`, `libxcursor-dev`, `libxrandr-dev`, `libxi-dev`, `libxkbcommon-dev`
### Bauen, starten, testen
```bash
git clone https://gitea.pansi.eu/harald/ink-notes.git
cd ink-notes
cargo run --release # oder ./run.sh
cargo test # Unit- und Integrationstests
```
Die CI prüft zusätzlich `cargo fmt --check` und `cargo clippy --all-targets -- -D warnings`.
### Projektstruktur
```text
src/
├── main.rs # Event-Loop (winit), Eingaben von Maus, Touch und Stift, Ablaufsteuerung
├── lib.rs # Bibliotheks-Exporte (für Tests)
├── canvas.rs # wgpu-Rendering: Papier, Muster, Bilder, Tinte, Nachbarseiten
├── shader.wgsl # Vertex- und Fragment-Shader
├── viewport.rs # Kamera (Zoom, Verschieben), Seitenstapel der durchgehenden Ansicht
├── filter.rs # 1€-Filter gegen Zittern
├── spline.rs # Catmull-Rom-Splines, Ecken-Erhaltung, Dreiecksstreifen
├── geometry.rs # Formerkennung (Douglas-Peucker, Kreis-Fit, …)
├── selection.rs # Auswahl, Griffe, Verschieben/Drehen/Skalieren
├── ruler.rs # Virtuelles Lineal
├── text.rs # Textfelder: Satz, Zeilenumbruch, Rasterung
├── background.rs # Papierfarben, Musterfarben, Karo/Linien/Punkte
├── image_layer.rs # Texturen für Bilder, PDF-Seiten und Text
├── assets.rs # Bilder und PDFs im Notizbuchordner
├── pdf_doc.rs # PDF lesen und rendern (hayro)
├── pdf_merge.rs # Importierte PDF-Seiten beim Export unterlegen
├── document.rs # Datenmodell: Notizbuch, Seiten, Striche, Seitengrößen
├── history.rs # Rückgängig/Wiederholen
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb
├── library.rs # Ordner der Notizbücher (library.json)
├── autosave.rs # Speichern im Hintergrund
├── export.rs # PDF-Export (printpdf)
├── page_export.rs # Seite als PNG/SVG, Auswahl als Bild
├── file_picker.rs # Eingebaute Dateiauswahl
├── settings.rs # Einstellungen (~/.config/ink-notes/ui.json)
├── platform.rs # Wayland/X11-Erkennung
├── updater.rs # Updates von Gitea (Signatur- und Prüfsummenprüfung)
└── ui.rs # Oberfläche (egui): Werkzeugleiste, Menüs, Dialoge
```
Mehr dazu in [`docs/ARCHITECTURE.de.md`](docs/ARCHITECTURE.de.md), das Speicherformat steht in [`docs/FILE_FORMAT.de.md`](docs/FILE_FORMAT.de.md).
---
## Releases bauen und veröffentlichen
```bash
./scripts/build-release.sh # .deb, .AppImage und SHA256SUMS.txt in target/
```
Veröffentlicht wird über Gitea Actions ([`.gitea/workflows/release.yaml`](.gitea/workflows/release.yaml)), sobald ein Tag `vX.Y.Z` gepusht wird: Tests, Pakete bauen, signieren, Release anlegen. Manuell geht es mit `GITEA_TOKEN=… ./scripts/publish-gitea-release.sh`.
### Flatpak
Das Manifest liegt in [`packaging/flatpak/`](packaging/flatpak/) (Runtime `org.freedesktop.Platform` 26.08, Rust aus der SDK-Erweiterung `rust-stable`). Lokal bauen und installieren:
```bash
flatpak-builder --user --install --force-clean target/flatpak-build packaging/flatpak/eu.pansi.ink-notes.yml
flatpak run eu.pansi.ink-notes
```
Nach jeder Änderung an `Cargo.lock` muss `packaging/flatpak/cargo-sources.json` mit dem [flatpak-cargo-generator](https://github.com/flatpak/flatpak-builder-tools/tree/master/cargo) neu erzeugt werden (`python3 flatpak-cargo-generator.py Cargo.lock -o packaging/flatpak/cargo-sources.json`). In der Sandbox darf ink-notes Bilder, Downloads und Schreibtisch lesen und in den Dokumente-Ordner exportieren; die Notizbücher liegen unter `~/.var/app/eu.pansi.ink-notes/`.
### Release-Signatur einrichten (einmalig)
Updates werden in der App nur mit gültiger Minisign-Signatur installiert. Signiert wird mit [rsign2](https://crates.io/crates/rsign2) (`cargo install rsign2 --locked`). Schlüsselpaar ohne Passwort erzeugen (es wird nur als CI-Secret verwendet):
```bash
rsign generate -W -p ink-notes.pub -s ink-notes.key
```
1. Die zweite Zeile von `ink-notes.pub` (beginnt mit `RW`) in `src/updater.rs` als `UPDATE_SIGNING_PUBLIC_KEY` eintragen.
2. Den Inhalt von `ink-notes.key` in Gitea unter *Repository → Einstellungen → Actions → Secrets* als `MINISIGN_SECRET_KEY` hinterlegen.
3. `ink-notes.key` offline aufbewahren. Geht er verloren, können bestehende Installationen keine Updates mehr prüfen.
`rsign generate -W` verschlüsselt den Schlüssel mit leerem Passwort; das `minisign`-Programm (z. B. Ubuntu-Paket 0.11) kann ihn ohne Terminal nicht lesen, deshalb signieren Skript und CI mit `rsign`. In der CI ist die Signatur Pflicht (`INK_NOTES_REQUIRE_SIGNATURE=1`), und das Skript prüft, dass der geheime Schlüssel zum eingebauten öffentlichen passt.
### CI-Runner (act_runner)
Der Workflow läuft auf dem Label `ubuntu-latest`. Im `config.yaml` des Runners muss es zugeordnet sein, z. B.:
```yaml
runner:
labels:
- "ubuntu-latest:docker://catthehacker/ubuntu:act-latest"
```
Bleibt ein Job auf „Waiting to run“ stehen: Ist der Runner online (*Einstellungen → Aktionen → Runner*) und stimmt das Label mit `runs-on` überein? `sudo` verwendet der Workflow nur, wenn der Runner nicht als `root` läuft.
---
## Sicherheit der Updates
1. **Signatur:** Jedes Release enthält `SHA256SUMS.txt.minisig`. Die App lädt `SHA256SUMS.txt` und die Signatur vor dem Paket und prüft sie gegen den eingebauten öffentlichen Schlüssel (`minisign-verify`). Ohne gültige Signatur wird nicht installiert.
2. **Prüfsumme:** Der SHA-256-Wert des heruntergeladenen Pakets muss zum Eintrag in der signierten Liste passen, sonst wird die Datei gelöscht.
3. **Nur HTTPS:** Auch Download-Links und Weiterleitungen müssen HTTPS sein. Bei Weiterleitungen auf einen anderen Server wird der `Authorization`-Header nicht mitgeschickt.
4. **Eigener Download-Ordner:** `~/.cache/ink-notes/updates` (Rechte `0700`); Dateinamen mit Pfadtrennern, `..` oder Steuerzeichen werden abgelehnt.
5. **AppImage-Austausch:** Die neue Datei wird neben der alten angelegt, die alte als `.bak` gesichert und dann per `rename` ersetzt – die laufende Datei wird nie überschrieben.
---
## Mitmachen
Fehlermeldungen, Ideen und Beiträge sind willkommen – am liebsten als [Issue auf Gitea](https://gitea.pansi.eu/harald/ink-notes/issues); [GitHub](https://github.com/haraldpansi-gif/ink-notes) ist ein Spiegel. Details in [CONTRIBUTING.de.md](CONTRIBUTING.de.md), Sicherheitslücken bitte vertraulich wie in [SECURITY.de.md](SECURITY.de.md) beschrieben. Es gilt der [Code of Conduct](CODE_OF_CONDUCT.md).
---
## Lizenz
[MIT](LICENSE), Copyright © 2026 Harald Pansi.
+158 -148
View File
@@ -1,224 +1,228 @@
<p align="center"> <p align="center">
<img src="assets/ink-notes-banner.svg" alt="ink-notes Banner" width="100%"> <img src="assets/ink-notes-banner-en.svg" alt="ink-notes banner" width="100%">
</p> </p>
<p align="center"> <p align="center">
<strong>Eine moderne Notizbuch-App für Linux zum Schreiben mit Stift, Touch und Maus</strong><br> <strong>A modern notebook app for Linux for writing with pen, touch and mouse</strong><br>
Geschrieben in <strong>Rust</strong> mit <strong>wgpu</strong>, <strong>winit</strong> und <strong>egui</strong>. Written in <strong>Rust</strong> with <strong>wgpu</strong>, <strong>winit</strong> and <strong>egui</strong>.
</p> </p>
--- <p align="center"><strong>English</strong> · <a href="README.de.md">Deutsch</a></p>
ink-notes speichert Handschrift als Vektoren, erkennt auf Wunsch Formen, kann PDFs zum Beschriften importieren, Bilder und Textfelder einfügen und alles wieder als PDF, PNG oder SVG exportieren. Gespeichert wird automatisch im Hintergrund.
Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemsprache, umstellen lässt sie sich im ☰-Menü unter „Sprache / Language“.
--- ---
## Funktionen ink-notes stores handwriting as vectors, can recognize shapes, imports PDFs for annotation, inserts images and text boxes, and exports everything again as PDF, PNG or SVG. Saving happens automatically in the background.
### Schreiben und Zeichnen It is mainly tested on Zorin OS 18 / Ubuntu 24.04 (Wayland and X11).
- **Vier Stifte** in der Werkzeugleiste, jeder mit eigener Farbe und Stärke (0,15–5 mm, mit Druckstärke). Ändert man Farbe oder Stärke, merkt sich der gewählte Stift das – auch über einen Neustart hinweg. **Textmarker** (halbtransparent, 4–8 mm) in vier Farben; innerhalb eines Strichs wird er nicht dunkler, erst ein neuer Strich deckt stärker.
- **Eigene Farben:** Jede Farbe in der Werkzeugleiste (Stifte, Textmarker, Text) lässt sich anpassen – gewählte Farbe erneut antippen, eine Vorgabe oder mit 🎨 einen freien Farbton wählen. Die Leiste merkt sich die Farben. Am Bildschirm erscheinen sie genauso wie im PDF- und Bildexport.
- **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, der Druckverlauf wird geglättet, überflüssige Punkte entfallen, die Enden werden rund.
- **Druckkurve** im ☰-Menü an den eigenen Stift anpassen (weicher/härter, Schwelle gegen Grundrauschen).
- **Tasten am Stift** im ☰-Menü belegen: Radieren (Standard), Auswählen, Textmarker, Stift oder nichts – gilt, solange die Taste gedrückt ist. Jede Taste erscheint dort nach dem ersten Drücken.
- **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker. Die Stiftrückseite radiert ebenfalls.
- **Virtuelles Lineal** (`L`): verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden gerade.
- **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.
### Formerkennung The interface is available in English and German; ink-notes follows the system language, and you can switch it in the ☰ menu under "Sprache / Language".
- **Formen-Werkzeug** (`S`/`F`) oder **Halten** am Strichende (über 400 ms stillhalten): Die Skizze wird durch eine saubere Form ersetzt.
- 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.
### 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.
- Auswahl über die Palette **umfärben**, **kopieren/einfügen** (`Strg + C/V`), **löschen** (`Entf`).
- `Strg + C` legt die Auswahl zusätzlich als Bild in die Zwischenablage, z. B. für Chat oder Mail.
- **Rückgängig/Wiederholen** (`Strg + Z/Y`) für alle Änderungen, je Seite.
### Text, Bilder und PDFs ## Features
- **Text-Werkzeug** (`T`): ins Blatt tippen und schreiben – normale oder Code-Schrift (feste Zeichenbreite, Tab rückt ein), 8–36 pt, Zeilenumbruch am Feldrand. Vorhandenen Text antippen zum Ändern, `Esc` beendet. Textfelder lassen sich wie Bilder verschieben, drehen und skalieren. In anderen Programmen kopierter Text wird mit `Strg + V` zum Textfeld.
- **Bilder** (PNG, JPEG, GIF, WebP, BMP) über `Strg + I`, aus der Zwischenablage oder per Ziehen ins Fenster einfügen.
- **Einfügen per Finger:** Lange (½ s) mit dem Finger aufs Blatt drücken öffnet ein Menü „Einfügen“ – Screenshot, kopierter Text oder kopierte Objekte landen genau an dieser Stelle.
- **PDF importieren** (☰-Menü): Jede PDF-Seite wird eine Notizbuchseite mit der PDF-Seite als Hintergrund. Hochformat-Seiten werden DIN A4, Querformat-Seiten (z. B. Folien) bekommen eine Querformat-Seite ohne leeren Streifen darunter. Beim Hineinzoomen werden PDF-Seiten schärfer nachgerendert.
### Seiten und Ansicht ### Writing and drawing
- **Hintergründe:** blanko, kariert (5 mm), liniert (7,5 mm), Punktraster (5 mm). Die **Farbe von Karos, Linien und Punkten** ist wählbar (hell, mittel, kräftig, blau, grün) und gilt auch im Export. - **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.
- **Papierfarbe** der Anzeige: weiß, creme, dunkelgrau, schwarz; auf dunklem Papier werden dunkle Stiftfarben auf Wunsch aufgehellt. Gespeichert und exportiert wird immer auf weißem Papier. - **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.
- **Durchgehendes Scrollen:** Seiten hängen lückenlos untereinander, eine dunkle Linie markiert die Grenze; wer im unteren Fünftel der letzten Seite schreibt, bekommt automatisch eine neue Seite. Abschaltbar (dann Seite für Seite blättern, mit Pfeilen in der Werkzeugleiste). - **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.
- **Seiten einfügen und löschen:** ➕ in der Werkzeugleiste fügt nach der aktuellen Seite eine leere Seite ein. Löschen im ☰-Menü (mit Rückfrage); die zuletzt gelöschte Seite lässt sich dort wiederherstellen, solange die App läuft. - **Pressure curve** adjustable to your pen in the ☰ menu (softer/harder, threshold against base noise).
- **Zoom** von 25 % bis 1000 % (`Strg + Mausrad`, zwei Finger, `Strg + +/-/0`), Verschieben mit mittlerer Maustaste, `Leertaste + Ziehen` oder einem Finger. - **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.
- **Seiten** anhängen oder direkt nach der aktuellen Seite einfügen, **Seitentitel** vergeben; die Seitenübersicht zeigt Vorschaubilder oder ein Inhaltsverzeichnis. Seitentitel werden im PDF zu Lesezeichen. - **Eraser** with three modes: whole strokes, only the part you wipe over (partial stroke), or highlighter only. The back end of the pen erases too.
- **Werkzeugleiste** links oder rechts andocken (für Links- und Rechtshänder). - **Virtual ruler** (`L`): move it, rotate it by its handle (snaps to 15°); strokes along an edge become straight.
- **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.
### Notizbücher und Speichern ### Shape recognition
- Mehrere **Notizbücher** anlegen, umbenennen, wechseln und löschen (gelöschte landen zunächst im Papierkorb und lassen sich wiederherstellen). - **Shape tool** (`S`/`F`) or **hold** at the end of a stroke (keep still for more than 400 ms): the sketch is replaced by a clean shape in the width of the current pen or highlighter.
- **Ordner** für Notizbücher, beliebig verschachtelt (z. B. 3. Semester › Mathe): anlegen, umbenennen, löschen (der Inhalt rückt eine Ebene nach oben) und Notizbücher wie Ordner (samt Inhalt) mit 📂 verschieben. Neue Notizbücher landen im gerade geöffneten Ordner. - 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).
- **Automatisches Speichern** im Hintergrund: 1,5 Sekunden nach der letzten Änderung, bei ununterbrochenem Schreiben spätestens alle 10 Sekunden. Zeichnen wird dabei nicht unterbrochen. - Recognition is purely geometric (arc length, Douglas-Peucker, circle fitting), without machine learning. Not everything is recognized – whatever does not fit stays handwriting.
- Jede Datei wird erst vollständig in eine temporäre Datei geschrieben und dann ersetzt – ein Absturz beim Speichern hinterlässt keine halbe Datei.
- **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. ### Selecting and editing
- `Strg + S` speichert sofort. Der Punkt am ☰-Knopf zeigt den Stand: grün = gespeichert, orange = Änderungen warten, gelb = speichert gerade, rot = Fehler. - **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`).
- `Ctrl + C` also puts the selection on the clipboard as an image, e.g. for chat or email.
- **Undo/redo** (`Ctrl + Z/Y`) for all changes, per page.
### Text, images and PDFs
- **Text tool** (`T`): tap the page and type – regular or code font (fixed character width, Tab indents), 8–36 pt, line wrapping at the box edge. Tap existing text to change it, `Esc` finishes. Text boxes can be moved, rotated and scaled like images. Text copied in other programs becomes a text box with `Ctrl + V`.
- **Images** (PNG, JPEG, GIF, WebP, BMP) via `Ctrl + I`, from the clipboard, or by dragging them into the window.
- **Paste with a finger:** Pressing the page with a finger for half a second opens a "Paste" menu – a screenshot, copied text or copied objects land exactly at that spot.
- **Import PDF** (☰ menu): Every PDF page becomes a notebook page with the PDF page as background. Portrait pages become A4; landscape pages (e.g. slides) get a landscape page without an empty strip below. When you zoom in, PDF pages are re-rendered more sharply.
### Pages and view
- **Backgrounds:** blank, squared (5 mm), ruled (7.5 mm), dot grid (5 mm). The **color of squares, lines and dots** is selectable (light, medium, strong, blue, green) and also applies to exports.
- **Paper color** on screen: white, cream, dark gray, black; on dark paper, dark pen colors can optionally be brightened. Saving and exporting always use white paper.
- **Continuous scrolling, endless canvas or fixed pages, per notebook:** chosen when creating a notebook (default: continuous; or endless) and switchable in the ☰ menu – where fixed pages are available too (page by page with arrows in the toolbar). In continuous mode the pages hang seamlessly below each other with a dark line marking the boundary. A quick swipe keeps gliding, and the scrollbar on the edge opposite the toolbar jumps through long PDFs with the page number shown. Writing in the bottom fifth of the last page adds a new page automatically. Endless is a single borderless canvas you can write on in every direction; the PDF export only contains the area you used.
- **Insert and delete pages:** ➕ in the toolbar inserts an empty page after the current one. Delete it in the ☰ menu (with confirmation); the most recently deleted page can be restored there while the app is running.
- **Zoom** from 25 % to 1000 % (`Ctrl + mouse wheel`, two fingers, `Ctrl + +/-/0`), pan with the middle mouse button, `Space + drag` or one finger.
- **Pages** can be appended or inserted right after the current page, and given **page titles**; the page overview shows thumbnails or a table of contents. Page titles become bookmarks in the PDF.
- **Toolbar** can be docked left or right (for left- and right-handed use).
### Notebooks and saving
- Create, rename, switch and delete multiple **notebooks** (deleted ones go to the trash first and can be restored).
- **Folders** for notebooks, nested as deep as you like (e.g. Semester 3 › Math): create, rename, delete (the content moves up one level), and move notebooks and folders (with their content) with 📂. New notebooks are created in the folder that is currently open.
- **Automatic saving** in the background: 1.5 seconds after the last change, and at least every 10 seconds while you keep writing. Drawing is never interrupted.
- Every file is first written completely to a temporary file and then replaced – a crash while saving never leaves a half-written file.
- **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.
### 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** (`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.
- **Einzelne Seite** als PNG (mit oder ohne Hintergrund) oder SVG über das ☰-Menü. - **Single page** as PNG (with or without background) or SVG via the ☰ menu.
### Eingabegeräte ### Input devices
- Stifte über das Tablet-Protokoll von Wayland bzw. X11 (Druck, Stiftrückseite, Seitentaste). - Pens via the Wayland or X11 tablet protocol (pressure, eraser end, side button).
- Solange der Stift aufliegt oder in der Nähe ist, werden Berührungen mit der Hand ignoriert. - While the pen touches the screen or hovers nearby, touches by the hand are ignored.
- Finger: standardmäßig scrollen und zoomen; Zeichnen mit dem Finger lässt sich im ☰-Menü einschalten. - Fingers: scroll and zoom by default; drawing with a finger can be enabled in the ☰ menu.
### Updates ### Updates
- Die App prüft auf neue Versionen auf Gitea und installiert sie auf Wunsch. Installiert wird nur, wenn die Minisign-Signatur und die SHA-256-Prüfsumme stimmen (Details unten). - The app checks Gitea for new versions and installs them on request. It only installs when the Minisign signature and the SHA-256 checksum match (details below).
- Als Flatpak sucht ink-notes nicht selbst nach Updates; die kommen dann über die Software-Verwaltung (`flatpak update`). - As a Flatpak, ink-notes does not look for updates itself; they come through the software manager (`flatpak update`).
--- ---
## Installation ## Installation
Pakete gibt es unter [Releases](https://gitea.pansi.eu/harald/ink-notes/releases) als `.deb` und `.AppImage` (x86_64). `<version>` steht für die Versionsnummer, z. B. `0.11.0`. Packages are available under [Releases](https://gitea.pansi.eu/harald/ink-notes/releases) as `.deb` and `.AppImage` (x86_64). `<version>` stands for the version number, e.g. `0.30.2`.
```bash ```bash
# Debian-Paket (Zorin OS 18 / Ubuntu 24.04) # Debian package (Zorin OS 18 / Ubuntu 24.04)
sudo apt install ./ink-notes_<version>_amd64.deb sudo apt install ./ink-notes_<version>_amd64.deb
# oder AppImage # or AppImage
chmod +x ink-notes_<version>_x86_64.AppImage chmod +x ink-notes_<version>_x86_64.AppImage
./ink-notes_<version>_x86_64.AppImage ./ink-notes_<version>_x86_64.AppImage
``` ```
Hinweise: Notes:
- Benötigt `glibc >= 2.39` (Ubuntu 24.04, Zorin OS 18) und einen Grafiktreiber mit Vulkan oder OpenGL. - Requires `glibc >= 2.39` (Ubuntu 24.04, Zorin OS 18) and a graphics driver with Vulkan or OpenGL.
- Meldet das AppImage einen Fehler zu `libfuse.so.2`, entweder `sudo apt install libfuse2t64` installieren oder mit `--appimage-extract-and-run` starten. - If the AppImage reports an error about `libfuse.so.2`, either install `sudo apt install libfuse2t64` or start it with `--appimage-extract-and-run`.
--- ---
## Tastenkürzel ## Keyboard shortcuts
| Aktion | Taste / Geste | | Action | Key / gesture |
| :--- | :--- | | :--- | :--- |
| Zeichnen | Stiftspitze, linke Maustaste (Finger, wenn eingeschaltet) | | Draw | Pen tip, left mouse button (finger, if enabled) |
| Radieren | `E` umschalten, rechte Maustaste, Stiftrückseite oder Seitentaste | | Erase | toggle with `E`, right mouse button, pen eraser end or side button |
| Stift in Schwarz / Blau / Rot | `B` / `U` / `R` | | Pen in black / blue / red | `B` / `U` / `R` |
| Formen-Werkzeug | `S` oder `F` | | Shape tool | `S` or `F` |
| Auswahlwerkzeug | `V` | | Selection tool | `V` |
| Text-Werkzeug | `T`, dann ins Blatt klicken; `Esc` beendet die Eingabe | | Text tool | `T`, then click on the page; `Esc` finishes typing |
| Lineal ein/aus | `L` | | Ruler on/off | `L` |
| Hintergrund wechseln | `G` | | Change background | `G` |
| Auswahl um 90° drehen | `R` (bei Auswahl) | | Rotate selection by 90° | `R` (with a selection) |
| Kopieren / Einfügen | `Strg + C` / `Strg + V` | | Copy / paste | `Ctrl + C` / `Ctrl + V` |
| Auswahl löschen | `Entf` oder `Rücktaste` | | Delete selection | `Del` or `Backspace` |
| Rückgängig / Wiederholen | `Strg + Z` / `Strg + Y` oder `Strg + Umschalt + Z` | | Undo / redo | `Ctrl + Z` / `Ctrl + Y` or `Ctrl + Shift + Z` |
| Bild einfügen | `Strg + I` | | Insert image | `Ctrl + I` |
| Scrollen | Mausrad (`Umschalt` = waagerecht), ein Finger | | Scroll | mouse wheel (`Shift` = horizontal), one finger |
| Zoomen | `Strg + Mausrad`, zwei Finger, `Strg + +` / `Strg + -` | | Zoom | `Ctrl + mouse wheel`, two fingers, `Ctrl + +` / `Ctrl + -` |
| Zoom zurücksetzen | `Strg + 0` | | Reset zoom | `Ctrl + 0` |
| Verschieben | mittlere Maustaste oder `Leertaste + Ziehen` | | Pan | middle mouse button or `Space + drag` |
| Nächste / vorherige Seite | `Bild ab` oder `N` / `Bild auf` | | Next / previous page | `Page Down` or `N` / `Page Up` |
| Erste / letzte Seite | `Pos 1` / `Ende` | | First / last page | `Home` / `End` |
| Jetzt speichern | `Strg + S` | | Save now | `Ctrl + S` |
| PDF exportieren | `Strg + E` | | Export PDF | `Ctrl + E` |
| Menüs schließen, Auswahl aufheben | `Esc` | | Close menus, clear selection | `Esc` |
| Beenden (speichert vorher) | `Strg + Q` oder Fenster schließen | | Quit (saves first) | `Ctrl + Q` or close the window |
--- ---
## Entwicklung ## Development
### Voraussetzungen ### Requirements
- Rust 1.92 oder neuer (`rustup update stable`) - Rust 1.92 or newer (`rustup update stable`)
- Vulkan- oder OpenGL-Treiber (z. B. `mesa-vulkan-drivers`) - Vulkan or OpenGL driver (e.g. `mesa-vulkan-drivers`)
- X11-/Wayland-Bibliotheken: `libx11-dev`, `libxcursor-dev`, `libxrandr-dev`, `libxi-dev`, `libxkbcommon-dev` - X11/Wayland libraries: `libx11-dev`, `libxcursor-dev`, `libxrandr-dev`, `libxi-dev`, `libxkbcommon-dev`
### Bauen, starten, testen ### Build, run, test
```bash ```bash
git clone https://gitea.pansi.eu/harald/ink-notes.git git clone https://gitea.pansi.eu/harald/ink-notes.git
cd ink-notes cd ink-notes
cargo run --release # oder ./run.sh cargo run --release # or ./run.sh
cargo test # Unit- und Integrationstests cargo test # unit and integration tests
``` ```
Die CI prüft zusätzlich `cargo fmt --check` und `cargo clippy --all-targets -- -D warnings`. CI additionally checks `cargo fmt --check` and `cargo clippy --all-targets -- -D warnings`.
### Projektstruktur New code comments and commit messages are written in English; older code and history still contain German.
### Project structure
```text ```text
src/ src/
├── main.rs # Event-Loop (winit), Eingaben von Maus, Touch und Stift, Ablaufsteuerung ├── main.rs # Event loop (winit), mouse, touch and pen input, control flow
├── lib.rs # Bibliotheks-Exporte (für Tests) ├── lib.rs # Library exports (for tests)
├── canvas.rs # wgpu-Rendering: Papier, Muster, Bilder, Tinte, Nachbarseiten ├── canvas.rs # wgpu rendering: paper, patterns, images, ink, neighboring pages
├── shader.wgsl # Vertex- und Fragment-Shader ├── shader.wgsl # Vertex and fragment shaders
├── viewport.rs # Kamera (Zoom, Verschieben), Seitenstapel der durchgehenden Ansicht ├── viewport.rs # Camera (zoom, pan), page stack of the continuous view, swipe momentum
├── filter.rs # 1€-Filter gegen Zittern ├── filter.rs # 1€ filter against jitter
├── spline.rs # Catmull-Rom-Splines, Ecken-Erhaltung, Dreiecksstreifen ├── spline.rs # Catmull-Rom splines, corner preservation, triangle strips
├── geometry.rs # Formerkennung (Douglas-Peucker, Kreis-Fit, …) ├── geometry.rs # Shape recognition (Douglas-Peucker, circle fitting, …)
├── selection.rs # Auswahl, Griffe, Verschieben/Drehen/Skalieren ├── selection.rs # Selection, handles, move/rotate/scale
├── ruler.rs # Virtuelles Lineal ├── ruler.rs # Virtual ruler
├── text.rs # Textfelder: Satz, Zeilenumbruch, Rasterung ├── text.rs # Text boxes: layout, line wrapping, rasterization
├── background.rs # Papierfarben, Musterfarben, Karo/Linien/Punkte ├── background.rs # Paper colors, pattern colors, squares/lines/dots
├── image_layer.rs # Texturen für Bilder, PDF-Seiten und Text ├── image_layer.rs # Textures for images, PDF pages and text
├── assets.rs # Bilder und PDFs im Notizbuchordner ├── assets.rs # Images and PDFs in the notebook folder
├── pdf_doc.rs # PDF lesen und rendern (hayro) ├── pdf_doc.rs # Reading and rendering PDFs (hayro)
├── pdf_merge.rs # Importierte PDF-Seiten beim Export unterlegen ├── pdf_merge.rs # Underlaying imported PDF pages on export
├── document.rs # Datenmodell: Notizbuch, Seiten, Striche, Seitengrößen ├── document.rs # Data model: notebook, pages, strokes, page sizes
├── history.rs # Rückgängig/Wiederholen ├── history.rs # Undo/redo
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb ├── storage.rs # Loading/saving (atomic), notebooks, trash
├── library.rs # Ordner der Notizbücher (library.json) ├── library.rs # Notebook folders (library.json)
├── autosave.rs # Speichern im Hintergrund ├── autosave.rs # Saving in the background
├── export.rs # PDF-Export (printpdf) ├── export.rs # PDF export (printpdf)
├── page_export.rs # Seite als PNG/SVG, Auswahl als Bild ├── page_export.rs # Page as PNG/SVG, selection as image
├── file_picker.rs # Eingebaute Dateiauswahl ├── file_picker.rs # Built-in file picker
├── settings.rs # Einstellungen (~/.config/ink-notes/ui.json) ├── settings.rs # Settings (~/.config/ink-notes/ui.json)
├── platform.rs # Wayland/X11-Erkennung ├── platform.rs # Wayland/X11 detection
├── updater.rs # Updates von Gitea (Signatur- und Prüfsummenprüfung) ├── updater.rs # Updates from Gitea (signature and checksum verification)
└── ui.rs # Oberfläche (egui): Werkzeugleiste, Menüs, Dialoge └── ui.rs # Interface (egui): toolbar, menus, dialogs
``` ```
Mehr dazu in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md), das Speicherformat steht in [`docs/FILE_FORMAT.md`](docs/FILE_FORMAT.md). More in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md); the storage format is described in [`docs/FILE_FORMAT.md`](docs/FILE_FORMAT.md).
--- ---
## Releases bauen und veröffentlichen ## Building and publishing releases
```bash ```bash
./scripts/build-release.sh # .deb, .AppImage und SHA256SUMS.txt in target/ ./scripts/build-release.sh # .deb, .AppImage and SHA256SUMS.txt in target/
``` ```
Veröffentlicht wird über Gitea Actions ([`.gitea/workflows/release.yaml`](.gitea/workflows/release.yaml)), sobald ein Tag `vX.Y.Z` gepusht wird: Tests, Pakete bauen, signieren, Release anlegen. Manuell geht es mit `GITEA_TOKEN=… ./scripts/publish-gitea-release.sh`. Releases are published by Gitea Actions ([`.gitea/workflows/release.yaml`](.gitea/workflows/release.yaml)) as soon as a tag `vX.Y.Z` is pushed: tests, build packages, sign, create the release. To do it manually: `GITEA_TOKEN=… ./scripts/publish-gitea-release.sh`.
### Flatpak ### Flatpak
Das Manifest liegt in [`packaging/flatpak/`](packaging/flatpak/) (Runtime `org.freedesktop.Platform` 26.08, Rust aus der SDK-Erweiterung `rust-stable`). Lokal bauen und installieren: The manifest is in [`packaging/flatpak/`](packaging/flatpak/) (runtime `org.freedesktop.Platform` 26.08, Rust from the SDK extension `rust-stable`). Build and install locally:
```bash ```bash
flatpak-builder --user --install --force-clean target/flatpak-build packaging/flatpak/eu.pansi.ink-notes.yml flatpak-builder --user --install --force-clean target/flatpak-build packaging/flatpak/eu.pansi.ink-notes.yml
flatpak run eu.pansi.ink-notes flatpak run eu.pansi.ink-notes
``` ```
Nach jeder Änderung an `Cargo.lock` muss `packaging/flatpak/cargo-sources.json` mit dem [flatpak-cargo-generator](https://github.com/flatpak/flatpak-builder-tools/tree/master/cargo) neu erzeugt werden (`python3 flatpak-cargo-generator.py Cargo.lock -o packaging/flatpak/cargo-sources.json`). In der Sandbox darf ink-notes Bilder, Downloads und Schreibtisch lesen und in den Dokumente-Ordner exportieren; die Notizbücher liegen unter `~/.var/app/eu.pansi.ink-notes/`. After every change to `Cargo.lock`, `packaging/flatpak/cargo-sources.json` must be regenerated with the [flatpak-cargo-generator](https://github.com/flatpak/flatpak-builder-tools/tree/master/cargo) (`python3 flatpak-cargo-generator.py Cargo.lock -o packaging/flatpak/cargo-sources.json`). In the sandbox, ink-notes may read Pictures, Downloads and Desktop and export to the Documents folder; the notebooks live under `~/.var/app/eu.pansi.ink-notes/`.
### Release-Signatur einrichten (einmalig) ### Setting up the release signature (once)
Updates werden in der App nur mit gültiger Minisign-Signatur installiert. Signiert wird mit [rsign2](https://crates.io/crates/rsign2) (`cargo install rsign2 --locked`). Schlüsselpaar ohne Passwort erzeugen (es wird nur als CI-Secret verwendet): The app only installs updates with a valid Minisign signature. Signing uses [rsign2](https://crates.io/crates/rsign2) (`cargo install rsign2 --locked`). Generate a key pair without a password (it is only used as a CI secret):
```bash ```bash
rsign generate -W -p ink-notes.pub -s ink-notes.key rsign generate -W -p ink-notes.pub -s ink-notes.key
``` ```
1. Die zweite Zeile von `ink-notes.pub` (beginnt mit `RW`) in `src/updater.rs` als `UPDATE_SIGNING_PUBLIC_KEY` eintragen. 1. Put the second line of `ink-notes.pub` (starting with `RW`) into `src/updater.rs` as `UPDATE_SIGNING_PUBLIC_KEY`.
2. Den Inhalt von `ink-notes.key` in Gitea unter *Repository → Einstellungen → Actions → Secrets* als `MINISIGN_SECRET_KEY` hinterlegen. 2. Store the content of `ink-notes.key` in Gitea under *Repository → Settings → Actions → Secrets* as `MINISIGN_SECRET_KEY`.
3. `ink-notes.key` offline aufbewahren. Geht er verloren, können bestehende Installationen keine Updates mehr prüfen. 3. Keep `ink-notes.key` offline. If it is lost, existing installations can no longer verify updates.
`rsign generate -W` verschlüsselt den Schlüssel mit leerem Passwort; das `minisign`-Programm (z. B. Ubuntu-Paket 0.11) kann ihn ohne Terminal nicht lesen, deshalb signieren Skript und CI mit `rsign`. In der CI ist die Signatur Pflicht (`INK_NOTES_REQUIRE_SIGNATURE=1`), und das Skript prüft, dass der geheime Schlüssel zum eingebauten öffentlichen passt. `rsign generate -W` encrypts the key with an empty password; the `minisign` program (e.g. Ubuntu package 0.11) cannot read it without a terminal, which is why the script and CI sign with `rsign`. In CI the signature is mandatory (`INK_NOTES_REQUIRE_SIGNATURE=1`), and the script checks that the secret key matches the built-in public key.
### CI-Runner (act_runner) ### CI runner (act_runner)
Der Workflow läuft auf dem Label `ubuntu-latest`. Im `config.yaml` des Runners muss es zugeordnet sein, z. B.: The workflow runs on the label `ubuntu-latest`. It must be mapped in the runner's `config.yaml`, e.g.:
```yaml ```yaml
runner: runner:
@@ -226,20 +230,26 @@ runner:
- "ubuntu-latest:docker://catthehacker/ubuntu:act-latest" - "ubuntu-latest:docker://catthehacker/ubuntu:act-latest"
``` ```
Bleibt ein Job auf „Waiting to run“ stehen: Ist der Runner online (*Einstellungen → Aktionen → Runner*) und stimmt das Label mit `runs-on` überein? `sudo` verwendet der Workflow nur, wenn der Runner nicht als `root` läuft. If a job stays at "Waiting to run": is the runner online (*Settings → Actions → Runners*) and does the label match `runs-on`? The workflow only uses `sudo` when the runner does not run as `root`.
--- ---
## Sicherheit der Updates ## Update security
1. **Signatur:** Jedes Release enthält `SHA256SUMS.txt.minisig`. Die App lädt `SHA256SUMS.txt` und die Signatur vor dem Paket und prüft sie gegen den eingebauten öffentlichen Schlüssel (`minisign-verify`). Ohne gültige Signatur wird nicht installiert. 1. **Signature:** Every release contains `SHA256SUMS.txt.minisig`. The app downloads `SHA256SUMS.txt` and the signature before the package and verifies them against the built-in public key (`minisign-verify`). Without a valid signature, nothing is installed.
2. **Prüfsumme:** Der SHA-256-Wert des heruntergeladenen Pakets muss zum Eintrag in der signierten Liste passen, sonst wird die Datei gelöscht. 2. **Checksum:** The SHA-256 of the downloaded package must match the entry in the signed list, otherwise the file is deleted.
3. **Nur HTTPS:** Auch Download-Links und Weiterleitungen müssen HTTPS sein. Bei Weiterleitungen auf einen anderen Server wird der `Authorization`-Header nicht mitgeschickt. 3. **HTTPS only:** Download links and redirects must be HTTPS too. On redirects to a different server, the `Authorization` header is not sent along.
4. **Eigener Download-Ordner:** `~/.cache/ink-notes/updates` (Rechte `0700`); Dateinamen mit Pfadtrennern, `..` oder Steuerzeichen werden abgelehnt. 4. **Dedicated download folder:** `~/.cache/ink-notes/updates` (permissions `0700`); file names with path separators, `..` or control characters are rejected.
5. **AppImage-Austausch:** Die neue Datei wird neben der alten angelegt, die alte als `.bak` gesichert und dann per `rename` ersetzt – die laufende Datei wird nie überschrieben. 5. **AppImage replacement:** The new file is created next to the old one, the old one is kept as `.bak` and then replaced with `rename` – the running file is never overwritten.
--- ---
## Lizenz ## Contributing
Bug reports, ideas and contributions are welcome – preferably as an [issue on Gitea](https://gitea.pansi.eu/harald/ink-notes/issues); [GitHub](https://github.com/haraldpansi-gif/ink-notes) is a mirror. Issues may be written in English or German. Details in [CONTRIBUTING.md](CONTRIBUTING.md); please report security vulnerabilities privately as described in [SECURITY.md](SECURITY.md). The [Code of Conduct](CODE_OF_CONDUCT.md) applies.
---
## License
[MIT](LICENSE), Copyright © 2026 Harald Pansi. [MIT](LICENSE), Copyright © 2026 Harald Pansi.
+22
View File
@@ -0,0 +1,22 @@
# Sicherheit
[English](SECURITY.md) · **Deutsch**
## Unterstützte Versionen
Sicherheitskorrekturen gibt es nur für die jeweils **neueste Version** (siehe [Releases](https://gitea.pansi.eu/harald/ink-notes/releases)). Die App bietet Updates selbst an (☰ → Updates); als Flatpak kommen sie über die Software-Verwaltung.
## Sicherheitslücke melden
Bitte Sicherheitslücken **nicht** in einem öffentlichen Issue melden, sondern per E-Mail an **harald@pansi.eu** – möglichst mit:
- betroffener Version und Installationsart (.deb, AppImage, Flatpak, selbst gebaut),
- einer Beschreibung, was passieren kann,
- den Schritten oder einer Datei (z. B. ein präpariertes Notizbuch oder PDF), mit denen es sich nachstellen lässt.
Du bekommst innerhalb einer Woche eine Antwort. Ist die Lücke bestätigt, erscheint die Korrektur als neue Version; auf Wunsch wirst du in den Release-Notes genannt.
## Besonders interessant
- **Updates:** Prüfung der Minisign-Signatur und der SHA-256-Prüfsummen, Download und Austausch der Pakete (siehe „Sicherheit der Updates“ im [README](README.de.md#sicherheit-der-updates)).
- **Dateien öffnen:** importierte PDFs und Bilder, Notizbuch-Dateien ([Dateiformat](docs/FILE_FORMAT.de.md)) – z. B. Abstürze oder Schreiben außerhalb des Datenordners durch präparierte Dateien.
+22
View File
@@ -0,0 +1,22 @@
# Security Policy
**English** · [Deutsch](SECURITY.de.md)
## Supported versions
Security fixes are only provided for the **latest release** (see [Releases](https://gitea.pansi.eu/harald/ink-notes/releases)). The app offers updates itself (☰ → Updates); the Flatpak is updated through the software manager.
## Reporting a vulnerability
Please do **not** report vulnerabilities in a public issue. Send an e-mail to **harald@pansi.eu** instead, ideally with:
- the affected version and installation type (.deb, AppImage, Flatpak, built from source),
- a description of what can happen,
- steps or a file (e.g. a crafted notebook or PDF) to reproduce it.
You will get an answer within a week. Confirmed issues are fixed in a new release; on request you will be credited in the release notes. Reports in English or German are both fine.
## Areas of interest
- **Updates:** verification of the Minisign signature and SHA-256 checksums, downloading and replacing packages (see "Update security" in the [README](README.md#update-security)).
- **Opening files:** imported PDFs and images, notebook files ([file format](docs/FILE_FORMAT.md)) – e.g. crashes or writes outside the data folder caused by crafted files.
+178
View File
@@ -0,0 +1,178 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 360" width="1200" height="360">
<defs>
<!-- Haupt-Hintergrund-Verlauf -->
<linearGradient id="bannerBg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0f1420"/>
<stop offset="40%" stop-color="#141a29"/>
<stop offset="100%" stop-color="#090b12"/>
</linearGradient>
<!-- Subtiles Ambient Glow -->
<radialGradient id="ambientGlow" cx="15%" cy="50%" r="45%">
<stop offset="0%" stop-color="#3b82f6" stop-opacity="0.22"/>
<stop offset="70%" stop-color="#1d4ed8" stop-opacity="0.05"/>
<stop offset="100%" stop-color="#1d4ed8" stop-opacity="0"/>
</radialGradient>
<radialGradient id="ambientGlowRight" cx="85%" cy="30%" r="40%">
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.10"/>
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
</radialGradient>
<!-- Icon Hintergrund-Verlauf -->
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1e2638"/>
<stop offset="50%" stop-color="#141824"/>
<stop offset="100%" stop-color="#0c0e17"/>
</linearGradient>
<!-- Stiftkörper-Verlauf -->
<linearGradient id="penBody" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#3b82f6"/>
<stop offset="100%" stop-color="#1d4ed8"/>
</linearGradient>
<!-- Goldene Feder-Spitze -->
<linearGradient id="nibGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#fbbf24"/>
<stop offset="50%" stop-color="#f59e0b"/>
<stop offset="100%" stop-color="#d97706"/>
</linearGradient>
<!-- Tintenspur-Verlauf -->
<linearGradient id="inkGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#60a5fa" stop-opacity="0.95"/>
<stop offset="50%" stop-color="#3b82f6" stop-opacity="0.85"/>
<stop offset="100%" stop-color="#2563eb" stop-opacity="0.4"/>
</linearGradient>
<!-- Text Ink Gradient -->
<linearGradient id="textInkGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#60a5fa"/>
<stop offset="100%" stop-color="#38bdf8"/>
</linearGradient>
<!-- Leucht-Effekt -->
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="8" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
<filter id="iconShadow" x="-10%" y="-10%" width="120%" height="130%">
<feDropShadow dx="0" dy="12" stdDeviation="16" flood-color="#000000" flood-opacity="0.6"/>
</filter>
<!-- Gitter-Muster für Hintergrund -->
<pattern id="gridPattern" width="30" height="30" patternUnits="userSpaceOnUse">
<path d="M 30 0 L 0 0 0 30" fill="none" stroke="#94a3b8" stroke-width="0.8" opacity="0.07"/>
</pattern>
</defs>
<!-- Banner-Rahmen & Hintergrund -->
<rect width="1200" height="360" rx="20" fill="url(#bannerBg)" stroke="#27324b" stroke-width="2"/>
<!-- Hintergrund-Gitter (Notizbuch-Raster) -->
<rect width="1200" height="360" rx="20" fill="url(#gridPattern)"/>
<!-- Subtile Glows -->
<rect width="1200" height="360" rx="20" fill="url(#ambientGlow)"/>
<rect width="1200" height="360" rx="20" fill="url(#ambientGlowRight)"/>
<!-- ==================== LINKER BEREICH: APP-ICON ==================== -->
<g transform="translate(65, 55) scale(0.488)" filter="url(#iconShadow)">
<!-- App-Icon Hintergrund (Squircle) -->
<rect x="24" y="24" width="464" height="464" rx="104" ry="104" fill="url(#bgGrad)" stroke="#384260" stroke-width="4"/>
<!-- DIN-A4 Dokumenten-Gitter im Icon -->
<g opacity="0.14" stroke="#94a3b8" stroke-width="2">
<line x1="80" y1="160" x2="432" y2="160" />
<line x1="80" y1="220" x2="432" y2="220" />
<line x1="80" y1="280" x2="432" y2="280" />
<line x1="80" y1="340" x2="432" y2="340" />
<line x1="80" y1="400" x2="432" y2="400" />
<line x1="140" y1="100" x2="140" y2="430" />
<line x1="200" y1="100" x2="200" y2="430" />
<line x1="260" y1="100" x2="260" y2="430" />
<line x1="320" y1="100" x2="320" y2="430" />
<line x1="380" y1="100" x2="380" y2="430" />
</g>
<!-- Tintenspur -->
<path d="M 110 390 C 180 430, 260 380, 240 310 C 220 240, 280 190, 340 220"
fill="none" stroke="url(#inkGrad)" stroke-width="26" stroke-linecap="round" filter="url(#glow)"/>
<path d="M 110 390 C 180 430, 260 380, 240 310 C 220 240, 280 190, 340 220"
fill="none" stroke="#93c5fd" stroke-width="12" stroke-linecap="round"/>
<!-- Stylus / Füllfederhalter -->
<g transform="translate(240, 110) rotate(45)">
<rect x="-24" y="-140" width="48" height="150" rx="8" fill="url(#penBody)" stroke="#60a5fa" stroke-width="3"/>
<rect x="-22" y="10" width="44" height="26" rx="4" fill="#1e293b" stroke="#cbd5e1" stroke-width="2"/>
<rect x="-25" y="6" width="50" height="6" fill="#f8fafc"/>
<path d="M -20 36 L 20 36 L 12 85 L 0 115 L -12 85 Z" fill="url(#nibGrad)" stroke="#fef08a" stroke-width="2"/>
<line x1="0" y1="46" x2="0" y2="108" stroke="#78350f" stroke-width="3" stroke-linecap="round"/>
<circle cx="0" cy="65" r="4" fill="#78350f"/>
</g>
<!-- Glanzpunkt -->
<circle cx="110" cy="390" r="14" fill="#60a5fa" filter="url(#glow)"/>
<circle cx="110" cy="390" r="8" fill="#ffffff"/>
</g>
<!-- ==================== RECHTER BEREICH: TYPOGRAFIE & BADGES ==================== -->
<g transform="translate(360, 0)">
<!-- Version & Platform Pill -->
<g transform="translate(0, 68)">
<rect width="214" height="30" rx="15" fill="#1e293b" stroke="#334155" stroke-width="1.2"/>
<circle cx="16" cy="15" r="4" fill="#22c55e"/>
<text x="28" y="20" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="600" fill="#cbd5e1" letter-spacing="0.5">
LINUX • RUST &amp; WGPU
</text>
</g>
<!-- Haupttitel: ink-notes -->
<text x="0" y="162" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="800" font-size="64" letter-spacing="-1">
<tspan fill="url(#textInkGrad)">ink</tspan><tspan fill="#f8fafc">-notes</tspan>
</text>
<!-- Subtitle / Tagline -->
<text x="0" y="206" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="400" font-size="22" fill="#94a3b8">
Notebook &amp; PDF annotation app for pen, touch and mouse
</text>
<!-- Feature Pills / Highlights -->
<g transform="translate(0, 248)">
<!-- Pill 1: Niedrige Latenz -->
<g transform="translate(0, 0)">
<rect width="152" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
⚡ Fast wgpu inking
</text>
</g>
<!-- Pill 2: Stylus mit Druck -->
<g transform="translate(164, 0)">
<rect width="214" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
🖊️ Pressure &amp; palm rejection
</text>
</g>
<!-- Pill 3: Formerkennung -->
<g transform="translate(390, 0)">
<rect width="216" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
📐 Shape recognition &amp; ruler
</text>
</g>
<!-- Pill 4: 100% lokal -->
<g transform="translate(618, 0)">
<rect width="188" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
🔒 100% local &amp; atomic
</text>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.2 KiB

+206
View File
@@ -0,0 +1,206 @@
# Architektur von ink-notes
[English](ARCHITECTURE.md) · **Deutsch**
Dieses Dokument beschreibt, wie ink-notes intern aufgebaut ist: Eingabe und Strichverarbeitung, Rendering, Seiten und Ansicht, Formerkennung, Speichern und Export. Das Speicherformat steht in [`FILE_FORMAT.de.md`](FILE_FORMAT.de.md).
---
## 1. Überblick
ink-notes ist ein einzelnes Programm mit einem Event-Loop (`winit`) im Haupt-Thread. Dazu kommen drei Hintergrund-Threads:
| Thread | Aufgabe |
| :--- | :--- |
| Haupt-Thread | Eingaben, Datenmodell, Rendering (`wgpu`), Oberfläche (`egui`) |
| Autosave | Seiten und Manifest auf die Festplatte schreiben (`autosave.rs`) |
| PDF-Rendering | Importierte PDF-Seiten zu Texturen rendern (`pdf_doc.rs`, `hayro`) |
| Export | PDF/PNG/SVG-Export, damit die Oberfläche nicht hängt (`main.rs`) |
Stifteingaben kommen über `octotablet` (Tablet-Protokoll von Wayland bzw. X11), Maus und Touch über `winit`. Die Oberfläche (Werkzeugleiste, Menüs, Dialoge, Textbearbeitung) ist mit `egui` gebaut und wird im selben Render-Durchgang über das Blatt gezeichnet.
---
## 2. Vom Stift zum Strich
```
[Stift / Touch / Maus]
│ Bildschirmposition, Druck, Zeit
▼
[Bildschirm → Seitenkoordinaten] (viewport.rs)
│
▼
[Druckkurve] (settings.rs, vom Nutzer einstellbar)
│
▼
[1€-Filter] (filter.rs)
│
▼
[Dreiecksstreifen wie beim fertigen Strich] (spline.rs) → Live-Puffer auf der GPU
│ Stift abgehoben
▼
[Nachbearbeitung] (spline.rs: finalize_stroke)
│
▼
[Strich auf der Seite + Undo-Eintrag] (document.rs, history.rs)
```
### 2.1 1€-Filter
Langsame Bewegungen zittern, schnelle sollen nicht nachhinken. Der 1€-Filter (`filter.rs`) passt seine Grenzfrequenz an die Geschwindigkeit an:
$$f_c = f_{c,\min} + \beta \cdot |\dot{x}|$$
Bei langsamer Bewegung glättet er stark, bei schneller kaum.
### 2.2 Zentripetaler Catmull-Rom-Spline
Die gefilterten Punkte werden mit einem zentripetalen Catmull-Rom-Spline ($\alpha = 0{,}5$) interpoliert:
$$t_{i+1} = t_i + \lVert P_{i+1} - P_i \rVert^{\alpha}$$
Diese Parametrisierung erzeugt bei engen Kurven keine Schlaufen oder Spitzen und ist an den Stützstellen $C^1$-stetig.
### 2.3 Dreiecksstreifen
Aus den Spline-Punkten mit Radius $r_i$ (aus Druck und Strichstärke) entsteht ein Dreiecksstreifen:
$$V_{i,\text{links}} = P_i + N_i \cdot r_i, \qquad V_{i,\text{rechts}} = P_i - N_i \cdot r_i$$
$N_i$ ist die Normale der Laufrichtung; an inneren Punkten wird über benachbarte Segmente gemittelt.
### 2.4 Zwei Stufen
1. **Während des Schreibens:** Der Filter arbeitet nur mit bisherigen Punkten; der Strich wird bei jedem neuen Punkt mit derselben Tessellierung wie beim Absetzen (ausdünnen, Ecken, runde Kappen) in einen eigenen Live-Puffer geschrieben, damit er beim Abheben nicht springt.
2. **Beim Absetzen** wird der ganze Strich noch einmal bearbeitet (`finalize_stroke`):
- Druckverlauf mit dem Fenster $[0{,}25;\ 0{,}5;\ 0{,}25]$ glätten,
- Punkte mit Ramer-Douglas-Peucker ausdünnen ($\varepsilon = 0{,}75$ Einheiten $= 0{,}075$ mm),
- an Knicken über 65° den Spline auftrennen, damit Ecken spitz bleiben, und die Teile rund verbinden (ein Kreis um den Knick), damit die Spitze nicht abgeschnitten wirkt,
- runde Endkappen anfügen.
Danach wird der Live-Puffer geleert und der Strich in die Seite übernommen.
### 2.5 Lineal
Beginnt ein Strich nahe einer Linealkante (`ruler.rs`), werden alle seine Punkte auf diese Kante projiziert, bis der Stift abgehoben wird.
---
## 3. Koordinaten, Seiten und Ansicht
### 3.1 Seitenkoordinaten
Gerechnet wird in Seiteneinheiten: **10 Einheiten = 1 mm**. Eine DIN-A4-Seite ist 2100 × 2970 Einheiten groß. Jede Seite hat ihre eigene Größe (`Page::size`): Standard ist A4 hoch; beim PDF-Import bekommen Querformat-Seiten 2970 Einheiten Breite und eine Höhe nach Seitenverhältnis (`document::pdf_page_size`). Neue Seiten übernehmen die Größe der aktuellen.
### 3.2 Maßstab und Kamera
- `PageViewport` (`document.rs`) passt einen Rahmen, in den alle Seiten des Notizbuchs passen (`Notebook::fit_box`), ins Fenster ein. Daraus ergibt sich ein Maßstab (Bildschirmpixel je Einheit), der für alle Seiten gilt. Die aktuelle Seite liegt waagerecht zentriert und oben im Rahmen. Den Platz der Werkzeugleiste rechnet der Viewport heraus.
- `ViewportState` (`viewport.rs`) ergänzt Zoom (25 % bis 1000 %) und Verschiebung (Pan). Beim Zoomen bleibt der Punkt unter dem Mauszeiger stehen:
$$\vec{p}_{\text{neu}} = \vec{c} - (\vec{c} - \vec{p}_{\text{alt}}) \cdot \frac{z_{\text{neu}}}{z_{\text{alt}}}$$
- Im Shader wird jeder Punkt mit `transform_scale` und `transform_offset` (aus `CameraUniforms`) direkt in Gerätekoordinaten umgerechnet.
### 3.3 Durchgehende Ansicht
Ob ein Notizbuch Seite für Seite, durchgehend oder als endlose Fläche angezeigt wird, steht in seinem Manifest (`layout`, siehe [`FILE_FORMAT.de.md`](FILE_FORMAT.de.md)).
Alle Koordinaten beziehen sich auf die **aktuelle Seite**. `PageStack` (`viewport.rs`) legt die übrigen Seiten lückenlos darunter bzw. darüber (die Grenze zeichnet die Oberfläche als dunkle Linie), waagerecht zur aktuellen Seite zentriert. Jede sichtbare Nachbarseite wird mit einer eigenen Kamera gezeichnet, die um die Lage der Seite verschoben ist (höchstens 12 Nachbarseiten).
Nach dem Scrollen wird die Seite in der Fenstermitte zur aktuellen Seite; die Kamera wird dabei so verschoben, dass sich das Bild nicht bewegt. Während eines Strichs, einer Auswahl, beim Ziehen des Lineals oder beim Schreiben von Text wird nicht gewechselt. Wer auf einer sichtbaren Nachbarseite zu zeichnen beginnt, wechselt zuerst auf diese Seite.
Schnelles Wischen mit dem Finger gleitet nach (`Fling` in `viewport.rs`): Die Geschwindigkeit wird über die letzten 100 ms vor dem Abheben gemessen und nimmt danach exponentiell ab. Die Bildlaufleiste am Rand gegenüber der Werkzeugleiste springt direkt an eine Stelle im Notizbuch.
---
### 3.4 Endlose Fläche
Ein endloses Notizbuch hat eine einzige Seite von 10 × 10 m (`ENDLESS_PAGE_SIZE`). Damit bleibt alles wie bei jeder anderen Seite – Eingabe, Treffertests, Undo, Speichern –, nur der Rand ist praktisch unerreichbar. `PageViewport::calculate_endless` wählt Maßstab und Lage so, als läge eine DIN-A4-Seite am Startausschnitt (`ENDLESS_HOME`, ein A4-Rahmen in der Mitte); 100 % Zoom und „Ansicht zurücksetzen“ zeigen also diesen Rahmen. Bilder und Textfelder richten ihre Größe bzw. ihren Umbruch an DIN A4 aus (`Page::reference_size`, `Page::text_right_edge`).
Beim Wechsel von und zu Seiten (`Notebook::make_endless`/`make_paged`) wird der Inhalt um `ENDLESS_HOME` verschoben und der Verlauf der Seite geleert. Zurück zu Seiten geht nur, solange alles im Startausschnitt liegt. Export und Bilder einer Auswahl rastern bzw. exportieren nur einen Ausschnitt (`Page::cropped`), beim Export auf das Musterraster gelegt (`Page::endless_export_bounds`).
## 4. Rendering
Ein Frame besteht aus einem Render-Durchgang auf die Fensterfläche (`canvas.rs`):
1. Für jede sichtbare Nachbarseite: Papier und Muster, Bilder/PDF-Seite/Text, Tinte.
2. Für die aktuelle Seite: Papier, Muster (Karo, Linien, Punkte), Bildebene, gespeicherte Striche, Live-Strich.
3. Die `egui`-Oberfläche: Werkzeugleiste, Menüs, Seitengrenzen, Auswahlrahmen und Griffe, Lineal, Textbearbeitung.
Jeder Strich ist ein eigener Draw-Call mit eigener Nummer im Stencil-Puffer: Ein Pixel, das der Strich schon gefärbt hat, wird kein zweites Mal gefärbt. So bleibt halbtransparente Tinte (Textmarker) gleichmäßig, auch wo sich der Strich selbst überlappt (Anfangskappe, Kehren, langsames Hin- und Herfahren); erst ein neuer Strich deckt stärker. Nach 255 Strichen setzt ein bildschirmfüllendes Dreieck den Stencil-Puffer zurück.
### 4.1 Bildebene
`image_layer.rs` zeichnet texturierte Vierecke unter der Tinte: importierte PDF-Seiten, eingefügte Bilder und Textfelder. Texturen werden beim ersten Bedarf geladen, bekommen Mipmaps und werden freigegeben, wenn mehr als 384 MB belegt sind.
- **PDF-Seiten** rendert ein Hintergrund-Thread mit `hayro` (längste Kante 2560 Pixel, beim starken Hineinzoomen 5120 Pixel). Bis die schärfere Fassung fertig ist, bleibt die vorhandene sichtbar.
- **Textfelder** werden mit `ab_glyph` gerastert, in einer Auflösung, die dem Zoom folgt (in Vierteln einer Zweierpotenz, höchstens 8 Pixel je Einheit). Der Texturschlüssel enthält einen Hash von Inhalt, Schrift und Farbe; jede Änderung erzeugt eine neue Textur.
### 4.2 Papier- und Musterfarben
Papierfarbe (`PaperTheme`) und Musterfarbe (`PatternColor`) stehen in `background.rs`; sie sind linear angegeben und werden im Shader nach sRGB umgerechnet. Tintenfarben (Striche, Text) sind sRGB-Werte – dieselben Zahlen landen im PDF und in den Farbpunkten der Oberfläche. Gezeichnet wird ohne sRGB-Umrechnung der GPU (`render_format`), Texturen sind `Rgba8Unorm`: So wird halbtransparente Tinte wie im PDF in sRGB gemischt und sieht am Bildschirm genauso aus wie im Export. Auf dunklem Papier kann der Shader dunkle Tinte aufhellen (`ink_params` in `CameraUniforms`), gespeichert werden immer die Originalfarben.
---
## 5. Objekte auf der Seite
Alles auf einer Seite ist ein `Stroke` (`document.rs`):
- **Tinte:** Abtastpunkte, Werkzeug (Stift, Textmarker, Form), Breite, Farbe; die Dreiecke werden beim Laden aus den Punkten neu berechnet.
- **Formen:** zusätzlich eine `RecognizedShape` (siehe Abschnitt 6).
- **Bilder und Textfelder:** vier Eckpunkte (oben links, oben rechts, unten rechts, unten links) plus Verweis auf die Bilddatei bzw. ein `TextBox`. Weil die Ecken normale Punkte sind, wirken Verschieben, Drehen und Skalieren der Auswahl ohne Sonderfälle.
### 5.1 Textfelder
`text.rs` setzt den Text selbst: Zeilenumbruch an Leerzeichen (zu lange Wörter zeichenweise), Unterschneidung, Tabulator = 4 Leerzeichen. Bildschirm-Textur, PDF-Export und Bearbeitung verwenden dieselben Schriften (Ubuntu Light und Hack aus den eingebauten `egui`-Schriften) und denselben Umbruch. Bearbeitet wird in einem `egui`-Textfeld an der Stelle des Felds; danach wird die Höhe des Felds an den Text angepasst, Lage, Drehung und Skalierung bleiben.
### 5.2 Rückgängig
Jede Seite hat einen eigenen Verlauf (`history.rs`): Hinzufügen, Entfernen, Ersetzen und Ändern von Strichen. Verschieben/Drehen/Skalieren einer Auswahl, ein Radierzug mit dem Teilstrich-Radierer und eine Textbearbeitung sind jeweils ein Schritt.
**Platz schaffen** (`Notebook::make_space`) betrifft mehrere Seiten: Die Seite, auf der angesetzt wurde, und die folgenden bis vor die nächste PDF-Seite gelten als durchgehende Spalte. Alles unterhalb der Ansatzhöhe rutscht um denselben Betrag; ein Objekt, das über ein Seitenende ragen würde, beginnt 10 mm unter dem Anfang der nächsten Seite, und alles danach rutscht entsprechend weiter. Fehlende Seiten werden eingefügt. Der Schritt (`SpaceShift`: jeder verschobene Strich vorher/nachher mit Seite) landet im Verlauf der Seite, auf der angesetzt wurde; `Notebook::undo`/`redo` buchen ihn über alle Seiten zurück (später geänderte Striche bleiben unberührt) und entfernen dafür angelegte, leer gebliebene Seiten wieder.
---
## 6. Formerkennung
`geometry.rs` erkennt Formen rein geometrisch, ohne Machine Learning. Zuerst wird auf ein Achsenkreuz geprüft (alle Teilstücke liegen auf einer waagerechten und einer senkrechten Linie, beide reichen auf beiden Seiten über den Schnittpunkt hinaus). Geschlossene Formen (Kreis, Ellipse, Dreieck, Rechteck, Raute) treten gegeneinander an; gewählt wird die, deren Umriss am besten zu den Punkten passt. Danach folgen Vielecke, Pfeile, Koordinatensysteme und Linien. Übernommen wird eine Form nur, wenn der Strich im Mittel höchstens 4 % seiner Diagonale von ihr abweicht – sonst bleibt er Handschrift.
- **Linie:** Verhältnis von Abstand Anfang–Ende zur Strichlänge über 0,92.
- **Kreis, Ellipse:** Kreis- bzw. Ellipsen-Fit über die Punkte.
- **Rechteck, Dreieck, Raute, Vieleck, Pfeil:** Analyse der mit Douglas-Peucker vereinfachten Kontur (Eckenzahl, Winkel, Seitenverhältnisse).
- **Koordinatensystem:** zwei etwa rechtwinklige Achsen; der Winkel wird auf 90° gesetzt.
- **Achsenkreuz:** ein „+“ in einem Zug; die Achsen werden waagerecht und senkrecht ausgerichtet und bekommen an allen vier Enden Pfeilspitzen.
- **Oktaeder:** Skizze einer Doppelpyramide als 3D-Kantenmodell; nur, wenn kein einfacher Umriss passt.
**Halten am Strichende:** Bleibt der Stift über 400 ms innerhalb von 3 Pixeln ruhig, wird die erkannte Form schon vor dem Absetzen angezeigt und beim Absetzen übernommen – in der Breite des gewählten Stifts bzw. Textmarkers; ein eingerasteter Textmarker bleibt Textmarker. Mit dem Formen-Werkzeug wird jeder Strich beim Absetzen geprüft.
---
## 7. Speichern
```
[Änderung]
│ mark_page_dirty / mark_manifest_dirty
▼
[AutosaveController] ── 1,5 s nach der letzten Änderung, spätestens nach 10 s ──► [Autosave-Thread]
│
▼
[temporäre Datei schreiben + fsync]
│
▼
[rename über die alte Datei, Ordner-fsync]
```
- Der Haupt-Thread schreibt nie selbst; er übergibt fertige Seitendaten an den Autosave-Thread.
- Schlägt ein Schreibvorgang fehl, wird die Seite mit wachsendem Abstand (1 s bis 30 s) erneut versucht; der Status wird rot.
- `flush()` schreibt alles Ausstehende und wartet darauf. Es läuft bei `Strg + S`, beim Wechsel des Notizbuchs und beim Beenden. Schlägt es beim Beenden fehl, bleibt die App offen und bietet Wiederholen oder einen Notfall-Export der ungespeicherten Seiten an (`~/.local/share/ink-notes/recovery/`).
- Seiten werden erst beim Anzeigen geladen (Lazy Loading); Titel, Hintergrund und Größe stehen im Manifest, damit Seitenübersicht und durchgehende Ansicht sie vorher kennen.
- Ein zweites Fenster mit demselben Notizbuch öffnet es schreibgeschützt (Sperrdatei).
---
## 8. Export
`export.rs` erzeugt das PDF mit `printpdf`. Jede Seite bekommt ihre eigene Größe und diese Ebenen:
1. **Background:** Karo, Linien oder Punkte in der gewählten Musterfarbe (immer auf weißem Papier).
2. **PDF** (nur im Notfall): importierte PDF-Seite als Bild, falls sie sich nicht als Vektor übernehmen lässt.
3. **Images:** eingefügte Bilder (höchstens 300 dpi).
4. **Text:** Textfelder als echter Text mit eingebetteter Schrift (durchsuchbar, kopierbar). Jedes Feld liegt in einem eigenen Koordinatensystem, das eine Transformationsmatrix auf seine Ecken abbildet.
5. **Ink:** Striche als gefüllte Polygone, Formen als Pfade. Halbtransparente Striche (Textmarker) bekommen einen Grafikzustand mit Deckkraft (`ca`/`CA`); printpdf kann solche Zustände nicht anlegen, deshalb trägt `add_alpha_states` sie nach dem Speichern mit `lopdf` in die Seitenressourcen ein. Jeder Strich ist ein einziger Pfad und deckt sich daher nicht selbst doppelt.
Importierte PDF-Seiten werden anschließend als Form-XObject unter die Seite gelegt (`pdf_merge.rs`), bleiben also Vektorgrafik. Seitentitel werden zu Lesezeichen.
PNG und SVG einer Seite (`page_export.rs`) entstehen aus diesem PDF: `hayro` rastert es bzw. wandelt es in SVG um. So sehen alle Exporte gleich aus.
---
## 9. Updates
`updater.rs` fragt die Gitea-Releases ab, vergleicht die Version (SemVer) und lädt auf Wunsch das passende Paket. Vor der Installation prüft er die Minisign-Signatur von `SHA256SUMS.txt` gegen den eingebauten Schlüssel und danach die SHA-256-Prüfsumme des Pakets. Ein AppImage wird per `rename` ersetzt; ein `.deb`-Paket wird mit `xdg-open` an die Softwareinstallation des Systems übergeben. Details im [README](../README.de.md#sicherheit-der-updates) unter „Sicherheit der Updates“.
+111 -100
View File
@@ -1,195 +1,206 @@
# Architektur von ink-notes # ink-notes architecture
Dieses Dokument beschreibt, wie ink-notes intern aufgebaut ist: Eingabe und Strichverarbeitung, Rendering, Seiten und Ansicht, Formerkennung, Speichern und Export. Das Speicherformat steht in [`FILE_FORMAT.md`](FILE_FORMAT.md). **English** · [Deutsch](ARCHITECTURE.de.md)
This document describes how ink-notes is built internally: input and stroke processing, rendering, pages and view, shape recognition, saving and export. The storage format is described in [`FILE_FORMAT.md`](FILE_FORMAT.md).
--- ---
## 1. Überblick ## 1. Overview
ink-notes ist ein einzelnes Programm mit einem Event-Loop (`winit`) im Haupt-Thread. Dazu kommen drei Hintergrund-Threads: ink-notes is a single program with an event loop (`winit`) on the main thread, plus three background threads:
| Thread | Aufgabe | | Thread | Task |
| :--- | :--- | | :--- | :--- |
| Haupt-Thread | Eingaben, Datenmodell, Rendering (`wgpu`), Oberfläche (`egui`) | | Main thread | Input, data model, rendering (`wgpu`), interface (`egui`) |
| Autosave | Seiten und Manifest auf die Festplatte schreiben (`autosave.rs`) | | Autosave | Writes pages and manifest to disk (`autosave.rs`) |
| PDF-Rendering | Importierte PDF-Seiten zu Texturen rendern (`pdf_doc.rs`, `hayro`) | | PDF rendering | Renders imported PDF pages to textures (`pdf_doc.rs`, `hayro`) |
| Export | PDF/PNG/SVG-Export, damit die Oberfläche nicht hängt (`main.rs`) | | Export | PDF/PNG/SVG export, so the interface does not freeze (`main.rs`) |
Stifteingaben kommen über `octotablet` (Tablet-Protokoll von Wayland bzw. X11), Maus und Touch über `winit`. Die Oberfläche (Werkzeugleiste, Menüs, Dialoge, Textbearbeitung) ist mit `egui` gebaut und wird im selben Render-Durchgang über das Blatt gezeichnet. Pen input arrives via `octotablet` (the Wayland or X11 tablet protocol), mouse and touch via `winit`. The interface (toolbar, menus, dialogs, text editing) is built with `egui` and drawn over the page in the same render pass.
--- ---
## 2. Vom Stift zum Strich ## 2. From pen to stroke
``` ```
[Stift / Touch / Maus] [Pen / touch / mouse]
│ Bildschirmposition, Druck, Zeit │ screen position, pressure, time
▼ ▼
[Bildschirm → Seitenkoordinaten] (viewport.rs) [Screen → page coordinates] (viewport.rs)
│ │
▼ ▼
[Druckkurve] (settings.rs, vom Nutzer einstellbar) [Pressure curve] (settings.rs, user-adjustable)
│ │
▼ ▼
[1€-Filter] (filter.rs) [1€ filter] (filter.rs)
│ │
▼ ▼
[Catmull-Rom-Spline + Dreiecksstreifen] (spline.rs) → Live-Puffer auf der GPU [Triangle strip as for the final stroke] (spline.rs) → live buffer on the GPU
│ Stift abgehoben │ pen lifted
▼ ▼
[Nachbearbeitung] (spline.rs: finalize_stroke) [Post-processing] (spline.rs: finalize_stroke)
│ │
▼ ▼
[Strich auf der Seite + Undo-Eintrag] (document.rs, history.rs) [Stroke on the page + undo entry] (document.rs, history.rs)
``` ```
### 2.1 1€-Filter ### 2.1 1€ filter
Langsame Bewegungen zittern, schnelle sollen nicht nachhinken. Der 1€-Filter (`filter.rs`) passt seine Grenzfrequenz an die Geschwindigkeit an: Slow movements jitter, fast ones should not lag behind. The 1€ filter (`filter.rs`) adapts its cutoff frequency to the speed:
$$f_c = f_{c,\min} + \beta \cdot |\dot{x}|$$ $$f_c = f_{c,\min} + \beta \cdot |\dot{x}|$$
Bei langsamer Bewegung glättet er stark, bei schneller kaum. It smooths strongly during slow movement and hardly at all during fast movement.
### 2.2 Zentripetaler Catmull-Rom-Spline ### 2.2 Centripetal Catmull-Rom spline
Die gefilterten Punkte werden mit einem zentripetalen Catmull-Rom-Spline ($\alpha = 0{,}5$) interpoliert: The filtered points are interpolated with a centripetal Catmull-Rom spline ($\alpha = 0.5$):
$$t_{i+1} = t_i + \lVert P_{i+1} - P_i \rVert^{\alpha}$$ $$t_{i+1} = t_i + \lVert P_{i+1} - P_i \rVert^{\alpha}$$
Diese Parametrisierung erzeugt bei engen Kurven keine Schlaufen oder Spitzen und ist an den Stützstellen $C^1$-stetig. This parametrization does not create loops or cusps in tight curves and is $C^1$-continuous at the control points.
### 2.3 Dreiecksstreifen ### 2.3 Triangle strip
Aus den Spline-Punkten mit Radius $r_i$ (aus Druck und Strichstärke) entsteht ein Dreiecksstreifen: From the spline points with radius $r_i$ (from pressure and stroke width), a triangle strip is built:
$$V_{i,\text{links}} = P_i + N_i \cdot r_i, \qquad V_{i,\text{rechts}} = P_i - N_i \cdot r_i$$ $$V_{i,\text{left}} = P_i + N_i \cdot r_i, \qquad V_{i,\text{right}} = P_i - N_i \cdot r_i$$
$N_i$ ist die Normale der Laufrichtung; an inneren Punkten wird über benachbarte Segmente gemittelt. $N_i$ is the normal of the direction of travel; at inner points it is averaged over neighboring segments.
### 2.4 Zwei Stufen ### 2.4 Two stages
1. **Während des Schreibens:** Filter und Spline arbeiten nur mit bisherigen Punkten; der Strich wird sofort in einen eigenen Live-Puffer geschrieben. 1. **While writing:** The filter only works with the points so far; with every new point the stroke is written into its own live buffer using the same tessellation as when the pen is lifted (thinning, corners, round caps), so it does not jump when the pen lifts.
2. **Beim Absetzen** wird der ganze Strich noch einmal bearbeitet (`finalize_stroke`): 2. **When the pen is lifted**, the whole stroke is processed once more (`finalize_stroke`):
- Druckverlauf mit dem Fenster $[0{,}25;\ 0{,}5;\ 0{,}25]$ glätten, - smooth the pressure curve with the window $[0.25;\ 0.5;\ 0.25]$,
- Punkte mit Ramer-Douglas-Peucker ausdünnen ($\varepsilon = 0{,}75$ Einheiten $= 0{,}075$ mm), - thin out points with Ramer-Douglas-Peucker ($\varepsilon = 0.75$ units $= 0.075$ mm),
- an Knicken über 65° den Spline auftrennen, damit Ecken nicht abgerundet werden, - split the spline at bends over 65° so corners stay sharp, and join the parts round (a circle around the bend) so the tip does not look cut off,
- runde Endkappen anfügen. - add round end caps.
Danach wird der Live-Puffer geleert und der Strich in die Seite übernommen. Then the live buffer is cleared and the stroke is added to the page.
### 2.5 Lineal ### 2.5 Ruler
Beginnt ein Strich nahe einer Linealkante (`ruler.rs`), werden alle seine Punkte auf diese Kante projiziert, bis der Stift abgehoben wird. If a stroke starts near a ruler edge (`ruler.rs`), all its points are projected onto that edge until the pen is lifted.
--- ---
## 3. Koordinaten, Seiten und Ansicht ## 3. Coordinates, pages and view
### 3.1 Seitenkoordinaten ### 3.1 Page coordinates
Gerechnet wird in Seiteneinheiten: **10 Einheiten = 1 mm**. Eine DIN-A4-Seite ist 2100 × 2970 Einheiten groß. Jede Seite hat ihre eigene Größe (`Page::size`): Standard ist A4 hoch; beim PDF-Import bekommen Querformat-Seiten 2970 Einheiten Breite und eine Höhe nach Seitenverhältnis (`document::pdf_page_size`). Neue Seiten übernehmen die Größe der aktuellen. Everything is computed in page units: **10 units = 1 mm**. An A4 page is 2100 × 2970 units. Each page has its own size (`Page::size`): the default is A4 portrait; on PDF import, landscape pages get a width of 2970 units and a height according to the aspect ratio (`document::pdf_page_size`). New pages take over the size of the current one.
### 3.2 Maßstab und Kamera ### 3.2 Scale and camera
- `PageViewport` (`document.rs`) passt einen Rahmen, in den alle Seiten des Notizbuchs passen (`Notebook::fit_box`), ins Fenster ein. Daraus ergibt sich ein Maßstab (Bildschirmpixel je Einheit), der für alle Seiten gilt. Die aktuelle Seite liegt waagerecht zentriert und oben im Rahmen. Den Platz der Werkzeugleiste rechnet der Viewport heraus. - `PageViewport` (`document.rs`) fits a box that holds all pages of the notebook (`Notebook::fit_box`) into the window. This yields one scale (screen pixels per unit) that applies to all pages. The current page is centered horizontally and placed at the top of the box. The viewport subtracts the space taken by the toolbar.
- `ViewportState` (`viewport.rs`) ergänzt Zoom (25 % bis 1000 %) und Verschiebung (Pan). Beim Zoomen bleibt der Punkt unter dem Mauszeiger stehen: - `ViewportState` (`viewport.rs`) adds zoom (25 % to 1000 %) and panning. When zooming, the point under the mouse cursor stays in place:
$$\vec{p}_{\text{neu}} = \vec{c} - (\vec{c} - \vec{p}_{\text{alt}}) \cdot \frac{z_{\text{neu}}}{z_{\text{alt}}}$$ $$\vec{p}_{\text{new}} = \vec{c} - (\vec{c} - \vec{p}_{\text{old}}) \cdot \frac{z_{\text{new}}}{z_{\text{old}}}$$
- Im Shader wird jeder Punkt mit `transform_scale` und `transform_offset` (aus `CameraUniforms`) direkt in Gerätekoordinaten umgerechnet. - In the shader, every point is converted directly to device coordinates with `transform_scale` and `transform_offset` (from `CameraUniforms`).
### 3.3 Durchgehende Ansicht ### 3.3 Continuous view
Alle Koordinaten beziehen sich auf die **aktuelle Seite**. `PageStack` (`viewport.rs`) legt die übrigen Seiten lückenlos darunter bzw. darüber (die Grenze zeichnet die Oberfläche als dunkle Linie), waagerecht zur aktuellen Seite zentriert. Jede sichtbare Nachbarseite wird mit einer eigenen Kamera gezeichnet, die um die Lage der Seite verschoben ist (höchstens 12 Nachbarseiten). Whether a notebook is shown page by page, continuously or as an endless canvas is stored in its manifest (`layout`, see [`FILE_FORMAT.md`](FILE_FORMAT.md)).
Nach dem Scrollen wird die Seite in der Fenstermitte zur aktuellen Seite; die Kamera wird dabei so verschoben, dass sich das Bild nicht bewegt. Während eines Strichs, einer Auswahl, beim Ziehen des Lineals oder beim Schreiben von Text wird nicht gewechselt. Wer auf einer sichtbaren Nachbarseite zu zeichnen beginnt, wechselt zuerst auf diese Seite. All coordinates refer to the **current page**. `PageStack` (`viewport.rs`) places the other pages seamlessly below and above it (the interface draws the boundary as a dark line), centered horizontally on the current page. Each visible neighboring page is drawn with its own camera, shifted by the page's position (at most 12 neighboring pages).
After scrolling, the page in the middle of the window becomes the current page; the camera is shifted so that the picture does not move. There is no switch during a stroke, a selection, while dragging the ruler or while typing text. Starting to draw on a visible neighboring page switches to that page first.
A quick finger swipe keeps gliding (`Fling` in `viewport.rs`): the speed is measured over the last 100 ms before the finger lifts and then decays exponentially. The scrollbar on the edge opposite the toolbar jumps directly to a position in the notebook.
--- ---
### 3.4 Endless canvas
An endless notebook has a single page of 10 × 10 m (`ENDLESS_PAGE_SIZE`). This keeps everything the same as with any other page – input, hit tests, undo, saving – only the edge is practically unreachable. `PageViewport::calculate_endless` chooses scale and position as if an A4 page lay at the starting area (`ENDLESS_HOME`, an A4 frame in the middle); 100 % zoom and "Reset view" therefore show this frame. Images and text boxes base their size and wrapping on A4 (`Page::reference_size`, `Page::text_right_edge`).
When switching to and from pages (`Notebook::make_endless`/`make_paged`), the content is shifted by `ENDLESS_HOME` and the page's history is cleared. Switching back to pages is only possible while everything lies within the starting area. Export and images of a selection only rasterize or export a section (`Page::cropped`), aligned to the pattern grid on export (`Page::endless_export_bounds`).
## 4. Rendering ## 4. Rendering
Ein Frame besteht aus einem Render-Durchgang auf die Fensterfläche (`canvas.rs`): A frame consists of one render pass onto the window surface (`canvas.rs`):
1. Für jede sichtbare Nachbarseite: Papier und Muster, Bilder/PDF-Seite/Text, Tinte. 1. For each visible neighboring page: paper and pattern, images/PDF page/text, ink.
2. Für die aktuelle Seite: Papier, Muster (Karo, Linien, Punkte), Bildebene, gespeicherte Striche, Live-Strich. 2. For the current page: paper, pattern (squares, lines, dots), image layer, stored strokes, live stroke.
3. Die `egui`-Oberfläche: Werkzeugleiste, Menüs, Seitengrenzen, Auswahlrahmen und Griffe, Lineal, Textbearbeitung. 3. The `egui` interface: toolbar, menus, page boundaries, selection frame and handles, ruler, text editing.
Jeder Strich ist ein eigener Draw-Call mit eigener Nummer im Stencil-Puffer: Ein Pixel, das der Strich schon gefärbt hat, wird kein zweites Mal gefärbt. So bleibt halbtransparente Tinte (Textmarker) gleichmäßig, auch wo sich der Strich selbst überlappt (Anfangskappe, Kehren, langsames Hin- und Herfahren); erst ein neuer Strich deckt stärker. Nach 255 Strichen setzt ein bildschirmfüllendes Dreieck den Stencil-Puffer zurück. Each stroke is its own draw call with its own number in the stencil buffer: a pixel the stroke has already colored is not colored a second time. This keeps semi-transparent ink (highlighter) even where the stroke overlaps itself (start cap, turns, slow back-and-forth); only a new stroke covers more strongly. After 255 strokes, a full-screen triangle resets the stencil buffer.
### 4.1 Bildebene ### 4.1 Image layer
`image_layer.rs` zeichnet texturierte Vierecke unter der Tinte: importierte PDF-Seiten, eingefügte Bilder und Textfelder. Texturen werden beim ersten Bedarf geladen, bekommen Mipmaps und werden freigegeben, wenn mehr als 384 MB belegt sind. `image_layer.rs` draws textured quads below the ink: imported PDF pages, inserted images and text boxes. Textures are loaded when first needed, get mipmaps, and are released when more than 384 MB are in use.
- **PDF-Seiten** rendert ein Hintergrund-Thread mit `hayro` (längste Kante 2560 Pixel, beim starken Hineinzoomen 5120 Pixel). Bis die schärfere Fassung fertig ist, bleibt die vorhandene sichtbar. - **PDF pages** are rendered by a background thread with `hayro` (longest edge 2560 pixels, 5120 pixels when zoomed in strongly). Until the sharper version is ready, the existing one stays visible.
- **Textfelder** werden mit `ab_glyph` gerastert, in einer Auflösung, die dem Zoom folgt (in Vierteln einer Zweierpotenz, höchstens 8 Pixel je Einheit). Der Texturschlüssel enthält einen Hash von Inhalt, Schrift und Farbe; jede Änderung erzeugt eine neue Textur. - **Text boxes** are rasterized with `ab_glyph` at a resolution that follows the zoom (in quarters of a power of two, at most 8 pixels per unit). The texture key contains a hash of content, font and color; every change creates a new texture.
### 4.2 Papier- und Musterfarben ### 4.2 Paper and pattern colors
Papierfarbe (`PaperTheme`) und Musterfarbe (`PatternColor`) stehen in `background.rs`; sie sind linear angegeben und werden im Shader nach sRGB umgerechnet. Tintenfarben (Striche, Text) sind sRGB-Werte – dieselben Zahlen landen im PDF und in den Farbpunkten der Oberfläche. Gezeichnet wird ohne sRGB-Umrechnung der GPU (`render_format`), Texturen sind `Rgba8Unorm`: So wird halbtransparente Tinte wie im PDF in sRGB gemischt und sieht am Bildschirm genauso aus wie im Export. Auf dunklem Papier kann der Shader dunkle Tinte aufhellen (`ink_params` in `CameraUniforms`), gespeichert werden immer die Originalfarben. Paper color (`PaperTheme`) and pattern color (`PatternColor`) are defined in `background.rs`; they are given in linear space and converted to sRGB in the shader. Ink colors (strokes, text) are sRGB values – the same numbers end up in the PDF and in the color dots of the interface. Drawing happens without the GPU's sRGB conversion (`render_format`), and textures are `Rgba8Unorm`: this way semi-transparent ink is blended in sRGB like in the PDF and looks the same on screen as in the export. On dark paper the shader can brighten dark ink (`ink_params` in `CameraUniforms`); the original colors are always what gets saved.
--- ---
## 5. Objekte auf der Seite ## 5. Objects on the page
Alles auf einer Seite ist ein `Stroke` (`document.rs`): Everything on a page is a `Stroke` (`document.rs`):
- **Tinte:** Abtastpunkte, Werkzeug (Stift, Textmarker, Form), Breite, Farbe; die Dreiecke werden beim Laden aus den Punkten neu berechnet. - **Ink:** sample points, tool (pen, highlighter, shape), width, color; the triangles are recomputed from the points when loading.
- **Formen:** zusätzlich eine `RecognizedShape` (siehe Abschnitt 6). - **Shapes:** additionally a `RecognizedShape` (see section 6).
- **Bilder und Textfelder:** vier Eckpunkte (oben links, oben rechts, unten rechts, unten links) plus Verweis auf die Bilddatei bzw. ein `TextBox`. Weil die Ecken normale Punkte sind, wirken Verschieben, Drehen und Skalieren der Auswahl ohne Sonderfälle. - **Images and text boxes:** four corner points (top left, top right, bottom right, bottom left) plus a reference to the image file or a `TextBox`. Because the corners are ordinary points, moving, rotating and scaling a selection work without special cases.
### 5.1 Textfelder ### 5.1 Text boxes
`text.rs` setzt den Text selbst: Zeilenumbruch an Leerzeichen (zu lange Wörter zeichenweise), Unterschneidung, Tabulator = 4 Leerzeichen. Bildschirm-Textur, PDF-Export und Bearbeitung verwenden dieselben Schriften (Ubuntu Light und Hack aus den eingebauten `egui`-Schriften) und denselben Umbruch. Bearbeitet wird in einem `egui`-Textfeld an der Stelle des Felds; danach wird die Höhe des Felds an den Text angepasst, Lage, Drehung und Skalierung bleiben. `text.rs` lays out the text itself: line wrapping at spaces (overlong words character by character), kerning, tab = 4 spaces. The screen texture, the PDF export and editing use the same fonts (Ubuntu Light and Hack from the fonts built into `egui`) and the same wrapping. Editing happens in an `egui` text field at the position of the box; afterwards the box height is adapted to the text, while position, rotation and scaling stay.
### 5.2 Rückgängig ### 5.2 Undo
Jede Seite hat einen eigenen Verlauf (`history.rs`): Hinzufügen, Entfernen, Ersetzen und Ändern von Strichen. Verschieben/Drehen/Skalieren einer Auswahl, ein Radierzug mit dem Teilstrich-Radierer und eine Textbearbeitung sind jeweils ein Schritt. Each page has its own history (`history.rs`): adding, removing, replacing and modifying strokes. Moving/rotating/scaling a selection, one stroke of the partial-stroke eraser and one text edit are each a single step.
**Platz schaffen** (`Notebook::make_space`) betrifft mehrere Seiten: Die Seite, auf der angesetzt wurde, und die folgenden bis vor die nächste PDF-Seite gelten als durchgehende Spalte. Alles unterhalb der Ansatzhöhe rutscht um denselben Betrag; ein Objekt, das über ein Seitenende ragen würde, beginnt 10 mm unter dem Anfang der nächsten Seite, und alles danach rutscht entsprechend weiter. Fehlende Seiten werden eingefügt. Der Schritt (`SpaceShift`: jeder verschobene Strich vorher/nachher mit Seite) landet im Verlauf der Seite, auf der angesetzt wurde; `Notebook::undo`/`redo` buchen ihn über alle Seiten zurück (später geänderte Striche bleiben unberührt) und entfernen dafür angelegte, leer gebliebene Seiten wieder. **Make space** (`Notebook::make_space`) affects several pages: the page where you started and the following ones up to the next PDF page count as one continuous column. Everything below the starting height moves by the same amount; an object that would extend past the end of a page starts 10 mm below the top of the next page, and everything after it moves further accordingly. Missing pages are inserted. The step (`SpaceShift`: every moved stroke before/after, with its page) goes into the history of the page where you started; `Notebook::undo`/`redo` apply it back across all pages (strokes changed later stay untouched) and remove pages that were created for it and stayed empty.
--- ---
## 6. Formerkennung ## 6. Shape recognition
`geometry.rs` erkennt Formen rein geometrisch, ohne Machine Learning. Zuerst wird auf ein Achsenkreuz geprüft (alle Teilstücke liegen auf einer waagerechten und einer senkrechten Linie, beide reichen auf beiden Seiten über den Schnittpunkt hinaus). Geschlossene Formen (Kreis, Ellipse, Dreieck, Rechteck, Raute) treten gegeneinander an; gewählt wird die, deren Umriss am besten zu den Punkten passt. Danach folgen Vielecke, Pfeile, Koordinatensysteme und Linien. Übernommen wird eine Form nur, wenn der Strich im Mittel höchstens 4 % seiner Diagonale von ihr abweicht – sonst bleibt er Handschrift. `geometry.rs` recognizes shapes purely geometrically, without machine learning. It first checks for an axis cross (all segments lie on one horizontal and one vertical line, both extending past the intersection on both sides). Closed shapes (circle, ellipse, triangle, rectangle, rhombus) compete with each other; the one whose outline fits the points best wins. Then polygons, arrows, coordinate systems and lines follow. A shape is only accepted if the stroke deviates from it by at most 4 % of its diagonal on average – otherwise it stays handwriting.
- **Linie:** Verhältnis von Abstand Anfang–Ende zur Strichlänge über 0,92. - **Line:** ratio of start–end distance to stroke length above 0.92.
- **Kreis, Ellipse:** Kreis- bzw. Ellipsen-Fit über die Punkte. - **Circle, ellipse:** circle or ellipse fit through the points.
- **Rechteck, Dreieck, Raute, Vieleck, Pfeil:** Analyse der mit Douglas-Peucker vereinfachten Kontur (Eckenzahl, Winkel, Seitenverhältnisse). - **Rectangle, triangle, rhombus, polygon, arrow:** analysis of the contour simplified with Douglas-Peucker (number of corners, angles, aspect ratios).
- **Koordinatensystem:** zwei etwa rechtwinklige Achsen; der Winkel wird auf 90° gesetzt. - **Coordinate system:** two roughly perpendicular axes; the angle is set to 90°.
- **Achsenkreuz:** ein „+“ in einem Zug; die Achsen werden waagerecht und senkrecht ausgerichtet und bekommen an allen vier Enden Pfeilspitzen. - **Axis cross:** a "+" in one stroke; the axes are aligned horizontally and vertically and get arrowheads at all four ends.
- **Oktaeder:** Skizze einer Doppelpyramide als 3D-Kantenmodell; nur, wenn kein einfacher Umriss passt. - **Octahedron:** sketch of a double pyramid as a 3D edge model; only if no simple outline fits.
**Halten am Strichende:** Bleibt der Stift über 400 ms innerhalb von 3 Pixeln ruhig, wird die erkannte Form schon vor dem Absetzen angezeigt und beim Absetzen übernommen. Mit dem Formen-Werkzeug wird jeder Strich beim Absetzen geprüft. **Holding at the end of a stroke:** If the pen stays within 3 pixels for more than 400 ms, the recognized shape is shown before lifting and taken over when the pen is lifted. It uses the width of the selected pen or highlighter; a snapped highlighter stays a highlighter. With the shape tool, every stroke is checked when the pen is lifted.
--- ---
## 7. Speichern ## 7. Saving
``` ```
[Änderung] [Change]
│ mark_page_dirty / mark_manifest_dirty │ mark_page_dirty / mark_manifest_dirty
▼ ▼
[AutosaveController] ── 1,5 s nach der letzten Änderung, spätestens nach 10 s ──► [Autosave-Thread] [AutosaveController] ── 1.5 s after the last change, at the latest after 10 s ──► [Autosave thread]
│ │
▼ ▼
[temporäre Datei schreiben + fsync] [write temporary file + fsync]
│ │
▼ ▼
[rename über die alte Datei, Ordner-fsync] [rename over the old file, folder fsync]
``` ```
- Der Haupt-Thread schreibt nie selbst; er übergibt fertige Seitendaten an den Autosave-Thread. - The main thread never writes itself; it hands finished page data to the autosave thread.
- Schlägt ein Schreibvorgang fehl, wird die Seite mit wachsendem Abstand (1 s bis 30 s) erneut versucht; der Status wird rot. - If a write fails, the page is retried with increasing delay (1 s to 30 s); the status turns red.
- `flush()` schreibt alles Ausstehende und wartet darauf. Es läuft bei `Strg + S`, beim Wechsel des Notizbuchs und beim Beenden. Schlägt es beim Beenden fehl, bleibt die App offen und bietet Wiederholen oder einen Notfall-Export der ungespeicherten Seiten an (`~/.local/share/ink-notes/recovery/`). - `flush()` writes everything pending and waits for it. It runs on `Ctrl + S`, when switching notebooks and when quitting. If it fails when quitting, the app stays open and offers to retry or to make an emergency export of the unsaved pages (`~/.local/share/ink-notes/recovery/`).
- Seiten werden erst beim Anzeigen geladen (Lazy Loading); Titel, Hintergrund und Größe stehen im Manifest, damit Seitenübersicht und durchgehende Ansicht sie vorher kennen. - Pages are only loaded when shown (lazy loading); title, background and size are in the manifest, so the page overview and the continuous view know them in advance.
- Ein zweites Fenster mit demselben Notizbuch öffnet es schreibgeschützt (Sperrdatei). - A second window with the same notebook opens it read-only (lock file).
--- ---
## 8. Export ## 8. Export
`export.rs` erzeugt das PDF mit `printpdf`. Jede Seite bekommt ihre eigene Größe und diese Ebenen: `export.rs` creates the PDF with `printpdf`. Each page gets its own size and these layers:
1. **Background:** Karo, Linien oder Punkte in der gewählten Musterfarbe (immer auf weißem Papier). 1. **Background:** squares, lines or dots in the chosen pattern color (always on white paper).
2. **PDF** (nur im Notfall): importierte PDF-Seite als Bild, falls sie sich nicht als Vektor übernehmen lässt. 2. **PDF** (fallback only): imported PDF page as an image, if it cannot be taken over as vectors.
3. **Images:** eingefügte Bilder (höchstens 300 dpi). 3. **Images:** inserted images (at most 300 dpi).
4. **Text:** Textfelder als echter Text mit eingebetteter Schrift (durchsuchbar, kopierbar). Jedes Feld liegt in einem eigenen Koordinatensystem, das eine Transformationsmatrix auf seine Ecken abbildet. 4. **Text:** text boxes as real text with embedded font (searchable, copyable). Each box lives in its own coordinate system that a transformation matrix maps onto its corners.
5. **Ink:** Striche als gefüllte Polygone, Formen als Pfade. Halbtransparente Striche (Textmarker) bekommen einen Grafikzustand mit Deckkraft (`ca`/`CA`); printpdf kann solche Zustände nicht anlegen, deshalb trägt `add_alpha_states` sie nach dem Speichern mit `lopdf` in die Seitenressourcen ein. Jeder Strich ist ein einziger Pfad und deckt sich daher nicht selbst doppelt. 5. **Ink:** strokes as filled polygons, shapes as paths. Semi-transparent strokes (highlighter) get a graphics state with opacity (`ca`/`CA`); printpdf cannot create such states, so `add_alpha_states` adds them to the page resources with `lopdf` after saving. Each stroke is a single path and therefore does not cover itself twice.
Importierte PDF-Seiten werden anschließend als Form-XObject unter die Seite gelegt (`pdf_merge.rs`), bleiben also Vektorgrafik. Seitentitel werden zu Lesezeichen. Imported PDF pages are then placed under the page as a form XObject (`pdf_merge.rs`), so they stay vector graphics. Page titles become bookmarks.
PNG und SVG einer Seite (`page_export.rs`) entstehen aus diesem PDF: `hayro` rastert es bzw. wandelt es in SVG um. So sehen alle Exporte gleich aus. PNG and SVG of a page (`page_export.rs`) are created from this PDF: `hayro` rasterizes it or converts it to SVG. This way all exports look the same.
--- ---
## 9. Updates ## 9. Updates
`updater.rs` fragt die Gitea-Releases ab, vergleicht die Version (SemVer) und lädt auf Wunsch das passende Paket. Vor der Installation prüft er die Minisign-Signatur von `SHA256SUMS.txt` gegen den eingebauten Schlüssel und danach die SHA-256-Prüfsumme des Pakets. Ein AppImage wird per `rename` ersetzt; ein `.deb`-Paket wird mit `xdg-open` an die Softwareinstallation des Systems übergeben. Details im README unter „Sicherheit der Updates“. `updater.rs` queries the Gitea releases, compares the version (SemVer) and downloads the matching package on request. Before installing, it verifies the Minisign signature of `SHA256SUMS.txt` against the built-in key and then the SHA-256 checksum of the package. An AppImage is replaced via `rename`; a `.deb` package is handed to the system's software installer with `xdg-open`. Details in the [README](../README.md#update-security) under "Update security".
+199
View File
@@ -0,0 +1,199 @@
# Dateiformat von ink-notes
[English](FILE_FORMAT.md) · **Deutsch**
Dieses Dokument beschreibt, wo und wie ink-notes Notizbücher speichert, und die Versionen des Dateiformats. Aktuell ist **Version 9**.
---
## 1. Ordner
```text
~/.local/share/ink-notes/ ($XDG_DATA_HOME/ink-notes)
├── notebooks/
│ ├── library.json Ordner der Notizbücher (siehe unten)
│ ├── <notizbuch-id>/
│ │ ├── manifest.json Metadaten und Seitenreihenfolge
│ │ ├── manifest.json.bak Kopie des vorherigen Manifests
│ │ ├── page_1.json eine Datei je Seite
│ │ ├── page_2.json
│ │ ├── page_7.json.corrupt-<ts> beiseitegelegte beschädigte Seite (falls vorgekommen)
│ │ ├── assets/ eingefügte Bilder und importierte PDFs
│ │ │ ├── <sha256>.png
│ │ │ └── <sha256>.pdf
│ │ └── .lock Sperrdatei, solange das Notizbuch geöffnet ist
│ └── .trash/ gelöschte Notizbücher (<id>_<zeitstempel>)
└── recovery/ Notfall-Sicherungen ungespeicherter Seiten
~/.config/ink-notes/ui.json ($XDG_CONFIG_HOME) Einstellungen der Oberfläche
```
- **Notizbuch-IDs** werden aus dem Titel und dem Zeitpunkt gebildet (z. B. `mathematik_1775460000`); das erste Notizbuch heißt `default`.
- **Assets** sind nach dem SHA-256-Hash ihres Inhalts benannt; dasselbe Bild wird nur einmal gespeichert. Bilder werden als PNG oder (unverändert) JPEG abgelegt, PDFs unverändert.
- Alle Dateien werden mit Rechten `0600`, Ordner mit `0700` angelegt.
### Ordner (`library.json`)
Ordner wie „3. Semester › Mathe“ sind rein logisch: Die Notizbücher bleiben in ihren Verzeichnissen, `library.json` hält nur fest, welcher Ordner welches Notizbuch enthält. Umbenennen, Verschieben (samt Inhalt) und Löschen von Ordnern ändern deshalb nie eine Notizbuch-Datei.
```json
{
"folders": ["3. Semester", "3. Semester/Mathe", "4. Semester"],
"notebooks": { "analysis_1775460000": "3. Semester/Mathe" }
}
```
- Ordner sind Pfade mit `/` als Trenner; Namen enthalten selbst kein `/` und höchstens 80 Zeichen.
- Notizbücher ohne Eintrag liegen auf der obersten Ebene.
- Wird ein Ordner gelöscht, rücken seine Notizbücher und Unterordner eine Ebene nach oben.
- Fehlt die Datei oder ist sie unlesbar, liegen alle Notizbücher auf der obersten Ebene; fehlende übergeordnete Ordner werden beim Laden ergänzt.
---
## 2. Manifest (`manifest.json`)
```json
{
"format_version": 9,
"id": "vorlesung_1775460000",
"title": "Vorlesung",
"created_at": 1775460000,
"modified_at": 1775463600,
"current_page_index": 0,
"pages": [
{ "id": 1, "background": "Blank", "title": "Einführung", "size": [2970.0, 1671.0] },
{ "id": 2, "background": "Grid5mm" }
],
"page_ids": [1, 2],
"next_stroke_id": 42,
"next_page_id": 3,
"layout": "Paged"
}
```
| Feld | Bedeutung |
| :--- | :--- |
| `format_version` | Formatversion (siehe Abschnitt 5) |
| `id`, `title` | ID (= Ordnername) und Anzeigename |
| `created_at`, `modified_at` | UNIX-Zeit in Sekunden |
| `current_page_index` | zuletzt geöffnete Seite |
| `pages` | Seiten in ihrer Reihenfolge: `id`, `background`, optional `title` und `size` (siehe unten) – damit Seitenübersicht und Ansicht sie kennen, ohne jede Seite zu laden |
| `page_ids` | dieselbe Reihenfolge als reine ID-Liste (für ältere Versionen) |
| `next_stroke_id`, `next_page_id` | nächste freie IDs |
| `layout` | Anordnung der Seiten: `Paged` (Seite für Seite), `Continuous` (durchgehend untereinander) oder `Endless` (endlose Fläche, siehe unten). Seit 0.28; fehlt das Feld, gilt die frühere globale Einstellung `continuous_scroll` aus `ui.json`. Seit 0.30 werden neue Notizbücher als `Continuous` angelegt |
**Endlose Notizbücher** (Version 9) haben genau eine Seite der Größe `[100000.0, 100000.0]` (10 × 10 m). Der Startausschnitt ist ein DIN-A4-Rahmen in ihrer Mitte (ab `[48950, 48515]`); Koordinaten bleiben positiv. Beim Export wird nur der beschriebene Bereich mit 10 mm Rand zur PDF-Seite.
---
## 3. Seite (`page_<id>.json`)
Jede Seite liegt in einer eigenen Datei (kompaktes JSON, hier zur Lesbarkeit umbrochen):
```json
{
"format_version": 8,
"id": 1,
"background": "Blank",
"title": "Einführung",
"size": [2970.0, 1671.0],
"pdf_background": { "asset": "<sha256>.pdf", "page": 0, "width_pt": 960.0, "height_pt": 540.0 },
"strokes": [ … ]
}
```
| Feld | Bedeutung |
| :--- | :--- |
| `background` | Muster: `Blank`, `Grid5mm`, `Lined`, `DotGrid` |
| `title` | Seitentitel (optional) |
| `size` | Seitengröße `[breite, höhe]` in Seiteneinheiten (10 = 1 mm); fehlt bei DIN A4 hoch (`[2100, 2970]`). Höchstens 2 × 2 m, außer der endlosen Seite `[100000, 100000]` |
| `pdf_background` | importierte PDF-Seite als Hintergrund (optional): Datei, Seitennummer ab 0, Größe in Punkt |
| `strokes` | alle Objekte der Seite, von unten nach oben |
### 3.1 Objekte (`strokes`)
Jedes Objekt hat eine `id`, eine `color` (RGBA, sRGB, Werte 0–1) und `samples`: Punkte mit `x`, `y` (Seiteneinheiten), `p` (Druck 0–1) und `t` (Zeit in Sekunden). Die Dreiecke für die Anzeige werden beim Laden aus den Punkten berechnet und nicht gespeichert.
**Tinte**
```json
{ "id": 7, "color": [0.12, 0.12, 0.14, 1.0],
"samples": [{ "x": 350.2, "y": 420.5, "p": 0.65, "t": 12.345 }, …],
"tool": "pen", "width": 5.0, "finalized": true }
```
- `tool`: `pen`, `highlighter` oder `shape` (ein zu einer Form eingerasteter Textmarker bleibt `highlighter` und hat zusätzlich `shape`)
- `width`: Strichbreite in Seiteneinheiten (5.0 = 0,5 mm)
- `finalized`: Der Strich wurde nach dem Absetzen nachbearbeitet (Glättung, Ecken, Endkappen)
**Formen** haben zusätzlich `shape`, z. B.:
```json
"shape": { "Rectangle": { "corners": [[500, 600], [800, 600], [800, 800], [500, 800]] } }
```
Mögliche Formen: `Line { start, end }`, `Arrow { start, end, double }`, `Circle { center, radius }`, `Ellipse { center, radius_x, radius_y, rotation }`, `Rectangle { corners }`, `Triangle { corners }`, `Rhombus { corners }`, `Polygon { vertices }`, `CoordinateSystem { origin, x_axis_end, y_axis_end, with_arrows, negative_ends? }`, `Octahedron { … }`. Punkte sind `[x, y]`. Beim Achsenkreuz enthält `negative_ends` die Enden der x- und der y-Achse auf der anderen Seite des Ursprungs; ältere Versionen ignorieren das Feld und zeigen ein L-förmiges Koordinatensystem.
**Bilder** haben `image` (Dateiname in `assets/`); die vier `samples` sind die Ecken oben links, oben rechts, unten rechts, unten links:
```json
{ "id": 9, "color": [1, 1, 1, 1], "samples": [ …4 Ecken… ], "width": 0.0, "image": "<sha256>.png" }
```
**Textfelder** haben `text`; die vier `samples` sind wie bei Bildern die Ecken des Felds:
```json
"text": { "content": "Überschrift\nzweite Zeile", "size": 42.3, "font": "Sans", "wrap_width": 1500.0 }
```
- `size`: Schriftgröße (Em) in Seiteneinheiten (12 pt ≈ 42,3)
- `font`: `Sans` (Ubuntu Light) oder `Mono` (Hack)
- `wrap_width`: Umbruchbreite in Seiteneinheiten; die Ecken bilden das Feld dieser Breite (und der Höhe des gesetzten Texts) ab, Drehung und Skalierung stecken in den Ecken
---
## 4. Sicherheit beim Speichern
### Atomares Schreiben
1. Der Inhalt wird in `<datei>.tmp.<pid>.<nanosekunden>` geschrieben und mit `fsync` auf den Datenträger gebracht.
2. `rename` ersetzt die alte Datei in einem Schritt.
3. Danach wird auch der Ordner mit `fsync` gesichert.
Ein Absturz während des Speicherns hinterlässt deshalb entweder die alte oder die neue Datei, nie eine halbe.
### Beschädigte Dateien
- Lässt sich eine Seite nicht lesen, wird sie nach `page_<id>.json.corrupt-<zeitstempel>` verschoben (nicht gelöscht) und die Seite schreibgeschützt angezeigt.
- Ist `manifest.json` beschädigt oder fehlt, wird `manifest.json.bak` verwendet.
### Gelöschte Seiten
Eine gelöschte Seite verschwindet aus `manifest.json`; ihre Datei `page_<id>.json` bleibt im Ordner liegen. Seitenkennungen werden nie wiederverwendet (`next_page_id`), die Datei kann also mit keiner neuen Seite kollidieren. Wird die Seite in derselben Sitzung wiederhergestellt, schreibt die App sie neu.
### Neuere Versionen
Dateien mit einer höheren `format_version` als der unterstützten werden nicht geöffnet – so kann eine ältere App-Version keine Inhalte verlieren, die sie nicht kennt.
### Migration
Beim ersten Speichern einer unversionierten Datei (Version 0) wird vorher eine Kopie `*.v0.bak` angelegt.
---
## 5. Versionen
| Version | App | Änderung |
| :--- | :--- | :--- |
| 0 | – | ohne `format_version` |
| 1 | – | `format_version`, Hintergründe im Manifest |
| 2 | – | `tool`, `width`, `finalized` an Strichen; kompaktes JSON |
| 3 | 0.3.7 | Formen `Ellipse` und `Arrow` |
| 4 | 0.5 | Bilder (`image`, Ordner `assets/`) |
| 5 | 0.6.1 | importierte PDF-Seiten (`pdf_background`) |
| 6 | 0.7 | Seitentitel (`title` in Seite und Manifest) |
| 7 | 0.10 | Textfelder (`text`) |
| 8 | 0.11 | Seitengröße je Seite (`size` in Seite und Manifest) |
| 9 | 0.29 | Seitenanordnung je Notizbuch (`layout` im Manifest), endlose Notizbücher |
Jede neue Version ändert nur Ergänzungen; ältere Dateien werden ohne Umwandlung gelesen und beim nächsten Speichern mit der aktuellen Versionsnummer geschrieben.
---
## 6. Warum JSON
Ein Binärformat (z. B. `postcard`) würde Dateien um weitere 20–30 % verkleinern. JSON bleibt trotzdem, weil es sich ansehen, vergleichen und im Notfall von Hand reparieren lässt und das Speichern einer Seite auch bei vielen Strichen deutlich unter einer Millisekunde dauert.
+91 -84
View File
@@ -1,37 +1,39 @@
# Dateiformat von ink-notes # ink-notes file format
Dieses Dokument beschreibt, wo und wie ink-notes Notizbücher speichert, und die Versionen des Dateiformats. Aktuell ist **Version 8**. **English** · [Deutsch](FILE_FORMAT.de.md)
This document describes where and how ink-notes stores notebooks, and the versions of the file format. The current version is **9**.
--- ---
## 1. Ordner ## 1. Folders
```text ```text
~/.local/share/ink-notes/ ($XDG_DATA_HOME/ink-notes) ~/.local/share/ink-notes/ ($XDG_DATA_HOME/ink-notes)
├── notebooks/ ├── notebooks/
│ ├── library.json Ordner der Notizbücher (siehe unten) │ ├── library.json notebook folders (see below)
│ ├── <notizbuch-id>/ │ ├── <notebook-id>/
│ │ ├── manifest.json Metadaten und Seitenreihenfolge │ │ ├── manifest.json metadata and page order
│ │ ├── manifest.json.bak Kopie des vorherigen Manifests │ │ ├── manifest.json.bak copy of the previous manifest
│ │ ├── page_1.json eine Datei je Seite │ │ ├── page_1.json one file per page
│ │ ├── page_2.json │ │ ├── page_2.json
│ │ ├── page_7.json.corrupt-<ts> beiseitegelegte beschädigte Seite (falls vorgekommen) │ │ ├── page_7.json.corrupt-<ts> damaged page set aside (if it ever happened)
│ │ ├── assets/ eingefügte Bilder und importierte PDFs │ │ ├── assets/ inserted images and imported PDFs
│ │ │ ├── <sha256>.png │ │ │ ├── <sha256>.png
│ │ │ └── <sha256>.pdf │ │ │ └── <sha256>.pdf
│ │ └── .lock Sperrdatei, solange das Notizbuch geöffnet ist │ │ └── .lock lock file while the notebook is open
│ └── .trash/ gelöschte Notizbücher (<id>_<zeitstempel>) │ └── .trash/ deleted notebooks (<id>_<timestamp>)
└── recovery/ Notfall-Sicherungen ungespeicherter Seiten └── recovery/ emergency backups of unsaved pages
~/.config/ink-notes/ui.json ($XDG_CONFIG_HOME) Einstellungen der Oberfläche ~/.config/ink-notes/ui.json ($XDG_CONFIG_HOME) interface settings
``` ```
- **Notizbuch-IDs** werden aus dem Titel und dem Zeitpunkt gebildet (z. B. `mathematik_1775460000`); das erste Notizbuch heißt `default`. - **Notebook IDs** are built from the title and the time (e.g. `mathematik_1775460000`); the first notebook is called `default`.
- **Assets** sind nach dem SHA-256-Hash ihres Inhalts benannt; dasselbe Bild wird nur einmal gespeichert. Bilder werden als PNG oder (unverändert) JPEG abgelegt, PDFs unverändert. - **Assets** are named after the SHA-256 hash of their content; the same image is stored only once. Images are stored as PNG or (unchanged) JPEG, PDFs unchanged.
- Alle Dateien werden mit Rechten `0600`, Ordner mit `0700` angelegt. - All files are created with permissions `0600`, folders with `0700`.
### Ordner (`library.json`) ### Folders (`library.json`)
Ordner wie „3. Semester › Mathe“ sind rein logisch: Die Notizbücher bleiben in ihren Verzeichnissen, `library.json` hält nur fest, welcher Ordner welches Notizbuch enthält. Umbenennen, Verschieben (samt Inhalt) und Löschen von Ordnern ändern deshalb nie eine Notizbuch-Datei. Folders such as "Semester 3 › Math" are purely logical: notebooks stay in their directories, and `library.json` only records which folder contains which notebook. Renaming, moving (with content) and deleting folders therefore never changes a notebook file.
```json ```json
{ {
@@ -40,10 +42,10 @@ Ordner wie „3. Semester › Mathe“ sind rein logisch: Die Notizbücher bleib
} }
``` ```
- Ordner sind Pfade mit `/` als Trenner; Namen enthalten selbst kein `/` und höchstens 80 Zeichen. - Folders are paths with `/` as separator; names themselves contain no `/` and at most 80 characters.
- Notizbücher ohne Eintrag liegen auf der obersten Ebene. - Notebooks without an entry are on the top level.
- Wird ein Ordner gelöscht, rücken seine Notizbücher und Unterordner eine Ebene nach oben. - When a folder is deleted, its notebooks and subfolders move up one level.
- Fehlt die Datei oder ist sie unlesbar, liegen alle Notizbücher auf der obersten Ebene; fehlende übergeordnete Ordner werden beim Laden ergänzt. - If the file is missing or unreadable, all notebooks are on the top level; missing parent folders are added when loading.
--- ---
@@ -51,7 +53,7 @@ Ordner wie „3. Semester › Mathe“ sind rein logisch: Die Notizbücher bleib
```json ```json
{ {
"format_version": 8, "format_version": 9,
"id": "vorlesung_1775460000", "id": "vorlesung_1775460000",
"title": "Vorlesung", "title": "Vorlesung",
"created_at": 1775460000, "created_at": 1775460000,
@@ -63,25 +65,29 @@ Ordner wie „3. Semester › Mathe“ sind rein logisch: Die Notizbücher bleib
], ],
"page_ids": [1, 2], "page_ids": [1, 2],
"next_stroke_id": 42, "next_stroke_id": 42,
"next_page_id": 3 "next_page_id": 3,
"layout": "Paged"
} }
``` ```
| Feld | Bedeutung | | Field | Meaning |
| :--- | :--- | | :--- | :--- |
| `format_version` | Formatversion (siehe Abschnitt 5) | | `format_version` | format version (see section 5) |
| `id`, `title` | ID (= Ordnername) und Anzeigename | | `id`, `title` | ID (= folder name) and display name |
| `created_at`, `modified_at` | UNIX-Zeit in Sekunden | | `created_at`, `modified_at` | UNIX time in seconds |
| `current_page_index` | zuletzt geöffnete Seite | | `current_page_index` | page opened last |
| `pages` | Seiten in ihrer Reihenfolge: `id`, `background`, optional `title` und `size` (siehe unten) – damit Seitenübersicht und Ansicht sie kennen, ohne jede Seite zu laden | | `pages` | pages in their order: `id`, `background`, optional `title` and `size` (see below) – so the page overview and the view know them without loading every page |
| `page_ids` | dieselbe Reihenfolge als reine ID-Liste (für ältere Versionen) | | `page_ids` | the same order as a plain ID list (for older versions) |
| `next_stroke_id`, `next_page_id` | nächste freie IDs | | `next_stroke_id`, `next_page_id` | next free IDs |
| `layout` | page arrangement: `Paged` (page by page), `Continuous` (seamlessly below each other) or `Endless` (endless canvas, see below). Since 0.28; if the field is missing, the former global setting `continuous_scroll` from `ui.json` applies. Since 0.30, new notebooks are created as `Continuous` |
**Endless notebooks** (version 9) have exactly one page of size `[100000.0, 100000.0]` (10 × 10 m). The starting area is an A4 frame in its middle (from `[48950, 48515]`); coordinates stay positive. On export, only the used area plus a 10 mm margin becomes the PDF page.
--- ---
## 3. Seite (`page_<id>.json`) ## 3. Page (`page_<id>.json`)
Jede Seite liegt in einer eigenen Datei (kompaktes JSON, hier zur Lesbarkeit umbrochen): Each page is stored in its own file (compact JSON, wrapped here for readability):
```json ```json
{ {
@@ -95,19 +101,19 @@ Jede Seite liegt in einer eigenen Datei (kompaktes JSON, hier zur Lesbarkeit umb
} }
``` ```
| Feld | Bedeutung | | Field | Meaning |
| :--- | :--- | | :--- | :--- |
| `background` | Muster: `Blank`, `Grid5mm`, `Lined`, `DotGrid` | | `background` | pattern: `Blank`, `Grid5mm`, `Lined`, `DotGrid` |
| `title` | Seitentitel (optional) | | `title` | page title (optional) |
| `size` | Seitengröße `[breite, höhe]` in Seiteneinheiten (10 = 1 mm); fehlt bei DIN A4 hoch (`[2100, 2970]`) | | `size` | page size `[width, height]` in page units (10 = 1 mm); omitted for A4 portrait (`[2100, 2970]`). At most 2 × 2 m, except for the endless page `[100000, 100000]` |
| `pdf_background` | importierte PDF-Seite als Hintergrund (optional): Datei, Seitennummer ab 0, Größe in Punkt | | `pdf_background` | imported PDF page as background (optional): file, page number starting at 0, size in points |
| `strokes` | alle Objekte der Seite, von unten nach oben | | `strokes` | all objects on the page, from bottom to top |
### 3.1 Objekte (`strokes`) ### 3.1 Objects (`strokes`)
Jedes Objekt hat eine `id`, eine `color` (RGBA, linear, Werte 0–1) und `samples`: Punkte mit `x`, `y` (Seiteneinheiten), `p` (Druck 0–1) und `t` (Zeit in Sekunden). Die Dreiecke für die Anzeige werden beim Laden aus den Punkten berechnet und nicht gespeichert. Every object has an `id`, a `color` (RGBA, sRGB, values 0–1) and `samples`: points with `x`, `y` (page units), `p` (pressure 0–1) and `t` (time in seconds). The triangles for display are computed from the points when loading and are not stored.
**Tinte** **Ink**
```json ```json
{ "id": 7, "color": [0.12, 0.12, 0.14, 1.0], { "id": 7, "color": [0.12, 0.12, 0.14, 1.0],
@@ -115,78 +121,79 @@ Jedes Objekt hat eine `id`, eine `color` (RGBA, linear, Werte 0–1) und `sample
"tool": "pen", "width": 5.0, "finalized": true } "tool": "pen", "width": 5.0, "finalized": true }
``` ```
- `tool`: `pen`, `highlighter` oder `shape` - `tool`: `pen`, `highlighter` or `shape` (a highlighter snapped to a shape stays `highlighter` and also has `shape`)
- `width`: Strichbreite in Seiteneinheiten (5.0 = 0,5 mm) - `width`: stroke width in page units (5.0 = 0.5 mm)
- `finalized`: Der Strich wurde nach dem Absetzen nachbearbeitet (Glättung, Ecken, Endkappen) - `finalized`: the stroke was post-processed after the pen was lifted (smoothing, corners, end caps)
**Formen** haben zusätzlich `shape`, z. B.: **Shapes** additionally have `shape`, e.g.:
```json ```json
"shape": { "Rectangle": { "corners": [[500, 600], [800, 600], [800, 800], [500, 800]] } } "shape": { "Rectangle": { "corners": [[500, 600], [800, 600], [800, 800], [500, 800]] } }
``` ```
Mögliche Formen: `Line { start, end }`, `Arrow { start, end, double }`, `Circle { center, radius }`, `Ellipse { center, radius_x, radius_y, rotation }`, `Rectangle { corners }`, `Triangle { corners }`, `Rhombus { corners }`, `Polygon { vertices }`, `CoordinateSystem { origin, x_axis_end, y_axis_end, with_arrows, negative_ends? }`, `Octahedron { … }`. Punkte sind `[x, y]`. Beim Achsenkreuz enthält `negative_ends` die Enden der x- und der y-Achse auf der anderen Seite des Ursprungs; ältere Versionen ignorieren das Feld und zeigen ein L-förmiges Koordinatensystem. Possible shapes: `Line { start, end }`, `Arrow { start, end, double }`, `Circle { center, radius }`, `Ellipse { center, radius_x, radius_y, rotation }`, `Rectangle { corners }`, `Triangle { corners }`, `Rhombus { corners }`, `Polygon { vertices }`, `CoordinateSystem { origin, x_axis_end, y_axis_end, with_arrows, negative_ends? }`, `Octahedron { … }`. Points are `[x, y]`. For an axis cross, `negative_ends` contains the ends of the x and y axes on the other side of the origin; older versions ignore the field and show an L-shaped coordinate system.
**Bilder** haben `image` (Dateiname in `assets/`); die vier `samples` sind die Ecken oben links, oben rechts, unten rechts, unten links: **Images** have `image` (file name in `assets/`); the four `samples` are the corners top left, top right, bottom right, bottom left:
```json ```json
{ "id": 9, "color": [1, 1, 1, 1], "samples": [ …4 Ecken… ], "width": 0.0, "image": "<sha256>.png" } { "id": 9, "color": [1, 1, 1, 1], "samples": [ …4 corners… ], "width": 0.0, "image": "<sha256>.png" }
``` ```
**Textfelder** haben `text`; die vier `samples` sind wie bei Bildern die Ecken des Felds: **Text boxes** have `text`; like for images, the four `samples` are the corners of the box:
```json ```json
"text": { "content": "Überschrift\nzweite Zeile", "size": 42.3, "font": "Sans", "wrap_width": 1500.0 } "text": { "content": "Heading\nsecond line", "size": 42.3, "font": "Sans", "wrap_width": 1500.0 }
``` ```
- `size`: Schriftgröße (Em) in Seiteneinheiten (12 pt ≈ 42,3) - `size`: font size (em) in page units (12 pt ≈ 42.3)
- `font`: `Sans` (Ubuntu Light) oder `Mono` (Hack) - `font`: `Sans` (Ubuntu Light) or `Mono` (Hack)
- `wrap_width`: Umbruchbreite in Seiteneinheiten; die Ecken bilden das Feld dieser Breite (und der Höhe des gesetzten Texts) ab, Drehung und Skalierung stecken in den Ecken - `wrap_width`: wrap width in page units; the corners map the box of this width (and the height of the laid-out text), rotation and scaling are contained in the corners
--- ---
## 4. Sicherheit beim Speichern ## 4. Safe saving
### Atomares Schreiben ### Atomic writes
1. Der Inhalt wird in `<datei>.tmp.<pid>.<nanosekunden>` geschrieben und mit `fsync` auf den Datenträger gebracht. 1. The content is written to `<file>.tmp.<pid>.<nanoseconds>` and flushed to disk with `fsync`.
2. `rename` ersetzt die alte Datei in einem Schritt. 2. `rename` replaces the old file in one step.
3. Danach wird auch der Ordner mit `fsync` gesichert. 3. Afterwards the folder is synced with `fsync` too.
Ein Absturz während des Speicherns hinterlässt deshalb entweder die alte oder die neue Datei, nie eine halbe. A crash while saving therefore leaves either the old or the new file, never a half-written one.
### Beschädigte Dateien ### Damaged files
- Lässt sich eine Seite nicht lesen, wird sie nach `page_<id>.json.corrupt-<zeitstempel>` verschoben (nicht gelöscht) und die Seite schreibgeschützt angezeigt. - If a page cannot be read, it is moved to `page_<id>.json.corrupt-<timestamp>` (not deleted) and the page is shown read-only.
- Ist `manifest.json` beschädigt oder fehlt, wird `manifest.json.bak` verwendet. - If `manifest.json` is damaged or missing, `manifest.json.bak` is used.
### Gelöschte Seiten ### Deleted pages
Eine gelöschte Seite verschwindet aus `manifest.json`; ihre Datei `page_<id>.json` bleibt im Ordner liegen. Seitenkennungen werden nie wiederverwendet (`next_page_id`), die Datei kann also mit keiner neuen Seite kollidieren. Wird die Seite in derselben Sitzung wiederhergestellt, schreibt die App sie neu. A deleted page disappears from `manifest.json`; its file `page_<id>.json` stays in the folder. Page IDs are never reused (`next_page_id`), so the file cannot collide with a new page. If the page is restored in the same session, the app writes it again.
### Neuere Versionen ### Newer versions
Dateien mit einer höheren `format_version` als der unterstützten werden nicht geöffnet – so kann eine ältere App-Version keine Inhalte verlieren, die sie nicht kennt. Files with a higher `format_version` than supported are not opened – this way an older app version cannot lose content it does not know.
### Migration ### Migration
Beim ersten Speichern einer unversionierten Datei (Version 0) wird vorher eine Kopie `*.v0.bak` angelegt. Before the first save of an unversioned file (version 0), a copy `*.v0.bak` is created.
--- ---
## 5. Versionen ## 5. Versions
| Version | App | Änderung | | Version | App | Change |
| :--- | :--- | :--- | | :--- | :--- | :--- |
| 0 | – | ohne `format_version` | | 0 | – | without `format_version` |
| 1 | – | `format_version`, Hintergründe im Manifest | | 1 | – | `format_version`, backgrounds in the manifest |
| 2 | – | `tool`, `width`, `finalized` an Strichen; kompaktes JSON | | 2 | – | `tool`, `width`, `finalized` on strokes; compact JSON |
| 3 | 0.3.7 | Formen `Ellipse` und `Arrow` | | 3 | 0.3.7 | shapes `Ellipse` and `Arrow` |
| 4 | 0.5 | Bilder (`image`, Ordner `assets/`) | | 4 | 0.5 | images (`image`, folder `assets/`) |
| 5 | 0.6.1 | importierte PDF-Seiten (`pdf_background`) | | 5 | 0.6.1 | imported PDF pages (`pdf_background`) |
| 6 | 0.7 | Seitentitel (`title` in Seite und Manifest) | | 6 | 0.7 | page titles (`title` in page and manifest) |
| 7 | 0.10 | Textfelder (`text`) | | 7 | 0.10 | text boxes (`text`) |
| 8 | 0.11 | Seitengröße je Seite (`size` in Seite und Manifest) | | 8 | 0.11 | page size per page (`size` in page and manifest) |
| 9 | 0.29 | page arrangement per notebook (`layout` in the manifest), endless notebooks |
Jede neue Version ändert nur Ergänzungen; ältere Dateien werden ohne Umwandlung gelesen und beim nächsten Speichern mit der aktuellen Versionsnummer geschrieben. Each new version only adds things; older files are read without conversion and written with the current version number on the next save.
--- ---
## 6. Warum JSON ## 6. Why JSON
Ein Binärformat (z. B. `postcard`) würde Dateien um weitere 20–30 % verkleinern. JSON bleibt trotzdem, weil es sich ansehen, vergleichen und im Notfall von Hand reparieren lässt und das Speichern einer Seite auch bei vielen Strichen deutlich unter einer Millisekunde dauert. A binary format (e.g. `postcard`) would make files another 20–30 % smaller. JSON stays anyway, because it can be inspected, diffed and repaired by hand in an emergency, and saving a page takes well under a millisecond even with many strokes.
+40
View File
@@ -84,6 +84,46 @@
</branding> </branding>
<content_rating type="oars-1.1" /> <content_rating type="oars-1.1" />
<releases> <releases>
<release version="0.30.2" date="2026-10-20">
<description>
<p>Ctrl+Plus and Ctrl+Minus zoom only the page again, no longer the whole interface. Text boxes being edited now sit exactly where the text ends up.</p>
</description>
</release>
<release version="0.30.1" date="2026-10-20">
<description>
<p>Snapped highlighter shapes keep the highlighter width. Sharp corners such as the tip of a "1" are drawn round instead of cut off, and strokes no longer jump when the pen lifts. Finger swipes glide more reliably.</p>
</description>
</release>
<release version="0.30.0" date="2026-10-19">
<description>
<p>Faster scrolling through long notebooks and PDFs: a quick finger swipe keeps gliding, and a scrollbar with page numbers jumps through the notebook. The mouse wheel scrolls further per step. New notebooks scroll continuously by default.</p>
</description>
</release>
<release version="0.29.1" date="2026-10-19">
<description>
<p>Moving, rotating and scaling large selections is smooth again and no longer keeps the processor busy. On endless notebooks the mouse wheel scrolls (Ctrl+wheel zooms).</p>
</description>
</release>
<release version="0.29.0" date="2026-10-18">
<description>
<p>Endless notebooks: one surface without edges to write, pan and zoom in every direction. Choose it when creating a notebook or switch a single-page notebook in the menu; PDF export contains only the written area.</p>
</description>
</release>
<release version="0.28.0" date="2026-10-18">
<description>
<p>Each notebook remembers whether it flips page by page or scrolls continuously. New notebooks start with fixed pages; the layout can be chosen when creating a notebook and changed later in the menu.</p>
</description>
</release>
<release version="0.27.1" date="2026-10-18">
<description>
<p>The pens in the toolbar are shown as pencils in their own colour, so they are easy to tell apart.</p>
</description>
</release>
<release version="0.27.0" date="2026-10-18">
<description>
<p>Pictures keep their proportions when resized. A lock button in the selection tool switches between proportional and free resizing; pictures start locked, other objects free.</p>
</description>
</release>
<release version="0.26.2" date="2026-10-13"> <release version="0.26.2" date="2026-10-13">
<description> <description>
<p>Smoother scrolling through large imported PDFs: pages no longer stall the display while they are prepared, and pages scrolled past quickly are skipped.</p> <p>Smoother scrolling through large imported PDFs: pages no longer stall the display while they are prepared, and pages scrolled past quickly are skipped.</p>
+7 -7
View File
@@ -1,14 +1,14 @@
# Flatpak-Manifest für ink-notes (Flathub). # Flatpak manifest for ink-notes (Flathub).
# #
# Lokal bauen und installieren (aus dem Repository-Wurzelverzeichnis): # Build and install locally (from the repository root):
# flatpak-builder --user --install --force-clean target/flatpak-build \ # flatpak-builder --user --install --force-clean target/flatpak-build \
# packaging/flatpak/eu.pansi.ink-notes.yml # packaging/flatpak/eu.pansi.ink-notes.yml
# #
# cargo-sources.json nach jeder Änderung an Cargo.lock neu erzeugen: # Regenerate cargo-sources.json after every change to Cargo.lock:
# python3 flatpak-cargo-generator.py Cargo.lock -o packaging/flatpak/cargo-sources.json # python3 flatpak-cargo-generator.py Cargo.lock -o packaging/flatpak/cargo-sources.json
# (flatpak-builder-tools, https://github.com/flatpak/flatpak-builder-tools/tree/master/cargo) # (flatpak-builder-tools, https://github.com/flatpak/flatpak-builder-tools/tree/master/cargo)
# #
# Für die Einreichung bei Flathub die Quelle `type: dir` durch das Release-Tag ersetzen # For the Flathub submission, replace the `type: dir` source with the release tag
# (type: git, url, tag, commit). # (type: git, url, tag, commit).
id: eu.pansi.ink-notes id: eu.pansi.ink-notes
runtime: org.freedesktop.Platform runtime: org.freedesktop.Platform
@@ -22,9 +22,9 @@ finish-args:
- --socket=fallback-x11 - --socket=fallback-x11
- --share=ipc - --share=ipc
- --device=dri - --device=dri
# Die eingebaute Dateiauswahl (Bilder und PDFs einfügen) arbeitet ohne Desktop-Portal und # The built-in file picker (inserting images and PDFs) works without a desktop portal and
# bietet Bilder, Downloads und Schreibtisch an; Export (PDF, PNG, SVG) in den Dokumente-Ordner. # offers Pictures, Downloads and Desktop; exports (PDF, PNG, SVG) go to the Documents folder.
# Ziehen aus dem Dateimanager geht über das Dokumenten-Portal auch aus anderen Ordnern. # Dragging from the file manager works from other folders too, via the document portal.
- --filesystem=xdg-pictures:ro - --filesystem=xdg-pictures:ro
- --filesystem=xdg-download:ro - --filesystem=xdg-download:ro
- --filesystem=xdg-desktop:ro - --filesystem=xdg-desktop:ro
+40
View File
@@ -84,6 +84,46 @@
</branding> </branding>
<content_rating type="oars-1.1" /> <content_rating type="oars-1.1" />
<releases> <releases>
<release version="0.30.2" date="2026-10-20">
<description>
<p>Ctrl+Plus and Ctrl+Minus zoom only the page again, no longer the whole interface. Text boxes being edited now sit exactly where the text ends up.</p>
</description>
</release>
<release version="0.30.1" date="2026-10-20">
<description>
<p>Snapped highlighter shapes keep the highlighter width. Sharp corners such as the tip of a "1" are drawn round instead of cut off, and strokes no longer jump when the pen lifts. Finger swipes glide more reliably.</p>
</description>
</release>
<release version="0.30.0" date="2026-10-19">
<description>
<p>Faster scrolling through long notebooks and PDFs: a quick finger swipe keeps gliding, and a scrollbar with page numbers jumps through the notebook. The mouse wheel scrolls further per step. New notebooks scroll continuously by default.</p>
</description>
</release>
<release version="0.29.1" date="2026-10-19">
<description>
<p>Moving, rotating and scaling large selections is smooth again and no longer keeps the processor busy. On endless notebooks the mouse wheel scrolls (Ctrl+wheel zooms).</p>
</description>
</release>
<release version="0.29.0" date="2026-10-18">
<description>
<p>Endless notebooks: one surface without edges to write, pan and zoom in every direction. Choose it when creating a notebook or switch a single-page notebook in the menu; PDF export contains only the written area.</p>
</description>
</release>
<release version="0.28.0" date="2026-10-18">
<description>
<p>Each notebook remembers whether it flips page by page or scrolls continuously. New notebooks start with fixed pages; the layout can be chosen when creating a notebook and changed later in the menu.</p>
</description>
</release>
<release version="0.27.1" date="2026-10-18">
<description>
<p>The pens in the toolbar are shown as pencils in their own colour, so they are easy to tell apart.</p>
</description>
</release>
<release version="0.27.0" date="2026-10-18">
<description>
<p>Pictures keep their proportions when resized. A lock button in the selection tool switches between proportional and free resizing; pictures start locked, other objects free.</p>
</description>
</release>
<release version="0.26.2" date="2026-10-13"> <release version="0.26.2" date="2026-10-13">
<description> <description>
<p>Smoother scrolling through large imported PDFs: pages no longer stall the display while they are prepared, and pages scrolled past quickly are skipped.</p> <p>Smoother scrolling through large imported PDFs: pages no longer stall the display while they are prepared, and pages scrolled past quickly are skipped.</p>
+58 -50
View File
@@ -1,63 +1,71 @@
## ink-notes {{TAG_NAME}} ## ink-notes {{TAG_NAME}}
Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit 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:
- **Flüssigeres Scrollen in großen PDFs**: Neu gerenderte PDF-Seiten halten die Anzeige nicht mehr an, und Seiten, über die man schnell hinwegscrollt, werden übersprungen statt der Reihe nach gerendert - **Text box fix**: Ctrl+Plus/Minus now only zooms the page, no longer the whole interface as well – that made text jump to another place when leaving a text box
- **Screenshots für Flathub**: Die App-Beschreibung (Metainfo) zeigt jetzt drei Screenshots; an der App selbst ändert sich nichts - **Writing fixes**: A snapped highlighter keeps its width; tips (e.g. of a "1" or of exponents) are drawn round instead of cut off, and the stroke no longer jumps when the pen lifts. Finger swipes glide more reliably
- **Auswählen mit dem Finger**: Im Auswahlwerkzeug wählt und verschiebt ein Finger Objekte (auf leerer Fläche scrollt er weiter); Text und Platz schaffen gehen ebenfalls mit dem Finger - **Faster scrolling**: A quick finger swipe keeps gliding; in the continuous view a scrollbar (opposite the toolbar, with the page number while dragging) jumps through long notebooks and PDFs; the mouse wheel scrolls twice as far. New notebooks are continuous; fixed pages are still available in the ☰ menu
- **Flatpak**: Manifest unter packaging/flatpak (Vorbereitung für Flathub); Exporte landen im Dokumente-Ordner aus den Benutzerordnern (z. B. ~/Dokumente) - **Selection fix**: Moving, rotating and scaling large selections is smooth again and no longer maxes out the CPU (since 0.27 the selection was copied several times per frame). On the endless canvas the mouse wheel scrolls (Ctrl+wheel zooms)
- **Englische Oberfläche / English interface**: ink-notes folgt der Systemsprache (Deutsch oder Englisch); umstellen im ☰-Menü unter „Sprache / Language“ - **Endless canvas**: New page arrangement "♾ Endless" – a borderless canvas to write, pan and zoom in every direction. Selectable when creating a notebook or in the ☰ menu (for notebooks with one page); the PDF export only contains the used area. New file format version 9: older app versions no longer open notebooks of this version
- **Halten zum Einrasten**: verträgt jetzt das leichte Zittern eines ruhenden Stifts, damit Formen auch im normalen Stift-Modus einrasten - **Page arrangement per notebook**: Selectable when creating a notebook – "Fixed pages" (default) or "Continuous" – and switchable later in the ☰ menu. Existing notebooks keep their previous setting
- **Achsenkreuz aus zwei Strichen**: Eine waagerechte und eine senkrechte Gerade über Kreuz zeichnen – die zweite mit Halten oder dem Formen-Werkzeug – und beide werden zum Achsenkreuz mit Pfeilspitzen an allen vier Enden; Rückgängig stellt die erste Gerade wieder her - **Pens in their color**: The pens in the toolbar appear as pens in their own color instead of a thin stroke, so they can be told apart at a glance
- **Bildschirmtastatur**: Unter GNOME/Zorin (Wayland) erscheint die Bildschirmtastatur jetzt beim Antippen eines Namensfelds – mit Finger oder Stift; Tippen und Rücktaste funktionieren. Außerdem geht der erste Fingertipp nach Mausbenutzung nicht mehr verloren - **Scale images without distortion**: With the selection tool, a lock below the pen colors switches between uniform (🔒) and free scaling (🔓). Images start locked, strokes and shapes free; on a computer, the Shift key also locks
- **App-Symbol**: Das Fenster meldet sich als eu.pansi.ink-notes – Dock und Taskleiste zeigen das ink-notes-Symbol statt eines allgemeinen Symbols (unter X11 auch als Fenstersymbol) - **Smoother scrolling in large PDFs**: Newly rendered PDF pages no longer pause the display, and pages you scroll past quickly are skipped instead of rendered one after another
- **Platz schaffen**: Neues Werkzeug ↕ – aufs Blatt tippen und nach unten ziehen, alles darunter rutscht mit, auch auf den folgenden Seiten (über das Seitenende hinaus geht es auf der nächsten Seite weiter, fehlende Seiten werden angehängt); nach oben ziehen schließt leeren Platz; ein Schritt für Rückgängig - **Screenshots for Flathub**: The app description (metainfo) now shows three screenshots; nothing changes in the app itself
- **Achsenkreuz**: Ein „+“ in einem Zug (mit Halten oder dem Formen-Werkzeug) wird zum Achsenkreuz mit Pfeilspitzen an allen vier Enden; auch im PDF-/Bildexport - **Selecting with a finger**: In the selection tool, a finger selects and moves objects (on empty space it keeps scrolling); text and make space work with a finger too
- **Fehlerbehebung Stift**: Das erste Antippen mit dem Stift trifft jetzt – Knöpfe und Notizbücher reagieren sofort statt erst beim zweiten Tippen; Striche beginnen nicht mehr mit einer Linie von der vorherigen Stiftposition - **Flatpak**: Manifest under packaging/flatpak (preparation for Flathub); exports go to the Documents folder from the user directories (e.g. ~/Documents)
- **Tasten am Stift belegen**: Im ☰-Menü unter Eingabe jede Stifttaste mit Radieren, Auswählen, Textmarker, Stift oder Nichts belegen (gilt, solange die Taste gedrückt ist); Tasten erscheinen nach dem ersten Drücken und leuchten beim Drücken grün - **English interface**: ink-notes follows the system language (German or English); switch it in the ☰ menu under "Sprache / Language"
- **Ordner verschieben**: Ordner lassen sich jetzt wie Notizbücher mit 📂 samt Inhalt in andere Ordner oder auf die oberste Ebene verschieben - **Hold to snap**: now tolerates the slight jitter of a resting pen, so shapes also snap in the normal pen mode
- **Ordner für Notizbücher**: Beliebig verschachtelte Ordner (z. B. 3. Semester › Mathe) im Dokumenten-Manager anlegen, umbenennen und löschen, Notizbücher mit 📂 verschieben; neue Notizbücher landen im geöffneten Ordner. Die Notizbuch-Dateien bleiben unverändert (Zuordnung in library.json) - **Axis cross from two strokes**: Draw a horizontal and a vertical line crossing each other – the second one with hold or the shape tool – and both become an axis cross with arrowheads at all four ends; undo restores the first line
- **Einfügen per Finger**: Lange (½ s) mit dem Finger aufs Blatt drücken öffnet ein Menü „Einfügen“ – Screenshot, kopierter Text oder kopierte Objekte landen genau an dieser Stelle (wie in Rnote) - **On-screen keyboard**: On GNOME/Zorin (Wayland) the on-screen keyboard now appears when tapping a name field – with finger or pen; typing and backspace work. Also, the first finger tap after using the mouse is no longer lost
- **Eigene Farben**: Jede Farbe der Werkzeugleiste (Stifte, Textmarker, Text) lässt sich anpassen – erneut antippen, Vorgabe oder mit 🎨 frei wählen; die Leiste merkt sich die Farben. Farben am Bildschirm wie im PDF-/Bildexport (Schwarz ist schwarz, Textmarker mischt wie im PDF); Karos und Linien im PDF so hell wie am Bildschirm - **App icon**: The window identifies itself as eu.pansi.ink-notes – the dock and taskbar show the ink-notes icon instead of a generic one (on X11 also as window icon)
- **Beta-Feedback II**: Vier Stifte mit eigener Farbe und Stärke, die sich Änderungen merken; Textmarker wird innerhalb eines Strichs nicht dunkler und ist jetzt auch im PDF-/PNG-/SVG-Export durchsichtig; Seiten lückenlos untereinander mit Trennlinie; neue Seite per ➕ in der Werkzeugleiste; Seite löschen im ☰-Menü (wiederherstellbar); Koordinatensystem ohne Loch am Ursprung - **Make space**: New tool ↕ – tap the page and drag down, everything below moves along, also on the following pages (past the end of a page it continues on the next one, missing pages are appended); dragging up closes empty space; one step for undo
- **Fehlerbehebungen**: Umbenennen und Löschen in der Notizbuchverwaltung reagieren wieder; Karos, Linien und Punkte werden pro Pixel berechnet und liegen in jedem Zoom gleichmäßig (mindestens ein Pixel breit, kantengeglättet) - **Axis cross**: A "+" in one stroke (with hold or the shape tool) becomes an axis cross with arrowheads at all four ends; also in PDF/image export
- **Strichqualität**: Kleine Schleifen wie in „o“, „a“ oder „e“ bleiben rund (der fertige Strich entsteht aus den ungefilterten Stiftpunkten); Strichkanten werden kantengeglättet (4× Multisampling), runde Strichenden sind feiner unterteilt - **Pen fix**: The first tap with the pen now registers – buttons and notebooks react immediately instead of on the second tap; strokes no longer start with a line from the previous pen position
- **Musterfarbe**: Karos, Linien und Punkte in hell, mittel, kräftig, blau oder grün (☰-Menü, gilt auch im Export); Punkte des Punktrasters am Bildschirm so groß wie im Export (0,4 mm). README und Dokumentation überarbeitet - **Assign pen buttons**: In the ☰ menu under input, assign each pen button to erase, select, highlighter, pen or nothing (active while the button is held); buttons appear after the first press and light up green when pressed
- **Beta-Feedback**: Querformat-PDFs (z. B. PowerPoint-Folien) bekommen Querformat-Seiten ohne leeren Streifen (Dateiformat-Version 8); neue Seiten direkt nach der aktuellen einfügen; Auswahlwerkzeug funktioniert mit dem Stift; Zoom bis 1000 % mit schärfer nachgerenderten PDF-Seiten - **Move folders**: Folders can now be moved with 📂 including their content into other folders or to the top level, like notebooks
- **Text-Werkzeug**: Mit T ins Blatt tippen und schreiben – normale oder Code-Schrift, 8–36 pt, Zeilenumbruch am Feldrand; Textfelder verschieben, drehen und skalieren wie Bilder; im PDF echter, durchsuchbarer Text; kopierter Text wird mit Strg+V zum Textfeld (Dateiformat-Version 7) - **Folders for notebooks**: Create, rename and delete nested folders (e.g. Semester 3 › Math) in the document manager and move notebooks with 📂; new notebooks are created in the open folder. The notebook files stay unchanged (assignment in library.json)
- **Durchgehendes Scrollen**: Seiten untereinander mit Mausrad, Finger oder Touchpad scrollen (Strg+Mausrad zoomt); Schreiben im unteren Fünftel der letzten Seite hängt automatisch eine neue an; abschaltbar unter ☰ → Ansicht - **Paste with a finger**: Pressing the page with a finger for half a second opens a "Paste" menu – a screenshot, copied text or copied objects land exactly at that spot (like in Rnote)
- **Virtuelles Lineal**: Ein- und ausblenden mit L oder über die Werkzeugleiste; verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden exakt gerade – mit Stift, Textmarker und Formwerkzeug - **Custom colors**: Every toolbar color (pens, highlighter, text) can be changed – tap it again, pick a preset or any color with 🎨; the toolbar remembers the colors. Colors on screen match the PDF/image export (black is black, the highlighter blends like in the PDF); squares and lines in the PDF are as light as on screen
- **Teilstrich-Radierer**: Nur den überstrichenen Teil eines Strichs entfernen – ein Radierzug ist ein Undo-Schritt - **Beta feedback II**: Four pens with their own color and width that remember changes; the highlighter no longer gets darker within one stroke and is now transparent in PDF/PNG/SVG exports too; pages seamlessly below each other with a separator line; new page via ➕ in the toolbar; delete a page in the ☰ menu (restorable); coordinate system without a gap at the origin
- **Export & Zwischenablage**: Aktuelle Seite als PNG (auch transparent) oder SVG; Strg+C legt die Auswahl zusätzlich als Bild in die Zwischenablage für Chat, Mail oder Office - **Fixes**: Renaming and deleting in the notebook manager respond again; squares, lines and dots are computed per pixel and are even at every zoom level (at least one pixel wide, anti-aliased)
- **Seitentitel & Inhaltsverzeichnis**: Seiten benennen, Inhaltsansicht in der Seitenübersicht, Titel werden zu PDF-Lesezeichen (Dateiformat-Version 6) - **Stroke quality**: Small loops as in "o", "a" or "e" stay round (the final stroke is built from the unfiltered pen points); stroke edges are anti-aliased (4× multisampling), round stroke ends are subdivided more finely
- **PDF importieren & annotieren**: Vorlesungsfolien, Übungsblätter oder Formulare als neues Notizbuch öffnen (☰-Menü oder Drag & Drop), darauf schreiben und markieren; beim Export liegen die Originalseiten verlustfrei als Vektor unter den Notizen (Dateiformat-Version 5: ältere Versionen öffnen solche Notizbücher nicht) - **Pattern color**: Squares, lines and dots in light, medium, strong, blue or green (☰ menu, also applies to exports); dots of the dot grid on screen are as large as in the export (0.4 mm). README and documentation revised
- **Bilder einfügen**: Screenshots per Strg+V, Bilddateien über die eingebaute Dateiauswahl (Strg+I) oder per Drag & Drop; verschieben, drehen, skalieren und darauf schreiben – auch im PDF-Export (Dateiformat-Version 4: ältere Versionen öffnen solche Notizbücher nicht) - **Beta feedback**: Landscape PDFs (e.g. PowerPoint slides) get landscape pages without an empty strip (file format version 8); insert new pages right after the current one; the selection tool works with the pen; zoom up to 1000 % with PDF pages re-rendered more sharply
- **Dunkelmodus & Papierfarben**: Weiß, Creme, Dunkelgrau oder Schwarz; auf dunklem Papier werden dunkle Stiftfarben automatisch aufgehellt (nur Anzeige – Dateien und PDF-Export bleiben unverändert) - **Text tool**: Tap the page with T and type – regular or code font, 8–36 pt, line wrapping at the box edge; move, rotate and scale text boxes like images; real, searchable text in the PDF; copied text becomes a text box with Ctrl+V (file format version 7)
- **Nur Textmarker radieren**: Markierungen korrigieren, ohne die Handschrift darunter zu löschen – auch mit Stiftrückseite und Seitentaste - **Continuous scrolling**: Scroll through pages below each other with mouse wheel, finger or touchpad (Ctrl+wheel zooms); writing in the bottom fifth of the last page appends a new one automatically; can be turned off under ☰ → View
- **Einstellbare Druckkurve**: Kennlinie weich bis hart und Mindestdruck mit Vorschau, zum Anpassen an den eigenen Stift - **Virtual ruler**: Show and hide with L or via the toolbar; move it, rotate it by the handle (snaps to 15°); strokes along an edge become exactly straight – with pen, highlighter and shape tool
- **Neue Formen**: Ellipsen sowie in einem Zug gezeichnete Pfeile und Doppelpfeile werden erkannt (Dateiformat-Version 3: ältere Versionen öffnen solche Notizbücher nicht) - **Partial-stroke eraser**: Remove only the part of a stroke you wipe over – one erase stroke is one undo step
- **Stabiler Start**: Kein Absturz mehr, wenn die Grafikkarte nicht nutzbar ist (z. B. fehlende Rechte auf /dev/dri); Software-Renderer als Ausweichlösung und verständliche Fehlermeldung - **Export & clipboard**: Current page as PNG (also transparent) or SVG; Ctrl+C also puts the selection on the clipboard as an image for chat, email or office apps
- **WSL (WSLg)**: Läuft unter WSL automatisch über X11 statt Wayland, da WSLg die Wayland-Verbindung abbricht; übersteuerbar mit `INK_NOTES_DISPLAY=x11|wayland` - **Page titles & table of contents**: Name pages, table of contents view in the page overview, titles become PDF bookmarks (file format version 6)
- **Wichtige Fehlerbehebung**: Nach einem Neustart auf einer späteren Seite konnte Weiterschreiben den bisherigen Inhalt dieser Seite überschreiben (betrifft v0.2.3 – v0.3.2) - **Import & annotate PDFs**: Open lecture slides, exercise sheets or forms as a new notebook (☰ menu or drag & drop), write and highlight on them; on export the original pages lie losslessly as vectors under the notes (file format version 5: older versions do not open such notebooks)
- **Neue Werkzeugleiste**: Schmale senkrechte Leiste mit Symbol-Knöpfen, links oder rechts andockbar; selten genutzte Funktionen im ☰-Menü, das Blatt wird daneben zentriert - **Insert images**: Screenshots via Ctrl+V, image files via the built-in file picker (Ctrl+I) or drag & drop; move, rotate, scale and write on them – also in the PDF export (file format version 4: older versions do not open such notebooks)
- **Einheitliche Dialoge**: Deckende Fenster ohne doppelte Titel, Seitenauswahl mit Vorschaubildern, größere Bedienelemente in allen Popovern - **Dark mode & paper colors**: White, cream, dark gray or black; on dark paper, dark pen colors are brightened automatically (display only – files and PDF export stay unchanged)
- **UI per Stift und Touch bedienbar**: Werkzeugleiste, Popover und Dialoge reagieren jetzt auch auf Stift und Finger, auch bei Display-Skalierung - **Erase highlighter only**: Correct highlights without deleting the handwriting underneath – also with the pen's eraser end and side button
- **Aktive Stylus-Unterstützung (Lazarite / MPP / AES)**: Stifteingabe über das Tablet-Protokoll von Wayland bzw. X11, mit Druckstärke - **Adjustable pressure curve**: Curve from soft to hard and minimum pressure with preview, to match your own pen
- **Touchscreen Multi-Touch**: Zoomen mit zwei Fingern, Verschieben mit einem; Zeichnen mit dem Finger zuschaltbar - **New shapes**: Ellipses as well as arrows and double arrows drawn in one stroke are recognized (file format version 3: older versions do not open such notebooks)
- **Zwei-Stufen-Inking-Modell**: Glättung während des Schreibens (1€-Filter), beim Absetzen Nachbearbeitung mit Ecken-Erhaltung - **Stable start**: No more crash when the graphics card cannot be used (e.g. missing permissions on /dev/dri); software renderer as fallback and an understandable error message
- **Geometrie- und Formerkennung**: Geraden, Pfeile & Doppelpfeile, Rechtecke, Kreise, Ellipsen, Dreiecke, Rauten, Vielecke & Koordinatensysteme mit spitzen Pfeilspitzen (Draw-and-Hold); Freiformen bleiben unverändert - **WSL (WSLg)**: Runs via X11 instead of Wayland under WSL automatically, because WSLg drops the Wayland connection; override with `INK_NOTES_DISPLAY=x11|wayland`
- **Mehrere Notizbücher**: Anlegen, umbenennen, löschen (mit Papierkorb); automatisches Speichern im Hintergrund - **Important fix**: After a restart on a later page, continuing to write could overwrite the previous content of that page (affects v0.2.3 – v0.3.2)
- **Datensicherheit**: Versioniertes Dateiformat mit Migration, beschädigte Seiten werden beiseitegelegt statt überschrieben, Schutz vor gleichzeitigem Öffnen, atomares Speichern - **New toolbar**: Narrow vertical bar with icon buttons, dockable left or right; rarely used functions in the ☰ menu, the page is centered next to it
- **In-App Updater**: Prüft auf neue Versionen auf Gitea und installiert sie auf Wunsch – nur mit gültiger Minisign-Signatur und über HTTPS - **Consistent dialogs**: Opaque windows without duplicate titles, page picker with thumbnails, larger controls in all popovers
- **PDF-Export**: Striche und Formen als Vektoren, für Druck und Weitergabe - **UI usable with pen and touch**: Toolbar, popovers and dialogs now also respond to pen and finger, also with display scaling
- **Active stylus support (Lazarite / MPP / AES)**: Pen input via the Wayland or X11 tablet protocol, with pressure
- **Multi-touch touchscreen**: Zoom with two fingers, pan with one; drawing with a finger can be enabled
- **Two-stage inking model**: Smoothing while writing (1€ filter), post-processing with corner preservation when the pen is lifted
- **Geometry and shape recognition**: Lines, arrows & double arrows, rectangles, circles, ellipses, triangles, rhombuses, polygons & coordinate systems with sharp arrowheads (draw and hold); freeform strokes stay unchanged
- **Multiple notebooks**: Create, rename, delete (with trash); automatic saving in the background
- **Data safety**: Versioned file format with migration, damaged pages are set aside instead of overwritten, protection against opening twice, atomic saving
- **In-app updater**: Checks Gitea for new versions and installs them on request – only with a valid Minisign signature and over HTTPS
- **PDF export**: Strokes and shapes as vectors, for printing and sharing
### Installation: ### Installation:
- **Zorin OS / Ubuntu / Debian (.deb)**: - **Zorin OS / Ubuntu / Debian (.deb)**:
```bash ```bash
sudo dpkg -i ink-notes_{{VERSION}}_amd64.deb sudo dpkg -i ink-notes_{{VERSION}}_amd64.deb
``` ```
- **AppImage (andere Distributionen mit glibc ≥ 2.39)**: - **AppImage (other distributions with glibc ≥ 2.39)**:
```bash ```bash
chmod +x ink-notes_{{VERSION}}_x86_64.AppImage chmod +x ink-notes_{{VERSION}}_x86_64.AppImage
./ink-notes_{{VERSION}}_x86_64.AppImage ./ink-notes_{{VERSION}}_x86_64.AppImage
+28 -6
View File
@@ -710,14 +710,20 @@ impl CanvasState {
} }
fn recalculate_viewport(&mut self) { fn recalculate_viewport(&mut self) {
self.viewport = PageViewport::calculate_for( let insets = (self.ui_inset_left, self.ui_inset_right);
self.size.width as f32, let (width, height) = (self.size.width as f32, self.size.height as f32);
self.size.height as f32, self.viewport = if self.page_size == crate::document::ENDLESS_PAGE_SIZE {
PageViewport::calculate_endless(width, height, Self::VIEWPORT_MARGIN, insets)
} else {
PageViewport::calculate_for(
width,
height,
Self::VIEWPORT_MARGIN, Self::VIEWPORT_MARGIN,
(self.ui_inset_left, self.ui_inset_right), insets,
self.fit_box, self.fit_box,
self.page_size, self.page_size,
); )
};
self.update_uniforms(); self.update_uniforms();
} }
@@ -837,6 +843,18 @@ impl CanvasState {
changed changed
} }
/// Durchgehende Ansicht: Scrollt senkrecht, bis die Fenstermitte auf Höhe `y`
/// (Koordinaten der aktuellen Seite) liegt.
pub fn scroll_center_to(&mut self, y: f32) {
let c = self.camera.screen_to_page_unclamped(
self.viewport.page_x + self.viewport.page_width / 2.0,
self.size.height as f32 / 2.0,
&self.viewport,
);
self.camera.pan_offset[1] += c.y - y;
self.update_uniforms();
}
/// Durchgehende Ansicht: Die Seite mit der Ecke `origin` (Koordinaten der aktuellen Seite) /// Durchgehende Ansicht: Die Seite mit der Ecke `origin` (Koordinaten der aktuellen Seite)
/// wird zur Bezugsseite, ohne dass sich der Bildausschnitt bewegt. Danach ist die neue /// wird zur Bezugsseite, ohne dass sich der Bildausschnitt bewegt. Danach ist die neue
/// Seite mit [`Self::sync_page`] zu setzen. /// Seite mit [`Self::sync_page`] zu setzen.
@@ -1306,9 +1324,13 @@ impl CanvasState {
return; return;
} }
// Gleiche Tessellierung wie beim Absetzen (runde Kappen und Ecken), damit der Strich
// beim Abheben nicht sichtbar springt (Issue #33)
let color = self.current_ink_color; let color = self.current_ink_color;
let points = spline::rdp_filter_samples(&self.live_samples, spline::RDP_TOLERANCE_UNITS);
let corners = spline::detect_corners(&points, spline::CORNER_ANGLE_THRESHOLD_DEG);
let raw_strip = let raw_strip =
spline::generate_triangle_strip(&self.live_control_points, &self.spline_config); spline::generate_finalized_triangle_strip(&points, &corners, &self.spline_config);
self.live_vertices = raw_strip self.live_vertices = raw_strip
.into_iter() .into_iter()
.map(|v| InkVertex::new(v.position[0], v.position[1], color)) .map(|v| InkVertex::new(v.position[0], v.position[1], color))
+322 -36
View File
@@ -1,5 +1,6 @@
//! Notizbuch- und Seitenverwaltung mit DIN-A4-Koordinatensystem und Vektor-Stroke-Modell. //! Notizbuch- und Seitenverwaltung mit DIN-A4-Koordinatensystem und Vektor-Stroke-Modell.
use std::collections::{HashMap, HashSet};
use std::ops::Range; use std::ops::Range;
use crate::canvas::InkVertex; use crate::canvas::InkVertex;
@@ -20,6 +21,21 @@ pub const PAGE_HEIGHT: f32 = 2970.0;
pub const A4_SIZE: glam::Vec2 = glam::Vec2::new(PAGE_WIDTH, PAGE_HEIGHT); pub const A4_SIZE: glam::Vec2 = glam::Vec2::new(PAGE_WIDTH, PAGE_HEIGHT);
pub const UNITS_PER_MM: f32 = 10.0; pub const UNITS_PER_MM: f32 = 10.0;
/// Größe der Seite eines endlosen Notizbuchs (10 × 10 m, Issue #30): praktisch unbegrenzt,
/// aber mit festen, positiven Koordinaten – Ansicht, Speichern und Rückgängig funktionieren
/// so wie bei jeder anderen Seite.
pub const ENDLESS_PAGE_SIZE: glam::Vec2 = glam::Vec2::new(100_000.0, 100_000.0);
/// Linke obere Ecke des Startausschnitts (ein DIN-A4-Rahmen in der Mitte der endlosen Seite).
pub const ENDLESS_HOME: glam::Vec2 = glam::Vec2::new(
(ENDLESS_PAGE_SIZE.x - PAGE_WIDTH) / 2.0,
(ENDLESS_PAGE_SIZE.y - PAGE_HEIGHT) / 2.0,
);
/// Rand um den Inhalt beim Export einer endlosen Seite (10 mm).
const ENDLESS_EXPORT_MARGIN: f32 = 100.0;
/// Raster, auf das der Exportausschnitt einer endlosen Seite fällt: Vielfaches der Abstände von
/// Karos/Punkten (5 mm) und Linien (7 mm), damit das Muster wie am Bildschirm liegt.
const ENDLESS_EXPORT_SNAP: f32 = 350.0;
/// Ein gefilterter Stylus-Abtastpunkt in DIN-A4-Seitenkoordinaten. /// Ein gefilterter Stylus-Abtastpunkt in DIN-A4-Seitenkoordinaten.
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub struct StylusSample { pub struct StylusSample {
@@ -198,18 +214,19 @@ pub const IMAGE_UNITS_PER_PIXEL: f32 = UNITS_PER_MM * 25.4 / 96.0;
/// Ecken (oben links, oben rechts, unten rechts, unten links) für ein neu eingefügtes Bild /// Ecken (oben links, oben rechts, unten rechts, unten links) für ein neu eingefügtes Bild
/// von `width` × `height` Pixeln um `center`: Bildschirmgröße bei 96 dpi, höchstens 80 % der /// von `width` × `height` Pixeln um `center`: Bildschirmgröße bei 96 dpi, höchstens 80 % der
/// Seitenbreite und 60 % der Seitenhöhe eines Blatts der Größe `page_size`, und möglichst /// Breite und 60 % der Höhe von `reference` (meist die Seitengröße, auf der endlosen Seite
/// ganz auf dem Blatt. /// DIN A4), und möglichst ganz auf dem Blatt der Größe `page_size`.
pub fn place_image( pub fn place_image(
width: u32, width: u32,
height: u32, height: u32,
center: glam::Vec2, center: glam::Vec2,
page_size: glam::Vec2, page_size: glam::Vec2,
reference: glam::Vec2,
) -> [glam::Vec2; 4] { ) -> [glam::Vec2; 4] {
let natural = let natural =
glam::Vec2::new(width.max(1) as f32, height.max(1) as f32) * IMAGE_UNITS_PER_PIXEL; glam::Vec2::new(width.max(1) as f32, height.max(1) as f32) * IMAGE_UNITS_PER_PIXEL;
let fit = (page_size.x * 0.8 / natural.x) let fit = (reference.x * 0.8 / natural.x)
.min(page_size.y * 0.6 / natural.y) .min(reference.y * 0.6 / natural.y)
.min(1.0); .min(1.0);
let size = natural * fit; let size = natural * fit;
let half = size / 2.0; let half = size / 2.0;
@@ -597,18 +614,24 @@ impl Stroke {
/// Berechnet die Bounding Box aus den aktuellen Punkten/Vertizes neu. /// Berechnet die Bounding Box aus den aktuellen Punkten/Vertizes neu.
pub fn recompute_aabb(&mut self) { pub fn recompute_aabb(&mut self) {
// Ohne Zwischenliste: läuft beim Ziehen einer Auswahl für jeden Strich und jede Bewegung
let bounds = |mut pts: Box<dyn Iterator<Item = glam::Vec2> + '_>| {
pts.next()
.map_or(Aabb::new(glam::Vec2::ZERO, glam::Vec2::ZERO), |first| {
pts.fold(Aabb::new(first, first), |mut aabb, p| {
aabb.expand_by_point(p);
aabb
})
})
};
self.aabb = if !self.points.is_empty() { self.aabb = if !self.points.is_empty() {
let pts: Vec<glam::Vec2> = self.points.iter().map(|s| s.position).collect(); bounds(Box::new(self.points.iter().map(|s| s.position)))
Aabb::from_points(&pts)
} else if !self.vertices.is_empty() {
let pts: Vec<glam::Vec2> = self
.vertices
.iter()
.map(|v| glam::Vec2::new(v.position[0], v.position[1]))
.collect();
Aabb::from_points(&pts)
} else { } else {
Aabb::new(glam::Vec2::ZERO, glam::Vec2::ZERO) bounds(Box::new(
self.vertices
.iter()
.map(|v| glam::Vec2::new(v.position[0], v.position[1])),
))
}; };
} }
@@ -774,6 +797,56 @@ impl Page {
self self
} }
/// Seite eines endlosen Notizbuchs (siehe [`ENDLESS_PAGE_SIZE`]).
pub fn is_endless(&self) -> bool {
self.size == ENDLESS_PAGE_SIZE
}
/// Bezugsgröße für Einfügen und Textumbruch: DIN A4 auf der endlosen Seite, sonst die Seite.
pub fn reference_size(&self) -> glam::Vec2 {
if self.is_endless() {
A4_SIZE
} else {
self.size
}
}
/// Rechter Rand für ein neues Textfeld bei `x`: auf der endlosen Seite eine A4-Breite
/// weiter, sonst der Seitenrand.
pub fn text_right_edge(&self, x: f32) -> f32 {
if self.is_endless() {
x + PAGE_WIDTH
} else {
self.size.x
}
}
/// Kopie der Seite als Ausschnitt ab `origin` mit Größe `size`: Alle Objekte werden um
/// `-origin` verschoben (für Export und Bilder einer Auswahl).
pub fn cropped(&self, origin: glam::Vec2, size: glam::Vec2) -> Page {
let mut page = self.clone();
for stroke in &mut page.strokes {
stroke.translate(-origin);
}
page.size = size;
page.is_dirty = true;
page
}
/// Exportausschnitt einer endlosen Seite: der Inhalt mit 10 mm Rand (leer: der
/// Startausschnitt). Die linke obere Ecke liegt auf dem Musterraster.
pub fn endless_export_bounds(&self) -> (glam::Vec2, glam::Vec2) {
let Some(content) = Self::selection_aabb(&self.strokes) else {
return (ENDLESS_HOME, A4_SIZE);
};
let max_width = self.strokes.iter().map(|s| s.width).fold(0.0, f32::max);
let content = content.expanded(max_width / 2.0 + ENDLESS_EXPORT_MARGIN);
let origin = (content.min / ENDLESS_EXPORT_SNAP).floor() * ENDLESS_EXPORT_SNAP;
let origin = origin.max(glam::Vec2::ZERO);
let end = content.max.min(self.size);
(origin, (end - origin).max(glam::Vec2::splat(100.0)))
}
pub fn is_readonly(&self) -> bool { pub fn is_readonly(&self) -> bool {
self.persist == PersistState::ReadOnlyAfterLoadError self.persist == PersistState::ReadOnlyAfterLoadError
} }
@@ -1000,6 +1073,7 @@ impl Page {
/// Liefert Klone der Striche mit den angegebenen IDs. /// Liefert Klone der Striche mit den angegebenen IDs.
pub fn get_strokes(&self, ids: &[u64]) -> Vec<Stroke> { pub fn get_strokes(&self, ids: &[u64]) -> Vec<Stroke> {
let ids: HashSet<u64> = ids.iter().copied().collect();
self.strokes self.strokes
.iter() .iter()
.filter(|s| ids.contains(&s.id)) .filter(|s| ids.contains(&s.id))
@@ -1007,24 +1081,31 @@ impl Page {
.collect() .collect()
} }
/// Ersetzt die Striche mit den IDs aus `modified` an ihrer Stelle. Über eine Tabelle
/// statt je Strich die ganze Seite zu durchsuchen: Beim Ziehen einer großen Auswahl
/// läuft das bei jeder Stiftbewegung.
fn replace_strokes(&mut self, modified: &[Stroke]) {
let index: HashMap<u64, usize> = self
.strokes
.iter()
.enumerate()
.map(|(i, s)| (s.id, i))
.collect();
for m in modified {
if let Some(&pos) = index.get(&m.id) {
self.strokes[pos] = m.clone();
}
}
}
/// Aktualisiert eine Menge geänderter Striche transaktional im History-Stack. /// Aktualisiert eine Menge geänderter Striche transaktional im History-Stack.
pub fn modify_strokes(&mut self, modified: Vec<Stroke>) { pub fn modify_strokes(&mut self, modified: Vec<Stroke>) {
if modified.is_empty() { if modified.is_empty() {
return; return;
} }
let ids: Vec<u64> = modified.iter().map(|s| s.id).collect(); let ids: Vec<u64> = modified.iter().map(|s| s.id).collect();
let before: Vec<Stroke> = self let before = self.get_strokes(&ids);
.strokes self.replace_strokes(&modified);
.iter()
.filter(|s| ids.contains(&s.id))
.cloned()
.collect();
for m in modified.iter() {
if let Some(pos) = self.strokes.iter().position(|s| s.id == m.id) {
self.strokes[pos] = m.clone();
}
}
self.history.push(HistoryAction::ModifyStrokes { self.history.push(HistoryAction::ModifyStrokes {
before, before,
@@ -1035,9 +1116,33 @@ impl Page {
/// Aktualisiert Striche flüchtig (z. B. während des Ziehens / Dragging), OHNE die History zu verändern. /// Aktualisiert Striche flüchtig (z. B. während des Ziehens / Dragging), OHNE die History zu verändern.
pub fn update_strokes_ephemeral(&mut self, modified: &[Stroke]) { pub fn update_strokes_ephemeral(&mut self, modified: &[Stroke]) {
for m in modified { self.replace_strokes(modified);
if let Some(pos) = self.strokes.iter().position(|s| s.id == m.id) { self.is_dirty = true;
self.strokes[pos] = m.clone(); }
/// Wie [`Self::update_strokes_ephemeral`] für eine Transformation der Ausgangsstriche
/// `originals`: setzt die Striche auf der Seite auf ihren Ausgangszustand zurück und wendet
/// `transform` an. Verwendet die Puffer der Seitenstriche weiter, statt bei jeder
/// Stiftbewegung die ganze Auswahl zweimal zu kopieren.
pub fn transform_strokes_ephemeral(
&mut self,
originals: &[Stroke],
transform: impl Fn(&mut Stroke),
) {
let index: HashMap<u64, usize> = self
.strokes
.iter()
.enumerate()
.map(|(i, s)| (s.id, i))
.collect();
for original in originals {
if let Some(&pos) = index.get(&original.id) {
let stroke = &mut self.strokes[pos];
// Nur was Verschieben, Drehen und Skalieren verändern
stroke.points.clone_from(&original.points);
stroke.vertices.clone_from(&original.vertices);
stroke.shape.clone_from(&original.shape);
transform(stroke);
} }
} }
self.is_dirty = true; self.is_dirty = true;
@@ -1048,11 +1153,7 @@ impl Page {
if before.is_empty() || after.is_empty() { if before.is_empty() || after.is_empty() {
return; return;
} }
for m in &after { self.replace_strokes(&after);
if let Some(pos) = self.strokes.iter().position(|s| s.id == m.id) {
self.strokes[pos] = m.clone();
}
}
self.history self.history
.push(HistoryAction::ModifyStrokes { before, after }); .push(HistoryAction::ModifyStrokes { before, after });
self.is_dirty = true; self.is_dirty = true;
@@ -1194,6 +1295,61 @@ impl Notebook {
) )
} }
/// Endloses Notizbuch: eine einzige, praktisch unbegrenzte Seite (Issue #30).
pub fn is_endless(&self) -> bool {
self.pages.len() == 1 && self.pages[0].is_endless()
}
/// Macht aus einem Notizbuch mit einer einzigen Seite (ohne importiertes PDF) ein
/// endloses: Die Seite wird zur endlosen Fläche, ihr Inhalt landet im Startausschnitt.
/// Der Verlauf der Seite wird geleert (er kennt nur die alten Koordinaten).
pub fn make_endless(&mut self) -> bool {
if !self.can_make_endless() {
return false;
}
let page = &mut self.pages[0];
for stroke in &mut page.strokes {
stroke.translate(ENDLESS_HOME);
}
page.size = ENDLESS_PAGE_SIZE;
page.history.clear();
page.is_dirty = true;
true
}
/// Siehe [`Self::make_endless`].
pub fn can_make_endless(&self) -> bool {
matches!(self.pages.as_slice(), [page] if page.is_loaded
&& !page.is_endless()
&& page.pdf_background.is_none())
}
/// Siehe [`Self::make_paged`].
pub fn can_make_paged(&self) -> bool {
if !self.is_endless() || !self.pages[0].is_loaded {
return false;
}
Page::selection_aabb(&self.pages[0].strokes).is_none_or(|content| {
content.min.cmpge(ENDLESS_HOME).all() && content.max.cmple(ENDLESS_HOME + A4_SIZE).all()
})
}
/// Macht aus einem endlosen Notizbuch wieder eine DIN-A4-Seite – nur, wenn der Inhalt im
/// Startausschnitt liegt (sonst ginge er über den Seitenrand). Leert den Verlauf der Seite.
pub fn make_paged(&mut self) -> bool {
if !self.can_make_paged() {
return false;
}
let page = &mut self.pages[0];
for stroke in &mut page.strokes {
stroke.translate(-ENDLESS_HOME);
}
page.size = A4_SIZE;
page.history.clear();
page.is_dirty = true;
true
}
/// Hängt eine neue Seite mit dem angegebenen Hintergrund und der Größe der aktuellen /// Hängt eine neue Seite mit dem angegebenen Hintergrund und der Größe der aktuellen
/// Seite an. /// Seite an.
pub fn add_page(&mut self, background: PageBackground) -> usize { pub fn add_page(&mut self, background: PageBackground) -> usize {
@@ -1401,6 +1557,16 @@ impl Notebook {
if dy.abs() < 1.0 { if dy.abs() < 1.0 {
return None; return None;
} }
// Endlose Seite: keine weiteren Seiten anlegen, nichts über den Rand schieben
let page = &self.pages[page_index];
if page.is_endless()
&& page
.strokes
.iter()
.any(|s| s.aabb.min.y >= y && s.aabb.max.y + dy > page.size.y)
{
return None;
}
// Seiten als durchgehende Spalte: Lage jeder Seite (auch noch anzulegender) // Seiten als durchgehende Spalte: Lage jeder Seite (auch noch anzulegender)
let last_size = self.pages[run.end - 1].size; let last_size = self.pages[run.end - 1].size;
@@ -1691,6 +1857,32 @@ impl PageViewport {
} }
} }
/// Ansicht der endlosen Seite: Maßstab und Lage wie bei einer DIN-A4-Seite, die am
/// Startausschnitt ([`ENDLESS_HOME`]) der endlosen Seite liegt.
pub fn calculate_endless(
window_width: f32,
window_height: f32,
margin: f32,
insets: (f32, f32),
) -> Self {
let a4 = Self::calculate_for(
window_width,
window_height,
margin,
insets,
A4_SIZE,
A4_SIZE,
);
Self {
page_x: a4.page_x - ENDLESS_HOME.x * a4.scale,
page_y: a4.page_y - ENDLESS_HOME.y * a4.scale,
page_width: ENDLESS_PAGE_SIZE.x * a4.scale,
page_height: ENDLESS_PAGE_SIZE.y * a4.scale,
page_size: ENDLESS_PAGE_SIZE,
..a4
}
}
/// Transformiert einen Fenster-Pixelpunkt in Seitenkoordinaten. /// Transformiert einen Fenster-Pixelpunkt in Seitenkoordinaten.
/// ///
/// Liefert `None`, falls der Punkt außerhalb der Seite liegt. /// Liefert `None`, falls der Punkt außerhalb der Seite liegt.
@@ -2046,13 +2238,13 @@ mod tests {
fn test_place_image() { fn test_place_image() {
let center = glam::Vec2::new(PAGE_WIDTH / 2.0, PAGE_HEIGHT / 2.0); let center = glam::Vec2::new(PAGE_WIDTH / 2.0, PAGE_HEIGHT / 2.0);
// Kleines Bild: Bildschirmgröße, zentriert // Kleines Bild: Bildschirmgröße, zentriert
let [tl, tr, br, bl] = place_image(200, 100, center, A4_SIZE); let [tl, tr, br, bl] = place_image(200, 100, center, A4_SIZE, A4_SIZE);
assert!(((tr - tl).length() - 200.0 * IMAGE_UNITS_PER_PIXEL).abs() < 1e-3); assert!(((tr - tl).length() - 200.0 * IMAGE_UNITS_PER_PIXEL).abs() < 1e-3);
assert!(((bl - tl).length() - 100.0 * IMAGE_UNITS_PER_PIXEL).abs() < 1e-3); assert!(((bl - tl).length() - 100.0 * IMAGE_UNITS_PER_PIXEL).abs() < 1e-3);
assert!(((tl + br) / 2.0 - center).length() < 1e-3); assert!(((tl + br) / 2.0 - center).length() < 1e-3);
// Großer Screenshot: auf 80 % der Breite verkleinert, Seitenverhältnis bleibt // Großer Screenshot: auf 80 % der Breite verkleinert, Seitenverhältnis bleibt
let [tl, tr, br, _] = place_image(3840, 2160, center, A4_SIZE); let [tl, tr, br, _] = place_image(3840, 2160, center, A4_SIZE, A4_SIZE);
let size = br - tl; let size = br - tl;
assert!((size.x - PAGE_WIDTH * 0.8).abs() < 1e-2); assert!((size.x - PAGE_WIDTH * 0.8).abs() < 1e-2);
assert!((size.x / size.y - 3840.0 / 2160.0).abs() < 1e-3); assert!((size.x / size.y - 3840.0 / 2160.0).abs() < 1e-3);
@@ -2064,6 +2256,7 @@ mod tests {
400, 400,
glam::Vec2::new(-50.0, PAGE_HEIGHT + 50.0), glam::Vec2::new(-50.0, PAGE_HEIGHT + 50.0),
A4_SIZE, A4_SIZE,
A4_SIZE,
); );
for c in corners { for c in corners {
assert!(c.x >= -1e-3 && c.x <= PAGE_WIDTH + 1e-3); assert!(c.x >= -1e-3 && c.x <= PAGE_WIDTH + 1e-3);
@@ -2515,6 +2708,13 @@ mod tests {
// History should not have been updated yet // History should not have been updated yet
assert!(!page.history.can_redo()); assert!(!page.history.can_redo());
// Ziehen über die Ausgangsstriche: jede Bewegung gilt ab dem Ausgangszustand
for delta in [glam::Vec2::new(5.0, 0.0), glam::Vec2::new(20.0, 30.0)] {
page.transform_strokes_ephemeral(std::slice::from_ref(&s), |st| st.translate(delta));
}
assert_eq!(page.strokes[0], moved);
assert!(!page.history.can_redo());
// Transactional commit (mouse release) // Transactional commit (mouse release)
let before = vec![s.clone()]; let before = vec![s.clone()];
let after = vec![moved.clone()]; let after = vec![moved.clone()];
@@ -2534,4 +2734,90 @@ mod tests {
glam::Vec2::new(30.0, 40.0) glam::Vec2::new(30.0, 40.0)
); );
} }
fn line(id: u64, from: glam::Vec2, to: glam::Vec2) -> Stroke {
let sample = |position, timestamp| StylusSample {
position,
pressure: 0.5,
timestamp,
};
Stroke::new(
id,
vec![sample(from, 0.0), sample(to, 0.01)],
Vec::new(),
[0.0, 0.0, 0.0, 1.0],
)
}
#[test]
fn test_endless_notebook_round_trip() {
let mut nb = Notebook::new();
nb.pages[0].add_stroke(line(
1,
glam::Vec2::new(100.0, 100.0),
glam::Vec2::new(500.0, 300.0),
));
assert!(!nb.is_endless());
assert!(nb.make_endless());
assert!(nb.is_endless());
// Inhalt liegt im Startausschnitt, der Verlauf ist leer
let p = nb.pages[0].strokes[0].points[0].position;
assert!((p - (ENDLESS_HOME + glam::Vec2::new(100.0, 100.0))).length() < 1e-3);
assert!(!nb.pages[0].history.can_undo());
assert!(!nb.make_endless(), "schon endlos");
// Zurück zu Seiten: Inhalt wieder an der alten Stelle
assert!(nb.make_paged());
assert_eq!(nb.pages[0].size, A4_SIZE);
let p = nb.pages[0].strokes[0].points[0].position;
assert!((p - glam::Vec2::new(100.0, 100.0)).length() < 1e-2);
// Inhalt außerhalb des Startausschnitts: kein Zurück
assert!(nb.make_endless());
nb.pages[0].add_stroke(line(2, ENDLESS_HOME - 500.0, ENDLESS_HOME - 400.0));
assert!(!nb.can_make_paged());
assert!(!nb.make_paged());
assert!(nb.is_endless());
// Mehrere Seiten oder PDF-Seite: nicht endlos
let mut two = Notebook::new();
two.add_page(PageBackground::Blank);
assert!(!two.make_endless());
}
#[test]
fn test_endless_viewport_shows_home_like_a4() {
let a4 = PageViewport::calculate_for(1600.0, 900.0, 20.0, (0.0, 0.0), A4_SIZE, A4_SIZE);
let endless = PageViewport::calculate_endless(1600.0, 900.0, 20.0, (0.0, 0.0));
assert_eq!(endless.page_size, ENDLESS_PAGE_SIZE);
assert!((endless.scale - a4.scale).abs() < 1e-6);
// Die Ecke des Startausschnitts liegt dort, wo sonst die A4-Seite beginnt
let home = endless
.screen_to_page(a4.page_x + 1.0, a4.page_y + 1.0)
.unwrap();
assert!((home - ENDLESS_HOME - glam::Vec2::splat(1.0 / a4.scale)).length() < 0.05);
// Weit neben dem Startausschnitt lässt sich weiter schreiben
assert!(endless.screen_to_page(-5000.0, -5000.0).is_some());
}
#[test]
fn test_endless_export_bounds_follow_content() {
let mut page = Page::new(1, PageBackground::Grid5mm).with_size(ENDLESS_PAGE_SIZE);
// Leer: der Startausschnitt
assert_eq!(page.endless_export_bounds(), (ENDLESS_HOME, A4_SIZE));
let from = glam::Vec2::new(30_123.0, 40_456.0);
let to = glam::Vec2::new(36_000.0, 41_000.0);
page.add_stroke(line(1, from, to));
let (origin, size) = page.endless_export_bounds();
// Ecke auf dem Musterraster, Inhalt mit Rand vollständig enthalten
assert_eq!(origin % ENDLESS_EXPORT_SNAP, glam::Vec2::ZERO);
assert!(origin.cmple(from - ENDLESS_EXPORT_MARGIN).all());
assert!((origin + size).cmpge(to + ENDLESS_EXPORT_MARGIN).all());
assert!(size.x < 7_000.0 && size.y < 2_000.0, "{size:?}");
let cropped = page.cropped(origin, size);
assert_eq!(cropped.size, size);
assert!((cropped.strokes[0].points[0].position - (from - origin)).length() < 1e-2);
}
} }
+65
View File
@@ -191,6 +191,28 @@ pub fn export_notebook_to_bytes(
assets: Option<&AssetStore>, assets: Option<&AssetStore>,
pattern: PatternColor, pattern: PatternColor,
) -> Result<Vec<u8>, Box<dyn Error>> { ) -> Result<Vec<u8>, Box<dyn Error>> {
// Endlose Seite: nur der beschriebene Ausschnitt wird zur PDF-Seite
if notebook.pages.iter().any(|p| p.is_endless()) {
let pages = notebook
.pages
.iter()
.map(|p| {
if p.is_endless() {
let (origin, size) = p.endless_export_bounds();
p.cropped(origin, size)
} else {
p.clone()
}
})
.collect();
let cropped = Notebook::from_pages(
pages,
notebook.current_page_index,
notebook.next_stroke_id,
notebook.next_page_id,
);
return export_notebook_to_bytes(&cropped, strategy, assets, pattern);
}
let doc = generate_pdf_document(notebook, strategy, assets, pattern)?; let doc = generate_pdf_document(notebook, strategy, assets, pattern)?;
let bytes = add_alpha_states(fix_text_encoding(doc.save_to_bytes()?, notebook)?, notebook)?; let bytes = add_alpha_states(fix_text_encoding(doc.save_to_bytes()?, notebook)?, notebook)?;
@@ -1772,6 +1794,49 @@ mod tests {
.is_ok()); .is_ok());
} }
#[test]
fn test_endless_page_exports_only_the_written_area() {
let mut notebook = Notebook::new();
assert!(notebook.make_endless());
let sample = |x: f32, y: f32| StylusSample {
position: glam::Vec2::new(x, y),
pressure: 0.5,
timestamp: 0.0,
};
let page = &mut notebook.pages[0];
page.add_stroke(Stroke::new(
1,
vec![sample(20_000.0, 30_000.0), sample(23_000.0, 31_000.0)],
vec![
InkVertex::new(20_000.0, 30_000.0, [0.0, 0.0, 0.0, 1.0]),
InkVertex::new(23_000.0, 31_000.0, [0.0, 0.0, 0.0, 1.0]),
InkVertex::new(23_000.0, 31_010.0, [0.0, 0.0, 0.0, 1.0]),
],
[0.0, 0.0, 0.0, 1.0],
));
let (_, size) = notebook.pages[0].endless_export_bounds();
let bytes = export_notebook_to_bytes(
&notebook,
ExportStrategy::FilledPolygon,
None,
PatternColor::default(),
)
.unwrap();
let sizes = crate::pdf_doc::page_sizes(bytes).unwrap();
assert_eq!(sizes.len(), 1);
// Seiteneinheiten (10 = 1 mm) in Punkt
let pt = |units: f32| units / 10.0 / 25.4 * 72.0;
assert!(
(sizes[0].0 - pt(size.x)).abs() < 1.0,
"{sizes:?} statt {size:?}"
);
assert!(
(sizes[0].1 - pt(size.y)).abs() < 1.0,
"{sizes:?} statt {size:?}"
);
assert!(sizes[0].0 < pt(4_000.0), "nicht die ganze endlose Seite");
}
#[test] #[test]
fn test_page_titles_become_pdf_bookmarks() { fn test_page_titles_become_pdf_bookmarks() {
let mut notebook = Notebook::new(); let mut notebook = Notebook::new();
+12 -3
View File
@@ -20,8 +20,6 @@ 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)"),
("An: Seiten hängen untereinander, Mausrad und Finger scrollen (Strg+Mausrad zoomt); wer unten auf der letzten Seite schreibt, bekommt automatisch eine neue Seite.\nAus: Seite für Seite blättern.", "On: pages are stacked, mouse wheel and finger scroll (Ctrl+wheel zooms); writing at the bottom of the last page adds a new page automatically.\nOff: flip page by page."),
("ANSICHT", "VIEW"),
("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"),
@@ -88,10 +86,12 @@ pub const EN: &[(&str, &str)] = &[
("Eigene Farbe wählen", "Choose a custom color"), ("Eigene Farbe wählen", "Choose a custom color"),
("Ein Ordner kann nicht in sich selbst verschoben werden", "A folder cannot be moved into itself"), ("Ein Ordner kann nicht in sich selbst verschoben werden", "A folder cannot be moved into itself"),
("Ein-/Ausgabefehler: {e}", "I/O error: {e}"), ("Ein-/Ausgabefehler: {e}", "I/O error: {e}"),
("Eine Fläche ohne Rand: in alle Richtungen schreiben, verschieben und zoomen. Wechsel nur bei einer einzigen Seite ohne PDF bzw. zurück, solange alles im Startausschnitt liegt.", "One surface without edges: write, pan and zoom in every direction. Switching only works for a single page without a PDF, and back only while everything lies in the starting area."),
("Einfügen (Strg+V)", "Paste (Ctrl+V)"), ("Einfügen (Strg+V)", "Paste (Ctrl+V)"),
("EINGABE", "INPUT"), ("EINGABE", "INPUT"),
("Einstellung nicht gespeichert: {err}", "Setting not saved: {err}"), ("Einstellung nicht gespeichert: {err}", "Setting not saved: {err}"),
("Ellipse", "Ellipse"), ("Ellipse", "Ellipse"),
("Endlos geht nur bei einem Notizbuch mit einer einzigen Seite ohne PDF", "Endless only works for a notebook with a single page without a PDF"),
("Entwicklung / Lokal", "Development / local"), ("Entwicklung / Lokal", "Development / local"),
("Export fehlgeschlagen: {err}", "Export failed: {err}"), ("Export fehlgeschlagen: {err}", "Export failed: {err}"),
("Farbe {number} – erneut tippen, um sie anzupassen", "Color {number} – tap again to adjust it"), ("Farbe {number} – erneut tippen, um sie anzupassen", "Color {number} – tap again to adjust it"),
@@ -267,6 +267,7 @@ pub const EN: &[(&str, &str)] = &[
("Schwarz", "Black"), ("Schwarz", "Black"),
("Sechseck (Hexagon)", "Hexagon"), ("Sechseck (Hexagon)", "Hexagon"),
("Seite als", "Page as"), ("Seite als", "Page as"),
("Seite für Seite blättern", "Flip page by page"),
("Seite ist schreibgeschützt", "Page is read-only"), ("Seite ist schreibgeschützt", "Page is read-only"),
("Seite konnte nicht geladen werden", "Page could not be loaded"), ("Seite konnte nicht geladen werden", "Page could not be loaded"),
("Seite wird exportiert …", "Exporting page …"), ("Seite wird exportiert …", "Exporting page …"),
@@ -285,7 +286,11 @@ pub const EN: &[(&str, &str)] = &[
("Seite {number} – {title}", "Page {number} – {title}"), ("Seite {number} – {title}", "Page {number} – {title}"),
("Seite {number}", "Page {number}"), ("Seite {number}", "Page {number}"),
("Seiten ({count})", "Pages ({count})"), ("Seiten ({count})", "Pages ({count})"),
("Seiten durchgehend scrollen", "Scroll pages continuously"), ("SEITEN DIESES NOTIZBUCHS", "PAGES OF THIS NOTEBOOK"),
("Seiten hängen untereinander, Mausrad und Finger scrollen (Strg+Mausrad zoomt); wer unten auf der letzten Seite schreibt, bekommt automatisch eine neue Seite.", "Pages are stacked, mouse wheel and finger scroll (Ctrl+wheel zooms); writing at the bottom of the last page adds a new page automatically."),
("Seiten:", "Pages:"),
("Seitenverhältnis frei – tippen zum Sperren", "Aspect ratio free – tap to lock"),
("Seitenverhältnis gesperrt – tippen zum Freigeben", "Aspect ratio locked – tap to unlock"),
("Serialisierungsfehler: {e}", "Serialization error: {e}"), ("Serialisierungsfehler: {e}", "Serialization error: {e}"),
("Server-Einstellungen gespeichert", "Server settings saved"), ("Server-Einstellungen gespeichert", "Server settings saved"),
("Server:", "Server:"), ("Server:", "Server:"),
@@ -373,6 +378,7 @@ pub const EN: &[(&str, &str)] = &[
("ZOOM", "ZOOM"), ("ZOOM", "ZOOM"),
("Zu viele Weiterleitungen (Redirect-Schleife abgebrochen).", "Too many redirects (redirect loop stopped)."), ("Zu viele Weiterleitungen (Redirect-Schleife abgebrochen).", "Too many redirects (redirect loop stopped)."),
("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ücksetzen (Strg+0)", "Reset (Ctrl+0)"), ("Zurücksetzen (Strg+0)", "Reset (Ctrl+0)"),
("{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"),
@@ -395,6 +401,7 @@ pub const EN: &[(&str, &str)] = &[
("↩ Rückgängig", "↩ Undo"), ("↩ Rückgängig", "↩ Undo"),
("⏳ Update lädt … {percent} %", "⏳ Downloading update … {percent} %"), ("⏳ Update lädt … {percent} %", "⏳ Downloading update … {percent} %"),
("⏷ {mm} mm Platz", "⏷ {mm} mm space"), ("⏷ {mm} mm Platz", "⏷ {mm} mm space"),
("♾ Endlos", "♾ Endless"),
("⚙ Erweiterte Server-Einstellungen", "⚙ Advanced server settings"), ("⚙ Erweiterte Server-Einstellungen", "⚙ Advanced server settings"),
("⚠ Beschädigt", "⚠ Damaged"), ("⚠ Beschädigt", "⚠ Damaged"),
("⚠ Datenintegritäts-Warnung", "⚠ Data integrity warning"), ("⚠ Datenintegritäts-Warnung", "⚠ Data integrity warning"),
@@ -416,12 +423,14 @@ pub const EN: &[(&str, &str)] = &[
("📂 Ordner öffnen", "📂 Open folder"), ("📂 Ordner öffnen", "📂 Open folder"),
("📄 Als PDF exportieren (Strg+E)", "📄 Export as PDF (Ctrl+E)"), ("📄 Als PDF exportieren (Strg+E)", "📄 Export as PDF (Ctrl+E)"),
("📄 PDF importieren …", "📄 Import PDF …"), ("📄 PDF importieren …", "📄 Import PDF …"),
("📄 Feste Seiten", "📄 Fixed pages"),
("📄 PDF importieren", "📄 Import PDF"), ("📄 PDF importieren", "📄 Import PDF"),
("📋 Einfügen", "📋 Paste"), ("📋 Einfügen", "📋 Paste"),
("📑 Inhalt", "📑 Contents"), ("📑 Inhalt", "📑 Contents"),
("📚 Alle", "📚 All"), ("📚 Alle", "📚 All"),
("📚 Notizbücher", "📚 Notebooks"), ("📚 Notizbücher", "📚 Notebooks"),
("📚 Oberste Ebene", "📚 Top level"), ("📚 Oberste Ebene", "📚 Top level"),
("📜 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"),
("🔄 Suche nach Updates …", "🔄 Checking for updates …"), ("🔄 Suche nach Updates …", "🔄 Checking for updates …"),
("🔄 Updates & Versionen", "🔄 Updates & versions"), ("🔄 Updates & Versionen", "🔄 Updates & versions"),
+167 -65
View File
@@ -31,12 +31,12 @@ use ink_notes::osk::{self, KeyboardRequester};
use ink_notes::pdf_doc::PdfRenderService; use ink_notes::pdf_doc::PdfRenderService;
use ink_notes::platform::{self, DisplayBackend}; use ink_notes::platform::{self, DisplayBackend};
use ink_notes::selection::{ use ink_notes::selection::{
render_selection_overlay, DragMode, SelectionHandleHit, SelectionState, render_selection_overlay, scale_factors, DragMode, SelectionHandleHit, SelectionState,
}; };
use ink_notes::settings::{ use ink_notes::settings::{
SidebarSide, StylusAction, StylusButton, UiSettings, MAX_STYLUS_BUTTONS, SidebarSide, StylusAction, StylusButton, UiSettings, MAX_STYLUS_BUTTONS,
}; };
use ink_notes::storage::{self, NotebookManifest}; use ink_notes::storage::{self, NotebookLayout, NotebookManifest};
use ink_notes::t; use ink_notes::t;
use ink_notes::ui::{self, ActiveTool, UiAction, UiState}; use ink_notes::ui::{self, ActiveTool, UiAction, UiState};
use ink_notes::updater::UpdaterController; use ink_notes::updater::UpdaterController;
@@ -326,9 +326,12 @@ fn commit_stroke_to_notebook(
) { ) {
// Zweite Gerade über Kreuz mit dem vorigen Strich: beide werden zum Achsenkreuz; der // Zweite Gerade über Kreuz mit dem vorigen Strich: beide werden zum Achsenkreuz; der
// vorige Strich wird ersetzt, ein Rückgängig stellt ihn wieder her // vorige Strich wird ersetzt, ein Rückgängig stellt ihn wieder her
if let Some(shape) = &snapped_shape { let stroke_width = ui_state.current_stroke_width_mm() * ink_notes::document::UNITS_PER_MM;
if let Some(shape) = snapped_shape
.as_ref()
.filter(|_| ui_state.active_tool != ActiveTool::Highlighter)
{
if let Some((previous_id, cross)) = axis_cross_with_previous(notebook, shape) { if let Some((previous_id, cross)) = axis_cross_with_previous(notebook, shape) {
let stroke_width = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM;
canvas.cancel_stroke(); canvas.cancel_stroke();
let stroke = geometry::shape_to_stroke( let stroke = geometry::shape_to_stroke(
cross, cross,
@@ -347,7 +350,6 @@ fn commit_stroke_to_notebook(
let new_id = notebook.next_stroke_id(); let new_id = notebook.next_stroke_id();
let stroke_opt = if let Some(shape) = snapped_shape { let stroke_opt = if let Some(shape) = snapped_shape {
let stroke_width = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM;
let color = canvas.current_ink_color; let color = canvas.current_ink_color;
canvas.cancel_stroke(); canvas.cancel_stroke();
Some(geometry::shape_to_stroke( Some(geometry::shape_to_stroke(
@@ -577,6 +579,7 @@ fn insert_image_on_page(
imported.height, imported.height,
center, center,
notebook.current_page().size, notebook.current_page().size,
notebook.current_page().reference_size(),
); );
let page = notebook.current_page_mut(); let page = notebook.current_page_mut();
page.add_stroke(ink_notes::document::Stroke::new_image( page.add_stroke(ink_notes::document::Stroke::new_image(
@@ -649,12 +652,18 @@ fn selection_press(
.. ..
}, },
_, _,
) => DragMode::Scale { ) => {
let initial_strokes = page.get_strokes(&selection_state.selected_ids);
// Schloss der Auswahl (Bilder: an, Issue #29); Shift sperrt zusätzlich
let keep_aspect = shift || selection_state.keeps_aspect(page);
DragMode::Scale {
fixed_corner_page, fixed_corner_page,
start_pos_page, start_pos_page,
initial_box_size, initial_box_size,
initial_strokes: page.get_strokes(&selection_state.selected_ids), initial_strokes,
}, keep_aspect,
}
}
(SelectionHandleHit::Body, Some(p)) => DragMode::Move { (SelectionHandleHit::Body, Some(p)) => DragMode::Move {
start_page: p, start_page: p,
initial_strokes: page.get_strokes(&selection_state.selected_ids), initial_strokes: page.get_strokes(&selection_state.selected_ids),
@@ -684,70 +693,65 @@ fn selection_press(
_ => return false, _ => return false,
}; };
selection_state.drag_mode = mode; selection_state.drag_mode = mode;
selection_state.pending_drag = None;
true true
} }
/// Wendet die zuletzt gemeldete Zugposition der Auswahl an (siehe [`selection_drag`]).
fn flush_selection_drag(
page: &mut ink_notes::document::Page,
canvas: &mut CanvasState,
selection_state: &mut SelectionState,
) {
if let Some(page_pt) = selection_state.pending_drag.take() {
selection_drag(page_pt, page, canvas, selection_state);
}
}
/// Auswahlwerkzeug: Ziehen nach `page_pt` – Rahmen aufziehen oder die Auswahl verschieben, /// Auswahlwerkzeug: Ziehen nach `page_pt` – Rahmen aufziehen oder die Auswahl verschieben,
/// drehen bzw. skalieren (vorläufig, ohne Undo-Schritt). /// drehen bzw. skalieren (vorläufig, ohne Undo-Schritt). Bei Stiftbewegungen über
/// `pending_drag` und [`flush_selection_drag`] nur einmal je Bild, weil das bei großen
/// Auswahlen teuer ist.
fn selection_drag( fn selection_drag(
page_pt: Vec2, page_pt: Vec2,
page: &mut ink_notes::document::Page, page: &mut ink_notes::document::Page,
canvas: &mut CanvasState, canvas: &mut CanvasState,
selection_state: &mut SelectionState, selection_state: &mut SelectionState,
) { ) {
let mut updated = match &mut selection_state.drag_mode { match &mut selection_state.drag_mode {
DragMode::BoxSelect { current_page, .. } => { DragMode::BoxSelect { current_page, .. } => {
*current_page = page_pt; *current_page = page_pt;
return; return;
} }
DragMode::None => return, DragMode::None => return,
DragMode::Move { DragMode::Move {
initial_strokes, .. start_page,
} initial_strokes,
| DragMode::Rotate { } => {
initial_strokes, ..
}
| DragMode::Scale {
initial_strokes, ..
} => initial_strokes.clone(),
};
match &selection_state.drag_mode {
DragMode::Move { start_page, .. } => {
let delta = page_pt - *start_page; let delta = page_pt - *start_page;
updated.iter_mut().for_each(|s| s.translate(delta)); page.transform_strokes_ephemeral(initial_strokes, |s| s.translate(delta));
} }
DragMode::Rotate { DragMode::Rotate {
center_page, center_page,
start_angle, start_angle,
.. initial_strokes,
} => { } => {
let angle = (page_pt.y - center_page.y).atan2(page_pt.x - center_page.x) - start_angle; let angle = (page_pt.y - center_page.y).atan2(page_pt.x - center_page.x) - *start_angle;
updated let center = *center_page;
.iter_mut() page.transform_strokes_ephemeral(initial_strokes, |s| s.rotate(center, angle));
.for_each(|s| s.rotate(*center_page, angle));
} }
DragMode::Scale { DragMode::Scale {
fixed_corner_page, fixed_corner_page,
start_pos_page, start_pos_page,
keep_aspect,
initial_strokes,
.. ..
} => { } => {
let orig = *start_pos_page - *fixed_corner_page; let fixed = *fixed_corner_page;
let new = page_pt - *fixed_corner_page; let factors = scale_factors(fixed, *start_pos_page, page_pt, *keep_aspect);
let factor = |n: f32, o: f32| { page.transform_strokes_ephemeral(initial_strokes, |s| s.scale(fixed, factors));
if o.abs() > 1.0 {
(n / o).clamp(0.05, 20.0)
} else {
1.0
} }
};
let factors = Vec2::new(factor(new.x, orig.x), factor(new.y, orig.y));
updated
.iter_mut()
.for_each(|s| s.scale(*fixed_corner_page, factors));
} }
DragMode::BoxSelect { .. } | DragMode::None => return,
}
page.update_strokes_ephemeral(&updated);
canvas.sync_page(page); canvas.sync_page(page);
} }
@@ -760,7 +764,10 @@ fn selection_release(
manifest: &mut NotebookManifest, manifest: &mut NotebookManifest,
autosave: &mut AutosaveController, autosave: &mut AutosaveController,
selection_state: &mut SelectionState, selection_state: &mut SelectionState,
canvas: &mut CanvasState,
) -> bool { ) -> bool {
// Die letzte Bewegung gehört noch zum Ziehen
flush_selection_drag(notebook.current_page_mut(), canvas, selection_state);
match std::mem::take(&mut selection_state.drag_mode) { match std::mem::take(&mut selection_state.drag_mode) {
DragMode::BoxSelect { DragMode::BoxSelect {
start_page, start_page,
@@ -830,7 +837,7 @@ fn insert_text_on_page(
let mut edit = ink_notes::text::TextEditing::new_at( let mut edit = ink_notes::text::TextEditing::new_at(
notebook.current_page().id, notebook.current_page().id,
position, position,
notebook.current_page().size.x, notebook.current_page().text_right_edge(position.x),
ui_state.text_size_pt * ink_notes::text::UNITS_PER_PT, ui_state.text_size_pt * ink_notes::text::UNITS_PER_PT,
ui_state.text_font, ui_state.text_font,
ui_state.text_color, ui_state.text_color,
@@ -924,7 +931,7 @@ fn text_tool_click(
None => Some(ink_notes::text::TextEditing::new_at( None => Some(ink_notes::text::TextEditing::new_at(
page.id, page.id,
page_pt, page_pt,
page.size.x, page.text_right_edge(page_pt.x),
ui_state.text_size_pt * ink_notes::text::UNITS_PER_PT, ui_state.text_size_pt * ink_notes::text::UNITS_PER_PT,
ui_state.text_font, ui_state.text_font,
ui_state.text_color, ui_state.text_color,
@@ -1316,7 +1323,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// 3. egui Context und State initialisieren // 3. egui Context und State initialisieren
let egui_ctx = egui::Context::default(); let egui_ctx = egui::Context::default();
egui_ctx.set_visuals(egui::Visuals::dark()); ui::configure_context(&egui_ctx);
// 4. In-App Gitea Updater initialisieren und im Hintergrund nach Releases fragen // 4. In-App Gitea Updater initialisieren und im Hintergrund nach Releases fragen
// Ergebnisse der PDF-Exporte aus dem Hintergrundthread // Ergebnisse der PDF-Exporte aus dem Hintergrundthread
@@ -1417,6 +1424,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Langes Drücken mit einem Finger öffnet das Kontextmenü // Langes Drücken mit einem Finger öffnet das Kontextmenü
let mut long_press = ui::LongPress::default(); let mut long_press = ui::LongPress::default();
let mut touch_pan_last_pos = (0.0_f32, 0.0_f32); let mut touch_pan_last_pos = (0.0_f32, 0.0_f32);
// Nachgleiten nach schnellem Wischen mit dem Finger (Issue #28)
let mut fling = ink_notes::viewport::Fling::default();
// Stylus / Digitizer Zustand (Octotablet / Wayland Tablet Protocol) // Stylus / Digitizer Zustand (Octotablet / Wayland Tablet Protocol)
let mut is_stylus_drawing = false; let mut is_stylus_drawing = false;
@@ -1524,6 +1533,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
event_loop.run(move |event, target| { event_loop.run(move |event, target| {
// Anordnung der Seiten gehört zum offenen Notizbuch (Issue #30)
// Endlos ist ein Notizbuch an seiner einzigen, endlosen Seite erkennbar
ui_state.endless = notebook.is_endless();
ui_state.continuous_scroll = !ui_state.endless
&& manifest.layout_or(ui_settings.continuous_scroll) == NotebookLayout::Continuous;
ui_state.endless_switch_possible = if ui_state.endless {
notebook.can_make_paged()
} else {
notebook.can_make_endless()
};
match event { match event {
Event::AboutToWait => { Event::AboutToWait => {
let prev_status = autosave.status(); let prev_status = autosave.status();
@@ -1609,7 +1628,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
if is_stylus_selecting { if is_stylus_selecting {
is_stylus_selecting = false; is_stylus_selecting = false;
selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state); selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state, &mut canvas);
window.request_redraw(); window.request_redraw();
} }
if let Some(tool) = tool_before_button.take() { if let Some(tool) = tool_before_button.take() {
@@ -1705,7 +1724,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
} else if is_stylus_selecting { } else if is_stylus_selecting {
if let Some(page_pt) = canvas.screen_to_page(sx, sy) { if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
selection_drag(page_pt, notebook.current_page_mut(), &mut canvas, &mut selection_state); selection_state.pending_drag = Some(page_pt);
window.request_redraw(); window.request_redraw();
} }
} else if is_stylus_drawing && is_drawing { } else if is_stylus_drawing && is_drawing {
@@ -1849,7 +1868,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if is_stylus_selecting { if is_stylus_selecting {
is_stylus_selecting = false; is_stylus_selecting = false;
if selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state) { if selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state, &mut canvas) {
window.request_redraw(); window.request_redraw();
} }
} }
@@ -1914,9 +1933,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
stroke_snapped_shape = Some(shape.clone()); stroke_snapped_shape = Some(shape.clone());
// Kreuzt die Gerade den vorigen Strich, zeigt die Vorschau schon das // Kreuzt die Gerade den vorigen Strich, zeigt die Vorschau schon das
// Achsenkreuz (übernommen wird es beim Absetzen) // Achsenkreuz (übernommen wird es beim Absetzen)
let shape = axis_cross_with_previous(&notebook, &shape) // Textmarker bilden kein Achsenkreuz
.map_or(shape, |(_, cross)| cross); let shape = if ui_state.active_tool == ActiveTool::Highlighter {
let width_units = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM; shape
} else {
axis_cross_with_previous(&notebook, &shape)
.map_or(shape, |(_, cross)| cross)
};
let width_units = ui_state.current_stroke_width_mm() * ink_notes::document::UNITS_PER_MM;
let (samples, vertices) = geometry::shape_to_mesh(&shape, width_units, canvas.current_ink_color); let (samples, vertices) = geometry::shape_to_mesh(&shape, width_units, canvas.current_ink_color);
canvas.set_live_shape_mesh(samples, vertices); canvas.set_live_shape_mesh(samples, vertices);
ui_state.set_export_message(t!("{shape} eingerastet!", shape = shape.name())); ui_state.set_export_message(t!("{shape} eingerastet!", shape = shape.name()));
@@ -1946,6 +1970,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
window.request_redraw(); window.request_redraw();
} }
// Nachgleiten nach dem Wischen
if let Some(delta) = fling.step(Instant::now()) {
canvas.pan(delta.x, delta.y);
window.request_redraw();
}
// Von egui angefordertes, verzögertes Neuzeichnen (Tooltips, Animationen) // Von egui angefordertes, verzögertes Neuzeichnen (Tooltips, Animationen)
if let Some(at) = egui_repaint_at { if let Some(at) = egui_repaint_at {
if Instant::now() >= at { if Instant::now() >= at {
@@ -1961,6 +1991,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|| (is_drawing && !stroke_already_snapped) || (is_drawing && !stroke_already_snapped)
|| is_stylus_drawing || is_stylus_drawing
|| is_stylus_in_proximity || is_stylus_in_proximity
|| fling.is_active()
{ {
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) = [egui_repaint_at, long_press.deadline()] } else if let Some(at) = [egui_repaint_at, long_press.deadline()]
@@ -2065,6 +2096,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
pinch_prev_center = None; pinch_prev_center = None;
is_touch_drawing = false; is_touch_drawing = false;
is_touch_panning = false; is_touch_panning = false;
fling.stop();
is_stylus_drawing = false; is_stylus_drawing = false;
is_stylus_selecting = false; is_stylus_selecting = false;
is_stylus_in_proximity = false; is_stylus_in_proximity = false;
@@ -2521,6 +2553,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
return; return;
} }
fling.stop();
// 1. Pan via mittlere Maustaste oder Leertaste + Linksklick // 1. Pan via mittlere Maustaste oder Leertaste + Linksklick
if button == MouseButton::Middle || (button == MouseButton::Left && is_space_pressed) { if button == MouseButton::Middle || (button == MouseButton::Left && is_space_pressed) {
is_panning = true; is_panning = true;
@@ -2617,7 +2650,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
if ui_state.active_tool == ActiveTool::Select if ui_state.active_tool == ActiveTool::Select
&& !is_stylus_selecting && !is_stylus_selecting
&& selection_release(modifiers.shift_key(), &mut notebook, &mut manifest, &mut autosave, &mut selection_state) && selection_release(modifiers.shift_key(), &mut notebook, &mut manifest, &mut autosave, &mut selection_state, &mut canvas)
{ {
window.request_redraw(); window.request_redraw();
} }
@@ -2673,7 +2706,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
window.request_redraw(); window.request_redraw();
} else if ui_state.active_tool == ActiveTool::Select && selection_state.drag_mode != DragMode::None { } else if ui_state.active_tool == ActiveTool::Select && selection_state.drag_mode != DragMode::None {
if let Some(page_pt) = canvas.screen_to_page(sx, sy) { if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
selection_drag(page_pt, notebook.current_page_mut(), &mut canvas, &mut selection_state); selection_state.pending_drag = Some(page_pt);
window.request_redraw(); window.request_redraw();
} }
} else if is_erasing { } else if is_erasing {
@@ -2706,15 +2739,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
WindowEvent::MouseWheel { delta, .. } => { WindowEvent::MouseWheel { delta, .. } => {
fling.stop();
if is_pointer_in_ui { if is_pointer_in_ui {
return; return;
} }
// Durchgehende Ansicht: Mausrad scrollt (mit Umschalt waagerecht), // Durchgehende Ansicht und endlose Fläche: Mausrad scrollt (mit Umschalt
// Strg + Mausrad zoomt // waagerecht), Strg + Mausrad zoomt
if ui_state.continuous_scroll && !modifiers.control_key() { if (ui_state.continuous_scroll || ui_state.endless) && !modifiers.control_key() {
let (mut dx, mut dy) = match delta { let (mut dx, mut dy) = match delta {
MouseScrollDelta::LineDelta(x, y) => { MouseScrollDelta::LineDelta(x, y) => {
let line = 60.0 * window.scale_factor() as f32; let line = 120.0 * window.scale_factor() as f32;
(x * line, y * line) (x * line, y * line)
} }
MouseScrollDelta::PixelDelta(pos) => (pos.x as f32, pos.y as f32), MouseScrollDelta::PixelDelta(pos) => (pos.x as f32, pos.y as f32),
@@ -2787,6 +2821,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
match touch.phase { match touch.phase {
TouchPhase::Started => { TouchPhase::Started => {
// Jede neue Berührung hält ein gleitendes Blatt an
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(
&egui_ctx, &egui_ctx,
@@ -2942,6 +2978,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let dy = ty - touch_pan_last_pos.1; let dy = ty - touch_pan_last_pos.1;
touch_pan_last_pos = (tx, ty); touch_pan_last_pos = (tx, ty);
canvas.pan(dx, dy); canvas.pan(dx, dy);
fling.track(Vec2::new(dx, dy), Instant::now());
window.request_redraw(); window.request_redraw();
} else if is_touch_drawing { } else if is_touch_drawing {
current_pressure = pressure; current_pressure = pressure;
@@ -2974,7 +3011,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
} else if ui_state.active_tool == ActiveTool::Select && selection_state.drag_mode != DragMode::None { } else if ui_state.active_tool == ActiveTool::Select && selection_state.drag_mode != DragMode::None {
if let Some(page_pt) = canvas.screen_to_page(tx, ty) { if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
selection_drag(page_pt, notebook.current_page_mut(), &mut canvas, &mut selection_state); selection_state.pending_drag = Some(page_pt);
window.request_redraw(); window.request_redraw();
} }
} }
@@ -2988,6 +3025,21 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
is_pinch_gesturing = false; is_pinch_gesturing = false;
pinch_prev_distance = None; pinch_prev_distance = None;
pinch_prev_center = None; pinch_prev_center = None;
if is_touch_panning && touch.phase == TouchPhase::Ended {
// Letztes Stück bis zum Abheben noch mitnehmen
let (dx, dy) = (tx - touch_pan_last_pos.0, ty - touch_pan_last_pos.1);
canvas.pan(dx, dy);
fling.track(Vec2::new(dx, dy), Instant::now());
let velocity = fling.release(Instant::now());
if std::env::var_os("INK_NOTES_DEBUG_FLING").is_some() {
eprintln!(
"[Nachgleiten] {:.0} px/s beim Abheben, gleitet: {}",
velocity.length(),
fling.is_active()
);
}
window.request_redraw();
}
is_touch_panning = false; is_touch_panning = false;
if is_erasing { if is_erasing {
@@ -3001,7 +3053,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
if ui_state.active_tool == ActiveTool::Select if ui_state.active_tool == ActiveTool::Select
&& selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state) && selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state, &mut canvas)
{ {
window.request_redraw(); window.request_redraw();
} }
@@ -3044,6 +3096,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
WindowEvent::RedrawRequested => { WindowEvent::RedrawRequested => {
flush_selection_drag(notebook.current_page_mut(), &mut canvas, &mut selection_state);
// 0. Durchgehende Ansicht: Nach dem Scrollen oder Zoomen wird die Seite in // 0. Durchgehende Ansicht: Nach dem Scrollen oder Zoomen wird die Seite in
// der Fenstermitte zur aktuellen (nicht während eines Strichs, einer // der Fenstermitte zur aktuellen (nicht während eines Strichs, einer
// Auswahl oder beim Ziehen des Lineals). Wer auf einer Nachbarseite // Auswahl oder beim Ziehen des Lineals). Wer auf einer Nachbarseite
@@ -3051,7 +3104,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
canvas.set_fit_box(notebook.fit_box()); canvas.set_fit_box(notebook.fit_box());
if ui_state.continuous_scroll { if ui_state.continuous_scroll {
let stack = notebook.page_stack(); let stack = notebook.page_stack();
canvas.clamp_scroll(&stack); if canvas.clamp_scroll(&stack) {
// Am Anfang oder Ende des Notizbuchs angekommen
fling.stop();
}
// Unabhängig von der Bezugsseite: Lage bezogen auf die erste Seite // Unabhängig von der Bezugsseite: Lage bezogen auf die erste Seite
let scroll = [ let scroll = [
canvas.camera.zoom, canvas.camera.zoom,
@@ -3103,6 +3159,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// 3. Toolbar & Widgets rendern // 3. Toolbar & Widgets rendern
updater.update(); updater.update();
ui_state.page_view = Some(page_view(&canvas, window.scale_factor() as f32)); ui_state.page_view = Some(page_view(&canvas, window.scale_factor() as f32));
ui_state.selection_aspect_lock = (!selection_state.is_empty())
.then(|| selection_state.keeps_aspect(notebook.current_page()));
ui_state.restorable_page = last_deleted_page ui_state.restorable_page = last_deleted_page
.as_ref() .as_ref()
.filter(|(id, _, _)| *id == manifest.id) .filter(|(id, _, _)| *id == manifest.id)
@@ -3203,6 +3261,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
UiAction::ToggleAspectLock => {
if !selection_state.is_empty() {
selection_state.toggle_aspect_lock(notebook.current_page());
window.request_redraw();
}
}
UiAction::RotateSelected => { UiAction::RotateSelected => {
if !selection_state.is_empty() if !selection_state.is_empty()
&& selection_state.rotate_selected_90_deg(notebook.current_page_mut()) { && selection_state.rotate_selected_90_deg(notebook.current_page_mut()) {
@@ -3292,6 +3356,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
} }
} }
// Die endlose Fläche bleibt eine einzige Seite
UiAction::AddPage if notebook.is_endless() => {}
UiAction::AddPage => { UiAction::AddPage => {
// Leere Seite direkt nach der aktuellen (in PDFs: nach der Folie, // Leere Seite direkt nach der aktuellen (in PDFs: nach der Folie,
// auf der man gerade ist) und dorthin wechseln // auf der man gerade ist) und dorthin wechseln
@@ -3556,11 +3622,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
window.request_redraw(); window.request_redraw();
} }
UiAction::CreateNotebook { title, folder } => { UiAction::CreateNotebook { title, folder, layout } => {
let _ = autosave.flush(); let _ = autosave.flush();
match storage::create_new_notebook(&storage_base_dir, &title) { match storage::create_new_notebook(&storage_base_dir, &title) {
Ok((new_nb, new_manifest, new_dir)) => { Ok((mut new_nb, mut new_manifest, new_dir)) => {
let id = new_manifest.id.clone(); let id = new_manifest.id.clone();
new_manifest.layout = Some(layout);
if layout == NotebookLayout::Endless {
new_nb.make_endless();
}
change_library(&storage_base_dir, &mut ui_state, |library| { change_library(&storage_base_dir, &mut ui_state, |library| {
library.move_notebook(&id, &folder) library.move_notebook(&id, &folder)
}); });
@@ -3570,6 +3640,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
notebook = new_nb; notebook = new_nb;
manifest = new_manifest; manifest = new_manifest;
storage_dir = new_dir; storage_dir = new_dir;
autosave.mark_dirty(&notebook, &mut manifest);
canvas.reset_view();
selection_state.clear(); selection_state.clear();
ui_state.current_notebook_id = manifest.id.clone(); ui_state.current_notebook_id = manifest.id.clone();
ui_state.document_title = manifest.title.clone(); ui_state.document_title = manifest.title.clone();
@@ -3814,9 +3886,39 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state); save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
window.request_redraw(); window.request_redraw();
} }
UiAction::SetContinuousScroll(on) => { UiAction::ScrollTo(y) => {
ui_settings.continuous_scroll = on; fling.stop();
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state); canvas.scroll_center_to(y);
window.request_redraw();
}
UiAction::SetNotebookLayout(layout) => {
// Von und zur endlosen Fläche nur mit einer Seite, deren Inhalt passt
let was_endless = notebook.is_endless();
let to_endless = layout == NotebookLayout::Endless;
let converted = match (was_endless, to_endless) {
(false, true) => notebook.make_endless(),
(true, false) => notebook.make_paged(),
_ => true,
};
if !converted {
ui_state.set_export_message(if to_endless {
t!("Endlos geht nur bei einem Notizbuch mit einer einzigen Seite ohne PDF").to_string()
} else {
t!("Zurück zu Seiten geht nur, solange alles im Startausschnitt liegt").to_string()
});
} else {
manifest.layout = Some(layout);
ui_state.endless = to_endless;
ui_state.continuous_scroll = layout == NotebookLayout::Continuous;
if was_endless != to_endless {
selection_state.clear();
canvas.sync_page(notebook.current_page_mut());
canvas.reset_view();
autosave.mark_dirty(&notebook, &mut manifest);
} else {
autosave.mark_manifest_dirty(&manifest);
}
}
window.request_redraw(); window.request_redraw();
} }
UiAction::SaveStylusButtons => { UiAction::SaveStylusButtons => {
+11 -25
View File
@@ -13,7 +13,7 @@ use hayro::{PixmapSettings, RenderCache, RenderSettings};
use crate::assets::{AssetStore, RgbaImage}; use crate::assets::{AssetStore, RgbaImage};
use crate::background::PatternColor; use crate::background::PatternColor;
use crate::document::{Aabb, Notebook, Page, PageBackground, A4_WIDTH_MM, PAGE_WIDTH}; use crate::document::{Aabb, Notebook, Page, PageBackground};
use crate::export::{export_notebook_to_bytes, ExportStrategy}; use crate::export::{export_notebook_to_bytes, ExportStrategy};
use crate::t; use crate::t;
@@ -145,38 +145,24 @@ pub fn selection_image(
}; };
let max_width = selected.strokes.iter().map(|s| s.width).fold(0.0, f32::max); let max_width = selected.strokes.iter().map(|s| s.width).fold(0.0, f32::max);
let bounds: Aabb = bounds.expanded(max_width / 2.0 + 4.0); let bounds: Aabb = bounds.expanded(max_width / 2.0 + 4.0);
let size = bounds.max - bounds.min;
let full = rasterize( if size.min_element() < 1.0 {
single_page_pdf(&selected, assets, PatternColor::default())?, return Ok(None);
}
// Nur den Ausschnitt rastern (auf der endlosen Seite wäre die ganze Seite riesig)
let cropped = selected.cropped(bounds.min, size);
let image = rasterize(
single_page_pdf(&cropped, assets, PatternColor::default())?,
SELECTION_PNG_DPI, SELECTION_PNG_DPI,
true, true,
)?; )?;
// Seiteneinheiten → Pixel Ok(Some(image))
let px_per_unit = SELECTION_PNG_DPI / 25.4 * A4_WIDTH_MM / PAGE_WIDTH;
let x0 = ((bounds.min.x * px_per_unit).floor().max(0.0) as u32).min(full.width);
let y0 = ((bounds.min.y * px_per_unit).floor().max(0.0) as u32).min(full.height);
let x1 = ((bounds.max.x * px_per_unit).ceil().max(0.0) as u32).min(full.width);
let y1 = ((bounds.max.y * px_per_unit).ceil().max(0.0) as u32).min(full.height);
if x1 <= x0 || y1 <= y0 {
return Ok(None);
}
let (w, h) = (x1 - x0, y1 - y0);
let mut pixels = Vec::with_capacity((w * h * 4) as usize);
for y in y0..y1 {
let start = ((y * full.width + x0) * 4) as usize;
pixels.extend_from_slice(&full.pixels[start..start + (w * 4) as usize]);
}
Ok(Some(RgbaImage {
width: w,
height: h,
pixels,
}))
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::document::{Stroke, StylusSample, PAGE_HEIGHT}; use crate::document::{Stroke, StylusSample, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH};
fn page_with_line() -> Page { fn page_with_line() -> Page {
let mut page = Page::new(1, PageBackground::Grid5mm); let mut page = Page::new(1, PageBackground::Grid5mm);
+130 -2
View File
@@ -36,9 +36,34 @@ pub enum DragMode {
start_pos_page: Vec2, start_pos_page: Vec2,
initial_box_size: Vec2, initial_box_size: Vec2,
initial_strokes: Vec<Stroke>, initial_strokes: Vec<Stroke>,
/// Seitenverhältnis beibehalten (Schloss der Auswahl oder Shift gedrückt)
keep_aspect: bool,
}, },
} }
/// Skalierungsfaktoren, wenn der Eckgriff von `start` (relativ zur festen Ecke `fixed`)
/// nach `current` gezogen wird. Mit `keep_aspect` wird gleichmäßig skaliert: der Faktor ist
/// die Projektion der Zugposition auf die Diagonale, damit das Seitenverhältnis bleibt.
pub fn scale_factors(fixed: Vec2, start: Vec2, current: Vec2, keep_aspect: bool) -> Vec2 {
let orig = start - fixed;
let new = current - fixed;
if keep_aspect {
let len_sq = orig.length_squared();
if len_sq <= 1.0 {
return Vec2::ONE;
}
return Vec2::splat((new.dot(orig) / len_sq).clamp(0.05, 20.0));
}
let factor = |n: f32, o: f32| {
if o.abs() > 1.0 {
(n / o).clamp(0.05, 20.0)
} else {
1.0
}
};
Vec2::new(factor(new.x, orig.x), factor(new.y, orig.y))
}
/// Ergebnis eines Hit-Tests auf Auswahl-Griffe (Handles). /// Ergebnis eines Hit-Tests auf Auswahl-Griffe (Handles).
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub enum SelectionHandleHit { pub enum SelectionHandleHit {
@@ -63,7 +88,13 @@ pub enum SelectionHandleHit {
pub struct SelectionState { pub struct SelectionState {
pub selected_ids: Vec<u64>, pub selected_ids: Vec<u64>,
pub drag_mode: DragMode, pub drag_mode: DragMode,
/// Letzte Zugposition, die noch nicht angewendet ist: Der Stift meldet sich öfter, als
/// gezeichnet wird; angewendet wird einmal je Bild (siehe `flush_selection_drag`).
pub pending_drag: Option<Vec2>,
pub clipboard: Vec<Stroke>, pub clipboard: Vec<Stroke>,
/// Von Hand umgeschaltetes Seitenverhältnis-Schloss und die Auswahl (IDs), für die es
/// gilt. Ändert sich die Auswahl, gilt wieder die Vorgabe (siehe [`Self::keeps_aspect`]).
aspect_lock: Option<(Vec<u64>, bool)>,
} }
impl SelectionState { impl SelectionState {
@@ -71,10 +102,31 @@ impl SelectionState {
Self { Self {
selected_ids: Vec::new(), selected_ids: Vec::new(),
drag_mode: DragMode::None, drag_mode: DragMode::None,
pending_drag: None,
clipboard: Vec::new(), clipboard: Vec::new(),
aspect_lock: None,
} }
} }
/// Ob Skalieren an den Eckgriffen das Seitenverhältnis der Auswahl beibehält. Vorgabe:
/// ja, sobald ein Bild ausgewählt ist (Issue #29); per Knopf für diese Auswahl umschaltbar.
pub fn keeps_aspect(&self, page: &Page) -> bool {
match &self.aspect_lock {
Some((ids, locked)) if *ids == self.selected_ids => *locked,
// Läuft bei jedem Bild: erst die (wenigen) Bilder suchen, ohne Striche zu kopieren
_ => page
.strokes
.iter()
.any(|s| s.is_image() && self.selected_ids.contains(&s.id)),
}
}
/// Schaltet das Seitenverhältnis-Schloss der aktuellen Auswahl um.
pub fn toggle_aspect_lock(&mut self, page: &Page) {
let locked = !self.keeps_aspect(page);
self.aspect_lock = Some((self.selected_ids.clone(), locked));
}
/// Prüft, ob aktuell Objekte ausgewählt sind. /// Prüft, ob aktuell Objekte ausgewählt sind.
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.selected_ids.is_empty() self.selected_ids.is_empty()
@@ -112,8 +164,13 @@ impl SelectionState {
/// Berechnet die gemeinsame AABB aller ausgewählten Objekte. /// Berechnet die gemeinsame AABB aller ausgewählten Objekte.
pub fn get_selection_aabb(&self, page: &Page) -> Option<Aabb> { pub fn get_selection_aabb(&self, page: &Page) -> Option<Aabb> {
let strokes = page.get_strokes(&self.selected_ids); // Läuft bei jedem Bild: ohne die ausgewählten Striche zu kopieren
Page::selection_aabb(&strokes) let ids: std::collections::HashSet<u64> = self.selected_ids.iter().copied().collect();
page.strokes
.iter()
.filter(|s| ids.contains(&s.id))
.map(|s| s.aabb)
.reduce(|a, b| a.union(&b))
} }
/// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten /// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten
@@ -523,6 +580,77 @@ mod tests {
assert!((orig_pos.x - restored_pos.x).abs() < 1e-4); assert!((orig_pos.x - restored_pos.x).abs() < 1e-4);
} }
#[test]
fn test_scale_factors_free_and_keep_aspect() {
let fixed = Vec2::new(0.0, 0.0);
let start = Vec2::new(200.0, 100.0);
// Frei: Breite und Höhe unabhängig
let free = scale_factors(fixed, start, Vec2::new(400.0, 100.0), false);
assert_eq!(free, Vec2::new(2.0, 1.0));
// Seitenverhältnis fest: gleicher Faktor in beiden Richtungen
let locked = scale_factors(fixed, start, Vec2::new(400.0, 100.0), true);
assert!((locked.x - locked.y).abs() < 1e-6);
assert!((locked.x - 1.8).abs() < 1e-4);
// Entlang der Diagonale genau verdoppelt
let diag = scale_factors(fixed, start, Vec2::new(400.0, 200.0), true);
assert!((diag - Vec2::splat(2.0)).length() < 1e-5);
// Über die feste Ecke hinaus: kein Spiegeln
let past = scale_factors(fixed, start, Vec2::new(-50.0, -50.0), true);
assert_eq!(past, Vec2::splat(0.05));
// Senkrechte Linie (Breite 0): frei bleibt x unverändert, fest skaliert beides
let line = Vec2::new(0.0, 100.0);
assert_eq!(
scale_factors(fixed, line, Vec2::new(30.0, 200.0), false),
Vec2::new(1.0, 2.0)
);
assert_eq!(
scale_factors(fixed, line, Vec2::new(30.0, 200.0), true),
Vec2::splat(2.0)
);
}
#[test]
fn test_aspect_lock_defaults_to_images_and_toggles_per_selection() {
let mut page = Page::new(1, PageBackground::Blank);
let corners = [
Vec2::new(0.0, 0.0),
Vec2::new(100.0, 0.0),
Vec2::new(100.0, 50.0),
Vec2::new(0.0, 50.0),
];
page.add_stroke(Stroke::new_image(1, "a.png".into(), corners));
page.add_stroke(make_test_stroke(2, 200.0, 200.0, [0.1, 0.1, 0.1, 1.0]));
let mut sel = SelectionState::new();
// Vorgabe: Bilder gesperrt, Striche frei
sel.select_single(1);
assert!(sel.keeps_aspect(&page));
sel.select_single(2);
assert!(!sel.keeps_aspect(&page));
// Umschalten gilt für diese Auswahl
sel.toggle_aspect_lock(&page);
assert!(sel.keeps_aspect(&page));
sel.select_single(1);
sel.toggle_aspect_lock(&page);
assert!(!sel.keeps_aspect(&page));
sel.toggle_aspect_lock(&page);
assert!(sel.keeps_aspect(&page));
sel.toggle_aspect_lock(&page);
// Neue Auswahl: wieder die Vorgabe
sel.select_multiple(vec![1, 2]);
assert!(sel.keeps_aspect(&page));
sel.select_single(1);
assert!(
!sel.keeps_aspect(&page),
"gleiche Auswahl behält das Umschalten"
);
sel.clear();
sel.select_single(1);
assert!(!sel.keeps_aspect(&page));
}
#[test] #[test]
fn test_recolor_ignores_images() { fn test_recolor_ignores_images() {
let mut page = Page::new(1, PageBackground::Blank); let mut page = Page::new(1, PageBackground::Blank);
+2 -1
View File
@@ -212,7 +212,8 @@ pub struct UiSettings {
pub pattern_color: PatternColor, pub pattern_color: PatternColor,
/// Druckkennlinie des Stifts /// Druckkennlinie des Stifts
pub pressure_curve: PressureCurve, pub pressure_curve: PressureCurve,
/// Seiten durchgehend untereinander anzeigen (sonst einzeln blättern) /// Frühere globale Einstellung „Seiten durchgehend scrollen“: gilt nur noch für Notizbücher
/// ohne eigene Anordnung im Manifest (vor 0.28 angelegt), wird nicht mehr geändert
pub continuous_scroll: bool, pub continuous_scroll: bool,
/// Stifte der Werkzeugleiste und der gewählte davon /// Stifte der Werkzeugleiste und der gewählte davon
pub pen_presets: [PenPreset; PEN_PRESET_COUNT], pub pen_presets: [PenPreset; PEN_PRESET_COUNT],
+63 -2
View File
@@ -601,6 +601,7 @@ pub fn generate_finalized_triangle_strip(
// Runde Kappen fein genug unterteilt, dass sie auch stark vergrößert rund wirken // Runde Kappen fein genug unterteilt, dass sie auch stark vergrößert rund wirken
const CAP_STEPS: usize = 10; const CAP_STEPS: usize = 10;
const CORNER_STEPS: usize = 24;
let mut vertices = Vec::new(); let mut vertices = Vec::new();
// 3. Runde Start-Kappe vor dem ersten Run // 3. Runde Start-Kappe vor dem ersten Run
@@ -622,12 +623,22 @@ pub fn generate_finalized_triangle_strip(
// 4. Runs und Ecken-Übergänge verbinden // 4. Runs und Ecken-Übergänge verbinden
for (run_idx, run) in runs.iter().enumerate() { for (run_idx, run) in runs.iter().enumerate() {
if run_idx > 0 { if run_idx > 0 {
// Ecken-Verbindung: Nahtlose C0-Verbindung am Knick // Runde Ecken-Verbindung: Ein voller Kreis um den Knick füllt die Außenseite, sonst
// enden beide Abschnitte stumpf und die Spitze (etwa einer „1“) wirkt abgeschnitten.
// Alle Dreiecke liegen im Kreis, auch die Übergänge zum vorigen und nächsten Run.
let s_corner = &run[0]; let s_corner = &run[0];
let p_c = s_corner.position; let p_c = s_corner.position;
let pr_c = s_corner.pressure; let pr_c = s_corner.pressure;
let r = s_corner.radius;
let prev_right = runs[run_idx - 1].last().map_or(s_corner.right, |s| s.right);
let d = prev_right - p_c;
let a0 = d.y.atan2(d.x);
for k in 0..=CORNER_STEPS {
let a = a0 + (k as f32 / CORNER_STEPS as f32) * std::f32::consts::TAU;
let rim = p_c + glam::Vec2::new(a.cos(), a.sin()) * r;
vertices.push(Vertex::new(p_c.x, p_c.y, pr_c, 0.0)); vertices.push(Vertex::new(p_c.x, p_c.y, pr_c, 0.0));
vertices.push(Vertex::new(p_c.x, p_c.y, pr_c, 0.0)); vertices.push(Vertex::new(rim.x, rim.y, pr_c, 1.0));
}
vertices.push(Vertex::new(s_corner.left.x, s_corner.left.y, pr_c, -1.0)); vertices.push(Vertex::new(s_corner.left.x, s_corner.left.y, pr_c, -1.0));
vertices.push(Vertex::new(s_corner.right.x, s_corner.right.y, pr_c, 1.0)); vertices.push(Vertex::new(s_corner.right.x, s_corner.right.y, pr_c, 1.0));
} }
@@ -900,6 +911,56 @@ mod tests {
assert!((corner_pt.position.y - 0.0).abs() < 1e-3); assert!((corner_pt.position.y - 0.0).abs() < 1e-3);
} }
/// Liegt `p` in einem der Dreiecke des Triangle-Strips?
fn strip_covers(vertices: &[InkVertex], p: glam::Vec2) -> bool {
let pos = |v: &InkVertex| glam::Vec2::new(v.position[0], v.position[1]);
let cross = |a: glam::Vec2, b: glam::Vec2, c: glam::Vec2| (b - a).perp_dot(c - a);
vertices.windows(3).any(|t| {
let (a, b, c) = (pos(&t[0]), pos(&t[1]), pos(&t[2]));
let (d1, d2, d3) = (cross(a, b, p), cross(b, c, p), cross(c, a, p));
let neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0;
let pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0;
!(neg && pos)
})
}
#[test]
fn test_sharp_corner_tip_is_not_cut_off() {
// „1“: schräg hinauf bis (100, 0), dann senkrecht hinunter (Issue #33)
let mut raw = Vec::new();
for i in 0..=20 {
let u = i as f32 / 20.0;
raw.push(StylusSample {
position: glam::Vec2::new(60.0 + 40.0 * u, 80.0 - 80.0 * u),
pressure: 0.5,
timestamp: i as f64 * 0.01,
});
}
for i in 1..=30 {
raw.push(StylusSample {
position: glam::Vec2::new(100.0, i as f32 * 5.0),
pressure: 0.5,
timestamp: (20 + i) as f64 * 0.01,
});
}
let config = SplineConfig::from_width_units(10.0);
let stroke = finalize_stroke_with_config(1, &raw, [0.0; 4], &config);
assert_eq!(
detect_corners(&stroke.points, CORNER_ANGLE_THRESHOLD_DEG).len(),
1
);
// Die Spitze reicht rund um den Knick so weit wie die Strichbreite
let r = calculate_radius(0.5, config.min_radius, config.max_radius) * 0.95;
for deg in [-150.0_f32, -120.0, -90.0, -60.0, -30.0, 0.0] {
let a = deg.to_radians();
let p = glam::Vec2::new(100.0, 0.0) + glam::Vec2::new(a.cos(), a.sin()) * r;
assert!(
strip_covers(&stroke.vertices, p),
"Spitze bei {deg}° abgeschnitten"
);
}
}
#[test] #[test]
fn test_small_loops_stay_round() { fn test_small_loops_stay_round() {
// Kleines „o“ (Radius 0,8 mm) in 0,1 s mit 200 Hz, mit Vorschub wie im Wort // Kleines „o“ (Radius 0,8 mm) in 0,1 s mit 200 Hz, mit Vorschub wie im Wort
+67 -1
View File
@@ -168,7 +168,9 @@ impl StrokeData {
/// - Version 6: Seitentitel (`title` in Seite und Manifest). /// - Version 6: Seitentitel (`title` in Seite und Manifest).
/// - Version 7: Textfelder (`text` am Strich). /// - Version 7: Textfelder (`text` am Strich).
/// - Version 8: Seitengröße je Seite (`size` in Seite und Manifest), Querformat für Folien. /// - Version 8: Seitengröße je Seite (`size` in Seite und Manifest), Querformat für Folien.
pub const CURRENT_FORMAT_VERSION: u32 = 8; /// - Version 9: endlose Notizbücher (`layout: "Endless"` im Manifest, eine Seite mit
/// `document::ENDLESS_PAGE_SIZE`). Ältere Versionen würden sie als riesige Seite öffnen.
pub const CURRENT_FORMAT_VERSION: u32 = 9;
/// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0). /// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0).
pub fn legacy_format_version() -> u32 { pub fn legacy_format_version() -> u32 {
@@ -200,8 +202,12 @@ fn stored_size(size: glam::Vec2) -> Option<[f32; 2]> {
} }
/// Seitengröße aus einer (evtl. von Hand bearbeiteten) Datei; unbrauchbare Werte ergeben A4. /// Seitengröße aus einer (evtl. von Hand bearbeiteten) Datei; unbrauchbare Werte ergeben A4.
/// Höchstens 2 × 2 m – außer der endlosen Seite.
pub fn page_size_from(stored: Option<[f32; 2]>) -> glam::Vec2 { pub fn page_size_from(stored: Option<[f32; 2]>) -> glam::Vec2 {
match stored { match stored {
Some(size) if glam::Vec2::from(size) == crate::document::ENDLESS_PAGE_SIZE => {
crate::document::ENDLESS_PAGE_SIZE
}
Some([w, h]) if w.is_finite() && h.is_finite() && w >= 100.0 && h >= 100.0 => { Some([w, h]) if w.is_finite() && h.is_finite() && w >= 100.0 && h >= 100.0 => {
glam::Vec2::new(w.min(20_000.0), h.min(20_000.0)) glam::Vec2::new(w.min(20_000.0), h.min(20_000.0))
} }
@@ -280,9 +286,34 @@ pub struct NotebookManifest {
pub page_ids: Vec<u64>, pub page_ids: Vec<u64>,
pub next_stroke_id: u64, pub next_stroke_id: u64,
pub next_page_id: u64, pub next_page_id: u64,
/// Anordnung der Seiten. Fehlt bei Notizbüchern von vor 0.28; dann gilt die frühere
/// globale Einstellung `continuous_scroll` aus `ui.json` (siehe [`Self::layout_or`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub layout: Option<NotebookLayout>,
}
/// Anordnung der Seiten eines Notizbuchs (Issue #30).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum NotebookLayout {
/// Feste Seiten: Seite für Seite blättern
#[default]
Paged,
/// Seiten hängen untereinander und werden durchgehend gescrollt
Continuous,
/// Eine einzige, praktisch unbegrenzte Fläche (siehe `document::ENDLESS_PAGE_SIZE`)
Endless,
} }
impl NotebookManifest { impl NotebookManifest {
/// Anordnung des Notizbuchs; ohne eigene Angabe die frühere globale Einstellung.
pub fn layout_or(&self, legacy_continuous: bool) -> NotebookLayout {
self.layout.unwrap_or(if legacy_continuous {
NotebookLayout::Continuous
} else {
NotebookLayout::Paged
})
}
pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self { pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
let now = std::time::SystemTime::now() let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
@@ -299,6 +330,7 @@ impl NotebookManifest {
page_ids: vec![1], page_ids: vec![1],
next_stroke_id: 1, next_stroke_id: 1,
next_page_id: 2, next_page_id: 2,
layout: Some(NotebookLayout::Continuous),
} }
} }
@@ -1604,6 +1636,40 @@ mod tests {
assert!(shutdown_res.is_ok()); assert!(shutdown_res.is_ok());
} }
#[test]
fn test_notebook_layout_is_saved_and_old_manifests_fall_back() {
let temp_dir = tempfile::tempdir().unwrap();
let (_nb, mut manifest, dir) = create_new_notebook(temp_dir.path(), "Layout").unwrap();
// Neue Notizbücher: durchgehend, unabhängig von der früheren globalen Einstellung
assert_eq!(manifest.layout, Some(NotebookLayout::Continuous));
assert_eq!(manifest.layout_or(false), NotebookLayout::Continuous);
manifest.layout = Some(NotebookLayout::Paged);
save_manifest(&dir, &manifest).unwrap();
let (_nb, loaded) = load_notebook(&dir).unwrap();
assert_eq!(loaded.layout, Some(NotebookLayout::Paged));
// Manifest von vor 0.28 ohne `layout`: es gilt die globale Einstellung
let mut json: serde_json::Value =
serde_json::from_str(&fs::read_to_string(dir.join("manifest.json")).unwrap()).unwrap();
json.as_object_mut().unwrap().remove("layout");
let old: NotebookManifest = serde_json::from_value(json).unwrap();
assert_eq!(old.layout, None);
assert_eq!(old.layout_or(true), NotebookLayout::Continuous);
assert_eq!(old.layout_or(false), NotebookLayout::Paged);
// … und schreibt beim Speichern auch keins dazu
assert!(!serde_json::to_string(&old).unwrap().contains("\"layout\""));
// Endlos: die endlose Seite übersteht Speichern und Laden
let (mut nb, mut manifest, dir) = create_new_notebook(temp_dir.path(), "Endlos").unwrap();
assert!(nb.make_endless());
manifest.layout = Some(NotebookLayout::Endless);
save_notebook(&dir, &nb, &mut manifest).unwrap();
let (loaded_nb, loaded) = load_notebook(&dir).unwrap();
assert_eq!(loaded.layout, Some(NotebookLayout::Endless));
assert!(loaded_nb.is_endless());
}
#[test] #[test]
fn test_notebook_management() { fn test_notebook_management() {
let temp_dir = tempfile::tempdir().expect("Konnte temporäres Verzeichnis nicht anlegen"); let temp_dir = tempfile::tempdir().expect("Konnte temporäres Verzeichnis nicht anlegen");
+463 -35
View File
@@ -22,7 +22,7 @@ use crate::settings::{
PenPreset, PressureCurve, SidebarSide, DEFAULT_HIGHLIGHTER_COLORS, DEFAULT_PEN_PRESETS, PenPreset, PressureCurve, SidebarSide, DEFAULT_HIGHLIGHTER_COLORS, DEFAULT_PEN_PRESETS,
DEFAULT_TEXT_COLORS, PEN_PRESET_COUNT, DEFAULT_TEXT_COLORS, PEN_PRESET_COUNT,
}; };
use crate::storage::NotebookSummary; use crate::storage::{NotebookLayout, NotebookSummary};
use crate::text::{TextEditing, TextFont, TEXT_SIZES_PT}; use crate::text::{TextEditing, TextFont, TEXT_SIZES_PT};
use crate::{t, tn}; use crate::{t, tn};
@@ -60,8 +60,9 @@ impl ActiveTool {
/// Werkzeug, das ein mit diesem Werkzeug gezeichneter Strich erhält. /// Werkzeug, das ein mit diesem Werkzeug gezeichneter Strich erhält.
pub fn stroke_tool(&self, has_shape: bool) -> StrokeTool { pub fn stroke_tool(&self, has_shape: bool) -> StrokeTool {
match self { match self {
_ if has_shape => StrokeTool::Shape, // Ein eingerasteter Textmarker bleibt Textmarker (durchscheinend, eigener Radierer)
ActiveTool::Highlighter => StrokeTool::Highlighter, ActiveTool::Highlighter => StrokeTool::Highlighter,
_ if has_shape => StrokeTool::Shape,
_ => StrokeTool::Pen, _ => StrokeTool::Pen,
} }
} }
@@ -255,12 +256,15 @@ pub enum UiAction {
/// Zwischenablage an dieser Stelle der aktuellen Seite einfügen (Kontextmenü) /// Zwischenablage an dieser Stelle der aktuellen Seite einfügen (Kontextmenü)
PasteAt(glam::Vec2), PasteAt(glam::Vec2),
RotateSelected, RotateSelected,
/// Seitenverhältnis beim Skalieren der Auswahl sperren bzw. freigeben
ToggleAspectLock,
// Dokumenten- & Notizbuch-Verwaltung // Dokumenten- & Notizbuch-Verwaltung
OpenNotebook(String), OpenNotebook(String),
/// Neues Notizbuch im Ordner `folder` anlegen und öffnen /// Neues Notizbuch im Ordner `folder` anlegen und öffnen
CreateNotebook { CreateNotebook {
title: String, title: String,
folder: String, folder: String,
layout: NotebookLayout,
}, },
/// Ordnerverwaltung (siehe `library.rs`) /// Ordnerverwaltung (siehe `library.rs`)
CreateFolder { CreateFolder {
@@ -315,8 +319,11 @@ pub enum UiAction {
SaveStylusButtons, SaveStylusButtons,
/// Druckkurve speichern (gilt während des Ziehens bereits über `UiState::pressure_curve`) /// Druckkurve speichern (gilt während des Ziehens bereits über `UiState::pressure_curve`)
SavePressureCurve(PressureCurve), SavePressureCurve(PressureCurve),
/// Seiten durchgehend untereinander statt einzeln anzeigen (wird gespeichert) /// Anordnung der Seiten des offenen Notizbuchs (wird im Manifest gespeichert)
SetContinuousScroll(bool), SetNotebookLayout(NotebookLayout),
/// Durchgehende Ansicht: Fenstermitte auf diese Höhe (Koordinaten der aktuellen Seite)
/// scrollen – Bildlaufleiste, Issue #28
ScrollTo(f32),
/// Farbe von Karos, Linien und Punkten (wird gespeichert, gilt auch für den Export) /// Farbe von Karos, Linien und Punkten (wird gespeichert, gilt auch für den Export)
SetPatternColor(PatternColor), SetPatternColor(PatternColor),
/// Bilddatei auf der aktuellen Seite einfügen /// Bilddatei auf der aktuellen Seite einfügen
@@ -362,6 +369,8 @@ pub struct UiState {
pub document_title: String, pub document_title: String,
pub available_notebooks: Vec<NotebookSummary>, pub available_notebooks: Vec<NotebookSummary>,
pub new_notebook_title_input: String, pub new_notebook_title_input: String,
/// Anordnung für das nächste neue Notizbuch
pub new_notebook_layout: NotebookLayout,
pub renaming_notebook_id: Option<String>, pub renaming_notebook_id: Option<String>,
/// Ordner der Notizbücher und der gerade angezeigte Ordner im Dokumenten-Manager /// Ordner der Notizbücher und der gerade angezeigte Ordner im Dokumenten-Manager
pub library: crate::library::Library, pub library: crate::library::Library,
@@ -410,6 +419,8 @@ pub struct UiState {
pub confirm_delete_page: bool, pub confirm_delete_page: bool,
/// Seitenzahl der zuletzt gelöschten Seite, solange sie wiederhergestellt werden kann /// Seitenzahl der zuletzt gelöschten Seite, solange sie wiederhergestellt werden kann
pub restorable_page: Option<usize>, pub restorable_page: Option<usize>,
/// Seitenverhältnis-Schloss der Auswahl (`None`: nichts ausgewählt)
pub selection_aspect_lock: Option<bool>,
/// Fehler beim Flushen vor dem Beenden /// Fehler beim Flushen vor dem Beenden
pub shutdown_error: Option<crate::autosave::AutosaveError>, pub shutdown_error: Option<crate::autosave::AutosaveError>,
/// Fensterseite der Werkzeugleiste /// Fensterseite der Werkzeugleiste
@@ -428,6 +439,11 @@ pub struct UiState {
pub pattern_color: PatternColor, pub pattern_color: PatternColor,
/// Seiten hängen untereinander und werden durchgehend gescrollt /// Seiten hängen untereinander und werden durchgehend gescrollt
pub continuous_scroll: bool, pub continuous_scroll: bool,
/// Offenes Notizbuch ist eine endlose Fläche (eine Seite ohne Rand, Issue #30)
pub endless: bool,
/// Wechsel zur bzw. von der endlosen Fläche ist gerade möglich (eine Seite ohne PDF bzw.
/// Inhalt im Startausschnitt)
pub endless_switch_possible: bool,
/// Text-Werkzeug: Farbe, Schriftgröße (Punkt) und Schriftart neuer Textfelder /// Text-Werkzeug: Farbe, Schriftgröße (Punkt) und Schriftart neuer Textfelder
pub text_color: [f32; 4], pub text_color: [f32; 4],
pub text_size_pt: f32, pub text_size_pt: f32,
@@ -443,6 +459,8 @@ pub struct UiState {
/// Eingeblendetes Lineal /// Eingeblendetes Lineal
pub ruler: Option<Ruler>, pub ruler: Option<Ruler>,
pub ruler_drag: Option<RulerDrag>, pub ruler_drag: Option<RulerDrag>,
/// Bildlaufleiste wird gezogen: Abstand des Fingers von der Oberkante des Griffs
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) /// Fenstergröße in logischen Punkten (vom letzten Bild)
@@ -479,6 +497,7 @@ impl Default for UiState {
document_title: t!("Standard-Notizbuch").to_string(), document_title: t!("Standard-Notizbuch").to_string(),
available_notebooks: Vec::new(), available_notebooks: Vec::new(),
new_notebook_title_input: String::new(), new_notebook_title_input: String::new(),
new_notebook_layout: NotebookLayout::Continuous,
renaming_notebook_id: None, renaming_notebook_id: None,
library: crate::library::Library::default(), library: crate::library::Library::default(),
notebook_folder: String::new(), notebook_folder: String::new(),
@@ -508,6 +527,7 @@ impl Default for UiState {
pressed_stylus_button: None, pressed_stylus_button: None,
confirm_delete_page: false, confirm_delete_page: false,
restorable_page: None, restorable_page: None,
selection_aspect_lock: None,
shutdown_error: None, shutdown_error: None,
sidebar_side: SidebarSide::Left, sidebar_side: SidebarSide::Left,
language: None, language: None,
@@ -517,6 +537,8 @@ impl Default for UiState {
adapt_ink_colors: true, adapt_ink_colors: true,
pattern_color: PatternColor::Light, pattern_color: PatternColor::Light,
continuous_scroll: true, continuous_scroll: true,
endless: false,
endless_switch_possible: true,
text_color: PEN_COLOR_PRESETS[0].0, text_color: PEN_COLOR_PRESETS[0].0,
text_size_pt: crate::text::DEFAULT_TEXT_SIZE_PT, text_size_pt: crate::text::DEFAULT_TEXT_SIZE_PT,
text_font: TextFont::Sans, text_font: TextFont::Sans,
@@ -527,6 +549,7 @@ impl Default for UiState {
queued_actions: Vec::new(), queued_actions: Vec::new(),
ruler: None, ruler: None,
ruler_drag: None, ruler_drag: None,
scrollbar_grab: None,
page_view: None, page_view: None,
screen_size: egui::Vec2::ZERO, screen_size: egui::Vec2::ZERO,
page_title_input: String::new(), page_title_input: String::new(),
@@ -1182,8 +1205,8 @@ fn color_editor(
changed changed
} }
/// Stift der Werkzeugleiste: ein Strich in Farbe und (angedeuteter) Stärke auf einem /// Stift der Werkzeugleiste: ein schräg liegender Stift in seiner Farbe; die Stärke steht
/// Stück Papier, damit auch schwarze Tinte auf der dunklen Leiste sichtbar ist. /// im Tooltip.
fn pen_preset_swatch( fn pen_preset_swatch(
ui: &mut egui::Ui, ui: &mut egui::Ui,
preset: PenPreset, preset: PenPreset,
@@ -1193,22 +1216,49 @@ fn pen_preset_swatch(
) -> egui::Response { ) -> egui::Response {
let (rect, response) = ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::click()); let (rect, response) = ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::click());
if ui.is_rect_visible(rect) { if ui.is_rect_visible(rect) {
let paper = rect.shrink(2.0); // Schräg liegender Stift ganz in seiner Farbe, damit die Stifte auf einen Blick zu
ui.painter().rect_filled( // unterscheiden sind (Issue #31); die helle Kontur hält dunkle Farben sichtbar
paper,
egui::Rounding::same(5.0),
egui::Color32::from_gray(245),
);
let [r, g, b, _] = preset.color.map(|c| (c.clamp(0.0, 1.0) * 255.0) as u8); let [r, g, b, _] = preset.color.map(|c| (c.clamp(0.0, 1.0) * 255.0) as u8);
let width = (1.0 + preset.width_mm * 2.5).clamp(1.5, paper.height() * 0.35); let ink = egui::Color32::from_rgb(r, g, b);
let inset = 3.0 + width / 2.0; let c = rect.center();
ui.painter().line_segment( let dir = egui::vec2(1.0, -1.0).normalized();
[ let perp = egui::vec2(dir.y, -dir.x);
egui::pos2(paper.left() + inset, paper.bottom() - inset), let half_len = size * 0.44;
egui::pos2(paper.right() - inset, paper.top() + inset), let half_w = size * 0.17;
let p = |along: f32, across: f32| c + dir * along + perp * across;
let tip_start = -half_len * 0.3;
let lead_start = -half_len * 0.72;
let lead_w = half_w * (lead_start + half_len) / (tip_start + half_len);
let outline = egui::Stroke::new(1.0_f32, egui::Color32::from_gray(200));
let painter = ui.painter();
painter.add(egui::Shape::convex_polygon(
vec![
p(tip_start, -half_w),
p(half_len, -half_w),
p(half_len, half_w),
p(tip_start, half_w),
], ],
egui::Stroke::new(width, egui::Color32::from_rgb(r, g, b)), ink,
); outline,
));
painter.add(egui::Shape::convex_polygon(
vec![
p(-half_len, 0.0),
p(tip_start, -half_w),
p(tip_start, half_w),
],
egui::Color32::from_rgb(235, 210, 170),
outline,
));
painter.add(egui::Shape::convex_polygon(
vec![
p(-half_len, 0.0),
p(lead_start, -lead_w),
p(lead_start, lead_w),
],
ink,
egui::Stroke::NONE,
));
if is_selected { if is_selected {
ui.painter().rect_stroke( ui.painter().rect_stroke(
rect.shrink(0.5), rect.shrink(0.5),
@@ -1609,6 +1659,63 @@ fn page_title_list(ui: &mut egui::Ui, notebook: &Notebook) -> Option<usize> {
chosen chosen
} }
/// Auswahl der Seitenanordnung eines Notizbuchs. Ohne `can_cross` lässt sich nicht zwischen
/// Seiten und endloser Fläche wechseln; ohne `offer_paged` fehlen feste Seiten (beim Anlegen,
/// Issue #30 – umstellen geht im ☰-Menü). `true`, wenn sie geändert wurde.
fn layout_picker(
ui: &mut egui::Ui,
layout: &mut NotebookLayout,
can_cross: bool,
offer_paged: bool,
) -> bool {
let mut changed = false;
let endless_now = *layout == NotebookLayout::Endless;
ui.horizontal_wrapped(|ui| {
for (option, label, tooltip) in [
(
NotebookLayout::Paged,
t!("📄 Feste Seiten"),
t!("Seite für Seite blättern"),
),
(
NotebookLayout::Continuous,
t!("📜 Durchgehend"),
t!("Seiten hängen untereinander, Mausrad und Finger scrollen \
(Strg+Mausrad zoomt); wer unten auf der letzten Seite schreibt, \
bekommt automatisch eine neue Seite."),
),
(
NotebookLayout::Endless,
t!("♾ Endlos"),
t!(
"Eine Fläche ohne Rand: in alle Richtungen schreiben, verschieben und zoomen. \
Wechsel nur bei einer einzigen Seite ohne PDF bzw. zurück, solange alles im \
Startausschnitt liegt."
),
),
] {
if option == NotebookLayout::Paged && !offer_paged {
continue;
}
let enabled = can_cross || (option == NotebookLayout::Endless) == endless_now;
if ui
.add_enabled(
enabled,
egui::SelectableLabel::new(*layout == option, label),
)
.on_hover_text(tooltip)
.on_disabled_hover_text(tooltip)
.clicked()
&& *layout != option
{
*layout = option;
changed = true;
}
}
});
changed
}
/// Auswahlknopf für eine Papierfarbe: Farbmuster und Name. /// Auswahlknopf für eine Papierfarbe: Farbmuster und Name.
fn paper_theme_button(ui: &mut egui::Ui, theme: PaperTheme, selected: bool) -> egui::Response { fn paper_theme_button(ui: &mut egui::Ui, theme: PaperTheme, selected: bool) -> egui::Response {
let response = ui.add_sized( let response = ui.add_sized(
@@ -2253,6 +2360,147 @@ fn page_separators(ctx: &egui::Context, state: &UiState, notebook: &Notebook) {
} }
} }
/// Lage der Bildlaufleiste: Leiste (Trefferfläche) und Griff in logischen Punkten. Der Griff
/// zeigt, wo die Fenstermitte `center` zwischen Ober- und Unterkante des Notizbuchs liegt;
/// `visible` ist die sichtbare Höhe (Seiteneinheiten) und bestimmt seine Länge.
fn scrollbar_layout(
screen: egui::Rect,
side: SidebarSide,
(top, bottom): (f32, f32),
center: f32,
visible: f32,
) -> (egui::Rect, egui::Rect) {
const WIDTH: f32 = 28.0;
const MARGIN: f32 = 12.0;
// Gegenüber der Werkzeugleiste
let x = match side {
SidebarSide::Left => screen.right() - WIDTH,
SidebarSide::Right => screen.left(),
};
let track = egui::Rect::from_min_size(
egui::pos2(x, screen.top() + MARGIN),
egui::vec2(WIDTH, (screen.height() - 2.0 * MARGIN).max(1.0)),
);
let total = (bottom - top).max(1.0);
let thumb_h = (visible / (total + visible) * track.height())
.clamp(48.0_f32.min(track.height()), track.height());
let fraction = ((center - top) / total).clamp(0.0, 1.0);
let thumb = egui::Rect::from_min_size(
egui::pos2(
track.left(),
track.top() + fraction * (track.height() - thumb_h),
),
egui::vec2(WIDTH, thumb_h),
);
(track, thumb)
}
/// Durchgehende Ansicht: Bildlaufleiste am Fensterrand gegenüber der Werkzeugleiste. Den
/// Griff ziehen (oder auf die Leiste tippen) springt schnell durch lange Notizbücher und
/// PDFs (Issue #28); beim Ziehen steht die Seitenzahl daneben.
fn page_scrollbar(
ctx: &egui::Context,
state: &mut UiState,
notebook: &Notebook,
actions: &mut Vec<UiAction>,
) {
let Some(view) = state.page_view.filter(|_| state.continuous_scroll) else {
state.scrollbar_grab = None;
return;
};
if notebook.pages.len() < 2 {
state.scrollbar_grab = None;
return;
}
let stack = notebook.page_stack();
let (min, max) = stack.bounds();
let screen = ctx.screen_rect();
let center = view.to_page(screen.center()).y;
let visible = screen.height() / view.scale.max(1e-6);
let (track, thumb) =
scrollbar_layout(screen, state.sidebar_side, (min.y, max.y), center, visible);
let dark = state.paper_theme.is_dark();
let response = egui::Area::new(egui::Id::new("page_scrollbar"))
.order(egui::Order::Middle)
.fixed_pos(track.min)
.show(ctx, |ui| {
let (_, response) = ui.allocate_exact_size(track.size(), egui::Sense::drag());
let active = response.hovered() || response.dragged();
let bar_w = if active { 10.0 } else { 6.0 };
let bar = egui::Rect::from_center_size(
egui::pos2(thumb.center().x, thumb.center().y),
egui::vec2(bar_w, thumb.height()),
);
let alpha = if active { 200 } else { 110 };
let color = if dark {
egui::Color32::from_white_alpha(alpha)
} else {
egui::Color32::from_black_alpha(alpha)
};
ui.painter()
.rect_filled(bar, egui::Rounding::same(bar_w / 2.0), color);
response
})
.inner;
if response.drag_started() {
let y = response
.interact_pointer_pos()
.map_or(thumb.center().y, |p| p.y);
// Auf den Griff: dort festhalten; daneben: Griff unter den Finger springen lassen
state.scrollbar_grab = Some(if thumb.y_range().contains(y) {
y - thumb.top()
} else {
thumb.height() / 2.0
});
}
if !response.dragged() {
state.scrollbar_grab = None;
return;
}
let (Some(grab), Some(pos)) = (state.scrollbar_grab, response.interact_pointer_pos()) else {
return;
};
let free = (track.height() - thumb.height()).max(1.0);
let fraction = ((pos.y - grab - track.top()) / free).clamp(0.0, 1.0);
let target = min.y + fraction * (max.y - min.y);
if (target - center).abs() > 0.5 {
actions.push(UiAction::ScrollTo(target));
}
if let Some(page) = stack.page_at(target) {
let label_x = match state.sidebar_side {
SidebarSide::Left => track.left() - 8.0,
SidebarSide::Right => track.right() + 8.0,
};
let anchor = match state.sidebar_side {
SidebarSide::Left => egui::Align2::RIGHT_CENTER,
SidebarSide::Right => egui::Align2::LEFT_CENTER,
};
let painter = ctx.layer_painter(egui::LayerId::new(
egui::Order::Foreground,
egui::Id::new("page_scrollbar_label"),
));
let text = format!("{} / {}", page + 1, notebook.pages.len());
let galley =
painter.layout_no_wrap(text, egui::FontId::proportional(18.0), egui::Color32::WHITE);
let rect = anchor
.anchor_size(egui::pos2(label_x, pos.y), galley.size())
.expand2(egui::vec2(10.0, 6.0));
painter.rect_filled(
rect,
egui::Rounding::same(8.0),
egui::Color32::from_black_alpha(190),
);
painter.galley(
rect.min + egui::vec2(10.0, 6.0),
galley,
egui::Color32::WHITE,
);
}
ctx.request_repaint();
}
/// Zeichnet das Lineal über dem Blatt und bedient Verschieben und Drehen. /// Zeichnet das Lineal über dem Blatt und bedient Verschieben und Drehen.
fn ruler_overlay(ctx: &egui::Context, state: &mut UiState) { fn ruler_overlay(ctx: &egui::Context, state: &mut UiState) {
let (Some(mut ruler), Some(view)) = (state.ruler, state.page_view) else { let (Some(mut ruler), Some(view)) = (state.ruler, state.page_view) else {
@@ -2647,6 +2895,14 @@ pub fn is_screen_point_in_ui(
.is_some_and(|layer| layer.order != egui::Order::Background) .is_some_and(|layer| layer.order != egui::Order::Background)
} }
/// Richtet den egui-Kontext ein. Strg+Plus/Minus/0 zoomen das Blatt; egui darf damit nicht
/// zusätzlich die Oberfläche vergrößern, sonst passen Textfeld, Auswahl und Lineal nicht mehr
/// zum Blatt (Issue #34).
pub fn configure_context(ctx: &egui::Context) {
ctx.set_visuals(egui::Visuals::dark());
ctx.options_mut(|o| o.zoom_with_keyboard = false);
}
/// 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.
@@ -2660,6 +2916,7 @@ pub fn render_ui(
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_size = ctx.screen_rect().size();
page_separators(ctx, state, notebook); page_separators(ctx, state, notebook);
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);
@@ -3045,6 +3302,30 @@ pub fn render_ui(
used += btn + spacing; used += btn + spacing;
} }
} }
// Auswahl: unter den 4 Stiftfarben ist Platz für das Schloss des
// Seitenverhältnisses beim Skalieren (Bilder: gesperrt, Issue #29)
if state.active_tool == ActiveTool::Select {
let locked = state.selection_aspect_lock;
let (icon, tooltip) = if locked == Some(true) {
("🔒", t!("Seitenverhältnis gesperrt – tippen zum Freigeben"))
} else {
("🔓", t!("Seitenverhältnis frei – tippen zum Sperren"))
};
if sidebar_button(
ui,
swatch,
SidebarIcon::Glyph(icon),
locked == Some(true),
locked.is_some(),
None,
tooltip,
)
.clicked()
{
actions.push(UiAction::ToggleAspectLock);
}
used += swatch + spacing;
}
let reserved = let reserved =
UiState::MAX_PALETTE_LEN as f32 * (swatch + spacing); UiState::MAX_PALETTE_LEN as f32 * (swatch + spacing);
if reserved > used { if reserved > used {
@@ -3118,7 +3399,7 @@ pub fn render_ui(
// --- Seitennavigation --- // --- Seitennavigation ---
// In der durchgehenden Ansicht wird gescrollt; Blätterpfeile gibt // In der durchgehenden Ansicht wird gescrollt; Blätterpfeile gibt
// es nur in der Einzelseitenansicht // es nur in der Einzelseitenansicht
let paging = !state.continuous_scroll; let paging = !state.continuous_scroll && !state.endless;
if paging if paging
&& sidebar_button( && sidebar_button(
ui, ui,
@@ -3143,7 +3424,7 @@ pub fn render_ui(
btn, btn,
SidebarIcon::Text(&page_text), SidebarIcon::Text(&page_text),
state.show_page_jump_popup, state.show_page_jump_popup,
true, !state.endless,
None, None,
t!("Seite wählen"), t!("Seite wählen"),
); );
@@ -3172,7 +3453,7 @@ pub fn render_ui(
btn, btn,
SidebarIcon::Glyph("➕"), SidebarIcon::Glyph("➕"),
false, false,
true, !state.endless,
None, None,
t!("Neue Seite nach dieser einfügen"), t!("Neue Seite nach dieser einfügen"),
) )
@@ -3584,7 +3865,10 @@ pub fn render_ui(
} }
}); });
if ui if ui
.add_sized([ui.available_width(), 30.0], egui::Button::new(t!("➕ Neue Seite einfügen"))) .add_enabled_ui(!state.endless, |ui| {
ui.add_sized([ui.available_width(), 30.0], egui::Button::new(t!("➕ Neue Seite einfügen")))
})
.inner
.on_hover_text(t!("Leere Seite direkt nach der aktuellen Seite")) .on_hover_text(t!("Leere Seite direkt nach der aktuellen Seite"))
.clicked() .clicked()
{ {
@@ -3697,17 +3981,16 @@ pub fn render_ui(
} }
}); });
section(ui, t!("ANSICHT")); section(ui, t!("SEITEN DIESES NOTIZBUCHS"));
if ui let mut layout = if state.endless {
.checkbox(&mut state.continuous_scroll, t!("Seiten durchgehend scrollen")) NotebookLayout::Endless
.on_hover_text( } else if state.continuous_scroll {
t!("An: Seiten hängen untereinander, Mausrad und Finger scrollen \ NotebookLayout::Continuous
(Strg+Mausrad zoomt); wer unten auf der letzten Seite schreibt, \ } else {
bekommt automatisch eine neue Seite.\nAus: Seite für Seite blättern."), NotebookLayout::Paged
) };
.changed() if layout_picker(ui, &mut layout, state.endless_switch_possible, true) {
{ actions.push(UiAction::SetNotebookLayout(layout));
actions.push(UiAction::SetContinuousScroll(state.continuous_scroll));
} }
section(ui, t!("ZOOM")); section(ui, t!("ZOOM"));
@@ -4042,6 +4325,7 @@ pub fn render_ui(
actions.push(UiAction::CreateNotebook { actions.push(UiAction::CreateNotebook {
title, title,
folder: folder.clone(), folder: folder.clone(),
layout: state.new_notebook_layout,
}); });
state.new_notebook_title_input.clear(); state.new_notebook_title_input.clear();
} else if folder_btn.clicked() { } else if folder_btn.clicked() {
@@ -4053,6 +4337,10 @@ pub fn render_ui(
} }
} }
}); });
ui.horizontal(|ui| {
ui.label(t!("Seiten:"));
layout_picker(ui, &mut state.new_notebook_layout, true, false);
});
ui.add_space(8.0); ui.add_space(8.0);
ui.separator(); ui.separator();
@@ -4905,6 +5193,112 @@ mod tests {
ctx ctx
} }
#[test]
fn test_page_scrollbar_jumps_through_the_notebook() {
// Layout: Griff oben am Anfang, unten am Ende, gegenüber der Werkzeugleiste
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 850.0));
let (track, top) =
scrollbar_layout(screen, SidebarSide::Left, (0.0, 80_000.0), 0.0, 1000.0);
assert_eq!(track.right(), screen.right());
assert_eq!(top.top(), track.top());
assert!(top.height() >= 48.0);
let (_, end) =
scrollbar_layout(screen, SidebarSide::Left, (0.0, 80_000.0), 80_000.0, 1000.0);
assert_eq!(end.bottom(), track.bottom());
let (left, _) = scrollbar_layout(screen, SidebarSide::Right, (0.0, 1.0), 0.0, 1.0);
assert_eq!(left.left(), screen.left());
// Notizbuch mit 80 Seiten, durchgehend, Ansicht auf Seite 1
let mut notebook = Notebook::new();
for _ in 1..80 {
notebook.add_page(PageBackground::Blank);
}
let mut state = UiState::new();
state.continuous_scroll = true;
state.page_view = Some(PageView {
origin: egui::pos2(300.0, 0.0),
scale: 0.25,
});
let ctx = render_frames(&mut state, &notebook, 2);
let updater = crate::updater::UpdaterController::new();
let x = screen.right() - 14.0;
let mut actions = Vec::new();
// Griff greifen und bis ans untere Ende ziehen
for (pos, pressed) in [
(egui::pos2(x, 30.0), Some(true)),
(egui::pos2(x, 400.0), None),
(egui::pos2(x, 845.0), None),
(egui::pos2(x, 845.0), Some(false)),
] {
let mut events = vec![egui::Event::PointerMoved(pos)];
if let Some(pressed) = pressed {
events.push(egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::default(),
});
}
ctx.begin_frame(egui::RawInput {
screen_rect: Some(screen),
events,
..Default::default()
});
actions.extend(render_ui(
&ctx,
&mut state,
&notebook,
AutosaveStatus::Saved,
&updater,
));
let _ = ctx.end_frame();
}
let targets: Vec<f32> = actions
.iter()
.filter_map(|a| match a {
UiAction::ScrollTo(y) => Some(*y),
_ => None,
})
.collect();
let (_, max) = notebook.page_stack().bounds();
assert!(targets.len() >= 2, "{actions:?}");
assert!(targets.windows(2).all(|w| w[1] >= w[0]));
assert!((targets.last().unwrap() - max.y).abs() < 1.0, "{targets:?}");
assert!(state.scrollbar_grab.is_none());
// Feste Seiten: keine Leiste, Tippen an den Rand scrollt nicht
state.continuous_scroll = false;
let ctx = render_frames(&mut state, &notebook, 2);
let actions = click_at(&ctx, &mut state, &notebook, egui::pos2(x, 400.0));
assert!(!actions.iter().any(|a| matches!(a, UiAction::ScrollTo(_))));
}
#[test]
fn test_ctrl_plus_does_not_zoom_the_ui() {
let ctx = egui::Context::default();
configure_context(&ctx);
let ctrl = egui::Modifiers {
ctrl: true,
command: true,
..Default::default()
};
for _ in 0..2 {
ctx.begin_frame(egui::RawInput {
events: vec![egui::Event::Key {
key: egui::Key::Plus,
physical_key: None,
pressed: true,
repeat: false,
modifiers: ctrl,
}],
modifiers: ctrl,
..Default::default()
});
let _ = ctx.end_frame();
}
assert_eq!(ctx.zoom_factor(), 1.0);
}
#[test] #[test]
fn test_text_edit_overlay_takes_typing_and_escape() { fn test_text_edit_overlay_takes_typing_and_escape() {
let notebook = Notebook::new(); let notebook = Notebook::new();
@@ -5189,6 +5583,35 @@ mod tests {
} }
} }
#[test]
fn test_aspect_lock_button_only_with_selection() {
// Den Knopf in der Leiste des Auswahlwerkzeugs suchen: ohne Auswahl gesperrt
let notebook = Notebook::new();
let toggles = |lock: Option<bool>| {
let mut probe = UiState::new();
probe.active_tool = ActiveTool::Select;
render_frames(&mut probe, &notebook, 10);
let bar = probe.toolbar_rect.unwrap();
let mut found = 0;
let mut y = bar.min.y + 4.0;
while y < bar.max.y {
let mut state = UiState::new();
state.active_tool = ActiveTool::Select;
state.selection_aspect_lock = lock;
let ctx = render_frames(&mut state, &notebook, 10);
let actions = click_at(&ctx, &mut state, &notebook, egui::pos2(bar.center().x, y));
if actions.contains(&UiAction::ToggleAspectLock) {
found += 1;
}
y += 4.0;
}
found
};
assert_eq!(toggles(None), 0);
assert!(toggles(Some(true)) > 0);
assert!(toggles(Some(false)) > 0);
}
#[test] #[test]
fn test_sidebar_fits_window_height_for_every_tool() { fn test_sidebar_fits_window_height_for_every_tool() {
let notebook = Notebook::new(); let notebook = Notebook::new();
@@ -5418,6 +5841,11 @@ mod tests {
// Eingerastete Form (Formwerkzeug oder Halten am Strichende) // Eingerastete Form (Formwerkzeug oder Halten am Strichende)
assert_eq!(ActiveTool::Shape.stroke_tool(true), StrokeTool::Shape); assert_eq!(ActiveTool::Shape.stroke_tool(true), StrokeTool::Shape);
assert_eq!(ActiveTool::Pen.stroke_tool(true), StrokeTool::Shape); assert_eq!(ActiveTool::Pen.stroke_tool(true), StrokeTool::Shape);
// Eingerasteter Textmarker (Issue #32)
assert_eq!(
ActiveTool::Highlighter.stroke_tool(true),
StrokeTool::Highlighter
);
} }
#[test] #[test]
+154
View File
@@ -305,6 +305,102 @@ impl ViewportState {
} }
} }
/// Schwung beim Scrollen mit dem Finger (Issue #28): Nach dem Abheben gleitet der
/// Ausschnitt mit der zuletzt gewischten Geschwindigkeit weiter und bremst langsam ab.
/// Geschwindigkeiten in Bildschirmpixeln je Sekunde.
#[derive(Debug, Clone, Default)]
pub struct Fling {
velocity: Vec2,
/// Fingerweg (aufsummiert) der letzten [`Self::WINDOW`] mit Zeitpunkt
trail: std::collections::VecDeque<(std::time::Instant, Vec2)>,
/// Zeit der letzten Fingerbewegung bzw. des letzten Gleitschritts
last: Option<std::time::Instant>,
active: bool,
}
impl Fling {
/// Abbremsen je Sekunde (Anteil der Geschwindigkeit, der nach 1 s bleibt: e^-FRICTION)
const FRICTION: f32 = 2.5;
/// Langsamer wischen gleitet nicht nach
const MIN_START_SPEED: f32 = 250.0;
/// Darunter hört das Gleiten auf
const MIN_SPEED: f32 = 40.0;
/// Liegt der Finger vor dem Abheben so lange still, gleitet nichts nach
const MAX_REST: std::time::Duration = std::time::Duration::from_millis(120);
/// Die Geschwindigkeit ergibt sich aus dem Weg in dieser Zeit vor dem Abheben. Über
/// mehrere Ereignisse gemessen, weil Touchscreens kurz vor dem Abheben oft nur noch
/// winzige Bewegungen melden und Ereignisse gebündelt ankommen, wenn ein Bild länger
/// zum Zeichnen braucht.
const WINDOW: std::time::Duration = std::time::Duration::from_millis(100);
/// Finger hat sich um `delta` verschoben.
pub fn track(&mut self, delta: Vec2, now: std::time::Instant) {
self.active = false;
let pos = self.trail.back().map_or(Vec2::ZERO, |&(_, p)| p) + delta;
self.trail.push_back((now, pos));
// Einen Punkt vor dem Fenster behalten, damit es ganz abgedeckt ist
while self.trail.len() > 2 && now.duration_since(self.trail[1].0) > Self::WINDOW {
self.trail.pop_front();
}
if delta != Vec2::ZERO {
self.last = Some(now);
}
}
/// Finger abgehoben: gleitet weiter, wenn zuletzt schnell genug gewischt wurde. Gibt die
/// gemessene Geschwindigkeit zurück (für die Fehlersuche).
pub fn release(&mut self, now: std::time::Instant) -> Vec2 {
let resting = self
.last
.is_none_or(|last| now.duration_since(last) > Self::MAX_REST);
self.velocity = match (self.trail.front(), self.trail.back()) {
(Some(&(t0, p0)), Some(&(_, p1))) => {
// Bis zum Abheben gemessen; gebündelte Ereignisse zählen mindestens 10 ms
let dt = now.duration_since(t0).as_secs_f32().max(0.01);
(p1 - p0) / dt
}
_ => Vec2::ZERO,
};
self.trail.clear();
let velocity = self.velocity;
self.active = !resting && velocity.length() >= Self::MIN_START_SPEED;
self.last = Some(now);
if !self.active {
self.stop();
}
velocity
}
/// Hält an (neue Berührung, Mausklick, Mausrad).
pub fn stop(&mut self) {
*self = Self::default();
}
pub fn is_active(&self) -> bool {
self.active
}
/// Verschiebung (Bildschirmpixel) seit dem letzten Schritt; `None`, wenn nichts gleitet.
pub fn step(&mut self, now: std::time::Instant) -> Option<Vec2> {
if !self.active {
return None;
}
let dt = self
.last
.map_or(0.0, |last| now.duration_since(last).as_secs_f32())
.min(0.1);
self.last = Some(now);
// Exakt integriert, damit die Strecke nicht von der Bildrate abhängt
let decay = (-Self::FRICTION * dt).exp();
let delta = self.velocity * (1.0 - decay) / Self::FRICTION;
self.velocity *= decay;
if self.velocity.length() < Self::MIN_SPEED {
self.stop();
}
Some(delta)
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -316,6 +412,64 @@ mod tests {
PageStack::new(vec![A4; count], current) PageStack::new(vec![A4; count], current)
} }
#[test]
fn test_fling_glides_and_slows_down() {
use std::time::{Duration, Instant};
let t0 = Instant::now();
let ms = |n: u64| t0 + Duration::from_millis(n);
let mut fling = Fling::default();
// Schnell nach oben wischen: 30 px alle 10 ms = 3000 px/s
for i in 0..=10 {
fling.track(Vec2::new(0.0, -30.0), ms(i * 10));
}
fling.release(ms(105));
assert!(fling.is_active());
let first = fling.step(ms(121)).unwrap();
assert!(first.y < -20.0, "{first:?}");
let mut total = first;
let mut t = 121;
while let Some(d) = fling.step(ms(t)) {
total += d;
t += 16;
assert!(t < 10_000, "gleitet endlos");
}
// Gleitet ungefähr v / FRICTION = 1200 px weiter, dann ist Schluss
assert!(total.y < -800.0 && total.y > -1300.0, "{total:?}");
assert!(!fling.is_active());
// Langsam schieben oder vor dem Abheben stillhalten: kein Nachgleiten
let mut slow = Fling::default();
for i in 0..=10 {
slow.track(Vec2::new(0.0, -2.0), ms(i * 10));
}
slow.release(ms(105));
assert!(!slow.is_active());
let mut rested = Fling::default();
for i in 0..=10 {
rested.track(Vec2::new(0.0, -30.0), ms(i * 10));
}
rested.release(ms(400));
assert!(!rested.is_active());
// Touchscreens melden kurz vor dem Abheben oft nur noch winzige Bewegungen
let mut lifted = Fling::default();
for i in 0..=8 {
lifted.track(Vec2::new(0.0, -30.0), ms(i * 10));
}
lifted.track(Vec2::new(0.0, -1.0), ms(90));
lifted.track(Vec2::new(0.0, 0.0), ms(100));
lifted.release(ms(104));
assert!(lifted.is_active());
// Gebündelt ankommende Ereignisse (ein Bild brauchte 50 ms)
let mut batched = Fling::default();
for t in [0, 50, 50, 50, 50, 100, 100, 100, 100] {
batched.track(Vec2::new(0.0, -20.0), ms(t));
}
batched.release(ms(100));
assert!(batched.is_active());
}
#[test] #[test]
fn test_continuous_layout() { fn test_continuous_layout() {
let stack = a4_stack(5, 1); let stack = a4_stack(5, 1);