Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bc29faddc | ||
|
|
8ea20aa783 | ||
|
|
1b30f2bd26 | ||
|
|
657a5ff16e | ||
|
|
8fddd9e2c3 | ||
|
|
1a98c9b8bb | ||
|
|
98b78d29e6 | ||
|
|
81ccd93438 | ||
|
|
cbf699b680 | ||
|
|
ade20ab3dc | ||
|
|
f24e7750e6 | ||
|
|
097933882c | ||
|
|
b3578175c2 | ||
|
|
1cb02bf8e9 | ||
|
|
26c4191e0f |
@@ -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."
|
||||
@@ -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. -->
|
||||
@@ -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."
|
||||
@@ -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. -->
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Mitmachen bei ink-notes
|
||||
|
||||
Danke, dass du ink-notes verbessern willst! *(English below.)*
|
||||
|
||||
## 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.md](SECURITY.md) beschrieben.
|
||||
|
||||
## Code beitragen
|
||||
|
||||
1. Größere Änderungen am besten vorher in einem Issue absprechen.
|
||||
2. Bauen und testen, wie im [README](README.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.md](docs/FILE_FORMAT.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)`.
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
## Contributing (English)
|
||||
|
||||
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.
|
||||
|
||||
- **Bugs and ideas:** check the [existing issues](https://gitea.pansi.eu/harald/ink-notes/issues) first, then use the bug report or feature request template. Please include the version (☰ → Updates), distribution, Wayland or X11, the input device and the steps to reproduce. German and English are both fine.
|
||||
- **Security issues:** please do not report them publicly; see [SECURITY.md](SECURITY.md).
|
||||
- **Code:** discuss larger changes in an issue first. Before submitting, `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings` and `cargo test` must pass. Add tests where it makes sense, document file format changes in `docs/FILE_FORMAT.md`, use Conventional Commits, and add English translations for new UI texts (`t!("…")`, see `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
@@ -1393,7 +1393,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ink-notes"
|
||||
version = "0.26.0"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"arboard",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ink-notes"
|
||||
version = "0.26.0"
|
||||
version = "0.28.0"
|
||||
edition = "2021"
|
||||
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||
|
||||
@@ -49,7 +49,7 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
|
||||
### 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:** 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).
|
||||
- **Feste Seiten oder durchgehendes Scrollen, je Notizbuch:** Beim Anlegen wählbar (Vorgabe: feste Seiten, Seite für Seite blättern mit Pfeilen in der Werkzeugleiste), später im ☰-Menü umschaltbar. Durchgehend hängen die Seiten lückenlos untereinander, eine dunkle Linie markiert die Grenze; wer im unteren Fünftel der letzten Seite schreibt, bekommt automatisch eine neue Seite.
|
||||
- **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.
|
||||
@@ -240,6 +240,12 @@ Bleibt ein Job auf „Waiting to run“ stehen: Ist der Runner online (*Einstell
|
||||
|
||||
---
|
||||
|
||||
## 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.md](CONTRIBUTING.md), Sicherheitslücken bitte vertraulich wie in [SECURITY.md](SECURITY.md) beschrieben. Es gilt der [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
|
||||
---
|
||||
|
||||
## Lizenz
|
||||
|
||||
[MIT](LICENSE), Copyright © 2026 Harald Pansi.
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
# Sicherheit / Security Policy
|
||||
|
||||
## Unterstützte Versionen / Supported versions
|
||||
|
||||
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.
|
||||
|
||||
Security fixes are only provided for the **latest release**. The app offers updates itself (☰ → Updates); the Flatpak is updated through the software manager.
|
||||
|
||||
## Sicherheitslücke melden / Reporting a vulnerability
|
||||
|
||||
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.
|
||||
|
||||
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, a description of the impact and 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.
|
||||
|
||||
## Besonders interessant / Areas of interest
|
||||
|
||||
- **Updates:** Prüfung der Minisign-Signatur und der SHA-256-Prüfsummen, Download und Austausch der Pakete (siehe „Sicherheit der Updates“ im [README](README.md#sicherheit-der-updates)).
|
||||
- **Dateien öffnen:** importierte PDFs und Bilder, Notizbuch-Dateien ([Dateiformat](docs/FILE_FORMAT.md)) – z. B. Abstürze oder Schreiben außerhalb des Datenordners durch präparierte Dateien.
|
||||
- **Updates:** verification of the Minisign signature and SHA-256 checksums, downloading and replacing packages.
|
||||
- **Opening files:** imported PDFs and images, notebook files – e.g. crashes or writes outside the data folder caused by crafted files.
|
||||
@@ -93,6 +93,8 @@ $$\vec{p}_{\text{neu}} = \vec{c} - (\vec{c} - \vec{p}_{\text{alt}}) \cdot \frac{
|
||||
- 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 oder durchgehend angezeigt wird, steht in seinem Manifest (`layout`, siehe [`FILE_FORMAT.md`](FILE_FORMAT.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.
|
||||
|
||||
+3
-1
@@ -63,7 +63,8 @@ Ordner wie „3. Semester › Mathe“ sind rein logisch: Die Notizbücher bleib
|
||||
],
|
||||
"page_ids": [1, 2],
|
||||
"next_stroke_id": 42,
|
||||
"next_page_id": 3
|
||||
"next_page_id": 3,
|
||||
"layout": "Paged"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -76,6 +77,7 @@ Ordner wie „3. Semester › Mathe“ sind rein logisch: Die Notizbücher bleib
|
||||
| `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) oder `Continuous` (durchgehend untereinander). Seit 0.28; fehlt das Feld, gilt die frühere globale Einstellung `continuous_scroll` aus `ui.json`. Ältere App-Versionen ignorieren es, daher keine neue Formatversion |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -61,12 +61,54 @@
|
||||
<url type="homepage">https://gitea.pansi.eu/harald/ink-notes</url>
|
||||
<url type="bugtracker">https://gitea.pansi.eu/harald/ink-notes/issues</url>
|
||||
<url type="vcs-browser">https://gitea.pansi.eu/harald/ink-notes</url>
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<caption>Notes with highlighter, handwriting and text boxes</caption>
|
||||
<caption xml:lang="de">Notizen mit Textmarker, Handschrift und Textfeldern</caption>
|
||||
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/notes.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Recognized shapes: coordinate system, triangle, circle, octahedron</caption>
|
||||
<caption xml:lang="de">Erkannte Formen: Koordinatensystem, Dreieck, Kreis, Oktaeder</caption>
|
||||
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/shapes.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Annotating imported PDF slides</caption>
|
||||
<caption xml:lang="de">Importierte PDF-Folien beschriften</caption>
|
||||
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/pdf.png</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<branding>
|
||||
<color type="primary" scheme_preference="light">#a8c8f0</color>
|
||||
<color type="primary" scheme_preference="dark">#1f3a5f</color>
|
||||
</branding>
|
||||
<content_rating type="oars-1.1" />
|
||||
<releases>
|
||||
<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">
|
||||
<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>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.26.1" date="2026-10-10">
|
||||
<description>
|
||||
<p>Screenshots in the app description for software centers. The app itself is unchanged.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.26.0" date="2026-10-10">
|
||||
<description>
|
||||
<p>Select and move objects with a finger; text and make-space tools also work with a finger. Exports go to the documents folder of the user directories.</p>
|
||||
|
||||
@@ -50,6 +50,4 @@ modules:
|
||||
- target
|
||||
- .git
|
||||
- .flatpak-builder
|
||||
- ideas.txt
|
||||
- issue.txt
|
||||
- cargo-sources.json
|
||||
|
||||
@@ -61,12 +61,54 @@
|
||||
<url type="homepage">https://gitea.pansi.eu/harald/ink-notes</url>
|
||||
<url type="bugtracker">https://gitea.pansi.eu/harald/ink-notes/issues</url>
|
||||
<url type="vcs-browser">https://gitea.pansi.eu/harald/ink-notes</url>
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<caption>Notes with highlighter, handwriting and text boxes</caption>
|
||||
<caption xml:lang="de">Notizen mit Textmarker, Handschrift und Textfeldern</caption>
|
||||
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/notes.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Recognized shapes: coordinate system, triangle, circle, octahedron</caption>
|
||||
<caption xml:lang="de">Erkannte Formen: Koordinatensystem, Dreieck, Kreis, Oktaeder</caption>
|
||||
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/shapes.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Annotating imported PDF slides</caption>
|
||||
<caption xml:lang="de">Importierte PDF-Folien beschriften</caption>
|
||||
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/pdf.png</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<branding>
|
||||
<color type="primary" scheme_preference="light">#a8c8f0</color>
|
||||
<color type="primary" scheme_preference="dark">#1f3a5f</color>
|
||||
</branding>
|
||||
<content_rating type="oars-1.1" />
|
||||
<releases>
|
||||
<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">
|
||||
<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>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.26.1" date="2026-10-10">
|
||||
<description>
|
||||
<p>Screenshots in the app description for software centers. The app itself is unchanged.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.26.0" date="2026-10-10">
|
||||
<description>
|
||||
<p>Select and move objects with a finger; text and make-space tools also work with a finger. Exports go to the documents folder of the user directories.</p>
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit Zorin OS 18 / Ubuntu 24.04).
|
||||
|
||||
### Highlights:
|
||||
- **Seitenanordnung je Notizbuch**: Beim Anlegen wählbar – „Feste Seiten“ (Vorgabe) oder „Durchgehend“ –, später im ☰-Menü umschaltbar. Bestehende Notizbücher behalten die bisherige Einstellung
|
||||
- **Stifte in ihrer Farbe**: Die Stifte in der Werkzeugleiste erscheinen als Stifte in ihrer eigenen Farbe statt als dünner Strich und sind so auf einen Blick zu unterscheiden
|
||||
- **Bilder ohne Verzerren skalieren**: Beim Auswahlwerkzeug schaltet ein Schloss unter den Stiftfarben zwischen gleichmäßigem (🔒) und freiem Skalieren (🔓) um. Bilder sind zu Beginn gesperrt, Striche und Formen frei; am Rechner sperrt zusätzlich die Umschalttaste
|
||||
- **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
|
||||
- **Screenshots für Flathub**: Die App-Beschreibung (Metainfo) zeigt jetzt drei Screenshots; an der App selbst ändert sich nichts
|
||||
- **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
|
||||
- **Flatpak**: Manifest unter packaging/flatpak (Vorbereitung für Flathub); Exporte landen im Dokumente-Ordner aus den Benutzerordnern (z. B. ~/Dokumente)
|
||||
- **Englische Oberfläche / English interface**: ink-notes folgt der Systemsprache (Deutsch oder Englisch); umstellen im ☰-Menü unter „Sprache / Language“
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 184 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
+8
-3
@@ -20,8 +20,6 @@ pub const EN: &[(&str, &str)] = &[
|
||||
("Alle", "All"),
|
||||
("Alles gespeichert", "All saved"),
|
||||
("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)"),
|
||||
("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"),
|
||||
@@ -267,6 +265,7 @@ pub const EN: &[(&str, &str)] = &[
|
||||
("Schwarz", "Black"),
|
||||
("Sechseck (Hexagon)", "Hexagon"),
|
||||
("Seite als", "Page as"),
|
||||
("Seite für Seite blättern", "Flip page by page"),
|
||||
("Seite ist schreibgeschützt", "Page is read-only"),
|
||||
("Seite konnte nicht geladen werden", "Page could not be loaded"),
|
||||
("Seite wird exportiert …", "Exporting page …"),
|
||||
@@ -285,7 +284,11 @@ pub const EN: &[(&str, &str)] = &[
|
||||
("Seite {number} – {title}", "Page {number} – {title}"),
|
||||
("Seite {number}", "Page {number}"),
|
||||
("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}"),
|
||||
("Server-Einstellungen gespeichert", "Server settings saved"),
|
||||
("Server:", "Server:"),
|
||||
@@ -416,12 +419,14 @@ pub const EN: &[(&str, &str)] = &[
|
||||
("📂 Ordner öffnen", "📂 Open folder"),
|
||||
("📄 Als PDF exportieren (Strg+E)", "📄 Export as PDF (Ctrl+E)"),
|
||||
("📄 PDF importieren …", "📄 Import PDF …"),
|
||||
("📄 Feste Seiten", "📄 Fixed pages"),
|
||||
("📄 PDF importieren", "📄 Import PDF"),
|
||||
("📋 Einfügen", "📋 Paste"),
|
||||
("📑 Inhalt", "📑 Contents"),
|
||||
("📚 Alle", "📚 All"),
|
||||
("📚 Notizbücher", "📚 Notebooks"),
|
||||
("📚 Oberste Ebene", "📚 Top level"),
|
||||
("📜 Durchgehend", "📜 Continuous"),
|
||||
("📦 Paket mit Software-Center öffnen / installieren", "📦 Open / install package with the software center"),
|
||||
("🔄 Suche nach Updates …", "🔄 Checking for updates …"),
|
||||
("🔄 Updates & Versionen", "🔄 Updates & versions"),
|
||||
|
||||
+94
-27
@@ -45,26 +45,57 @@ pub fn quad_vertices(corners: &[glam::Vec2; 4]) -> [ImageVertex; 6] {
|
||||
[tl, tr, bl, bl, tr, br]
|
||||
}
|
||||
|
||||
/// Mipmap-Stufen eines Bildes, jeweils halbe Kantenlänge bis 1 × 1.
|
||||
pub fn mip_chain(base: &RgbaImage) -> Vec<RgbaImage> {
|
||||
let mut levels = vec![base.clone()];
|
||||
let Some(mut current) =
|
||||
image::RgbaImage::from_raw(base.width, base.height, base.pixels.clone())
|
||||
else {
|
||||
return levels;
|
||||
};
|
||||
while current.width() > 1 || current.height() > 1 {
|
||||
let (w, h) = ((current.width() / 2).max(1), (current.height() / 2).max(1));
|
||||
current = image::imageops::resize(¤t, w, h, image::imageops::FilterType::Triangle);
|
||||
levels.push(RgbaImage {
|
||||
width: w,
|
||||
height: h,
|
||||
pixels: current.as_raw().clone(),
|
||||
});
|
||||
/// Mipmap-Stufen eines Bildes, jeweils halbe Kantenlänge bis 1 × 1. Für eine gerenderte
|
||||
/// PDF-Seite dauert das spürbar (2560 px: einige 10 ms); deshalb erzeugt sie der
|
||||
/// PDF-Render-Thread und nicht der Haupt-Thread.
|
||||
pub fn mip_chain(base: RgbaImage) -> Vec<RgbaImage> {
|
||||
let mut levels = vec![base];
|
||||
loop {
|
||||
let current = &levels[levels.len() - 1];
|
||||
if current.width <= 1 && current.height <= 1 {
|
||||
break;
|
||||
}
|
||||
let next = half_size(current);
|
||||
levels.push(next);
|
||||
}
|
||||
levels
|
||||
}
|
||||
|
||||
/// Halbiert ein Bild: Mittelwert aus je 2 × 2 Pixeln (bei ungerader Kante entfällt die
|
||||
/// letzte Spalte bzw. Zeile, bei Kantenlänge 1 wird nur in der anderen Richtung gemittelt).
|
||||
fn half_size(image: &RgbaImage) -> RgbaImage {
|
||||
let (w, h) = (image.width as usize, image.height as usize);
|
||||
let (nw, nh) = ((w / 2).max(1), (h / 2).max(1));
|
||||
let mut pixels = vec![0u8; nw * nh * 4];
|
||||
if image.pixels.len() < w * h * 4 {
|
||||
return RgbaImage {
|
||||
width: nw as u32,
|
||||
height: nh as u32,
|
||||
pixels,
|
||||
};
|
||||
}
|
||||
for y in 0..nh {
|
||||
let (y0, y1) = ((2 * y).min(h - 1), (2 * y + 1).min(h - 1));
|
||||
let (row0, row1) = (&image.pixels[y0 * w * 4..], &image.pixels[y1 * w * 4..]);
|
||||
let out = &mut pixels[y * nw * 4..(y + 1) * nw * 4];
|
||||
for x in 0..nw {
|
||||
let (x0, x1) = ((2 * x).min(w - 1) * 4, (2 * x + 1).min(w - 1) * 4);
|
||||
for c in 0..4 {
|
||||
let sum = row0[x0 + c] as u16
|
||||
+ row0[x1 + c] as u16
|
||||
+ row1[x0 + c] as u16
|
||||
+ row1[x1 + c] as u16;
|
||||
out[x * 4 + c] = ((sum + 2) / 4) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
RgbaImage {
|
||||
width: nw as u32,
|
||||
height: nh as u32,
|
||||
pixels,
|
||||
}
|
||||
}
|
||||
|
||||
/// Name der Ersatztextur für Bilder, deren Datei fehlt oder beschädigt ist.
|
||||
const MISSING: &str = "";
|
||||
/// Bis zu dieser Grafikspeichermenge bleiben Texturen anderer Seiten erhalten (schnelles
|
||||
@@ -74,6 +105,8 @@ const TEXTURE_CACHE_BYTES: u64 = 384 * 1024 * 1024;
|
||||
/// Antwort einer Texturquelle für einen Schlüssel.
|
||||
pub enum TextureSource {
|
||||
Ready(RgbaImage),
|
||||
/// Fertige Mipmap-Stufen (siehe [`mip_chain`]), z. B. vom PDF-Render-Thread
|
||||
ReadyMips(Vec<RgbaImage>),
|
||||
/// Wird gerade erzeugt; später erneut fragen
|
||||
Pending,
|
||||
Failed(String),
|
||||
@@ -233,10 +266,16 @@ impl ImageLayer {
|
||||
continue;
|
||||
}
|
||||
let (bind_group, bytes) = match load(&key) {
|
||||
TextureSource::Ready(image) => (
|
||||
self.upload_texture(device, queue, &key, &image),
|
||||
image.pixels.len() as u64 * 4 / 3,
|
||||
),
|
||||
TextureSource::Ready(image) => {
|
||||
self.upload_texture(device, queue, &key, mip_chain(image))
|
||||
}
|
||||
TextureSource::ReadyMips(levels) if !levels.is_empty() => {
|
||||
self.upload_texture(device, queue, &key, levels)
|
||||
}
|
||||
TextureSource::ReadyMips(_) => {
|
||||
eprintln!("[Bilder] '{key}' kann nicht angezeigt werden: leeres Bild");
|
||||
(self.missing_texture(device, queue), 4)
|
||||
}
|
||||
// Noch in Arbeit (PDF-Seite wird gerendert): beim nächsten Bild erneut fragen
|
||||
TextureSource::Pending => continue,
|
||||
TextureSource::Failed(err) => {
|
||||
@@ -342,17 +381,19 @@ impl ImageLayer {
|
||||
height: 1,
|
||||
pixels: vec![200, 200, 200, 255],
|
||||
};
|
||||
self.upload_texture(device, queue, MISSING, &gray)
|
||||
self.upload_texture(device, queue, MISSING, vec![gray]).0
|
||||
}
|
||||
|
||||
/// Lädt die Mipmap-Stufen hoch (Stufe 0 = volle Größe); liefert die Bind Group und den
|
||||
/// belegten Grafikspeicher.
|
||||
fn upload_texture(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
label: &str,
|
||||
image: &RgbaImage,
|
||||
) -> wgpu::BindGroup {
|
||||
let levels = mip_chain(image);
|
||||
levels: Vec<RgbaImage>,
|
||||
) -> (wgpu::BindGroup, u64) {
|
||||
let image = &levels[0];
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: wgpu::Extent3d {
|
||||
@@ -389,8 +430,9 @@ impl ImageLayer {
|
||||
},
|
||||
);
|
||||
}
|
||||
let bytes = levels.iter().map(|l| l.pixels.len() as u64).sum();
|
||||
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Image Texture Bind Group"),
|
||||
layout: &self.texture_layout,
|
||||
entries: &[
|
||||
@@ -403,7 +445,8 @@ impl ImageLayer {
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
},
|
||||
],
|
||||
})
|
||||
});
|
||||
(bind_group, bytes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,11 +482,35 @@ mod tests {
|
||||
height: 3,
|
||||
pixels: vec![128; 8 * 3 * 4],
|
||||
};
|
||||
let levels = mip_chain(&base);
|
||||
let levels = mip_chain(base);
|
||||
let sizes: Vec<(u32, u32)> = levels.iter().map(|l| (l.width, l.height)).collect();
|
||||
assert_eq!(sizes, vec![(8, 3), (4, 1), (2, 1), (1, 1)]);
|
||||
for l in &levels {
|
||||
assert_eq!(l.pixels.len(), (l.width * l.height * 4) as usize);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_half_size_averages_two_by_two_blocks() {
|
||||
// 3 × 2: links schwarz/weiß übereinander, Mitte grau; die dritte Spalte entfällt
|
||||
let px = |v: u8| [v, v, v, 255];
|
||||
let pixels = [px(0), px(100), px(7), px(255), px(100), px(7)].concat();
|
||||
let base = RgbaImage {
|
||||
width: 3,
|
||||
height: 2,
|
||||
pixels,
|
||||
};
|
||||
let half = half_size(&base);
|
||||
assert_eq!((half.width, half.height), (1, 1));
|
||||
// (0 + 100 + 255 + 100) / 4 = 113,75 → 114
|
||||
assert_eq!(half.pixels, vec![114, 114, 114, 255]);
|
||||
|
||||
// Kantenlänge 1: nur in der anderen Richtung mitteln
|
||||
let column = RgbaImage {
|
||||
width: 1,
|
||||
height: 2,
|
||||
pixels: [px(10), px(20)].concat(),
|
||||
};
|
||||
assert_eq!(half_size(&column).pixels, vec![15, 15, 15, 255]);
|
||||
}
|
||||
}
|
||||
|
||||
+53
-41
@@ -31,12 +31,12 @@ use ink_notes::osk::{self, KeyboardRequester};
|
||||
use ink_notes::pdf_doc::PdfRenderService;
|
||||
use ink_notes::platform::{self, DisplayBackend};
|
||||
use ink_notes::selection::{
|
||||
render_selection_overlay, DragMode, SelectionHandleHit, SelectionState,
|
||||
render_selection_overlay, scale_factors, DragMode, SelectionHandleHit, SelectionState,
|
||||
};
|
||||
use ink_notes::settings::{
|
||||
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::ui::{self, ActiveTool, UiAction, UiState};
|
||||
use ink_notes::updater::UpdaterController;
|
||||
@@ -649,12 +649,18 @@ fn selection_press(
|
||||
..
|
||||
},
|
||||
_,
|
||||
) => DragMode::Scale {
|
||||
fixed_corner_page,
|
||||
start_pos_page,
|
||||
initial_box_size,
|
||||
initial_strokes: page.get_strokes(&selection_state.selected_ids),
|
||||
},
|
||||
) => {
|
||||
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,
|
||||
start_pos_page,
|
||||
initial_box_size,
|
||||
initial_strokes,
|
||||
keep_aspect,
|
||||
}
|
||||
}
|
||||
(SelectionHandleHit::Body, Some(p)) => DragMode::Move {
|
||||
start_page: p,
|
||||
initial_strokes: page.get_strokes(&selection_state.selected_ids),
|
||||
@@ -729,18 +735,10 @@ fn selection_drag(
|
||||
DragMode::Scale {
|
||||
fixed_corner_page,
|
||||
start_pos_page,
|
||||
keep_aspect,
|
||||
..
|
||||
} => {
|
||||
let orig = *start_pos_page - *fixed_corner_page;
|
||||
let new = page_pt - *fixed_corner_page;
|
||||
let factor = |n: f32, o: f32| {
|
||||
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));
|
||||
let factors = scale_factors(*fixed_corner_page, *start_pos_page, page_pt, *keep_aspect);
|
||||
updated
|
||||
.iter_mut()
|
||||
.for_each(|s| s.scale(*fixed_corner_page, factors));
|
||||
@@ -941,25 +939,21 @@ fn texture_for(
|
||||
assets: &AssetStore,
|
||||
pdf_renderer: &mut PdfRenderService,
|
||||
) -> TextureSource {
|
||||
let result = match key.contains('#') {
|
||||
true => {
|
||||
let Some((Ok(path), page, max_pixels)) = ink_notes::pdf_doc::parse_page_key(key)
|
||||
.map(|(asset, page, px)| (assets.path_of(asset), page, px))
|
||||
else {
|
||||
return TextureSource::Failed(t!("Ungültiger PDF-Verweis '{key}'", key = key));
|
||||
};
|
||||
match pdf_renderer.take(key, path, page, max_pixels) {
|
||||
Some(result) => result,
|
||||
None => return TextureSource::Pending,
|
||||
}
|
||||
}
|
||||
false => assets
|
||||
.load_rgba(key, ink_notes::assets::MAX_DISPLAY_DIMENSION)
|
||||
.map_err(|e| e.to_string()),
|
||||
if !key.contains('#') {
|
||||
return match assets.load_rgba(key, ink_notes::assets::MAX_DISPLAY_DIMENSION) {
|
||||
Ok(image) => TextureSource::Ready(image),
|
||||
Err(err) => TextureSource::Failed(err.to_string()),
|
||||
};
|
||||
}
|
||||
let Some((Ok(path), page, max_pixels)) = ink_notes::pdf_doc::parse_page_key(key)
|
||||
.map(|(asset, page, px)| (assets.path_of(asset), page, px))
|
||||
else {
|
||||
return TextureSource::Failed(t!("Ungültiger PDF-Verweis '{key}'", key = key));
|
||||
};
|
||||
match result {
|
||||
Ok(image) => TextureSource::Ready(image),
|
||||
Err(err) => TextureSource::Failed(err),
|
||||
match pdf_renderer.take(key, path, page, max_pixels) {
|
||||
Some(Ok(levels)) => TextureSource::ReadyMips(levels),
|
||||
Some(Err(err)) => TextureSource::Failed(err),
|
||||
None => TextureSource::Pending,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1528,6 +1522,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
event_loop.run(move |event, target| {
|
||||
// Anordnung der Seiten gehört zum offenen Notizbuch (Issue #30)
|
||||
ui_state.continuous_scroll =
|
||||
manifest.layout_or(ui_settings.continuous_scroll) == NotebookLayout::Continuous;
|
||||
match event {
|
||||
Event::AboutToWait => {
|
||||
let prev_status = autosave.status();
|
||||
@@ -3107,6 +3104,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// 3. Toolbar & Widgets rendern
|
||||
updater.update();
|
||||
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
|
||||
.as_ref()
|
||||
.filter(|(id, _, _)| *id == manifest.id)
|
||||
@@ -3207,6 +3206,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::ToggleAspectLock => {
|
||||
if !selection_state.is_empty() {
|
||||
selection_state.toggle_aspect_lock(notebook.current_page());
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
UiAction::RotateSelected => {
|
||||
if !selection_state.is_empty()
|
||||
&& selection_state.rotate_selected_90_deg(notebook.current_page_mut()) {
|
||||
@@ -3560,11 +3565,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::CreateNotebook { title, folder } => {
|
||||
UiAction::CreateNotebook { title, folder, layout } => {
|
||||
let _ = autosave.flush();
|
||||
match storage::create_new_notebook(&storage_base_dir, &title) {
|
||||
Ok((new_nb, new_manifest, new_dir)) => {
|
||||
Ok((new_nb, mut new_manifest, new_dir)) => {
|
||||
let id = new_manifest.id.clone();
|
||||
new_manifest.layout = Some(layout);
|
||||
change_library(&storage_base_dir, &mut ui_state, |library| {
|
||||
library.move_notebook(&id, &folder)
|
||||
});
|
||||
@@ -3574,6 +3580,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
notebook = new_nb;
|
||||
manifest = new_manifest;
|
||||
storage_dir = new_dir;
|
||||
autosave.mark_manifest_dirty(&manifest);
|
||||
selection_state.clear();
|
||||
ui_state.current_notebook_id = manifest.id.clone();
|
||||
ui_state.document_title = manifest.title.clone();
|
||||
@@ -3818,9 +3825,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::SetContinuousScroll(on) => {
|
||||
ui_settings.continuous_scroll = on;
|
||||
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
||||
UiAction::SetNotebookLayout(layout) => {
|
||||
manifest.layout = Some(layout);
|
||||
ui_state.continuous_scroll = layout == NotebookLayout::Continuous;
|
||||
autosave.mark_manifest_dirty(&manifest);
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::SaveStylusButtons => {
|
||||
@@ -3924,6 +3932,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
canvas.set_neighbor_pages(&[]);
|
||||
}
|
||||
let assets = AssetStore::new(&storage_dir);
|
||||
// PDF-Seiten, die nach diesem Bild nicht mehr gebraucht werden (schnelles
|
||||
// Scrollen), verwirft end_frame, bevor sie gerendert werden
|
||||
pdf_renderer.begin_frame();
|
||||
canvas.prepare_images(&mut |key| texture_for(key, &assets, &mut pdf_renderer));
|
||||
// Nächste PDF-Seite vorab rendern, damit Blättern sofort klappt
|
||||
if !canvas.is_waiting_for_images() {
|
||||
@@ -3934,6 +3945,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
}
|
||||
pdf_renderer.end_frame();
|
||||
match canvas.render(&clipped_primitives, &screen_descriptor, &full_output.textures_delta) {
|
||||
Ok(_) => {}
|
||||
Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
|
||||
|
||||
+239
-29
@@ -1,11 +1,13 @@
|
||||
//! Lesen und Rendern importierter PDF-Dateien mit `hayro` (reines Rust, nur CPU).
|
||||
//!
|
||||
//! Das Rendern einer Seite kann einige hundert Millisekunden dauern; [`PdfRenderService`]
|
||||
//! erledigt es deshalb in einem Hintergrund-Thread und meldet fertige Seiten zurück.
|
||||
//! erledigt es deshalb in einem Hintergrund-Thread, samt Mipmaps für die Anzeige, und meldet
|
||||
//! fertige Seiten zurück.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::{self, Receiver, Sender};
|
||||
use std::sync::mpsc::{self, Receiver};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
|
||||
use hayro::hayro_interpret::InterpreterSettings;
|
||||
use hayro::hayro_syntax::{LoadPdfError, Pdf};
|
||||
@@ -104,30 +106,107 @@ struct RenderRequest {
|
||||
path: PathBuf,
|
||||
page: usize,
|
||||
max_pixels: u32,
|
||||
/// Wichtigkeit: kleiner zuerst (Reihenfolge der Anfragen im aktuellen Bild)
|
||||
rank: usize,
|
||||
}
|
||||
|
||||
/// Ergebnis eines Render-Auftrags.
|
||||
pub type RenderResult = Result<RgbaImage, String>;
|
||||
/// Wichtigkeit einer vorab gerenderten Seite: nach allen sichtbaren.
|
||||
const PREFETCH_RANK: usize = usize::MAX;
|
||||
|
||||
/// Warteschlange der noch nicht begonnenen Render-Aufträge, geteilt mit dem Render-Thread.
|
||||
#[derive(Default)]
|
||||
struct RenderQueue {
|
||||
requests: Vec<RenderRequest>,
|
||||
shutdown: bool,
|
||||
}
|
||||
|
||||
impl RenderQueue {
|
||||
/// Fügt einen Auftrag hinzu oder macht einen wartenden wichtiger.
|
||||
fn push(&mut self, request: RenderRequest) {
|
||||
match self.requests.iter_mut().find(|r| r.key == request.key) {
|
||||
Some(queued) => queued.rank = queued.rank.min(request.rank),
|
||||
None => self.requests.push(request),
|
||||
}
|
||||
}
|
||||
|
||||
/// Nimmt den wichtigsten Auftrag heraus.
|
||||
fn pop(&mut self) -> Option<RenderRequest> {
|
||||
let (index, _) = self
|
||||
.requests
|
||||
.iter()
|
||||
.enumerate()
|
||||
.min_by_key(|(_, r)| r.rank)?;
|
||||
Some(self.requests.swap_remove(index))
|
||||
}
|
||||
|
||||
/// Verwirft Aufträge, für die `rank_of` keinen Rang mehr liefert, und ordnet die übrigen
|
||||
/// neu. Liefert die Schlüssel der verworfenen.
|
||||
fn retain(&mut self, rank_of: impl Fn(&str) -> Option<usize>) -> Vec<String> {
|
||||
let mut dropped = Vec::new();
|
||||
self.requests.retain_mut(|r| match rank_of(&r.key) {
|
||||
Some(rank) => {
|
||||
r.rank = rank;
|
||||
true
|
||||
}
|
||||
None => {
|
||||
dropped.push(r.key.clone());
|
||||
false
|
||||
}
|
||||
});
|
||||
dropped
|
||||
}
|
||||
}
|
||||
|
||||
type SharedQueue = Arc<(Mutex<RenderQueue>, Condvar)>;
|
||||
|
||||
/// Ergebnis eines Render-Auftrags: die Mipmap-Stufen der Seite (siehe
|
||||
/// [`crate::image_layer::mip_chain`]), Stufe 0 in voller Größe.
|
||||
pub type RenderResult = Result<Vec<RgbaImage>, String>;
|
||||
|
||||
/// Rendert PDF-Seiten im Hintergrund. Fertige Seiten holt der Event-Loop mit
|
||||
/// [`PdfRenderService::take`] ab; `on_ready` weckt ihn dafür auf.
|
||||
///
|
||||
/// Je Bild meldet der Event-Loop zwischen [`Self::begin_frame`] und [`Self::end_frame`] mit
|
||||
/// `take` alle Seiten an, die er gerade braucht (die wichtigste zuerst). Gerendert wird die
|
||||
/// wichtigste wartende Seite zuerst; Seiten, die beim schnellen Scrollen schon wieder aus dem
|
||||
/// Bild sind, verwirft `end_frame`, bevor der Render-Thread sie beginnt.
|
||||
pub struct PdfRenderService {
|
||||
requests: Sender<RenderRequest>,
|
||||
queue: SharedQueue,
|
||||
worker: std::thread::JoinHandle<()>,
|
||||
results: Receiver<(String, RenderResult)>,
|
||||
/// Wartende oder gerade gerenderte Seiten
|
||||
pending: HashSet<String>,
|
||||
ready: HashMap<String, RenderResult>,
|
||||
/// Im aktuellen Bild angeforderte Seiten in ihrer Reihenfolge
|
||||
wanted: Vec<String>,
|
||||
/// Zuletzt vorab angeforderte Seite (bleibt erhalten, bis sie abgeholt wird)
|
||||
prefetched: Option<String>,
|
||||
}
|
||||
|
||||
impl PdfRenderService {
|
||||
pub fn new(on_ready: impl Fn() + Send + 'static) -> Self {
|
||||
let (req_tx, req_rx) = mpsc::channel::<RenderRequest>();
|
||||
let queue: SharedQueue = Arc::default();
|
||||
let (res_tx, res_rx) = mpsc::channel();
|
||||
std::thread::Builder::new()
|
||||
let worker_queue = Arc::clone(&queue);
|
||||
let worker = std::thread::Builder::new()
|
||||
.name("pdf-render".into())
|
||||
.spawn(move || {
|
||||
// Zuletzt geöffnetes PDF behalten: Blättern rendert meist dieselbe Datei
|
||||
let mut open_pdf: Option<(PathBuf, Pdf)> = None;
|
||||
while let Ok(request) = req_rx.recv() {
|
||||
loop {
|
||||
let request = {
|
||||
let (lock, ready) = &*worker_queue;
|
||||
let mut queue = lock.lock().unwrap_or_else(|e| e.into_inner());
|
||||
loop {
|
||||
if queue.shutdown {
|
||||
return;
|
||||
}
|
||||
if let Some(request) = queue.pop() {
|
||||
break request;
|
||||
}
|
||||
queue = ready.wait(queue).unwrap_or_else(|e| e.into_inner());
|
||||
}
|
||||
};
|
||||
if open_pdf.as_ref().map(|(p, _)| p) != Some(&request.path) {
|
||||
open_pdf = std::fs::read(&request.path)
|
||||
.map_err(|e| e.to_string())
|
||||
@@ -137,36 +216,64 @@ impl PdfRenderService {
|
||||
.map(|pdf| (request.path.clone(), pdf));
|
||||
}
|
||||
let result = match &open_pdf {
|
||||
Some((_, pdf)) => render_page(pdf, request.page, request.max_pixels),
|
||||
// Mipmaps hier statt im Haupt-Thread: sonst stockt das Scrollen bei
|
||||
// jeder neu gerenderten Seite (Issue #28)
|
||||
Some((_, pdf)) => render_page(pdf, request.page, request.max_pixels)
|
||||
.map(crate::image_layer::mip_chain),
|
||||
None => Err(t!("PDF kann nicht geöffnet werden").into()),
|
||||
};
|
||||
if res_tx.send((request.key, result)).is_err() {
|
||||
break;
|
||||
return;
|
||||
}
|
||||
on_ready();
|
||||
}
|
||||
})
|
||||
.expect("PDF-Render-Thread konnte nicht gestartet werden");
|
||||
Self {
|
||||
requests: req_tx,
|
||||
queue,
|
||||
worker,
|
||||
results: res_rx,
|
||||
pending: HashSet::new(),
|
||||
ready: HashMap::new(),
|
||||
wanted: Vec::new(),
|
||||
prefetched: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Beginnt die Anfragen eines neuen Bildes (siehe [`Self::end_frame`]).
|
||||
pub fn begin_frame(&mut self) {
|
||||
self.wanted.clear();
|
||||
}
|
||||
|
||||
/// Verwirft wartende Aufträge und fertige Ergebnisse für Seiten, die in diesem Bild nicht
|
||||
/// mehr angefordert wurden (außer der vorab angeforderten), und ordnet die übrigen nach
|
||||
/// der Reihenfolge der Anfragen.
|
||||
pub fn end_frame(&mut self) {
|
||||
let rank_of = |key: &str| {
|
||||
self.wanted
|
||||
.iter()
|
||||
.position(|k| k == key)
|
||||
.or_else(|| (self.prefetched.as_deref() == Some(key)).then_some(PREFETCH_RANK))
|
||||
};
|
||||
let dropped = {
|
||||
let mut queue = self.queue.0.lock().unwrap_or_else(|e| e.into_inner());
|
||||
queue.retain(rank_of)
|
||||
};
|
||||
for key in dropped {
|
||||
self.pending.remove(&key);
|
||||
}
|
||||
self.ready.retain(|key, _| rank_of(key).is_some());
|
||||
}
|
||||
|
||||
/// Rendert eine Seite vorab (z. B. die nächste), ohne auf das Ergebnis zu warten;
|
||||
/// [`Self::take`] liefert es später sofort.
|
||||
pub fn prefetch(&mut self, key: &str, path: PathBuf, page: usize) {
|
||||
self.collect();
|
||||
if self.ready.contains_key(key) || self.pending.contains(key) {
|
||||
self.prefetched = Some(key.to_string());
|
||||
if self.ready.contains_key(key) {
|
||||
return;
|
||||
}
|
||||
// Vorab gerenderte Seiten nicht unbegrenzt aufheben
|
||||
if self.ready.len() >= 2 {
|
||||
self.ready.clear();
|
||||
}
|
||||
self.request(key, path, page, DISPLAY_RENDER_PIXELS);
|
||||
self.request(key, path, page, DISPLAY_RENDER_PIXELS, PREFETCH_RANK);
|
||||
}
|
||||
|
||||
fn collect(&mut self) {
|
||||
@@ -176,21 +283,37 @@ impl PdfRenderService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Erteilt den Auftrag, falls noch keiner läuft. `false`, wenn der Thread beendet ist.
|
||||
fn request(&mut self, key: &str, path: PathBuf, page: usize, max_pixels: u32) -> bool {
|
||||
if !self.pending.insert(key.to_string()) {
|
||||
/// Erteilt den Auftrag bzw. macht einen wartenden wichtiger. `false`, wenn der
|
||||
/// Render-Thread beendet ist.
|
||||
fn request(
|
||||
&mut self,
|
||||
key: &str,
|
||||
path: PathBuf,
|
||||
page: usize,
|
||||
max_pixels: u32,
|
||||
rank: usize,
|
||||
) -> bool {
|
||||
if self.worker.is_finished() {
|
||||
self.pending.remove(key);
|
||||
return false;
|
||||
}
|
||||
let (lock, ready) = &*self.queue;
|
||||
let mut queue = lock.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Steht ein angefragter Auftrag nicht mehr in der Warteschlange, rendert ihn der
|
||||
// Thread gerade: nicht doppelt erteilen
|
||||
let waiting = queue.requests.iter().any(|r| r.key == key);
|
||||
if self.pending.contains(key) && !waiting {
|
||||
return true;
|
||||
}
|
||||
let request = RenderRequest {
|
||||
self.pending.insert(key.to_string());
|
||||
queue.push(RenderRequest {
|
||||
key: key.to_string(),
|
||||
path,
|
||||
page,
|
||||
max_pixels,
|
||||
};
|
||||
if self.requests.send(request).is_err() {
|
||||
self.pending.remove(key);
|
||||
return false;
|
||||
}
|
||||
rank,
|
||||
});
|
||||
ready.notify_one();
|
||||
true
|
||||
}
|
||||
|
||||
@@ -204,16 +327,29 @@ impl PdfRenderService {
|
||||
max_pixels: u32,
|
||||
) -> Option<RenderResult> {
|
||||
self.collect();
|
||||
if self.prefetched.as_deref() == Some(key) {
|
||||
self.prefetched = None;
|
||||
}
|
||||
if let Some(result) = self.ready.remove(key) {
|
||||
return Some(result);
|
||||
}
|
||||
if !self.request(key, path, page, max_pixels) {
|
||||
let rank = self.wanted.len();
|
||||
self.wanted.push(key.to_string());
|
||||
if !self.request(key, path, page, max_pixels, rank) {
|
||||
return Some(Err(t!("PDF-Render-Thread beendet").into()));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PdfRenderService {
|
||||
fn drop(&mut self) {
|
||||
let (lock, ready) = &*self.queue;
|
||||
lock.lock().unwrap_or_else(|e| e.into_inner()).shutdown = true;
|
||||
ready.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
@@ -303,8 +439,12 @@ pub(crate) mod tests {
|
||||
wake_rx
|
||||
.recv_timeout(std::time::Duration::from_secs(20))
|
||||
.unwrap();
|
||||
let image = service.take("a#0", path.clone(), 0, px).unwrap().unwrap();
|
||||
assert_eq!(image.width, DISPLAY_RENDER_PIXELS);
|
||||
let levels = service.take("a#0", path.clone(), 0, px).unwrap().unwrap();
|
||||
assert_eq!(levels[0].width, DISPLAY_RENDER_PIXELS);
|
||||
assert_eq!(
|
||||
(levels.last().unwrap().width, levels.last().unwrap().height),
|
||||
(1, 1)
|
||||
);
|
||||
|
||||
// Vorab rendern: danach sofort verfügbar
|
||||
service.prefetch("a#0", path.clone(), 0);
|
||||
@@ -314,6 +454,76 @@ pub(crate) mod tests {
|
||||
assert!(service.take("a#0", path, 0, px).unwrap().is_ok());
|
||||
}
|
||||
|
||||
fn request(key: &str, rank: usize) -> RenderRequest {
|
||||
RenderRequest {
|
||||
key: key.into(),
|
||||
path: PathBuf::new(),
|
||||
page: 0,
|
||||
max_pixels: 1,
|
||||
rank,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_queue_priorities_and_stale_requests() {
|
||||
let mut queue = RenderQueue::default();
|
||||
queue.push(request("a", 2));
|
||||
queue.push(request("b", 0));
|
||||
queue.push(request("c", 1));
|
||||
// Erneute Anfrage macht einen wartenden Auftrag wichtiger, ohne ihn zu verdoppeln
|
||||
queue.push(request("a", 0));
|
||||
assert_eq!(queue.requests.len(), 3);
|
||||
assert_eq!(queue.pop().map(|r| r.key), Some("a".into()));
|
||||
|
||||
// Nach dem Scrollen wird nur noch „c“ gebraucht: „b“ fällt weg
|
||||
let dropped = queue.retain(|key| (key == "c").then_some(0));
|
||||
assert_eq!(dropped, vec!["b".to_string()]);
|
||||
assert_eq!(queue.pop().map(|r| r.key), Some("c".into()));
|
||||
assert!(queue.pop().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_end_frame_drops_unwanted_results_but_keeps_prefetch() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("a.pdf");
|
||||
std::fs::write(&path, sample_pdf(&[(400.0, 300.0, 0), (400.0, 300.0, 0)])).unwrap();
|
||||
let (wake_tx, wake_rx) = mpsc::channel();
|
||||
let mut service = PdfRenderService::new(move || {
|
||||
let _ = wake_tx.send(());
|
||||
});
|
||||
let px = DISPLAY_RENDER_PIXELS;
|
||||
let wait = || {
|
||||
wake_rx
|
||||
.recv_timeout(std::time::Duration::from_secs(20))
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// Bild 1 braucht Seite 1; vorab gerendert wird Seite 2
|
||||
service.begin_frame();
|
||||
assert!(service.take("a#0", path.clone(), 0, px).is_none());
|
||||
service.prefetch("a#1", path.clone(), 1);
|
||||
service.end_frame();
|
||||
wait();
|
||||
wait();
|
||||
|
||||
// Bild 2 braucht keine Seite mehr (weggescrollt): Seite 1 wird verworfen,
|
||||
// die vorab gerenderte Seite 2 bleibt
|
||||
service.begin_frame();
|
||||
service.collect();
|
||||
service.end_frame();
|
||||
assert!(!service.ready.contains_key("a#0"));
|
||||
assert!(service.ready.contains_key("a#1"));
|
||||
assert!(service.pending.is_empty());
|
||||
|
||||
service.begin_frame();
|
||||
assert!(service.take("a#1", path.clone(), 1, px).unwrap().is_ok());
|
||||
// Seite 1 wird bei Bedarf neu gerendert
|
||||
assert!(service.take("a#0", path.clone(), 0, px).is_none());
|
||||
service.end_frame();
|
||||
wait();
|
||||
assert!(service.take("a#0", path, 0, px).unwrap().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_page_key() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -36,9 +36,34 @@ pub enum DragMode {
|
||||
start_pos_page: Vec2,
|
||||
initial_box_size: Vec2,
|
||||
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).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum SelectionHandleHit {
|
||||
@@ -64,6 +89,9 @@ pub struct SelectionState {
|
||||
pub selected_ids: Vec<u64>,
|
||||
pub drag_mode: DragMode,
|
||||
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 {
|
||||
@@ -72,9 +100,28 @@ impl SelectionState {
|
||||
selected_ids: Vec::new(),
|
||||
drag_mode: DragMode::None,
|
||||
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,
|
||||
_ => page
|
||||
.get_strokes(&self.selected_ids)
|
||||
.iter()
|
||||
.any(|s| s.is_image()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.selected_ids.is_empty()
|
||||
@@ -523,6 +570,77 @@ mod tests {
|
||||
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]
|
||||
fn test_recolor_ignores_images() {
|
||||
let mut page = Page::new(1, PageBackground::Blank);
|
||||
|
||||
+2
-1
@@ -212,7 +212,8 @@ pub struct UiSettings {
|
||||
pub pattern_color: PatternColor,
|
||||
/// Druckkennlinie des Stifts
|
||||
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,
|
||||
/// Stifte der Werkzeugleiste und der gewählte davon
|
||||
pub pen_presets: [PenPreset; PEN_PRESET_COUNT],
|
||||
|
||||
@@ -280,9 +280,32 @@ pub struct NotebookManifest {
|
||||
pub page_ids: Vec<u64>,
|
||||
pub next_stroke_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,
|
||||
}
|
||||
|
||||
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 {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -299,6 +322,7 @@ impl NotebookManifest {
|
||||
page_ids: vec![1],
|
||||
next_stroke_id: 1,
|
||||
next_page_id: 2,
|
||||
layout: Some(NotebookLayout::Paged),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1604,6 +1628,31 @@ mod tests {
|
||||
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: feste Seiten, unabhängig von der früheren globalen Einstellung
|
||||
assert_eq!(manifest.layout, Some(NotebookLayout::Paged));
|
||||
assert_eq!(manifest.layout_or(true), NotebookLayout::Paged);
|
||||
|
||||
manifest.layout = Some(NotebookLayout::Continuous);
|
||||
save_manifest(&dir, &manifest).unwrap();
|
||||
let (_nb, loaded) = load_notebook(&dir).unwrap();
|
||||
assert_eq!(loaded.layout, Some(NotebookLayout::Continuous));
|
||||
|
||||
// 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\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_notebook_management() {
|
||||
let temp_dir = tempfile::tempdir().expect("Konnte temporäres Verzeichnis nicht anlegen");
|
||||
|
||||
@@ -22,7 +22,7 @@ use crate::settings::{
|
||||
PenPreset, PressureCurve, SidebarSide, DEFAULT_HIGHLIGHTER_COLORS, DEFAULT_PEN_PRESETS,
|
||||
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::{t, tn};
|
||||
|
||||
@@ -255,12 +255,15 @@ pub enum UiAction {
|
||||
/// Zwischenablage an dieser Stelle der aktuellen Seite einfügen (Kontextmenü)
|
||||
PasteAt(glam::Vec2),
|
||||
RotateSelected,
|
||||
/// Seitenverhältnis beim Skalieren der Auswahl sperren bzw. freigeben
|
||||
ToggleAspectLock,
|
||||
// Dokumenten- & Notizbuch-Verwaltung
|
||||
OpenNotebook(String),
|
||||
/// Neues Notizbuch im Ordner `folder` anlegen und öffnen
|
||||
CreateNotebook {
|
||||
title: String,
|
||||
folder: String,
|
||||
layout: NotebookLayout,
|
||||
},
|
||||
/// Ordnerverwaltung (siehe `library.rs`)
|
||||
CreateFolder {
|
||||
@@ -315,8 +318,8 @@ pub enum UiAction {
|
||||
SaveStylusButtons,
|
||||
/// Druckkurve speichern (gilt während des Ziehens bereits über `UiState::pressure_curve`)
|
||||
SavePressureCurve(PressureCurve),
|
||||
/// Seiten durchgehend untereinander statt einzeln anzeigen (wird gespeichert)
|
||||
SetContinuousScroll(bool),
|
||||
/// Anordnung der Seiten des offenen Notizbuchs (wird im Manifest gespeichert)
|
||||
SetNotebookLayout(NotebookLayout),
|
||||
/// Farbe von Karos, Linien und Punkten (wird gespeichert, gilt auch für den Export)
|
||||
SetPatternColor(PatternColor),
|
||||
/// Bilddatei auf der aktuellen Seite einfügen
|
||||
@@ -362,6 +365,8 @@ pub struct UiState {
|
||||
pub document_title: String,
|
||||
pub available_notebooks: Vec<NotebookSummary>,
|
||||
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>,
|
||||
/// Ordner der Notizbücher und der gerade angezeigte Ordner im Dokumenten-Manager
|
||||
pub library: crate::library::Library,
|
||||
@@ -410,6 +415,8 @@ pub struct UiState {
|
||||
pub confirm_delete_page: bool,
|
||||
/// Seitenzahl der zuletzt gelöschten Seite, solange sie wiederhergestellt werden kann
|
||||
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
|
||||
pub shutdown_error: Option<crate::autosave::AutosaveError>,
|
||||
/// Fensterseite der Werkzeugleiste
|
||||
@@ -479,6 +486,7 @@ impl Default for UiState {
|
||||
document_title: t!("Standard-Notizbuch").to_string(),
|
||||
available_notebooks: Vec::new(),
|
||||
new_notebook_title_input: String::new(),
|
||||
new_notebook_layout: NotebookLayout::Paged,
|
||||
renaming_notebook_id: None,
|
||||
library: crate::library::Library::default(),
|
||||
notebook_folder: String::new(),
|
||||
@@ -508,6 +516,7 @@ impl Default for UiState {
|
||||
pressed_stylus_button: None,
|
||||
confirm_delete_page: false,
|
||||
restorable_page: None,
|
||||
selection_aspect_lock: None,
|
||||
shutdown_error: None,
|
||||
sidebar_side: SidebarSide::Left,
|
||||
language: None,
|
||||
@@ -1182,8 +1191,8 @@ fn color_editor(
|
||||
changed
|
||||
}
|
||||
|
||||
/// Stift der Werkzeugleiste: ein Strich in Farbe und (angedeuteter) Stärke auf einem
|
||||
/// Stück Papier, damit auch schwarze Tinte auf der dunklen Leiste sichtbar ist.
|
||||
/// Stift der Werkzeugleiste: ein schräg liegender Stift in seiner Farbe; die Stärke steht
|
||||
/// im Tooltip.
|
||||
fn pen_preset_swatch(
|
||||
ui: &mut egui::Ui,
|
||||
preset: PenPreset,
|
||||
@@ -1193,22 +1202,49 @@ fn pen_preset_swatch(
|
||||
) -> egui::Response {
|
||||
let (rect, response) = ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::click());
|
||||
if ui.is_rect_visible(rect) {
|
||||
let paper = rect.shrink(2.0);
|
||||
ui.painter().rect_filled(
|
||||
paper,
|
||||
egui::Rounding::same(5.0),
|
||||
egui::Color32::from_gray(245),
|
||||
);
|
||||
// Schräg liegender Stift ganz in seiner Farbe, damit die Stifte auf einen Blick zu
|
||||
// unterscheiden sind (Issue #31); die helle Kontur hält dunkle Farben sichtbar
|
||||
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 inset = 3.0 + width / 2.0;
|
||||
ui.painter().line_segment(
|
||||
[
|
||||
egui::pos2(paper.left() + inset, paper.bottom() - inset),
|
||||
egui::pos2(paper.right() - inset, paper.top() + inset),
|
||||
let ink = egui::Color32::from_rgb(r, g, b);
|
||||
let c = rect.center();
|
||||
let dir = egui::vec2(1.0, -1.0).normalized();
|
||||
let perp = egui::vec2(dir.y, -dir.x);
|
||||
let half_len = size * 0.44;
|
||||
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 {
|
||||
ui.painter().rect_stroke(
|
||||
rect.shrink(0.5),
|
||||
@@ -1609,6 +1645,38 @@ fn page_title_list(ui: &mut egui::Ui, notebook: &Notebook) -> Option<usize> {
|
||||
chosen
|
||||
}
|
||||
|
||||
/// Auswahl der Seitenanordnung eines Notizbuchs. `true`, wenn sie geändert wurde.
|
||||
fn layout_picker(ui: &mut egui::Ui, layout: &mut NotebookLayout) -> bool {
|
||||
let mut changed = false;
|
||||
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."),
|
||||
),
|
||||
] {
|
||||
if ui
|
||||
.selectable_label(*layout == option, label)
|
||||
.on_hover_text(tooltip)
|
||||
.clicked()
|
||||
&& *layout != option
|
||||
{
|
||||
*layout = option;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
changed
|
||||
}
|
||||
|
||||
/// Auswahlknopf für eine Papierfarbe: Farbmuster und Name.
|
||||
fn paper_theme_button(ui: &mut egui::Ui, theme: PaperTheme, selected: bool) -> egui::Response {
|
||||
let response = ui.add_sized(
|
||||
@@ -3045,6 +3113,30 @@ pub fn render_ui(
|
||||
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 =
|
||||
UiState::MAX_PALETTE_LEN as f32 * (swatch + spacing);
|
||||
if reserved > used {
|
||||
@@ -3697,17 +3789,15 @@ pub fn render_ui(
|
||||
}
|
||||
});
|
||||
|
||||
section(ui, t!("ANSICHT"));
|
||||
if ui
|
||||
.checkbox(&mut state.continuous_scroll, t!("Seiten durchgehend scrollen"))
|
||||
.on_hover_text(
|
||||
t!("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."),
|
||||
)
|
||||
.changed()
|
||||
{
|
||||
actions.push(UiAction::SetContinuousScroll(state.continuous_scroll));
|
||||
section(ui, t!("SEITEN DIESES NOTIZBUCHS"));
|
||||
let mut layout = if state.continuous_scroll {
|
||||
NotebookLayout::Continuous
|
||||
} else {
|
||||
NotebookLayout::Paged
|
||||
};
|
||||
if layout_picker(ui, &mut layout) {
|
||||
state.continuous_scroll = layout == NotebookLayout::Continuous;
|
||||
actions.push(UiAction::SetNotebookLayout(layout));
|
||||
}
|
||||
|
||||
section(ui, t!("ZOOM"));
|
||||
@@ -4042,6 +4132,7 @@ pub fn render_ui(
|
||||
actions.push(UiAction::CreateNotebook {
|
||||
title,
|
||||
folder: folder.clone(),
|
||||
layout: state.new_notebook_layout,
|
||||
});
|
||||
state.new_notebook_title_input.clear();
|
||||
} else if folder_btn.clicked() {
|
||||
@@ -4053,6 +4144,10 @@ pub fn render_ui(
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(t!("Seiten:"));
|
||||
layout_picker(ui, &mut state.new_notebook_layout);
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
ui.separator();
|
||||
@@ -5189,6 +5284,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, ¬ebook, 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, ¬ebook, 10);
|
||||
let actions = click_at(&ctx, &mut state, ¬ebook, 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]
|
||||
fn test_sidebar_fits_window_height_for_every_tool() {
|
||||
let notebook = Notebook::new();
|
||||
|
||||
Reference in New Issue
Block a user