Compare commits

..
21 Commits
Author SHA1 Message Date
haraldandClaude Opus 5.5 69e1640dc8 chore(release): bump version to 0.33.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-21 15:08:28 +02:00
haraldandClaude Opus 5.5 2a06e2604a fix: palm rejection, eraser sizes, PDF thumbnails, even polygon strokes (Issues #37-#41)
- #41: snapped triangles, rectangles and polygons keep the full stroke width on every
  side; corners are mitered, sharp ones bevelled (the old corner offset thinned the edges)
- #38: four eraser sizes, cycled by a button below the eraser modes and remembered in
  ui.json
- #37: a selection made with the pen's select button can be moved with the bare pen
- #39: fingers resting on the screen when the pen comes near, or touching it while the pen
  is near (and 0.7 s after), are ignored until lifted; the vendored winit ignores touches
  on the title bar meanwhile
- #40: the page picker shows imported PDF pages, rendered small in the background

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:06:39 +02:00
haraldandClaude Opus 5.5 31a57dc7a7 chore(release): bump version to 0.32.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 17m13s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:46:48 +02:00
haraldandClaude Opus 5.5 630fe31b76 feat: endless canvas grows only to the right and down (Issue #30)
The view stops 10 mm left of and above the start frame, or above and left
of content that lies further out in notebooks from 0.29/0.30, so a new
endless notebook starts at the top left like a sheet of paper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:57:26 +02:00
haraldandClaude Opus 5.5 08a5b014e0 fix: title bar buttons work with a finger on Wayland (Issue #36)
winit 0.29 draws the title bar itself on Wayland but only handles the
mouse there. A vendored copy of winit 0.29.15 (vendor/winit, used via
[patch.crates-io]) passes touches on the decorations to the frame like a
left click, so close, minimize and maximize work with a finger and
dragging the title bar moves the window.

CI no longer sets a global -D warnings, which would also apply to the
vendored crate; clippy still runs with -D warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:57:26 +02:00
haraldandClaude Opus 5.5 819bc7d223 fix: larger toolbar buttons, text field grows with the text (Issues #35, #34, #28)
- Toolbar: 2 px gaps and 3 px dividers instead of 3 px and 7 px, and the
  size estimate now matches the measured layout exactly. On a 900 px
  screen the buttons are back to 30 px despite the new view-only button.
- Text tool: a new field starts three letters wide and grows with the
  longest line up to the wrap width at the page edge.
- INK_NOTES_DEBUG_FLING now confirms it is active and logs every finger
  lift, including touches ignored because the pen is near.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:44:39 +02:00
haraldandClaude Opus 5.5 c38bfdd9b1 chore(release): bump version to 0.31.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 6m18s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 09:50:15 +02:00
haraldandClaude Opus 5.5 1643303b47 feat: view-only mode (Issue #35)
A 👁 button below the notebooks hides the tools. Pen, finger and mouse
then only scroll and zoom (the pen glides on like a finger), editing
shortcuts are ignored, Page Down no longer appends pages, and selection,
text box and ruler are closed when entering the mode.

The toolbar size estimate now includes the measured frame overhead and
can shrink buttons to 26 px, so the extra button still fits at 768 px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 09:20:34 +02:00
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
224 changed files with 62421 additions and 942 deletions

No files matched your search

+3
View File
@@ -48,6 +48,9 @@ jobs:
# schlagen cargo fmt und cargo clippy in den Quality Gates sofort fehl
components: rustfmt, clippy
cache: true
# No global -D warnings: it would also apply to the vendored winit (vendor/winit),
# which is built as a path dependency. The quality gates run clippy with -D warnings.
rustflags: ""
- name: Install rsign2 (Release-Signatur)
# RUSTFLAGS leeren: die Toolchain-Action setzt -D warnings, das soll nicht für rsign2 gelten
+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).
+19 -29
View File
@@ -1,43 +1,33 @@
# Mitmachen bei ink-notes
# Contributing to ink-notes
Danke, dass du ink-notes verbessern willst! *(English below.)*
**English** · [Deutsch](CONTRIBUTING.de.md)
## Wo?
Thank you for wanting to improve ink-notes!
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.
## Where?
## Fehler melden und Ideen vorschlagen
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.
- 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.
## Reporting bugs and suggesting ideas
## Code beitragen
- 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.
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):
## 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. 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/`).
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 -3
View File
@@ -1393,7 +1393,7 @@ dependencies = [
[[package]]
name = "ink-notes"
version = "0.29.0"
version = "0.33.0"
dependencies = [
"ab_glyph",
"arboard",
@@ -3718,8 +3718,6 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winit"
version = "0.29.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d59ad965a635657faf09c8f062badd885748428933dad8e8bdd64064d92e5ca"
dependencies = [
"ahash",
"android-activity",
+6 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ink-notes"
version = "0.29.0"
version = "0.33.0"
edition = "2021"
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
repository = "https://gitea.pansi.eu/harald/ink-notes"
@@ -69,3 +69,8 @@ strip = true
tempfile = "3.27.0"
# Prüft den WGSL-Shader in den Tests (dieselbe Version wie in wgpu 0.19)
naga = { version = "0.19", features = ["wgsl-in"] }
# winit 0.29 with touch support for the client-side title bar on Wayland (Issue #36),
# see vendor/winit/README.ink-notes.md
[patch.crates-io]
winit = { path = "vendor/winit" }
+254
View File
@@ -0,0 +1,254 @@
<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, in vier Größen (wird gemerkt). Die Stiftrückseite radiert ebenfalls.
- **Virtuelles Lineal** (`L`): verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden gerade.
- **Nur ansehen** (👁 in der Werkzeugleiste): Stift, Finger und Maus scrollen und zoomen nur; die Werkzeuge verschwinden, nichts lässt sich versehentlich ändern. Erneut 👁 tippen, um zu schreiben.
- **Platz schaffen** (↕ in der Werkzeugleiste): aufs Blatt tippen und nach unten ziehen – alles darunter rutscht mit, auch auf den folgenden Seiten. Was über ein Seitenende rutschen würde, beginnt auf der nächsten Seite; fehlende Seiten werden angehängt. Nach oben ziehen schließt leeren Platz. Ein Schritt für Rückgängig.
### 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, die oben links beginnt und nach rechts und unten beliebig weitergeht; 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 (samt importierter PDF-Seite) 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, und kurz danach, werden Berührungen mit der Hand ignoriert – auch auf der Titelleiste. Liegt die Hand schon auf, wenn der Stift kommt, wird sie bis zum Abheben 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.
+155 -150
View File
@@ -1,224 +1,229 @@
<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 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>.
<strong>A modern notebook app for Linux for writing with pen, touch and mouse</strong><br>
Written in <strong>Rust</strong> with <strong>wgpu</strong>, <strong>winit</strong> and <strong>egui</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“.
<p align="center"><strong>English</strong> · <a href="README.de.md">Deutsch</a></p>
---
## 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
- **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.
It is mainly tested on Zorin OS 18 / Ubuntu 24.04 (Wayland and X11).
### Formerkennung
- **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.
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".
### 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.
## Features
### 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.
- **Feste Seiten, durchgehendes Scrollen oder endlose Fläche, 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. 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).
### Writing and drawing
- **Four pens** in the toolbar, each with its own color and width (0.15–5 mm, pressure-sensitive). When you change a pen's color or width, it remembers that – also across restarts. **Highlighter** (semi-transparent, 4–8 mm) in four colors; it does not get darker within one stroke, only a new stroke covers more strongly.
- **Custom colors:** Every color in the toolbar (pens, highlighter, text) can be changed – tap the selected color again and pick a preset or any shade with 🎨. The toolbar remembers the colors. They look the same on screen as in PDF and image exports.
- **Two-stage smoothing:** While you write, a 1€ filter removes jitter and the line appears immediately. When you lift the pen, the stroke is refined: corners (bends over 65°) are kept and drawn round, the pressure curve is smoothed, redundant points are dropped and the ends become round.
- **Pressure curve** adjustable to your pen in the ☰ menu (softer/harder, threshold against base noise).
- **Pen buttons** can be assigned in the ☰ menu: erase (default), select, highlighter, pen or nothing – active while the button is held. Each button appears there after it is pressed for the first time.
- **Eraser** with three modes: whole strokes, only the part you wipe over (partial stroke), or highlighter only, in four sizes (remembered). The back end of the pen erases too.
- **Virtual ruler** (`L`): move it, rotate it by its handle (snaps to 15°); strokes along an edge become straight.
- **View only** (👁 in the toolbar): pen, finger and mouse only scroll and zoom; the tools disappear and nothing can be changed by accident. Tap 👁 again to write.
- **Make space** (↕ in the toolbar): tap the page and drag down – everything below moves along, also on the following pages. Whatever would slide past the end of a page continues on the next one; missing pages are appended. Dragging up closes empty space. Undo reverts it in one step.
### 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.
### Shape recognition
- **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.
- Recognized shapes: lines, arrows, circles, ellipses, rectangles, triangles, rhombuses, polygons, coordinate systems (L shape), axis crosses (a "+" in one stroke or two crossing straight lines, arrowheads on all four ends) and octahedra (sketch as a 3D solid).
- Recognition is purely geometric (arc length, Douglas-Peucker, circle fitting), without machine learning. Not everything is recognized – whatever does not fit stays handwriting.
### Selecting and editing
- **Selection tool** (`V`): tap or drag a frame, then move, scale by the corners, rotate by the handle, or rotate by 90° with `R`. Works with mouse, finger and pen.
- **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 that grows to the right and down as far as you like, starting at the top left; 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 (including the imported PDF page) 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
- **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ü.
- **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.
- **Single page** as PNG (with or without background) or SVG via the ☰ menu.
### 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.
### Input devices
- Pens via the Wayland or X11 tablet protocol (pressure, eraser end, side button).
- While the pen touches the screen or hovers nearby, and shortly after, touches by the hand are ignored – also on the title bar. A hand already resting on the screen when the pen comes near is ignored until it is lifted.
- Fingers: scroll and zoom by default; drawing with a finger can be enabled in the ☰ menu.
### 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`).
- 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).
- As a Flatpak, ink-notes does not look for updates itself; they come through the software manager (`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`.
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
# Debian-Paket (Zorin OS 18 / Ubuntu 24.04)
# Debian package (Zorin OS 18 / Ubuntu 24.04)
sudo apt install ./ink-notes_<version>_amd64.deb
# oder AppImage
# or 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.
Notes:
- Requires `glibc >= 2.39` (Ubuntu 24.04, Zorin OS 18) and a graphics driver with Vulkan or OpenGL.
- 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) |
| 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 |
| Draw | Pen tip, left mouse button (finger, if enabled) |
| Erase | toggle with `E`, right mouse button, pen eraser end or side button |
| Pen in black / blue / red | `B` / `U` / `R` |
| Shape tool | `S` or `F` |
| Selection tool | `V` |
| Text tool | `T`, then click on the page; `Esc` finishes typing |
| Ruler on/off | `L` |
| Change background | `G` |
| Rotate selection by 90° | `R` (with a selection) |
| Copy / paste | `Ctrl + C` / `Ctrl + V` |
| Delete selection | `Del` or `Backspace` |
| Undo / redo | `Ctrl + Z` / `Ctrl + Y` or `Ctrl + Shift + Z` |
| Insert image | `Ctrl + I` |
| Scroll | mouse wheel (`Shift` = horizontal), one finger |
| Zoom | `Ctrl + mouse wheel`, two fingers, `Ctrl + +` / `Ctrl + -` |
| Reset zoom | `Ctrl + 0` |
| Pan | middle mouse button or `Space + drag` |
| Next / previous page | `Page Down` or `N` / `Page Up` |
| First / last page | `Home` / `End` |
| Save now | `Ctrl + S` |
| Export PDF | `Ctrl + E` |
| Close menus, clear selection | `Esc` |
| Quit (saves first) | `Ctrl + Q` or close the window |
---
## Entwicklung
## Development
### 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`
### Requirements
- Rust 1.92 or newer (`rustup update stable`)
- Vulkan or OpenGL driver (e.g. `mesa-vulkan-drivers`)
- X11/Wayland libraries: `libx11-dev`, `libxcursor-dev`, `libxrandr-dev`, `libxi-dev`, `libxkbcommon-dev`
### Bauen, starten, testen
### Build, run, test
```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
cargo run --release # or ./run.sh
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
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
├── main.rs # Event loop (winit), mouse, touch and pen input, control flow
├── lib.rs # Library exports (for tests)
├── canvas.rs # wgpu rendering: paper, patterns, images, ink, neighboring pages
├── shader.wgsl # Vertex and fragment shaders
├── viewport.rs # Camera (zoom, pan), page stack of the continuous view, swipe momentum
├── filter.rs # 1€ filter against jitter
├── spline.rs # Catmull-Rom splines, corner preservation, triangle strips
├── geometry.rs # Shape recognition (Douglas-Peucker, circle fitting, …)
├── selection.rs # Selection, handles, move/rotate/scale
├── ruler.rs # Virtual ruler
├── text.rs # Text boxes: layout, line wrapping, rasterization
├── background.rs # Paper colors, pattern colors, squares/lines/dots
├── image_layer.rs # Textures for images, PDF pages and text
├── assets.rs # Images and PDFs in the notebook folder
├── pdf_doc.rs # Reading and rendering PDFs (hayro)
├── pdf_merge.rs # Underlaying imported PDF pages on export
├── document.rs # Data model: notebook, pages, strokes, page sizes
├── history.rs # Undo/redo
├── storage.rs # Loading/saving (atomic), notebooks, trash
├── library.rs # Notebook folders (library.json)
├── autosave.rs # Saving in the background
├── export.rs # PDF export (printpdf)
├── page_export.rs # Page as PNG/SVG, selection as image
├── file_picker.rs # Built-in file picker
├── settings.rs # Settings (~/.config/ink-notes/ui.json)
├── platform.rs # Wayland/X11 detection
├── updater.rs # Updates from Gitea (signature and checksum verification)
└── 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
./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
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
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/`.
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
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.
1. Put the second line of `ink-notes.pub` (starting with `RW`) into `src/updater.rs` as `UPDATE_SIGNING_PUBLIC_KEY`.
2. Store the content of `ink-notes.key` in Gitea under *Repository → Settings → Actions → Secrets* as `MINISIGN_SECRET_KEY`.
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
runner:
@@ -226,26 +231,26 @@ runner:
- "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.
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.
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. **Checksum:** The SHA-256 of the downloaded package must match the entry in the signed list, otherwise the file is deleted.
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. **Dedicated download folder:** `~/.cache/ink-notes/updates` (permissions `0700`); file names with path separators, `..` or control characters are rejected.
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.
---
## Mitmachen
## Contributing
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).
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.
---
## Lizenz
## License
[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.
+13 -17
View File
@@ -1,26 +1,22 @@
# Sicherheit / Security Policy
# Security Policy
## Unterstützte Versionen / Supported versions
**English** · [Deutsch](SECURITY.de.md)
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.
## Supported versions
Security fixes are only provided for the **latest release**. The app offers updates itself (☰ → Updates); the Flatpak is updated through the software manager.
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.
## Sicherheitslücke melden / Reporting a vulnerability
## Reporting a vulnerability
Bitte Sicherheitslücken **nicht** in einem öffentlichen Issue melden, sondern per E-Mail an **harald@pansi.eu** – möglichst mit:
Please do **not** report vulnerabilities in a public issue. Send an e-mail to **harald@pansi.eu** instead, ideally with:
- 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.
- 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.
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.
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.
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.
## Areas of interest
## 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.
- **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

+208
View File
@@ -0,0 +1,208 @@
# 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`).
Die Fläche wächst nur nach rechts und unten: Nach jedem Verschieben oder Zoomen hält `CanvasState::clamp_endless` die linke obere Ecke des sichtbaren Bereichs auf oder hinter `Page::endless_scroll_limit` – 10 mm links und oberhalb des Startausschnitts bzw. von Inhalt, der weiter oben oder links liegt (in Notizbüchern aus 0.29/0.30).
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“.
+110 -104
View File
@@ -1,202 +1,208 @@
# 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`) |
| 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`) |
| Main thread | Input, data model, rendering (`wgpu`), interface (`egui`) |
| Autosave | Writes pages and manifest to disk (`autosave.rs`) |
| PDF rendering | Renders imported PDF pages to textures (`pdf_doc.rs`, `hayro`) |
| Export | PDF/PNG/SVG export, so the interface does not freeze (`main.rs`) |
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]
│ Bildschirmposition, Druck, Zeit
[Pen / touch / mouse]
│ 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
│ Stift abgehoben
[Triangle strip as for the final stroke] (spline.rs) → live buffer on the GPU
│ 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
Langsame Bewegungen zittern, schnelle sollen nicht nachhinken. Der 1€-Filter (`filter.rs`) passt seine Grenzfrequenz an die Geschwindigkeit an:
### 2.1 1€ filter
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}|$$
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
Die gefilterten Punkte werden mit einem zentripetalen Catmull-Rom-Spline ($\alpha = 0{,}5$) interpoliert:
### 2.2 Centripetal Catmull-Rom spline
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}$$
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
Aus den Spline-Punkten mit Radius $r_i$ (aus Druck und Strichstärke) entsteht ein Dreiecksstreifen:
### 2.3 Triangle strip
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
1. **Während des Schreibens:** Filter und Spline arbeiten nur mit bisherigen Punkten; der Strich wird sofort in einen eigenen Live-Puffer geschrieben.
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 nicht abgerundet werden,
- runde Endkappen anfügen.
### 2.4 Two stages
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. **When the pen is lifted**, the whole stroke is processed once more (`finalize_stroke`):
- smooth the pressure curve with the window $[0.25;\ 0.5;\ 0.25]$,
- thin out points with Ramer-Douglas-Peucker ($\varepsilon = 0.75$ units $= 0.075$ mm),
- 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,
- 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
Beginnt ein Strich nahe einer Linealkante (`ruler.rs`), werden alle seine Punkte auf diese Kante projiziert, bis der Stift abgehoben wird.
### 2.5 Ruler
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
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.1 Page coordinates
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
- `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:
### 3.2 Scale and camera
- `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`) 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
Ob ein Notizbuch Seite für Seite, durchgehend oder als endlose Fläche angezeigt wird, steht in seinem Manifest (`layout`, siehe [`FILE_FORMAT.md`](FILE_FORMAT.md)).
### 3.3 Continuous view
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)).
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).
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).
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.
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 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`).
### 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`).
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`).
The canvas only grows to the right and down: after every pan or zoom, `CanvasState::clamp_endless` keeps the top left corner of the visible area at or beyond `Page::endless_scroll_limit` – 10 mm above and left of the starting area, or of content lying further up or left (in notebooks from 0.29/0.30).
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
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.
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.
1. For each visible neighboring page: paper and pattern, images/PDF page/text, ink.
2. For the current page: paper, pattern (squares, lines, dots), image layer, stored strokes, live stroke.
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
`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.1 Image layer
`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 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.
- **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
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.
### 4.2 Paper and pattern colors
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`):
- **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.
Everything on a page is a `Stroke` (`document.rs`):
- **Ink:** sample points, tool (pen, highlighter, shape), width, color; the triangles are recomputed from the points when loading.
- **Shapes:** additionally a `RecognizedShape` (see section 6).
- **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
`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.1 Text boxes
`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
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.
### 5.2 Undo
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.
- **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.
- **Line:** ratio of start–end distance to stroke length above 0.92.
- **Circle, ellipse:** circle or ellipse fit through the points.
- **Rectangle, triangle, rhombus, polygon, arrow:** analysis of the contour simplified with Douglas-Peucker (number of corners, angles, aspect ratios).
- **Coordinate system:** two roughly perpendicular axes; the angle is set to 90°.
- **Axis cross:** a "+" in one stroke; the axes are aligned horizontally and vertically and get arrowheads at all four ends.
- **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
▼
[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.
- 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).
- The main thread never writes itself; it hands finished page data to the autosave thread.
- If a write fails, the page is retried with increasing delay (1 s to 30 s); the status turns red.
- `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/`).
- 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.
- A second window with the same notebook opens it read-only (lock file).
---
## 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).
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.
1. **Background:** squares, lines or dots in the chosen pattern color (always on white paper).
2. **PDF** (fallback only): imported PDF page as an image, if it cannot be taken over as vectors.
3. **Images:** inserted images (at most 300 dpi).
4. **Text:** text boxes as real text with embedded font (searchable, copyable). Each box lives in its own coordinate system that a transformation matrix maps onto its corners.
5. **Ink:** strokes as filled polygons, shapes as paths. Semi-transparent strokes (highlighter) get a graphics state with opacity (`ca`/`CA`); printpdf cannot create such states, so `add_alpha_states` adds them to the page resources with `lopdf` after saving. Each stroke is a single path and therefore does not cover itself twice.
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
`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.
+87 -85
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 9**.
**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
~/.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
│ ├── library.json notebook folders (see below)
│ ├── <notebook-id>/
│ │ ├── manifest.json metadata and page order
│ │ ├── manifest.json.bak copy of the previous manifest
│ │ ├── page_1.json one file per page
│ │ ├── page_2.json
│ │ ├── page_7.json.corrupt-<ts> beiseitegelegte beschädigte Seite (falls vorgekommen)
│ │ ├── assets/ eingefügte Bilder und importierte PDFs
│ │ ├── page_7.json.corrupt-<ts> damaged page set aside (if it ever happened)
│ │ ├── assets/ inserted images and imported 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
│ │ └── .lock lock file while the notebook is open
│ └── .trash/ deleted notebooks (<id>_<timestamp>)
└── 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`.
- **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.
- **Notebook IDs** are built from the title and the time (e.g. `mathematik_1775460000`); the first notebook is called `default`.
- **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.
- All files are created with permissions `0600`, folders with `0700`.
### 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.
### Folders (`library.json`)
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
{
@@ -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.
- 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.
- Folders are paths with `/` as separator; names themselves contain no `/` and at most 80 characters.
- Notebooks without an entry are on the top level.
- When a folder is deleted, its notebooks and subfolders move up one level.
- If the file is missing or unreadable, all notebooks are on the top level; missing parent folders are added when loading.
---
@@ -68,24 +70,24 @@ Ordner wie „3. Semester › Mathe“ sind rein logisch: Die Notizbücher bleib
}
```
| Feld | Bedeutung |
| Field | Meaning |
| :--- | :--- |
| `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` |
| `format_version` | format version (see section 5) |
| `id`, `title` | ID (= folder name) and display name |
| `created_at`, `modified_at` | UNIX time in seconds |
| `current_page_index` | page opened last |
| `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` | the same order as a plain ID list (for older versions) |
| `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` |
**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.
**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
{
@@ -99,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` |
| `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 |
| `background` | pattern: `Blank`, `Grid5mm`, `Lined`, `DotGrid` |
| `title` | page title (optional) |
| `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` | imported PDF page as background (optional): file, page number starting at 0, size in points |
| `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
{ "id": 7, "color": [0.12, 0.12, 0.14, 1.0],
@@ -119,79 +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`, `highlighter` oder `shape`
- `width`: Strichbreite in Seiteneinheiten (5.0 = 0,5 mm)
- `finalized`: Der Strich wurde nach dem Absetzen nachbearbeitet (Glättung, Ecken, Endkappen)
- `tool`: `pen`, `highlighter` or `shape` (a highlighter snapped to a shape stays `highlighter` and also has `shape`)
- `width`: stroke width in page units (5.0 = 0.5 mm)
- `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
"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
{ "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
"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)
- `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
- `size`: font size (em) in page units (12 pt ≈ 42.3)
- `font`: `Sans` (Ubuntu Light) or `Mono` (Hack)
- `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
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.
### Atomic writes
1. The content is written to `<file>.tmp.<pid>.<nanoseconds>` and flushed to disk with `fsync`.
2. `rename` replaces the old file in one step.
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
- 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.
### Damaged files
- If a page cannot be read, it is moved to `page_<id>.json.corrupt-<timestamp>` (not deleted) and the page is shown read-only.
- If `manifest.json` is damaged or missing, `manifest.json.bak` is used.
### 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.
### Deleted pages
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
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.
### Newer versions
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
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` |
| 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 |
| 0 | – | without `format_version` |
| 1 | – | `format_version`, backgrounds in the manifest |
| 2 | – | `tool`, `width`, `finalized` on strokes; compact JSON |
| 3 | 0.3.7 | shapes `Ellipse` and `Arrow` |
| 4 | 0.5 | images (`image`, folder `assets/`) |
| 5 | 0.6.1 | imported PDF pages (`pdf_background`) |
| 6 | 0.7 | page titles (`title` in page and manifest) |
| 7 | 0.10 | text boxes (`text`) |
| 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.
+35
View File
@@ -84,6 +84,41 @@
</branding>
<content_rating type="oars-1.1" />
<releases>
<release version="0.33.0" date="2026-10-21">
<description>
<p>The hand is ignored while the pen is near the screen, also on the title bar. The eraser comes in four sizes. The page overview shows imported PDF pages. Snapped triangles and other shapes have the same width on every side. A selection made with the pen's select button can be moved with the pen.</p>
</description>
</release>
<release version="0.32.0" date="2026-10-21">
<description>
<p>The endless canvas starts at the top left and grows to the right and down. New text fields start short and grow with the text. The toolbar buttons are larger again on small screens. On Wayland, the title bar buttons work with a finger.</p>
</description>
</release>
<release version="0.31.0" date="2026-10-21">
<description>
<p>New view-only mode: with 👁 in the toolbar, pen, finger and mouse only scroll and zoom, and nothing can be changed by accident.</p>
</description>
</release>
<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>
+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 \
# 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
# (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).
id: eu.pansi.ink-notes
runtime: org.freedesktop.Platform
@@ -22,9 +22,9 @@ finish-args:
- --socket=fallback-x11
- --share=ipc
- --device=dri
# Die eingebaute Dateiauswahl (Bilder und PDFs einfügen) arbeitet ohne Desktop-Portal und
# bietet Bilder, Downloads und Schreibtisch an; Export (PDF, PNG, SVG) in den Dokumente-Ordner.
# Ziehen aus dem Dateimanager geht über das Dokumenten-Portal auch aus anderen Ordnern.
# The built-in file picker (inserting images and PDFs) works without a desktop portal and
# offers Pictures, Downloads and Desktop; exports (PDF, PNG, SVG) go to the Documents folder.
# Dragging from the file manager works from other folders too, via the document portal.
- --filesystem=xdg-pictures:ro
- --filesystem=xdg-download:ro
- --filesystem=xdg-desktop:ro
+35
View File
@@ -84,6 +84,41 @@
</branding>
<content_rating type="oars-1.1" />
<releases>
<release version="0.33.0" date="2026-10-21">
<description>
<p>The hand is ignored while the pen is near the screen, also on the title bar. The eraser comes in four sizes. The page overview shows imported PDF pages. Snapped triangles and other shapes have the same width on every side. A selection made with the pen's select button can be moved with the pen.</p>
</description>
</release>
<release version="0.32.0" date="2026-10-21">
<description>
<p>The endless canvas starts at the top left and grows to the right and down. New text fields start short and grow with the text. The toolbar buttons are larger again on small screens. On Wayland, the title bar buttons work with a finger.</p>
</description>
</release>
<release version="0.31.0" date="2026-10-21">
<description>
<p>New view-only mode: with 👁 in the toolbar, pen, finger and mouse only scroll and zoom, and nothing can be changed by accident.</p>
</description>
</release>
<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>
+61 -54
View File
@@ -1,67 +1,74 @@
## 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:
- **Endlose Fläche**: Neue Seitenanordnung „♾ Endlos“ – eine Fläche ohne Rand, auf der man in alle Richtungen schreibt, verschiebt und zoomt. Wählbar beim Anlegen oder im ☰-Menü (bei Notizbüchern mit einer Seite); der PDF-Export enthält nur den beschriebenen Bereich. Neue Dateiformatversion 9: ältere App-Versionen öffnen Notizbücher dieser Version nicht mehr
- **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“
- **Halten zum Einrasten**: verträgt jetzt das leichte Zittern eines ruhenden Stifts, damit Formen auch im normalen Stift-Modus einrasten
- **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
- **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
- **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)
- **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
- **Achsenkreuz**: Ein „+“ in einem Zug (mit Halten oder dem Formen-Werkzeug) wird zum Achsenkreuz mit Pfeilspitzen an allen vier Enden; auch im PDF-/Bildexport
- **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
- **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
- **Ordner verschieben**: Ordner lassen sich jetzt wie Notizbücher mit 📂 samt Inhalt in andere Ordner oder auf die oberste Ebene verschieben
- **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)
- **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)
- **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
- **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
- **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)
- **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
- **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
- **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
- **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)
- **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
- **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
- **Teilstrich-Radierer**: Nur den überstrichenen Teil eines Strichs entfernen – ein Radierzug ist ein Undo-Schritt
- **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
- **Seitentitel & Inhaltsverzeichnis**: Seiten benennen, Inhaltsansicht in der Seitenübersicht, Titel werden zu PDF-Lesezeichen (Dateiformat-Version 6)
- **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)
- **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)
- **Dunkelmodus & Papierfarben**: Weiß, Creme, Dunkelgrau oder Schwarz; auf dunklem Papier werden dunkle Stiftfarben automatisch aufgehellt (nur Anzeige – Dateien und PDF-Export bleiben unverändert)
- **Nur Textmarker radieren**: Markierungen korrigieren, ohne die Handschrift darunter zu löschen – auch mit Stiftrückseite und Seitentaste
- **Einstellbare Druckkurve**: Kennlinie weich bis hart und Mindestdruck mit Vorschau, zum Anpassen an den eigenen Stift
- **Neue Formen**: Ellipsen sowie in einem Zug gezeichnete Pfeile und Doppelpfeile werden erkannt (Dateiformat-Version 3: ältere Versionen öffnen solche Notizbücher nicht)
- **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
- **WSL (WSLg)**: Läuft unter WSL automatisch über X11 statt Wayland, da WSLg die Wayland-Verbindung abbricht; übersteuerbar mit `INK_NOTES_DISPLAY=x11|wayland`
- **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)
- **Neue Werkzeugleiste**: Schmale senkrechte Leiste mit Symbol-Knöpfen, links oder rechts andockbar; selten genutzte Funktionen im ☰-Menü, das Blatt wird daneben zentriert
- **Einheitliche Dialoge**: Deckende Fenster ohne doppelte Titel, Seitenauswahl mit Vorschaubildern, größere Bedienelemente in allen Popovern
- **UI per Stift und Touch bedienbar**: Werkzeugleiste, Popover und Dialoge reagieren jetzt auch auf Stift und Finger, auch bei Display-Skalierung
- **Aktive Stylus-Unterstützung (Lazarite / MPP / AES)**: Stifteingabe über das Tablet-Protokoll von Wayland bzw. X11, mit Druckstärke
- **Touchscreen Multi-Touch**: Zoomen mit zwei Fingern, Verschieben mit einem; Zeichnen mit dem Finger zuschaltbar
- **Zwei-Stufen-Inking-Modell**: Glättung während des Schreibens (1€-Filter), beim Absetzen Nachbearbeitung mit Ecken-Erhaltung
- **Geometrie- und Formerkennung**: Geraden, Pfeile & Doppelpfeile, Rechtecke, Kreise, Ellipsen, Dreiecke, Rauten, Vielecke & Koordinatensysteme mit spitzen Pfeilspitzen (Draw-and-Hold); Freiformen bleiben unverändert
- **Mehrere Notizbücher**: Anlegen, umbenennen, löschen (mit Papierkorb); automatisches Speichern im Hintergrund
- **Datensicherheit**: Versioniertes Dateiformat mit Migration, beschädigte Seiten werden beiseitegelegt statt überschrieben, Schutz vor gleichzeitigem Öffnen, atomares Speichern
- **In-App Updater**: Prüft auf neue Versionen auf Gitea und installiert sie auf Wunsch – nur mit gültiger Minisign-Signatur und über HTTPS
- **PDF-Export**: Striche und Formen als Vektoren, für Druck und Weitergabe
- **Palm rejection & more feedback**: A hand resting on the screen while the pen is near is ignored (also on the title bar, no more accidental paste menu or window moving); the eraser has four sizes (button below the eraser modes, remembered); the page overview shows imported PDF pages; snapped triangles, rectangles and polygons have the same width on every side; a selection made with the pen's select button can be moved with the bare pen
- **Feedback round**: The endless canvas starts at the top left and only grows to the right and down; new text fields start short and grow with the text; toolbar buttons are larger again on small screens; on Wayland the title bar buttons (close, minimize, maximize) and moving the window work with a finger
- **View only**: 👁 in the toolbar hides the tools – pen, finger and mouse only scroll and zoom (the pen glides on like a finger), nothing can be changed by accident; tap 👁 again to write
- **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
- **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
- **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
- **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)
- **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
- **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
- **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
- **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
- **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
- **Screenshots for Flathub**: The app description (metainfo) now shows three screenshots; nothing changes in the app itself
- **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
- **Flatpak**: Manifest under packaging/flatpak (preparation for Flathub); exports go to the Documents folder from the user directories (e.g. ~/Documents)
- **English interface**: ink-notes follows the system language (German or English); switch it in the ☰ menu under "Sprache / Language"
- **Hold to snap**: now tolerates the slight jitter of a resting pen, so shapes also snap in the normal pen mode
- **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
- **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
- **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)
- **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
- **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
- **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
- **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
- **Move folders**: Folders can now be moved with 📂 including their content into other folders or to the top level, like notebooks
- **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)
- **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)
- **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
- **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
- **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)
- **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
- **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
- **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
- **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)
- **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
- **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
- **Partial-stroke eraser**: Remove only the part of a stroke you wipe over – one erase stroke is one undo step
- **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
- **Page titles & table of contents**: Name pages, table of contents view in the page overview, titles become PDF bookmarks (file format version 6)
- **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)
- **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)
- **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)
- **Erase highlighter only**: Correct highlights without deleting the handwriting underneath – also with the pen's eraser end and side button
- **Adjustable pressure curve**: Curve from soft to hard and minimum pressure with preview, to match your own pen
- **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)
- **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
- **WSL (WSLg)**: Runs via X11 instead of Wayland under WSL automatically, because WSLg drops the Wayland connection; override with `INK_NOTES_DISPLAY=x11|wayland`
- **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)
- **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
- **Consistent dialogs**: Opaque windows without duplicate titles, page picker with thumbnails, larger controls in all popovers
- **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:
- **Zorin OS / Ubuntu / Debian (.deb)**:
```bash
sudo dpkg -i ink-notes_{{VERSION}}_amd64.deb
```
- **AppImage (andere Distributionen mit glibc ≥ 2.39)**:
- **AppImage (other distributions with glibc ≥ 2.39)**:
```bash
chmod +x ink-notes_{{VERSION}}_x86_64.AppImage
./ink-notes_{{VERSION}}_x86_64.AppImage
+29 -1
View File
@@ -843,6 +843,30 @@ impl CanvasState {
changed
}
/// Endless page: keeps the view right of and below `limit` (see
/// [`crate::document::Page::endless_scroll_limit`]). `true` if the view was corrected.
pub fn clamp_endless(&mut self, limit: glam::Vec2) -> bool {
let changed = self
.camera
.clamp_top_left([self.ui_inset_left, 0.0], limit, &self.viewport);
if changed {
self.update_uniforms();
}
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)
/// wird zur Bezugsseite, ohne dass sich der Bildausschnitt bewegt. Danach ist die neue
/// Seite mit [`Self::sync_page`] zu setzen.
@@ -1312,9 +1336,13 @@ impl CanvasState {
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 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 =
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
.into_iter()
.map(|v| InkVertex::new(v.position[0], v.position[1], color))
+105 -30
View File
@@ -1,5 +1,6 @@
//! Notizbuch- und Seitenverwaltung mit DIN-A4-Koordinatensystem und Vektor-Stroke-Modell.
use std::collections::{HashMap, HashSet};
use std::ops::Range;
use crate::canvas::InkVertex;
@@ -613,18 +614,24 @@ impl Stroke {
/// Berechnet die Bounding Box aus den aktuellen Punkten/Vertizes neu.
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() {
let pts: Vec<glam::Vec2> = self.points.iter().map(|s| s.position).collect();
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)
bounds(Box::new(self.points.iter().map(|s| s.position)))
} 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])),
))
};
}
@@ -826,6 +833,15 @@ impl Page {
page
}
/// Endless page: the view never shows anything left of or above this point – the start
/// frame or the content, whichever reaches further, with a 10 mm margin. The canvas
/// therefore only grows to the right and down (Issue #30).
pub fn endless_scroll_limit(&self) -> glam::Vec2 {
let top_left = Self::selection_aabb(&self.strokes)
.map_or(ENDLESS_HOME, |content| content.min.min(ENDLESS_HOME));
(top_left - ENDLESS_EXPORT_MARGIN).max(glam::Vec2::ZERO)
}
/// 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) {
@@ -1066,6 +1082,7 @@ impl Page {
/// Liefert Klone der Striche mit den angegebenen IDs.
pub fn get_strokes(&self, ids: &[u64]) -> Vec<Stroke> {
let ids: HashSet<u64> = ids.iter().copied().collect();
self.strokes
.iter()
.filter(|s| ids.contains(&s.id))
@@ -1073,24 +1090,31 @@ impl Page {
.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.
pub fn modify_strokes(&mut self, modified: Vec<Stroke>) {
if modified.is_empty() {
return;
}
let ids: Vec<u64> = modified.iter().map(|s| s.id).collect();
let before: Vec<Stroke> = self
.strokes
.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();
}
}
let before = self.get_strokes(&ids);
self.replace_strokes(&modified);
self.history.push(HistoryAction::ModifyStrokes {
before,
@@ -1101,9 +1125,33 @@ impl Page {
/// 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]) {
for m in modified {
if let Some(pos) = self.strokes.iter().position(|s| s.id == m.id) {
self.strokes[pos] = m.clone();
self.replace_strokes(modified);
self.is_dirty = true;
}
/// 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;
@@ -1114,11 +1162,7 @@ impl Page {
if before.is_empty() || after.is_empty() {
return;
}
for m in &after {
if let Some(pos) = self.strokes.iter().position(|s| s.id == m.id) {
self.strokes[pos] = m.clone();
}
}
self.replace_strokes(&after);
self.history
.push(HistoryAction::ModifyStrokes { before, after });
self.is_dirty = true;
@@ -2673,6 +2717,13 @@ mod tests {
// History should not have been updated yet
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)
let before = vec![s.clone()];
let after = vec![moved.clone()];
@@ -2758,6 +2809,30 @@ mod tests {
assert!(endless.screen_to_page(-5000.0, -5000.0).is_some());
}
#[test]
fn test_endless_scroll_limit_includes_content_left_of_the_start() {
let mut page = Page::new(1, PageBackground::Grid5mm).with_size(ENDLESS_PAGE_SIZE);
assert_eq!(
page.endless_scroll_limit(),
ENDLESS_HOME - ENDLESS_EXPORT_MARGIN
);
// Content right of and below the start frame does not move the limit
page.add_stroke(line(1, ENDLESS_HOME + 500.0, ENDLESS_HOME + 900.0));
assert_eq!(
page.endless_scroll_limit(),
ENDLESS_HOME - ENDLESS_EXPORT_MARGIN
);
// Older notebooks may have content further up and left: it stays reachable
let from = glam::Vec2::new(30_000.0, 40_000.0);
page.add_stroke(line(2, from, ENDLESS_HOME));
let limit = page.endless_scroll_limit();
assert!(limit.cmple(from - ENDLESS_EXPORT_MARGIN).all(), "{limit:?}");
assert!(
limit.cmpgt(from - 2.0 * ENDLESS_EXPORT_MARGIN).all(),
"{limit:?}"
);
}
#[test]
fn test_endless_export_bounds_follow_content() {
let mut page = Page::new(1, PageBackground::Grid5mm).with_size(ENDLESS_PAGE_SIZE);
+78 -13
View File
@@ -1801,6 +1801,52 @@ pub fn contour_rms(points: &[Vec2], shape: &RecognizedShape) -> Option<f32> {
Some((sum_sq / points.len() as f32).sqrt())
}
/// Longest miter of a polygon corner, in half stroke widths; sharper corners are bevelled.
const MITER_LIMIT: f32 = 2.0;
/// Vertex pairs (left, right of the outline direction) of a triangle strip at corner `curr`
/// between the edges from `prev` and to `next` with left normals `n_prev` and `n_next`. Both
/// edges keep the full half width `half_w` up to the corner (Issue #41): mild corners get one
/// mitered pair, sharp ones a bevel from two pairs that share the inner point.
fn polygon_corner_join(
curr: Vec2,
prev: Vec2,
next: Vec2,
n_prev: Vec2,
n_next: Vec2,
half_w: f32,
) -> Vec<[Vec2; 2]> {
let bisector = (n_prev + n_next).normalize_or_zero();
let cos_half = bisector.dot(n_prev);
if bisector == Vec2::ZERO || cos_half <= 1e-3 {
// Edge turns back on itself
return vec![[curr + n_prev * half_w, curr - n_prev * half_w]];
}
let miter = half_w / cos_half;
if miter <= half_w * MITER_LIMIT {
let offset = bisector * miter;
return vec![[curr + offset, curr - offset]];
}
// The inner point may not reach past the shorter adjacent edge
let shortest = (curr - prev).length().min((next - curr).length());
let inner_len = miter.min(shortest.max(half_w));
// Turning towards the left normal: the outer side is on the right
let turns_left = (next - curr).dot(n_prev) > 0.0;
if turns_left {
let inner = curr + bisector * inner_len;
vec![
[inner, curr - n_prev * half_w],
[inner, curr - n_next * half_w],
]
} else {
let inner = curr - bisector * inner_len;
vec![
[curr + n_prev * half_w, inner],
[curr + n_next * half_w, inner],
]
}
}
/// Erzeugt ein gleichmäßiges Triangle-Strip-Mesh für ein geschlossenes Polygon (Dreieck, Rechteck, Raute, N-Eck).
pub fn closed_polygon_to_mesh(
corners: &[Vec2],
@@ -1812,7 +1858,7 @@ pub fn closed_polygon_to_mesh(
return (Vec::new(), Vec::new());
}
let mut vertices = Vec::with_capacity((n + 1) * 2);
let mut vertices = Vec::with_capacity((2 * n + 1) * 2);
let mut samples = Vec::with_capacity(n * 8);
for i in 0..n {
@@ -1825,18 +1871,10 @@ pub fn closed_polygon_to_mesh(
let n_prev = Vec2::new(-d_prev.y, d_prev.x);
let n_next = Vec2::new(-d_next.y, d_next.x);
let corner_norm = (n_prev + n_next).normalize_or_zero() * half_w;
vertices.push(InkVertex::new(
curr.x + corner_norm.x,
curr.y + corner_norm.y,
color,
));
vertices.push(InkVertex::new(
curr.x - corner_norm.x,
curr.y - corner_norm.y,
color,
));
for [left, right] in polygon_corner_join(curr, prev, next, n_prev, n_next, half_w) {
vertices.push(InkVertex::new(left.x, left.y, color));
vertices.push(InkVertex::new(right.x, right.y, color));
}
let edge_len = (next - curr).length();
let steps = ((edge_len / 10.0).ceil() as usize).max(2);
@@ -2481,6 +2519,33 @@ mod tests {
);
}
#[test]
fn test_polygon_edges_keep_full_width() {
// Right and acute corners: no outline vertex comes closer than half a stroke width to
// the outline, so all three sides look equally thick (Issue #41); mitered corners
// reach at most MITER_LIMIT half widths out
let half_w = 10.0;
let corners = [
Vec2::new(0.0, 0.0),
Vec2::new(0.0, 300.0),
Vec2::new(300.0, 300.0),
];
let (_, vertices) = closed_polygon_to_mesh(&corners, half_w, [0.0, 0.0, 0.0, 1.0]);
assert!(vertices.len() > 2 * corners.len());
for v in &vertices {
let p = Vec2::new(v.position[0], v.position[1]);
let nearest = (0..3)
.map(|i| {
crate::document::point_to_segment_distance(p, corners[i], corners[(i + 1) % 3])
})
.fold(f32::MAX, f32::min);
assert!(
nearest > half_w - 0.01 && nearest < half_w * MITER_LIMIT + 0.01,
"{p:?} is {nearest} from the outline"
);
}
}
#[test]
fn test_detect_triangle() {
let mut points = Vec::new();
+6 -1
View File
@@ -211,6 +211,9 @@ pub const EN: &[(&str, &str)] = &[
("Notizbuch", "Notebook"),
("Notizbücher verwalten (aktuell: {title})", "Manage notebooks (current: {title})"),
("Nur Bilder (PNG, JPEG, GIF, WebP, BMP) und PDFs können eingefügt werden", "Only images (PNG, JPEG, GIF, WebP, BMP) and PDFs can be inserted"),
("Nur ansehen – tippen, um wieder zu schreiben", "View only – tap to write again"),
("Nur ansehen – 👁 erneut tippen, um zu schreiben", "View only – tap 👁 again to write"),
("Nur ansehen: Stift, Finger und Maus verschieben nur das Blatt, nichts wird verändert", "View only: pen, finger and mouse just move the page, nothing gets changed"),
("Nur Textmarker radieren – Handschrift darunter bleibt", "Erase highlighter only – handwriting underneath stays"),
("Nächste Seite (Bild ab)", "Next page (Page Down)"),
("Oktaeder (3D-Kantenmodell)", "Octahedron (3D wireframe)"),
@@ -252,8 +255,10 @@ pub const EN: &[(&str, &str)] = &[
("Quell-PDF fehlt", "Source PDF missing"),
("Quell-PDF ist verschlüsselt", "Source PDF is encrypted"),
("Radieren", "Erase"),
("Radierer (E oder rechte Maustaste) – darunter: ganze Striche, Teilstrich oder nur Textmarker", "Eraser (E or right mouse button) – below: whole strokes, partial stroke or highlighter only"),
("Radierer (E oder rechte Maustaste) – darunter: ganze Striche, Teilstrich, nur Textmarker und die Größe", "Eraser (E or right mouse button) – below: whole strokes, partial stroke, highlighter only and the size"),
("Radierer (Eraser)", "Eraser"),
("Radiergröße {size} mm", "Eraser size {size} mm"),
("Radiergröße {size} mm – tippen für die nächste Größe", "Eraser size {size} mm – tap for the next size"),
("Raute (Rhombus)", "Rhombus"),
("Rechteck", "Rectangle"),
("Rechts", "Right"),
+306 -60
View File
@@ -43,6 +43,9 @@ use ink_notes::updater::UpdaterController;
/// Kennung der App (Name der .desktop-Datei und des Symbols).
const APP_ID: &str = "eu.pansi.ink-notes";
/// After the pen leaves the screen, new touches still count as the writing hand for this long
/// (Issue #39).
const PALM_GRACE: Duration = Duration::from_millis(700);
/// Fenstersymbol aus dem eingebauten App-Symbol, auf 64 × 64 Pixel verkleinert: In voller
/// Größe (256 × 256) überschreitet es die Größe einer einzelnen X11-Anfrage und fehlt dann.
@@ -326,9 +329,12 @@ fn commit_stroke_to_notebook(
) {
// 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
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) {
let stroke_width = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM;
canvas.cancel_stroke();
let stroke = geometry::shape_to_stroke(
cross,
@@ -347,7 +353,6 @@ fn commit_stroke_to_notebook(
let new_id = notebook.next_stroke_id();
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;
canvas.cancel_stroke();
Some(geometry::shape_to_stroke(
@@ -691,62 +696,65 @@ fn selection_press(
_ => return false,
};
selection_state.drag_mode = mode;
selection_state.pending_drag = None;
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,
/// 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(
page_pt: Vec2,
page: &mut ink_notes::document::Page,
canvas: &mut CanvasState,
selection_state: &mut SelectionState,
) {
let mut updated = match &mut selection_state.drag_mode {
match &mut selection_state.drag_mode {
DragMode::BoxSelect { current_page, .. } => {
*current_page = page_pt;
return;
}
DragMode::None => return,
DragMode::Move {
initial_strokes, ..
}
| DragMode::Rotate {
initial_strokes, ..
}
| DragMode::Scale {
initial_strokes, ..
} => initial_strokes.clone(),
};
match &selection_state.drag_mode {
DragMode::Move { start_page, .. } => {
start_page,
initial_strokes,
} => {
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 {
center_page,
start_angle,
..
initial_strokes,
} => {
let angle = (page_pt.y - center_page.y).atan2(page_pt.x - center_page.x) - start_angle;
updated
.iter_mut()
.for_each(|s| s.rotate(*center_page, angle));
let angle = (page_pt.y - center_page.y).atan2(page_pt.x - center_page.x) - *start_angle;
let center = *center_page;
page.transform_strokes_ephemeral(initial_strokes, |s| s.rotate(center, angle));
}
DragMode::Scale {
fixed_corner_page,
start_pos_page,
keep_aspect,
initial_strokes,
..
} => {
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));
let fixed = *fixed_corner_page;
let factors = scale_factors(fixed, *start_pos_page, page_pt, *keep_aspect);
page.transform_strokes_ephemeral(initial_strokes, |s| s.scale(fixed, factors));
}
DragMode::BoxSelect { .. } | DragMode::None => return,
}
page.update_strokes_ephemeral(&updated);
canvas.sync_page(page);
}
@@ -759,7 +767,10 @@ fn selection_release(
manifest: &mut NotebookManifest,
autosave: &mut AutosaveController,
selection_state: &mut SelectionState,
canvas: &mut CanvasState,
) -> 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) {
DragMode::BoxSelect {
start_page,
@@ -1315,7 +1326,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// 3. egui Context und State initialisieren
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
// Ergebnisse der PDF-Exporte aus dem Hintergrundthread
@@ -1342,6 +1353,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
ui_state.continuous_scroll = ui_settings.continuous_scroll;
ui_state.pen_presets = ui_settings.pen_presets;
ui_state.stylus_buttons = ui_settings.stylus_buttons.clone();
ui_state.set_eraser_radius_mm(ui_settings.eraser_radius_mm);
ui_state.select_pen(ui_settings.active_pen);
ui_state.highlighter_colors = ui_settings.highlighter_colors;
ui_state.select_highlighter_color(ui_settings.active_highlighter);
@@ -1416,12 +1428,23 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Langes Drücken mit einem Finger öffnet das Kontextmenü
let mut long_press = ui::LongPress::default();
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();
// Diagnosis for Issue #28: log every finger lift and the swipe speed
let debug_fling = std::env::var_os("INK_NOTES_DEBUG_FLING").is_some();
if debug_fling {
eprintln!("[Nachgleiten] Diagnose aktiv – bitte mit dem Finger wischen");
}
// Stylus / Digitizer Zustand (Octotablet / Wayland Tablet Protocol)
let mut is_stylus_drawing = false;
// Stift zieht mit dem Auswahlwerkzeug (Rahmen, Verschieben, Drehen, Skalieren)
let mut is_stylus_selecting = false;
let mut is_stylus_in_proximity = false;
// Palm rejection (Issue #39): fingers that rest on the screen when the pen comes near, or
// that touch it while the pen is near, are ignored until they are lifted
let mut palm_touch_ids: std::collections::HashSet<u64> = std::collections::HashSet::new();
let mut stylus_left_at: Option<Instant> = None;
let mut stylus_tool_id: Option<octotablet::tool::ID> = None;
// Gedrückte Stifttaste (Kennung, Belegung) und das Werkzeug, das während eines Strichs mit
// einer Werkzeug-Taste (Auswählen, Textmarker, Stift) vertreten wird
@@ -1430,6 +1453,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Platz schaffen läuft mit dem Stift bzw. dem Finger (sonst mit der Maus)
let mut is_stylus_spacing = false;
let mut is_touch_spacing = false;
// View only: the pen moves the page and remembers its last screen position (Issue #35)
let mut stylus_pan_last: Option<(f32, f32)> = None;
let mut stylus_screen_pos = (0.0_f32, 0.0_f32);
// Stift hat auf der UI aufgesetzt: alle Ereignisse bis zum Abheben gehen an egui
let mut stylus_ui_capture = false;
@@ -1448,7 +1473,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut stroke_snapped_shape: Option<RecognizedShape> = None;
// Radier-Parameter
let eraser_radius = 30.0_f32; // 3 mm in DIN-A4-Einheiten
let stroke_max_radius = 12.0_f32;
update_window_title(
@@ -1572,6 +1596,46 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let tool_is_hardware_eraser =
tool.tool_type == Some(octotablet::tool::Type::Eraser);
let pen_arrives = !is_stylus_in_proximity
&& matches!(
event,
octotablet::events::ToolEvent::In { .. }
| octotablet::events::ToolEvent::Pose(_)
| octotablet::events::ToolEvent::Down
);
if pen_arrives {
// Fingers already on the screen are most likely the writing hand:
// undo what they started and ignore them until lifted
if debug_fling && !active_touches.is_empty() {
eprintln!("[Nachgleiten] Stift kommt: {} Finger als Handballen ignoriert", active_touches.len());
}
palm_touch_ids.extend(active_touches.drain().map(|(id, _)| id));
long_press.cancel();
if is_touch_drawing {
is_touch_drawing = false;
is_drawing = false;
canvas.cancel_stroke();
}
if is_touch_spacing {
is_touch_spacing = false;
ui_state.space_drag = None;
}
if selection_state.drag_mode != DragMode::None && !is_stylus_selecting {
selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state, &mut canvas);
}
is_erasing = false;
is_touch_panning = false;
is_pinch_gesturing = false;
pinch_prev_distance = None;
pinch_prev_center = None;
stylus_left_at = None;
#[cfg(target_os = "linux")]
winit::platform::wayland::ignore_decoration_touches_until(Some(
Instant::now() + Duration::from_secs(24 * 3600),
));
window.request_redraw();
}
match event {
octotablet::events::ToolEvent::In { .. } => {
is_stylus_in_proximity = true;
@@ -1579,6 +1643,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
octotablet::events::ToolEvent::Out
| octotablet::events::ToolEvent::Removed => {
if is_stylus_in_proximity {
stylus_left_at = Some(Instant::now());
#[cfg(target_os = "linux")]
winit::platform::wayland::ignore_decoration_touches_until(Some(
Instant::now() + PALM_GRACE,
));
}
is_stylus_in_proximity = false;
if stylus_ui_capture {
stylus_ui_capture = false;
@@ -1595,6 +1666,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
if stylus_tool_id == Some(tool.id()) {
stylus_tool_id = None;
stylus_pan_last = None;
if is_stylus_drawing {
is_stylus_drawing = false;
is_drawing = false;
@@ -1618,7 +1690,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
if is_stylus_selecting {
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();
}
if let Some(tool) = tool_before_button.take() {
@@ -1675,7 +1747,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Stiftbewegung an egui weitergeben (Hover, Schieberegler ziehen),
// solange nicht auf dem Blatt gezeichnet oder radiert wird
if stylus_ui_capture || (!is_stylus_drawing && !is_erasing && !is_stylus_selecting) {
if stylus_ui_capture
|| (!is_stylus_drawing && !is_erasing && !is_stylus_selecting && stylus_pan_last.is_none())
{
let ui_pos = egui::pos2(sx / scale, sy / scale);
egui_winit_state
.egui_input_mut()
@@ -1691,14 +1765,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
continue;
}
if is_stylus_spacing {
if let Some((lx, ly)) = stylus_pan_last {
let delta = Vec2::new(sx - lx, sy - ly);
stylus_pan_last = Some((sx, sy));
canvas.pan(delta.x, delta.y);
fling.track(delta, Instant::now());
window.request_redraw();
} else if is_stylus_spacing {
space_move((sx, sy), &canvas, &mut ui_state);
window.request_redraw();
} else if is_erasing {
focus_page_at!((sx, sy));
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
if notebook.erase_at(page_pt,
eraser_radius,
ui_state.eraser_radius(),
stroke_max_radius, ui_state.eraser_mode) {
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &mut manifest);
@@ -1714,7 +1794,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
} else if is_stylus_selecting {
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();
}
} else if is_stylus_drawing && is_drawing {
@@ -1759,6 +1839,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
continue;
}
if ui_state.view_mode {
// View only: the pen moves the page like a finger
fling.stop();
stylus_pan_last = Some((sx, sy));
continue;
}
focus_page_at!((sx, sy));
// Werkzeug-Taste gedrückt: für diesen Strich dieses Werkzeug
let button_action = stylus_held.map(|(_, action)| action);
@@ -1768,6 +1855,28 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
Some(StylusAction::Pen) => Some(ActiveTool::Pen),
_ => None,
};
// A selection made with the select button stays grabbable with
// the bare pen; elsewhere the pen draws and the selection ends
// (Issue #37)
let mut button_tool = button_tool;
if button_action.is_none()
&& !tool_is_hardware_eraser
&& (ui_state.active_tool.is_drawing()
|| ui_state.active_tool == ActiveTool::Shape)
&& !selection_state.is_empty()
{
let hit = selection_state.hit_test_handles(
notebook.current_page(),
&canvas,
Vec2::new(sx, sy),
egui_ctx.pixels_per_point(),
);
if matches!(hit, SelectionHandleHit::None) {
selection_state.clear();
} else {
button_tool = Some(ActiveTool::Select);
}
}
if let Some(tool) = button_tool
.filter(|&t| !tool_is_hardware_eraser && t != ui_state.active_tool)
{
@@ -1796,7 +1905,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
is_erasing = true;
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
if notebook.erase_at(page_pt,
eraser_radius,
ui_state.eraser_radius(),
stroke_max_radius, ui_state.eraser_mode) {
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &mut manifest);
@@ -1846,6 +1955,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
continue;
}
if stylus_pan_last.take().is_some() {
fling.release(Instant::now());
window.request_redraw();
}
if is_erasing {
is_erasing = false;
}
@@ -1858,7 +1972,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if is_stylus_selecting {
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();
}
}
@@ -1923,9 +2037,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
stroke_snapped_shape = Some(shape.clone());
// Kreuzt die Gerade den vorigen Strich, zeigt die Vorschau schon das
// Achsenkreuz (übernommen wird es beim Absetzen)
let shape = axis_cross_with_previous(&notebook, &shape)
.map_or(shape, |(_, cross)| cross);
let width_units = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM;
// Textmarker bilden kein Achsenkreuz
let shape = if ui_state.active_tool == ActiveTool::Highlighter {
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);
canvas.set_live_shape_mesh(samples, vertices);
ui_state.set_export_message(t!("{shape} eingerastet!", shape = shape.name()));
@@ -1955,6 +2074,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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)
if let Some(at) = egui_repaint_at {
if Instant::now() >= at {
@@ -1970,6 +2095,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|| (is_drawing && !stroke_already_snapped)
|| is_stylus_drawing
|| is_stylus_in_proximity
|| fling.is_active()
{
target.set_control_flow(ControlFlow::wait_duration(Duration::from_millis(8)));
} else if let Some(at) = [egui_repaint_at, long_press.deadline()]
@@ -2068,12 +2194,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
is_mouse_drawing = false;
active_touches.clear();
ui_touch_ids.clear();
palm_touch_ids.clear();
stylus_ui_capture = false;
is_pinch_gesturing = false;
pinch_prev_distance = None;
pinch_prev_center = None;
is_touch_drawing = false;
is_touch_panning = false;
fling.stop();
is_stylus_drawing = false;
is_stylus_selecting = false;
is_stylus_in_proximity = false;
@@ -2135,6 +2263,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let ctrl = modifiers.control_key();
let shift = modifiers.shift_key();
if ui_state.view_mode && !ui::key_allowed_in_view_mode(&logical_key, ctrl) {
return;
}
match &logical_key {
Key::Named(NamedKey::Escape) => {
let mut changed = false;
@@ -2372,6 +2504,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
// Nächste Seite: PageDown oder N (mit Lazy Loading)
// View only: never append a new page at the end
Key::Named(NamedKey::PageDown)
if ui_state.view_mode && notebook.current_page_index + 1 >= notebook.pages.len() => {}
Key::Named(NamedKey::PageDown) => {
selection_state.clear();
notebook.next_page();
@@ -2521,7 +2656,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
ElementState::Pressed => {
// Palm-Rejection: Keine Mausevents verarbeiten, wenn der Stift aktiv
// zeichnet oder auswählt (manche Systeme melden ihn zusätzlich als Maus)
if is_stylus_drawing || is_stylus_selecting {
if is_stylus_drawing || is_stylus_selecting || stylus_pan_last.is_some() {
return;
}
@@ -2530,8 +2665,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
return;
}
fling.stop();
// 1. Pan via mittlere Maustaste oder Leertaste + Linksklick
if button == MouseButton::Middle || (button == MouseButton::Left && is_space_pressed) {
// In view-only mode the left and right buttons move the page too (Issue #35)
if button == MouseButton::Middle
|| (button == MouseButton::Left && is_space_pressed)
|| (ui_state.view_mode && matches!(button, MouseButton::Left | MouseButton::Right))
{
is_panning = true;
pan_last_screen_pos = cursor_screen_pos;
return;
@@ -2542,7 +2682,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
is_erasing = true;
let (sx, sy) = cursor_screen_pos;
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
if notebook.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode) {
if notebook.erase_at(page_pt, ui_state.eraser_radius(), stroke_max_radius, ui_state.eraser_mode) {
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
@@ -2614,7 +2754,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
}
ElementState::Released => {
if button == MouseButton::Middle || (button == MouseButton::Left && is_panning) {
if button == MouseButton::Middle
|| (matches!(button, MouseButton::Left | MouseButton::Right) && is_panning)
{
is_panning = false;
}
if is_erasing {
@@ -2626,7 +2768,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
if ui_state.active_tool == ActiveTool::Select
&& !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();
}
@@ -2682,13 +2824,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
window.request_redraw();
} 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) {
selection_drag(page_pt, notebook.current_page_mut(), &mut canvas, &mut selection_state);
selection_state.pending_drag = Some(page_pt);
window.request_redraw();
}
} else if is_erasing {
focus_page_at!((sx, sy));
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
if notebook.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode) {
if notebook.erase_at(page_pt, ui_state.eraser_radius(), stroke_max_radius, ui_state.eraser_mode) {
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
@@ -2715,15 +2857,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
WindowEvent::MouseWheel { delta, .. } => {
fling.stop();
if is_pointer_in_ui {
return;
}
// Durchgehende Ansicht: Mausrad scrollt (mit Umschalt waagerecht),
// Strg + Mausrad zoomt
if ui_state.continuous_scroll && !modifiers.control_key() {
// Durchgehende Ansicht und endlose Fläche: Mausrad scrollt (mit Umschalt
// waagerecht), Strg + Mausrad zoomt
if (ui_state.continuous_scroll || ui_state.endless) && !modifiers.control_key() {
let (mut dx, mut dy) = match delta {
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)
}
MouseScrollDelta::PixelDelta(pos) => (pos.x as f32, pos.y as f32),
@@ -2780,8 +2923,21 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let is_eraser_action = ui_state.active_tool == ActiveTool::Eraser;
// Hardware-Palm-Rejection: Wenn der Stylus in Proximity ist oder zeichnet, Touch ignorieren
if is_stylus_drawing || is_stylus_in_proximity {
// Palm rejection: a touch that starts while the pen is near the screen (or
// was just lifted away) belongs to the hand and is ignored until lifted
let pen_near = is_stylus_drawing
|| is_stylus_in_proximity
|| stylus_left_at.is_some_and(|t| t.elapsed() < PALM_GRACE);
if touch.phase == TouchPhase::Started && pen_near {
palm_touch_ids.insert(touch.id);
}
if palm_touch_ids.contains(&touch.id) {
if matches!(touch.phase, TouchPhase::Ended | TouchPhase::Cancelled) {
palm_touch_ids.remove(&touch.id);
}
if debug_fling && touch.phase != TouchPhase::Moved {
eprintln!("[Nachgleiten] Finger ignoriert ({:?}): Stift in der Nähe", touch.phase);
}
return;
}
@@ -2796,6 +2952,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
match touch.phase {
TouchPhase::Started => {
// Jede neue Berührung hält ein gleitendes Blatt an
fling.stop();
// Event-Isolation für Touch-Eingaben
let is_touch_in_ui = ui::is_screen_point_in_ui(
&egui_ctx,
@@ -2815,6 +2973,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// beim Radieren, Auswählen oder Text (dort hält man auch mal still)
if active_touches.len() == 1
&& !is_pinch_gesturing
&& !ui_state.view_mode
&& (!ui_state.touch_draw_enabled
|| ui_state.active_tool.is_drawing()
|| ui_state.active_tool == ActiveTool::Shape)
@@ -2855,6 +3014,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Finger immer, Auswählen, wenn er ein Objekt oder die Auswahl trifft
// (Issue #26)
let finger_uses_tool = match ui_state.active_tool {
_ if ui_state.view_mode => false,
ActiveTool::Text | ActiveTool::Space => true,
ActiveTool::Select => {
focus_page_at!((tx, ty));
@@ -2872,7 +3032,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Halten soll hier kein Kontextmenü öffnen
long_press.cancel();
}
if !ui_state.touch_draw_enabled && !finger_uses_tool {
if (!ui_state.touch_draw_enabled || ui_state.view_mode) && !finger_uses_tool {
// Standard: 1 Finger scrollt / verschiebt den Canvas (Pan)
is_touch_panning = true;
touch_pan_last_pos = (tx, ty);
@@ -2882,7 +3042,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if is_eraser_action {
is_erasing = true;
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
if notebook.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode) {
if notebook.erase_at(page_pt, ui_state.eraser_radius(), stroke_max_radius, ui_state.eraser_mode) {
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
@@ -2951,6 +3111,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let dy = ty - touch_pan_last_pos.1;
touch_pan_last_pos = (tx, ty);
canvas.pan(dx, dy);
fling.track(Vec2::new(dx, dy), Instant::now());
window.request_redraw();
} else if is_touch_drawing {
current_pressure = pressure;
@@ -2974,7 +3135,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} else if is_erasing {
focus_page_at!((tx, ty));
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
if notebook.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode) {
if notebook.erase_at(page_pt, ui_state.eraser_radius(), stroke_max_radius, ui_state.eraser_mode) {
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
@@ -2983,7 +3144,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
} 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) {
selection_drag(page_pt, notebook.current_page_mut(), &mut canvas, &mut selection_state);
selection_state.pending_drag = Some(page_pt);
window.request_redraw();
}
}
@@ -2992,11 +3153,34 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
TouchPhase::Ended | TouchPhase::Cancelled => {
active_touches.remove(&touch.id);
long_press.cancel();
if debug_fling {
eprintln!(
"[Nachgleiten] Finger abgehoben ({:?}), verschiebt: {}, weitere Finger: {}",
touch.phase,
is_touch_panning,
active_touches.len()
);
}
if active_touches.is_empty() {
is_pinch_gesturing = false;
pinch_prev_distance = 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 debug_fling {
eprintln!(
"[Nachgleiten] {:.0} px/s beim Abheben, gleitet: {}",
velocity.length(),
fling.is_active()
);
}
window.request_redraw();
}
is_touch_panning = false;
if is_erasing {
@@ -3010,7 +3194,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
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();
}
@@ -3053,14 +3237,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
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
// der Fenstermitte zur aktuellen (nicht während eines Strichs, einer
// Auswahl oder beim Ziehen des Lineals). Wer auf einer Nachbarseite
// schreibt, bleibt dort, bis wieder gescrollt wird (Undo betrifft sie).
canvas.set_fit_box(notebook.fit_box());
if ui_state.continuous_scroll {
if notebook.is_endless() {
if canvas.clamp_endless(notebook.current_page().endless_scroll_limit()) {
fling.stop();
}
} else if ui_state.continuous_scroll {
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
let scroll = [
canvas.camera.zoom,
@@ -3132,6 +3324,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
// 4. UI-Aktionen verarbeiten
let mut pdf_thumbnails_wanted = Vec::new();
for action in actions {
match action {
UiAction::SetTool(tool) => {
@@ -3139,6 +3332,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
update_window_title(&window, &notebook, &manifest, tool, autosave.status());
window.request_redraw();
}
UiAction::SetViewMode(on) => {
ui_state.view_mode = on;
if on {
// Nothing may stay half-edited while only viewing
finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
selection_state.clear();
ui_state.ruler = None;
ui_state.ruler_drag = None;
canvas.ruler = None;
ui_state.set_export_message(t!("Nur ansehen – 👁 erneut tippen, um zu schreiben"));
}
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
}
UiAction::SetColor(color) => {
if !selection_state.is_empty()
&& selection_state.recolor_selected(notebook.current_page_mut(), color) {
@@ -3367,6 +3574,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
}
}
UiAction::RequestPdfThumbnails(pdfs) => {
pdf_thumbnails_wanted = pdfs;
}
UiAction::LoadAllPages => {
// Für die Vorschaubilder der Seitenauswahl; Fehler werden wie beim
// Blättern behandelt (Ersatzseite, Warnung beim Öffnen der Seite)
@@ -3839,6 +4049,11 @@ 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::ScrollTo(y) => {
fling.stop();
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();
@@ -3896,6 +4111,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
updated.active_highlighter = ui_state.active_highlighter;
updated.text_colors = ui_state.text_colors;
updated.active_text_color = ui_state.active_text_color;
updated.eraser_radius_mm = ui_state.eraser_radius_mm;
if updated != ui_settings {
ui_settings = updated;
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
@@ -3974,6 +4190,36 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Scrollen), verwirft end_frame, bevor sie gerendert werden
pdf_renderer.begin_frame();
canvas.prepare_images(&mut |key| texture_for(key, &assets, &mut pdf_renderer));
// Page picker: PDF pages rendered small, after the pages on screen (Issue #40)
for pdf in pdf_thumbnails_wanted {
let pixels = ink_notes::pdf_doc::THUMBNAIL_RENDER_PIXELS;
let key = format!("{}@{pixels}", pdf.key());
let result = match assets.path_of(&pdf.asset) {
Ok(path) => pdf_renderer.take(&key, path, pdf.page as usize, pixels),
Err(err) => Some(Err(err.to_string())),
};
let texture = match result {
None => continue,
Some(Ok(levels)) => levels.first().map(|image| {
let size = [image.width as usize, image.height as usize];
ui::Thumbnail(egui_ctx.load_texture(
&key,
egui::ColorImage::from_rgba_premultiplied(size, &image.pixels),
egui::TextureOptions::LINEAR,
))
}),
Some(Err(err)) => {
eprintln!("[PDF] Vorschau {key}: {err}");
None
}
};
// Bounded memory: a few hundred small pages at most
if ui_state.pdf_thumbnails.len() >= 300 {
ui_state.pdf_thumbnails.clear();
}
ui_state.pdf_thumbnails.insert(pdf.key(), texture);
window.request_redraw();
}
// Nächste PDF-Seite vorab rendern, damit Blättern sofort klappt
if !canvas.is_waiting_for_images() {
let next = notebook.pages.get(notebook.current_page_index + 1);
+2
View File
@@ -21,6 +21,8 @@ use crate::t;
pub const DISPLAY_RENDER_PIXELS: u32 = 2560;
/// Längste Kante beim starken Hineinzoomen (A4 ≈ 440 dpi).
pub const ZOOMED_RENDER_PIXELS: u32 = 5120;
/// Longest edge of a page picker thumbnail (sharp at display scale 2).
pub const THUMBNAIL_RENDER_PIXELS: u32 = 256;
/// Texturschlüssel einer PDF-Seite: `<asset>#<seite>`, gerendert mit mehr als
/// [`DISPLAY_RENDER_PIXELS`] als `<asset>#<seite>@<pixel>`. Liefert Asset, Seite und Pixel.
+14 -4
View File
@@ -88,6 +88,9 @@ pub enum SelectionHandleHit {
pub struct SelectionState {
pub selected_ids: Vec<u64>,
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>,
/// 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`]).
@@ -99,6 +102,7 @@ impl SelectionState {
Self {
selected_ids: Vec::new(),
drag_mode: DragMode::None,
pending_drag: None,
clipboard: Vec::new(),
aspect_lock: None,
}
@@ -109,10 +113,11 @@ impl SelectionState {
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
.get_strokes(&self.selected_ids)
.strokes
.iter()
.any(|s| s.is_image()),
.any(|s| s.is_image() && self.selected_ids.contains(&s.id)),
}
}
@@ -159,8 +164,13 @@ impl SelectionState {
/// Berechnet die gemeinsame AABB aller ausgewählten Objekte.
pub fn get_selection_aabb(&self, page: &Page) -> Option<Aabb> {
let strokes = page.get_strokes(&self.selected_ids);
Page::selection_aabb(&strokes)
// Läuft bei jedem Bild: ohne die ausgewählten Striche zu kopieren
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
+4
View File
@@ -227,6 +227,8 @@ pub struct UiSettings {
pub stylus_buttons: Vec<StylusButton>,
/// Sprache der Oberfläche; ohne Angabe die des Systems
pub language: Option<Language>,
/// Eraser radius in mm (Issue #38)
pub eraser_radius_mm: f32,
}
impl Default for UiSettings {
@@ -246,6 +248,7 @@ impl Default for UiSettings {
active_text_color: 0,
stylus_buttons: Vec::new(),
language: None,
eraser_radius_mm: 3.0,
}
}
}
@@ -345,6 +348,7 @@ mod tests {
},
],
language: Some(Language::English),
eraser_radius_mm: 6.0,
};
settings.save_to(&path).unwrap();
assert_eq!(UiSettings::load_from(&path), settings);
+64 -3
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
const CAP_STEPS: usize = 10;
const CORNER_STEPS: usize = 24;
let mut vertices = Vec::new();
// 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
for (run_idx, run) in runs.iter().enumerate() {
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 p_c = s_corner.position;
let pr_c = s_corner.pressure;
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));
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(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.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);
}
/// 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]
fn test_small_loops_stay_round() {
// Kleines „o“ (Radius 0,8 mm) in 0,1 s mit 200 Hz, mit Vorschub wie im Wort
+6 -6
View File
@@ -330,7 +330,7 @@ impl NotebookManifest {
page_ids: vec![1],
next_stroke_id: 1,
next_page_id: 2,
layout: Some(NotebookLayout::Paged),
layout: Some(NotebookLayout::Continuous),
}
}
@@ -1640,14 +1640,14 @@ mod tests {
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);
// 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::Continuous);
manifest.layout = Some(NotebookLayout::Paged);
save_manifest(&dir, &manifest).unwrap();
let (_nb, loaded) = load_notebook(&dir).unwrap();
assert_eq!(loaded.layout, Some(NotebookLayout::Continuous));
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 =
+27
View File
@@ -151,6 +151,20 @@ impl TextBox {
glam::Vec2::new(self.wrap_width.max(self.size), self.layout().height())
}
/// Width of the field while it is being edited: as wide as the longest line plus room for
/// the next character, at most the wrap width, so a new field starts short (Issue #34).
pub fn edit_width(&self) -> f32 {
let widest = self
.layout()
.lines
.iter()
.map(|line| line.width)
.fold(0.0, f32::max);
(widest + self.size)
.max(3.0 * self.size)
.min(self.wrap_width.max(self.size))
}
/// Setzt den Text mit Zeilenumbruch an Leerzeichen; zu lange Wörter werden zeichenweise
/// umbrochen, Zeilenumbrüche im Text bleiben erhalten.
pub fn layout(&self) -> TextLayout {
@@ -409,6 +423,19 @@ mod tests {
TextBox::new(content.to_string(), 50.0, TextFont::Sans, wrap_width)
}
#[test]
fn test_edit_width_grows_with_the_text() {
let empty = sample("", 1500.0);
assert!((empty.edit_width() - 150.0).abs() < 1e-3);
let short = sample("Hallo", 1500.0);
let longer = sample("Hallo Welt, wie geht es", 1500.0);
assert!(short.edit_width() < longer.edit_width());
assert!(longer.edit_width() < 1500.0);
// Long text wraps at the page edge and fills the full width
let long = sample(&"Wort ".repeat(100), 1500.0);
assert!((long.edit_width() - 1500.0).abs() < 1e-3);
}
#[test]
fn test_fonts_load() {
for font in [TextFont::Sans, TextFont::Mono] {
+947 -374
View File
File diff suppressed because it is too large. Load diff
+194
View File
@@ -157,6 +157,24 @@ impl ViewportState {
true
}
/// Endless page: shifts the view so that the screen point `corner` (top left of the free
/// area) shows nothing left of or above `limit`. `true` if the view was corrected.
pub fn clamp_top_left(
&mut self,
corner: [f32; 2],
limit: Vec2,
page_viewport: &PageViewport,
) -> bool {
let c = self.screen_to_page_unclamped(corner[0], corner[1], page_viewport);
let clamped = c.max(limit);
if (clamped - c).length() < 1e-3 {
return false;
}
self.pan_offset[0] += c.x - clamped.x;
self.pan_offset[1] += c.y - clamped.y;
true
}
/// Passt den Pan-Offset um eine Bildschirmverschiebung (in Fensterpixeln) an.
pub fn pan_by_screen_delta(
&mut self,
@@ -305,6 +323,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)]
mod tests {
use super::*;
@@ -316,6 +430,64 @@ mod tests {
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]
fn test_continuous_layout() {
let stack = a4_stack(5, 1);
@@ -413,6 +585,28 @@ mod tests {
assert!((c.x - A4.x).abs() < 0.1, "{c:?}");
}
#[test]
fn test_endless_view_stays_right_of_and_below_the_limit() {
use crate::document::{ENDLESS_HOME, ENDLESS_PAGE_SIZE};
let page_vp = PageViewport::calculate_endless(1280.0, 850.0, 24.0, (0.0, 0.0));
let limit = ENDLESS_HOME - 100.0;
let mut vp = ViewportState::new();
// The A4 start frame is centered: the wide window shows space left of it
assert!(vp.clamp_top_left([0.0, 0.0], limit, &page_vp));
let corner = vp.screen_to_page_unclamped(0.0, 0.0, &page_vp);
assert!((corner.x - limit.x).abs() < 0.1, "{corner:?}");
assert!(corner.y >= limit.y - 0.1, "{corner:?}");
// Right and down stay free
vp.pan_by_screen_delta(-5000.0, -5000.0, &page_vp);
assert!(!vp.clamp_top_left([0.0, 0.0], limit, &page_vp));
// Back up and left: stops at the limit
vp.pan_by_screen_delta(20000.0, 20000.0, &page_vp);
assert!(vp.clamp_top_left([0.0, 0.0], limit, &page_vp));
let corner = vp.screen_to_page_unclamped(0.0, 0.0, &page_vp);
assert!((corner - limit).length() < 0.1, "{corner:?}");
assert!(corner.cmplt(ENDLESS_PAGE_SIZE).all());
}
#[test]
fn test_viewport_zoom_limits() {
let mut vp = ViewportState::new();
+396
View File
@@ -0,0 +1,396 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2021"
rust-version = "1.65.0"
name = "winit"
version = "0.29.15"
authors = [
"The winit contributors",
"Pierre Krieger <pierre.krieger1708@gmail.com>",
]
description = "Cross-platform window creation library."
documentation = "https://docs.rs/winit"
readme = "README.md"
keywords = ["windowing"]
categories = ["gui"]
license = "Apache-2.0"
repository = "https://github.com/rust-windowing/winit"
[package.metadata.docs.rs]
default-target = "x86_64-unknown-linux-gnu"
features = [
"rwh_04",
"rwh_05",
"rwh_06",
"serde",
"android-native-activity",
]
rustdoc-args = [
"--cfg",
"docsrs",
]
targets = [
"i686-pc-windows-msvc",
"x86_64-pc-windows-msvc",
"x86_64-apple-darwin",
"i686-unknown-linux-gnu",
"x86_64-unknown-linux-gnu",
"x86_64-apple-ios",
"aarch64-linux-android",
"wasm32-unknown-unknown",
]
[dependencies.bitflags]
version = "2"
[dependencies.cursor-icon]
version = "1.1.0"
[dependencies.log]
version = "0.4"
[dependencies.mint]
version = "0.5.6"
optional = true
[dependencies.once_cell]
version = "1.12"
[dependencies.rwh_04]
version = "0.4"
optional = true
package = "raw-window-handle"
[dependencies.rwh_05]
version = "0.5.2"
features = ["std"]
optional = true
package = "raw-window-handle"
[dependencies.rwh_06]
version = "0.6"
features = ["std"]
optional = true
package = "raw-window-handle"
[dependencies.serde]
version = "1"
features = ["serde_derive"]
optional = true
[dependencies.smol_str]
version = "0.2.0"
[dev-dependencies.image]
version = "0.24.0"
features = ["png"]
default-features = false
[dev-dependencies.simple_logger]
version = "4.2.0"
default_features = false
[build-dependencies.cfg_aliases]
version = "0.1.1"
[features]
android-game-activity = ["android-activity/game-activity"]
android-native-activity = ["android-activity/native-activity"]
default = [
"rwh_06",
"x11",
"wayland",
"wayland-dlopen",
"wayland-csd-adwaita",
]
rwh_04 = [
"dep:rwh_04",
"ndk/rwh_04",
]
rwh_05 = [
"dep:rwh_05",
"ndk/rwh_05",
]
rwh_06 = [
"dep:rwh_06",
"ndk/rwh_06",
]
serde = [
"dep:serde",
"cursor-icon/serde",
"smol_str/serde",
]
wayland = [
"wayland-client",
"wayland-backend",
"wayland-protocols",
"wayland-protocols-plasma",
"sctk",
"ahash",
"memmap2",
]
wayland-csd-adwaita = [
"sctk-adwaita",
"sctk-adwaita/ab_glyph",
]
wayland-csd-adwaita-crossfont = [
"sctk-adwaita",
"sctk-adwaita/crossfont",
]
wayland-csd-adwaita-notitle = ["sctk-adwaita"]
wayland-dlopen = ["wayland-backend/dlopen"]
x11 = [
"x11-dl",
"bytemuck",
"percent-encoding",
"xkbcommon-dl/x11",
"x11rb",
]
[target."cfg(all(unix, not(any(target_os = \"redox\", target_family = \"wasm\", target_os = \"android\", target_os = \"ios\", target_os = \"macos\"))))".dependencies.ahash]
version = "0.8.3"
features = ["no-rng"]
optional = true
[target."cfg(all(unix, not(any(target_os = \"redox\", target_family = \"wasm\", target_os = \"android\", target_os = \"ios\", target_os = \"macos\"))))".dependencies.bytemuck]
version = "1.13.1"
optional = true
default-features = false
[target."cfg(all(unix, not(any(target_os = \"redox\", target_family = \"wasm\", target_os = \"android\", target_os = \"ios\", target_os = \"macos\"))))".dependencies.calloop]
version = "0.12.3"
[target."cfg(all(unix, not(any(target_os = \"redox\", target_family = \"wasm\", target_os = \"android\", target_os = \"ios\", target_os = \"macos\"))))".dependencies.libc]
version = "0.2.64"
[target."cfg(all(unix, not(any(target_os = \"redox\", target_family = \"wasm\", target_os = \"android\", target_os = \"ios\", target_os = \"macos\"))))".dependencies.memmap2]
version = "0.9.0"
optional = true
[target."cfg(all(unix, not(any(target_os = \"redox\", target_family = \"wasm\", target_os = \"android\", target_os = \"ios\", target_os = \"macos\"))))".dependencies.percent-encoding]
version = "2.0"
optional = true
[target."cfg(all(unix, not(any(target_os = \"redox\", target_family = \"wasm\", target_os = \"android\", target_os = \"ios\", target_os = \"macos\"))))".dependencies.rustix]
version = "0.38.4"
features = [
"std",
"system",
"thread",
"process",
]
default-features = false
[target."cfg(all(unix, not(any(target_os = \"redox\", target_family = \"wasm\", target_os = \"android\", target_os = \"ios\", target_os = \"macos\"))))".dependencies.sctk]
version = "0.18.0"
features = ["calloop"]
optional = true
default-features = false
package = "smithay-client-toolkit"
[target."cfg(all(unix, not(any(target_os = \"redox\", target_family = \"wasm\", target_os = \"android\", target_os = \"ios\", target_os = \"macos\"))))".dependencies.sctk-adwaita]
version = "0.8.0"
optional = true
default_features = false
[target."cfg(all(unix, not(any(target_os = \"redox\", target_family = \"wasm\", target_os = \"android\", target_os = \"ios\", target_os = \"macos\"))))".dependencies.wayland-backend]
version = "0.3.0"
features = ["client_system"]
optional = true
default_features = false
[target."cfg(all(unix, not(any(target_os = \"redox\", target_family = \"wasm\", target_os = \"android\", target_os = \"ios\", target_os = \"macos\"))))".dependencies.wayland-client]
version = "0.31.1"
optional = true
[target."cfg(all(unix, not(any(target_os = \"redox\", target_family = \"wasm\", target_os = \"android\", target_os = \"ios\", target_os = \"macos\"))))".dependencies.wayland-protocols]
version = "0.31.0"
features = ["staging"]
optional = true
[target."cfg(all(unix, not(any(target_os = \"redox\", target_family = \"wasm\", target_os = \"android\", target_os = \"ios\", target_os = \"macos\"))))".dependencies.wayland-protocols-plasma]
version = "0.2.0"
features = ["client"]
optional = true
[target."cfg(all(unix, not(any(target_os = \"redox\", target_family = \"wasm\", target_os = \"android\", target_os = \"ios\", target_os = \"macos\"))))".dependencies.x11-dl]
version = "2.19.1"
optional = true
[target."cfg(all(unix, not(any(target_os = \"redox\", target_family = \"wasm\", target_os = \"android\", target_os = \"ios\", target_os = \"macos\"))))".dependencies.x11rb]
version = "0.13.0"
features = [
"allow-unsafe-code",
"dl-libxcb",
"randr",
"resource_manager",
"xinput",
"xkb",
]
optional = true
default-features = false
[target."cfg(all(unix, not(any(target_os = \"redox\", target_family = \"wasm\", target_os = \"android\", target_os = \"ios\", target_os = \"macos\"))))".dependencies.xkbcommon-dl]
version = "0.4.2"
[target."cfg(any(target_os = \"ios\", target_os = \"macos\"))".dependencies.core-foundation]
version = "0.9.3"
[target."cfg(any(target_os = \"ios\", target_os = \"macos\"))".dependencies.objc2]
version = "0.4.1"
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dev-dependencies.softbuffer]
version = "0.3.0"
[target."cfg(target_family = \"wasm\")".dependencies.atomic-waker]
version = "1"
[target."cfg(target_family = \"wasm\")".dependencies.js-sys]
version = "0.3.64"
[target."cfg(target_family = \"wasm\")".dependencies.wasm-bindgen]
version = "0.2"
[target."cfg(target_family = \"wasm\")".dependencies.wasm-bindgen-futures]
version = "0.4"
[target."cfg(target_family = \"wasm\")".dependencies.web-time]
version = "0.2"
[target."cfg(target_family = \"wasm\")".dependencies.web_sys]
version = "0.3.64"
features = [
"AbortController",
"AbortSignal",
"console",
"CssStyleDeclaration",
"Document",
"DomRect",
"DomRectReadOnly",
"Element",
"Event",
"EventTarget",
"FocusEvent",
"HtmlCanvasElement",
"HtmlElement",
"IntersectionObserver",
"IntersectionObserverEntry",
"KeyboardEvent",
"MediaQueryList",
"MessageChannel",
"MessagePort",
"Node",
"PageTransitionEvent",
"PointerEvent",
"ResizeObserver",
"ResizeObserverBoxOptions",
"ResizeObserverEntry",
"ResizeObserverOptions",
"ResizeObserverSize",
"VisibilityState",
"Window",
"WheelEvent",
]
package = "web-sys"
[target."cfg(target_family = \"wasm\")".dev-dependencies.console_log]
version = "1"
[target."cfg(target_family = \"wasm\")".dev-dependencies.web-sys]
version = "0.3.22"
features = ["CanvasRenderingContext2d"]
[target."cfg(target_os = \"android\")".dependencies.android-activity]
version = "0.5.0"
[target."cfg(target_os = \"android\")".dependencies.ndk]
version = "0.8.0"
default-features = false
[target."cfg(target_os = \"android\")".dependencies.ndk-sys]
version = "0.5.0"
[target."cfg(target_os = \"ios\")".dependencies.icrate]
version = "0.0.4"
features = [
"dispatch",
"Foundation",
"Foundation_NSArray",
"Foundation_NSString",
"Foundation_NSProcessInfo",
"Foundation_NSThread",
"Foundation_NSSet",
]
[target."cfg(target_os = \"macos\")".dependencies.core-graphics]
version = "0.23.1"
[target."cfg(target_os = \"macos\")".dependencies.icrate]
version = "0.0.4"
features = [
"dispatch",
"Foundation",
"Foundation_NSArray",
"Foundation_NSAttributedString",
"Foundation_NSMutableAttributedString",
"Foundation_NSData",
"Foundation_NSDictionary",
"Foundation_NSString",
"Foundation_NSProcessInfo",
"Foundation_NSThread",
"Foundation_NSNumber",
]
[target."cfg(target_os = \"redox\")".dependencies.orbclient]
version = "0.3.42"
default-features = false
[target."cfg(target_os = \"redox\")".dependencies.redox_syscall]
version = "0.3"
[target."cfg(target_os = \"windows\")".dependencies.unicode-segmentation]
version = "1.7.1"
[target."cfg(target_os = \"windows\")".dependencies.windows-sys]
version = "0.48"
features = [
"Win32_Devices_HumanInterfaceDevice",
"Win32_Foundation",
"Win32_Globalization",
"Win32_Graphics_Dwm",
"Win32_Graphics_Gdi",
"Win32_Media",
"Win32_System_Com_StructuredStorage",
"Win32_System_Com",
"Win32_System_LibraryLoader",
"Win32_System_Ole",
"Win32_System_SystemInformation",
"Win32_System_SystemServices",
"Win32_System_Threading",
"Win32_System_WindowsProgramming",
"Win32_UI_Accessibility",
"Win32_UI_Controls",
"Win32_UI_HiDpi",
"Win32_UI_Input_Ime",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_Input_Pointer",
"Win32_UI_Input_Touch",
"Win32_UI_Shell",
"Win32_UI_TextServices",
"Win32_UI_WindowsAndMessaging",
]
# ink-notes: the vendored copy is built as a path dependency, so silence warnings that the
# crates.io build caps anyway
[lints.rust]
warnings = "allow"
+226
View File
@@ -0,0 +1,226 @@
[package]
name = "winit"
version = "0.29.15"
authors = ["The winit contributors", "Pierre Krieger <pierre.krieger1708@gmail.com>"]
description = "Cross-platform window creation library."
edition = "2021"
keywords = ["windowing"]
license = "Apache-2.0"
readme = "README.md"
repository = "https://github.com/rust-windowing/winit"
documentation = "https://docs.rs/winit"
categories = ["gui"]
rust-version = "1.65.0"
[package.metadata.docs.rs]
features = [
"rwh_04",
"rwh_05",
"rwh_06",
"serde",
# Enabled to get docs to compile
"android-native-activity",
]
default-target = "x86_64-unknown-linux-gnu"
# These are all tested in CI
targets = [
# Windows
"i686-pc-windows-msvc",
"x86_64-pc-windows-msvc",
# macOS
"x86_64-apple-darwin",
# Unix (X11 & Wayland)
"i686-unknown-linux-gnu",
"x86_64-unknown-linux-gnu",
# iOS
"x86_64-apple-ios",
# Android
"aarch64-linux-android",
# WebAssembly
"wasm32-unknown-unknown",
]
rustdoc-args = ["--cfg", "docsrs"]
[features]
default = ["rwh_06", "x11", "wayland", "wayland-dlopen", "wayland-csd-adwaita"]
x11 = ["x11-dl", "bytemuck", "percent-encoding", "xkbcommon-dl/x11", "x11rb"]
wayland = ["wayland-client", "wayland-backend", "wayland-protocols", "wayland-protocols-plasma", "sctk", "ahash", "memmap2"]
wayland-dlopen = ["wayland-backend/dlopen"]
wayland-csd-adwaita = ["sctk-adwaita", "sctk-adwaita/ab_glyph"]
wayland-csd-adwaita-crossfont = ["sctk-adwaita", "sctk-adwaita/crossfont"]
wayland-csd-adwaita-notitle = ["sctk-adwaita"]
android-native-activity = ["android-activity/native-activity"]
android-game-activity = ["android-activity/game-activity"]
serde = ["dep:serde", "cursor-icon/serde", "smol_str/serde"]
rwh_04 = ["dep:rwh_04", "ndk/rwh_04"]
rwh_05 = ["dep:rwh_05", "ndk/rwh_05"]
rwh_06 = ["dep:rwh_06", "ndk/rwh_06"]
[build-dependencies]
cfg_aliases = "0.1.1"
[dependencies]
bitflags = "2"
cursor-icon = "1.1.0"
log = "0.4"
mint = { version = "0.5.6", optional = true }
once_cell = "1.12"
rwh_04 = { package = "raw-window-handle", version = "0.4", optional = true }
rwh_05 = { package = "raw-window-handle", version = "0.5.2", features = ["std"], optional = true }
rwh_06 = { package = "raw-window-handle", version = "0.6", features = ["std"], optional = true }
serde = { version = "1", optional = true, features = ["serde_derive"] }
smol_str = "0.2.0"
[dev-dependencies]
image = { version = "0.24.0", default-features = false, features = ["png"] }
simple_logger = { version = "4.2.0", default_features = false }
winit = { path = ".", features = ["rwh_05"] }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dev-dependencies]
softbuffer = "0.3.0"
[target.'cfg(target_os = "android")'.dependencies]
android-activity = "0.5.0"
ndk = { version = "0.8.0", default-features = false }
ndk-sys = "0.5.0"
[target.'cfg(any(target_os = "ios", target_os = "macos"))'.dependencies]
core-foundation = "0.9.3"
objc2 = "0.4.1"
[target.'cfg(target_os = "macos")'.dependencies]
core-graphics = "0.23.1"
[target.'cfg(target_os = "macos")'.dependencies.icrate]
version = "0.0.4"
features = [
"dispatch",
"Foundation",
"Foundation_NSArray",
"Foundation_NSAttributedString",
"Foundation_NSMutableAttributedString",
"Foundation_NSData",
"Foundation_NSDictionary",
"Foundation_NSString",
"Foundation_NSProcessInfo",
"Foundation_NSThread",
"Foundation_NSNumber",
]
[target.'cfg(target_os = "ios")'.dependencies.icrate]
version = "0.0.4"
features = [
"dispatch",
"Foundation",
"Foundation_NSArray",
"Foundation_NSString",
"Foundation_NSProcessInfo",
"Foundation_NSThread",
"Foundation_NSSet",
]
[target.'cfg(target_os = "windows")'.dependencies]
unicode-segmentation = "1.7.1"
[target.'cfg(target_os = "windows")'.dependencies.windows-sys]
version = "0.48"
features = [
"Win32_Devices_HumanInterfaceDevice",
"Win32_Foundation",
"Win32_Globalization",
"Win32_Graphics_Dwm",
"Win32_Graphics_Gdi",
"Win32_Media",
"Win32_System_Com_StructuredStorage",
"Win32_System_Com",
"Win32_System_LibraryLoader",
"Win32_System_Ole",
"Win32_System_SystemInformation",
"Win32_System_SystemServices",
"Win32_System_Threading",
"Win32_System_WindowsProgramming",
"Win32_UI_Accessibility",
"Win32_UI_Controls",
"Win32_UI_HiDpi",
"Win32_UI_Input_Ime",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_Input_Pointer",
"Win32_UI_Input_Touch",
"Win32_UI_Shell",
"Win32_UI_TextServices",
"Win32_UI_WindowsAndMessaging",
]
[target.'cfg(all(unix, not(any(target_os = "redox", target_family = "wasm", target_os = "android", target_os = "ios", target_os = "macos"))))'.dependencies]
ahash = { version = "0.8.3", features = ["no-rng"], optional = true }
bytemuck = { version = "1.13.1", default-features = false, optional = true }
calloop = "0.12.3"
libc = "0.2.64"
memmap2 = { version = "0.9.0", optional = true }
percent-encoding = { version = "2.0", optional = true }
rustix = { version = "0.38.4", default-features = false, features = ["std", "system", "thread", "process"] }
sctk = { package = "smithay-client-toolkit", version = "0.18.0", default-features = false, features = ["calloop"], optional = true }
sctk-adwaita = { version = "0.8.0", default_features = false, optional = true }
wayland-backend = { version = "0.3.0", default_features = false, features = ["client_system"], optional = true }
wayland-client = { version = "0.31.1", optional = true }
wayland-protocols = { version = "0.31.0", features = [ "staging"], optional = true }
wayland-protocols-plasma = { version = "0.2.0", features = [ "client" ], optional = true }
x11-dl = { version = "2.19.1", optional = true }
x11rb = { version = "0.13.0", default-features = false, features = ["allow-unsafe-code", "dl-libxcb", "randr", "resource_manager", "xinput", "xkb"], optional = true }
xkbcommon-dl = "0.4.2"
[target.'cfg(target_os = "redox")'.dependencies]
orbclient = { version = "0.3.42", default-features = false }
redox_syscall = "0.3"
[target.'cfg(target_family = "wasm")'.dependencies.web_sys]
package = "web-sys"
version = "0.3.64"
features = [
'AbortController',
'AbortSignal',
'console',
'CssStyleDeclaration',
'Document',
'DomRect',
'DomRectReadOnly',
'Element',
'Event',
'EventTarget',
'FocusEvent',
'HtmlCanvasElement',
'HtmlElement',
'IntersectionObserver',
'IntersectionObserverEntry',
'KeyboardEvent',
'MediaQueryList',
'MessageChannel',
'MessagePort',
'Node',
'PageTransitionEvent',
'PointerEvent',
'ResizeObserver',
'ResizeObserverBoxOptions',
'ResizeObserverEntry',
'ResizeObserverOptions',
'ResizeObserverSize',
'VisibilityState',
'Window',
'WheelEvent'
]
[target.'cfg(target_family = "wasm")'.dependencies]
atomic-waker = "1"
js-sys = "0.3.64"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-time = "0.2"
[target.'cfg(target_family = "wasm")'.dev-dependencies]
console_log = "1"
web-sys = { version = "0.3.22", features = ['CanvasRenderingContext2d'] }
[workspace]
members = [
"run-wasm",
]
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright {yyyy} {name of copyright owner}
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+21
View File
@@ -0,0 +1,21 @@
# winit 0.29.15 for ink-notes
Copy of [winit 0.29.15](https://crates.io/crates/winit/0.29.15) (Apache-2.0, see
`LICENSE`), used through `[patch.crates-io]` in the top-level `Cargo.toml`. Examples, tests and
docs of the original crate were removed.
## Changes
- `src/platform_impl/linux/wayland/seat/touch/mod.rs`: on Wayland, winit draws the title bar
itself (client-side decorations) but only handles the mouse there. Touches on the title bar
are now passed to the frame like a left click: close, minimize and maximize work with a
finger, and dragging the title bar moves the window (Issue #36). All changes are marked with
`ink-notes patch`.
- `src/platform/wayland.rs`: `ignore_decoration_touches_until` lets ink-notes ignore touches on
the title bar while the pen is near the screen, so a resting palm does not move the window
(Issue #39).
- `Cargo.toml`: `[lints.rust] warnings = "allow"`, because a path dependency is not built with
capped lints like a crates.io dependency.
Remove the copy and the `[patch.crates-io]` entry when ink-notes moves to a winit version that
handles touch on its decorations.
+187
View File
@@ -0,0 +1,187 @@
# winit - Cross-platform window creation and management in Rust
[![Crates.io](https://img.shields.io/crates/v/winit.svg)](https://crates.io/crates/winit)
[![Docs.rs](https://docs.rs/winit/badge.svg)](https://docs.rs/winit)
[![CI Status](https://github.com/rust-windowing/winit/workflows/CI/badge.svg)](https://github.com/rust-windowing/winit/actions)
```toml
[dependencies]
winit = "0.29.15"
```
## [Documentation](https://docs.rs/winit)
For features _within_ the scope of winit, see [FEATURES.md](FEATURES.md).
For features _outside_ the scope of winit, see [Are we GUI Yet?](https://areweguiyet.com/) and [Are we game yet?](https://arewegameyet.rs/), depending on what kind of project you're looking to do.
## Contact Us
Join us in any of these:
[![Matrix](https://img.shields.io/badge/Matrix-%23rust--windowing%3Amatrix.org-blueviolet.svg)](https://matrix.to/#/#rust-windowing:matrix.org)
[![Libera.Chat](https://img.shields.io/badge/libera.chat-%23winit-red.svg)](https://web.libera.chat/#winit)
## Usage
Winit is a window creation and management library. It can create windows and lets you handle
events (for example: the window being resized, a key being pressed, a mouse movement, etc.)
produced by the window.
Winit is designed to be a low-level brick in a hierarchy of libraries. Consequently, in order to
show something on the window you need to use the platform-specific getters provided by winit, or
another library.
### Cargo Features
Winit provides the following features, which can be enabled in your `Cargo.toml` file:
* `serde`: Enables serialization/deserialization of certain types with [Serde](https://crates.io/crates/serde).
* `x11` (enabled by default): On Unix platform, compiles with the X11 backend
* `wayland` (enabled by default): On Unix platform, compiles with the Wayland backend
* `mint`: Enables mint (math interoperability standard types) conversions.
## MSRV Policy
This crate's Minimum Supported Rust Version (MSRV) is **1.65**. Changes to
the MSRV will be accompanied by a minor version bump.
As a **tentative** policy, the upper bound of the MSRV is given by the following
formula:
```
min(sid, stable - 3)
```
Where `sid` is the current version of `rustc` provided by [Debian Sid], and
`stable` is the latest stable version of Rust. This bound may be broken in case of a major ecosystem shift or a security vulnerability.
[Debian Sid]: https://packages.debian.org/sid/rustc
The exception is for the Android platform, where a higher Rust version
must be used for certain Android features. In this case, the MSRV will be
capped at the latest stable version of Rust minus three. This inconsistency is
not reflected in Cargo metadata, as it is not powerful enough to expose this
restriction.
All crates in the [`rust-windowing`] organizations have the
same MSRV policy.
[`rust-windowing`]: https://github.com/rust-windowing
### Platform-specific usage
#### Wayland
Note that windows don't appear on Wayland until you draw/present to them.
#### WebAssembly
To run the web example: `cargo run-wasm --example web`
Winit supports compiling to the `wasm32-unknown-unknown` target with `web-sys`.
On the web platform, a Winit window is backed by a `<canvas>` element. You can
either [provide Winit with a `<canvas>` element][web with_canvas], or [let Winit
create a `<canvas>` element which you can then retrieve][web canvas getter] and
insert it into the DOM yourself.
For the example code using Winit with WebAssembly, check out the [web example]. For
information on using Rust on WebAssembly, check out the [Rust and WebAssembly
book].
[web with_canvas]: https://docs.rs/winit/latest/wasm32-unknown-unknown/winit/platform/web/trait.WindowBuilderExtWebSys.html#tymethod.with_canvas
[web canvas getter]: https://docs.rs/winit/latest/wasm32-unknown-unknown/winit/platform/web/trait.WindowExtWebSys.html#tymethod.canvas
[web example]: ./examples/web.rs
[Rust and WebAssembly book]: https://rustwasm.github.io/book/
#### Android
The Android backend builds on (and exposes types from) the [`ndk`](https://docs.rs/ndk/latest/ndk/) crate.
Native Android applications need some form of "glue" crate that is responsible
for defining the main entry point for your Rust application as well as tracking
various life-cycle events and synchronizing with the main JVM thread.
Winit uses the [android-activity](https://github.com/rib/android-activity) as a
glue crate (prior to `0.28` it used
[ndk-glue](https://github.com/rust-windowing/android-ndk-rs/tree/master/ndk-glue)).
The version of the glue crate that your application depends on _must_ match the
version that Winit depends on because the glue crate is responsible for your
application's main entry point. If Cargo resolves multiple versions, they will
clash.
`winit` glue compatibility table:
| winit | ndk-glue |
| :---: | :--------------------------: |
| 0.29 | `android-activity = "0.5"` |
| 0.28 | `android-activity = "0.4"` |
| 0.27 | `ndk-glue = "0.7"` |
| 0.26 | `ndk-glue = "0.5"` |
| 0.25 | `ndk-glue = "0.3"` |
| 0.24 | `ndk-glue = "0.2"` |
The recommended way to avoid a conflict with the glue version is to avoid explicitly
depending on the `android-activity` crate, and instead consume the API that
is re-exported by Winit under `winit::platform::android::activity::*`
Running on an Android device needs a dynamic system library. Add this to Cargo.toml:
```toml
[lib]
name = "main"
crate-type = ["cdylib"]
```
All Android applications are based on an `Activity` subclass, and the
`android-activity` crate is designed to support different choices for this base
class. Your application _must_ specify the base class it needs via a feature flag:
| Base Class | Feature Flag | Notes |
| :--------------: | :---------------: | :-----: |
| `NativeActivity` | `android-native-activity` | Built-in to Android - it is possible to use without compiling any Java or Kotlin code. Java or Kotlin code may be needed to subclass `NativeActivity` to access some platform features. It does not derive from the [`AndroidAppCompat`] base class.|
| [`GameActivity`] | `android-game-activity` | Derives from [`AndroidAppCompat`], a defacto standard `Activity` base class that helps support a wider range of Android versions. Requires a build system that can compile Java or Kotlin and fetch Android dependencies from a [Maven repository][agdk_jetpack] (or link with an embedded [release][agdk_releases] of [`GameActivity`]) |
[`GameActivity`]: https://developer.android.com/games/agdk/game-activity
[`GameTextInput`]: https://developer.android.com/games/agdk/add-support-for-text-input
[`AndroidAppCompat`]: https://developer.android.com/reference/androidx/appcompat/app/AppCompatActivity
[agdk_jetpack]: https://developer.android.com/jetpack/androidx/releases/games
[agdk_releases]: https://developer.android.com/games/agdk/download#agdk-libraries
[Gradle]: https://developer.android.com/studio/build
For more details, refer to these `android-activity` [example applications](https://github.com/rust-mobile/android-activity/tree/main/examples).
##### Converting from `ndk-glue` to `android-activity`
If your application is currently based on `NativeActivity` via the `ndk-glue` crate and building with `cargo apk`, then the minimal changes would be:
1. Remove `ndk-glue` from your `Cargo.toml`
2. Enable the `"android-native-activity"` feature for Winit: `winit = { version = "0.29.15", features = [ "android-native-activity" ] }`
3. Add an `android_main` entrypoint (as above), instead of using the '`[ndk_glue::main]` proc macro from `ndk-macros` (optionally add a dependency on `android_logger` and initialize logging as above).
4. Pass a clone of the `AndroidApp` that your application receives to Winit when building your event loop (as shown above).
#### MacOS
A lot of functionality expects the application to be ready before you start
doing anything; this includes creating windows, fetching monitors, drawing,
and so on, see issues [#2238], [#2051] and [#2087].
If you encounter problems, you should try doing your initialization inside
`Event::Resumed`.
#### iOS
Similar to macOS, iOS's main `UIApplicationMain` does some init work that's required
by all UI-related code (see issue [#1705]). It would be best to consider creating your windows
inside `Event::Resumed`.
[#2238]: https://github.com/rust-windowing/winit/issues/2238
[#2051]: https://github.com/rust-windowing/winit/issues/2051
[#2087]: https://github.com/rust-windowing/winit/issues/2087
[#1705]: https://github.com/rust-windowing/winit/issues/1705
#### Redox OS
Redox OS has some functionality not yet present that will be implemented when
its orbital display server provides it.
+24
View File
@@ -0,0 +1,24 @@
use cfg_aliases::cfg_aliases;
fn main() {
// The script doesn't depend on our code
println!("cargo:rerun-if-changed=build.rs");
// Setup cfg aliases
cfg_aliases! {
// Systems.
android_platform: { target_os = "android" },
wasm_platform: { all(target_family = "wasm", not(target_os = "emscripten")) },
macos_platform: { target_os = "macos" },
ios_platform: { target_os = "ios" },
windows_platform: { target_os = "windows" },
apple: { any(target_os = "ios", target_os = "macos") },
free_unix: { all(unix, not(apple), not(android_platform), not(target_os = "emscripten")) },
redox: { target_os = "redox" },
// Native displays.
x11_platform: { all(feature = "x11", free_unix, not(wasm), not(redox)) },
wayland_platform: { all(feature = "wayland", free_unix, not(wasm), not(redox)) },
orbital_platform: { redox },
}
}
+3
View File
@@ -0,0 +1,3 @@
force_explicit_abi=true
use_field_init_shorthand=true
# merge_imports=true
+1001
View File
File diff suppressed because it is too large. Load diff
+142
View File
@@ -0,0 +1,142 @@
use std::{error, fmt};
use crate::platform_impl;
// TODO: Rename
/// An error that may be generated when requesting Winit state
#[derive(Debug)]
pub enum ExternalError {
/// The operation is not supported by the backend.
NotSupported(NotSupportedError),
/// The operation was ignored.
Ignored,
/// The OS cannot perform the operation.
Os(OsError),
}
/// The error type for when the requested operation is not supported by the backend.
#[derive(Clone)]
pub struct NotSupportedError {
_marker: (),
}
/// The error type for when the OS cannot perform the requested operation.
#[derive(Debug)]
pub struct OsError {
line: u32,
file: &'static str,
error: platform_impl::OsError,
}
/// A general error that may occur while running the Winit event loop
#[derive(Debug)]
pub enum EventLoopError {
/// The operation is not supported by the backend.
NotSupported(NotSupportedError),
/// The OS cannot perform the operation.
Os(OsError),
/// The event loop can't be re-run while it's already running
AlreadyRunning,
/// The event loop can't be re-created.
RecreationAttempt,
/// Application has exit with an error status.
ExitFailure(i32),
}
impl From<OsError> for EventLoopError {
fn from(value: OsError) -> Self {
Self::Os(value)
}
}
impl NotSupportedError {
#[inline]
#[allow(dead_code)]
pub(crate) fn new() -> NotSupportedError {
NotSupportedError { _marker: () }
}
}
impl OsError {
#[allow(dead_code)]
pub(crate) fn new(line: u32, file: &'static str, error: platform_impl::OsError) -> OsError {
OsError { line, file, error }
}
}
#[allow(unused_macros)]
macro_rules! os_error {
($error:expr) => {{
crate::error::OsError::new(line!(), file!(), $error)
}};
}
impl fmt::Display for OsError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
f.pad(&format!(
"os error at {}:{}: {}",
self.file, self.line, self.error
))
}
}
impl fmt::Display for ExternalError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
match self {
ExternalError::NotSupported(e) => e.fmt(f),
ExternalError::Ignored => write!(f, "Operation was ignored"),
ExternalError::Os(e) => e.fmt(f),
}
}
}
impl fmt::Debug for NotSupportedError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
f.debug_struct("NotSupportedError").finish()
}
}
impl fmt::Display for NotSupportedError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
f.pad("the requested operation is not supported by Winit")
}
}
impl fmt::Display for EventLoopError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
match self {
EventLoopError::AlreadyRunning => write!(f, "EventLoop is already running"),
EventLoopError::RecreationAttempt => write!(f, "EventLoop can't be recreated"),
EventLoopError::NotSupported(e) => e.fmt(f),
EventLoopError::Os(e) => e.fmt(f),
EventLoopError::ExitFailure(status) => write!(f, "Exit Failure: {status}"),
}
}
}
impl error::Error for OsError {}
impl error::Error for ExternalError {}
impl error::Error for NotSupportedError {}
impl error::Error for EventLoopError {}
#[cfg(test)]
mod tests {
#![allow(clippy::redundant_clone)]
use super::*;
// Eat attributes for testing
#[test]
fn ensure_fmt_does_not_panic() {
let _ = format!(
"{:?}, {}",
NotSupportedError::new(),
NotSupportedError::new().clone()
);
let _ = format!(
"{:?}, {}",
ExternalError::NotSupported(NotSupportedError::new()),
ExternalError::NotSupported(NotSupportedError::new())
);
}
}
+1342
View File
File diff suppressed because it is too large. Load diff
+475
View File
@@ -0,0 +1,475 @@
//! The [`EventLoop`] struct and assorted supporting types, including
//! [`ControlFlow`].
//!
//! If you want to send custom events to the event loop, use
//! [`EventLoop::create_proxy`] to acquire an [`EventLoopProxy`] and call its
//! [`send_event`](`EventLoopProxy::send_event`) method.
//!
//! See the root-level documentation for information on how to create and use an event loop to
//! handle events.
use std::marker::PhantomData;
use std::ops::Deref;
#[cfg(any(x11_platform, wayland_platform))]
use std::os::unix::io::{AsFd, AsRawFd, BorrowedFd, RawFd};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::{error, fmt};
#[cfg(not(wasm_platform))]
use std::time::{Duration, Instant};
#[cfg(wasm_platform)]
use web_time::{Duration, Instant};
use crate::error::EventLoopError;
use crate::{event::Event, monitor::MonitorHandle, platform_impl};
/// Provides a way to retrieve events from the system and from the windows that were registered to
/// the events loop.
///
/// An `EventLoop` can be seen more or less as a "context". Calling [`EventLoop::new`]
/// initializes everything that will be required to create windows. For example on Linux creating
/// an event loop opens a connection to the X or Wayland server.
///
/// To wake up an `EventLoop` from a another thread, see the [`EventLoopProxy`] docs.
///
/// Note that this cannot be shared across threads (due to platform-dependant logic
/// forbidding it), as such it is neither [`Send`] nor [`Sync`]. If you need cross-thread access, the
/// [`Window`] created from this _can_ be sent to an other thread, and the
/// [`EventLoopProxy`] allows you to wake up an `EventLoop` from another thread.
///
/// [`Window`]: crate::window::Window
pub struct EventLoop<T: 'static> {
pub(crate) event_loop: platform_impl::EventLoop<T>,
pub(crate) _marker: PhantomData<*mut ()>, // Not Send nor Sync
}
/// Target that associates windows with an [`EventLoop`].
///
/// This type exists to allow you to create new windows while Winit executes
/// your callback. [`EventLoop`] will coerce into this type (`impl<T> Deref for
/// EventLoop<T>`), so functions that take this as a parameter can also take
/// `&EventLoop`.
pub struct EventLoopWindowTarget<T: 'static> {
pub(crate) p: platform_impl::EventLoopWindowTarget<T>,
pub(crate) _marker: PhantomData<*mut ()>, // Not Send nor Sync
}
/// Object that allows building the event loop.
///
/// This is used to make specifying options that affect the whole application
/// easier. But note that constructing multiple event loops is not supported.
#[derive(Default)]
pub struct EventLoopBuilder<T: 'static> {
pub(crate) platform_specific: platform_impl::PlatformSpecificEventLoopAttributes,
_p: PhantomData<T>,
}
impl EventLoopBuilder<()> {
/// Start building a new event loop.
#[inline]
pub fn new() -> Self {
Self::with_user_event()
}
}
static EVENT_LOOP_CREATED: AtomicBool = AtomicBool::new(false);
impl<T> EventLoopBuilder<T> {
/// Start building a new event loop, with the given type as the user event
/// type.
#[inline]
pub fn with_user_event() -> Self {
Self {
platform_specific: Default::default(),
_p: PhantomData,
}
}
/// Builds a new event loop.
///
/// ***For cross-platform compatibility, the [`EventLoop`] must be created on the main thread,
/// and only once per application.***
///
/// Calling this function will result in display backend initialisation.
///
/// ## Panics
///
/// Attempting to create the event loop off the main thread will panic. This
/// restriction isn't strictly necessary on all platforms, but is imposed to
/// eliminate any nasty surprises when porting to platforms that require it.
/// `EventLoopBuilderExt::any_thread` functions are exposed in the relevant
/// [`platform`] module if the target platform supports creating an event
/// loop on any thread.
///
/// ## Platform-specific
///
/// - **Wayland/X11:** to prevent running under `Wayland` or `X11` unset `WAYLAND_DISPLAY`
/// or `DISPLAY` respectively when building the event loop.
/// - **Android:** must be configured with an `AndroidApp` from `android_main()` by calling
/// [`.with_android_app(app)`] before calling `.build()`, otherwise it'll panic.
///
/// [`platform`]: crate::platform
#[cfg_attr(
android,
doc = "[`.with_android_app(app)`]: crate::platform::android::EventLoopBuilderExtAndroid::with_android_app"
)]
#[cfg_attr(
not(android),
doc = "[`.with_android_app(app)`]: #only-available-on-android"
)]
#[inline]
pub fn build(&mut self) -> Result<EventLoop<T>, EventLoopError> {
if EVENT_LOOP_CREATED.swap(true, Ordering::Relaxed) {
return Err(EventLoopError::RecreationAttempt);
}
// Certain platforms accept a mutable reference in their API.
#[allow(clippy::unnecessary_mut_passed)]
Ok(EventLoop {
event_loop: platform_impl::EventLoop::new(&mut self.platform_specific)?,
_marker: PhantomData,
})
}
#[cfg(wasm_platform)]
pub(crate) fn allow_event_loop_recreation() {
EVENT_LOOP_CREATED.store(false, Ordering::Relaxed);
}
}
impl<T> fmt::Debug for EventLoop<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.pad("EventLoop { .. }")
}
}
impl<T> fmt::Debug for EventLoopWindowTarget<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.pad("EventLoopWindowTarget { .. }")
}
}
/// Set through [`EventLoopWindowTarget::set_control_flow()`].
///
/// Indicates the desired behavior of the event loop after [`Event::AboutToWait`] is emitted.
///
/// Defaults to [`Wait`].
///
/// [`Wait`]: Self::Wait
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub enum ControlFlow {
/// When the current loop iteration finishes, immediately begin a new iteration regardless of
/// whether or not new events are available to process.
Poll,
/// When the current loop iteration finishes, suspend the thread until another event arrives.
#[default]
Wait,
/// When the current loop iteration finishes, suspend the thread until either another event
/// arrives or the given time is reached.
///
/// Useful for implementing efficient timers. Applications which want to render at the display's
/// native refresh rate should instead use [`Poll`] and the VSync functionality of a graphics API
/// to reduce odds of missed frames.
///
/// [`Poll`]: Self::Poll
WaitUntil(Instant),
}
impl ControlFlow {
/// Creates a [`ControlFlow`] that waits until a timeout has expired.
///
/// In most cases, this is set to [`WaitUntil`]. However, if the timeout overflows, it is
/// instead set to [`Wait`].
///
/// [`WaitUntil`]: Self::WaitUntil
/// [`Wait`]: Self::Wait
pub fn wait_duration(timeout: Duration) -> Self {
match Instant::now().checked_add(timeout) {
Some(instant) => Self::WaitUntil(instant),
None => Self::Wait,
}
}
}
impl EventLoop<()> {
/// Alias for [`EventLoopBuilder::new().build()`].
///
/// [`EventLoopBuilder::new().build()`]: EventLoopBuilder::build
#[inline]
pub fn new() -> Result<EventLoop<()>, EventLoopError> {
EventLoopBuilder::new().build()
}
}
impl<T> EventLoop<T> {
#[deprecated = "Use `EventLoopBuilder::<T>::with_user_event().build()` instead."]
pub fn with_user_event() -> Result<EventLoop<T>, EventLoopError> {
EventLoopBuilder::<T>::with_user_event().build()
}
/// Runs the event loop in the calling thread and calls the given `event_handler` closure
/// to dispatch any pending events.
///
/// Since the closure is `'static`, it must be a `move` closure if it needs to
/// access any data from the calling context.
///
/// See the [`set_control_flow()`] docs on how to change the event loop's behavior.
///
/// ## Platform-specific
///
/// - **iOS:** Will never return to the caller and so values not passed to this function will
/// *not* be dropped before the process exits.
/// - **Web:** Will _act_ as if it never returns to the caller by throwing a Javascript exception
/// (that Rust doesn't see) that will also mean that the rest of the function is never executed
/// and any values not passed to this function will *not* be dropped.
///
/// Web applications are recommended to use
#[cfg_attr(
wasm_platform,
doc = "[`EventLoopExtWebSys::spawn()`][crate::platform::web::EventLoopExtWebSys::spawn()]"
)]
#[cfg_attr(not(wasm_platform), doc = "`EventLoopExtWebSys::spawn()`")]
/// [^1] instead of [`run()`] to avoid the need
/// for the Javascript exception trick, and to make it clearer that the event loop runs
/// asynchronously (via the browser's own, internal, event loop) and doesn't block the
/// current thread of execution like it does on other platforms.
///
/// This function won't be available with `target_feature = "exception-handling"`.
///
/// [`set_control_flow()`]: EventLoopWindowTarget::set_control_flow()
/// [`run()`]: Self::run()
/// [^1]: `EventLoopExtWebSys::spawn()` is only available on WASM.
#[inline]
#[cfg(not(all(wasm_platform, target_feature = "exception-handling")))]
pub fn run<F>(self, event_handler: F) -> Result<(), EventLoopError>
where
F: FnMut(Event<T>, &EventLoopWindowTarget<T>),
{
self.event_loop.run(event_handler)
}
/// Creates an [`EventLoopProxy`] that can be used to dispatch user events to the main event loop.
pub fn create_proxy(&self) -> EventLoopProxy<T> {
EventLoopProxy {
event_loop_proxy: self.event_loop.create_proxy(),
}
}
}
#[cfg(feature = "rwh_06")]
impl<T> rwh_06::HasDisplayHandle for EventLoop<T> {
fn display_handle(&self) -> Result<rwh_06::DisplayHandle<'_>, rwh_06::HandleError> {
rwh_06::HasDisplayHandle::display_handle(&**self)
}
}
#[cfg(feature = "rwh_05")]
unsafe impl<T> rwh_05::HasRawDisplayHandle for EventLoop<T> {
/// Returns a [`rwh_05::RawDisplayHandle`] for the event loop.
fn raw_display_handle(&self) -> rwh_05::RawDisplayHandle {
rwh_05::HasRawDisplayHandle::raw_display_handle(&**self)
}
}
#[cfg(any(x11_platform, wayland_platform))]
impl<T> AsFd for EventLoop<T> {
/// Get the underlying [EventLoop]'s `fd` which you can register
/// into other event loop, like [`calloop`] or [`mio`]. When doing so, the
/// loop must be polled with the [`pump_events`] API.
///
/// [`calloop`]: https://crates.io/crates/calloop
/// [`mio`]: https://crates.io/crates/mio
/// [`pump_events`]: crate::platform::pump_events::EventLoopExtPumpEvents::pump_events
fn as_fd(&self) -> BorrowedFd<'_> {
self.event_loop.as_fd()
}
}
#[cfg(any(x11_platform, wayland_platform))]
impl<T> AsRawFd for EventLoop<T> {
/// Get the underlying [EventLoop]'s raw `fd` which you can register
/// into other event loop, like [`calloop`] or [`mio`]. When doing so, the
/// loop must be polled with the [`pump_events`] API.
///
/// [`calloop`]: https://crates.io/crates/calloop
/// [`mio`]: https://crates.io/crates/mio
/// [`pump_events`]: crate::platform::pump_events::EventLoopExtPumpEvents::pump_events
fn as_raw_fd(&self) -> RawFd {
self.event_loop.as_raw_fd()
}
}
impl<T> Deref for EventLoop<T> {
type Target = EventLoopWindowTarget<T>;
fn deref(&self) -> &EventLoopWindowTarget<T> {
self.event_loop.window_target()
}
}
impl<T> EventLoopWindowTarget<T> {
/// Returns the list of all the monitors available on the system.
#[inline]
pub fn available_monitors(&self) -> impl Iterator<Item = MonitorHandle> {
#[allow(clippy::useless_conversion)] // false positive on some platforms
self.p
.available_monitors()
.into_iter()
.map(|inner| MonitorHandle { inner })
}
/// Returns the primary monitor of the system.
///
/// Returns `None` if it can't identify any monitor as a primary one.
///
/// ## Platform-specific
///
/// **Wayland / Web:** Always returns `None`.
#[inline]
pub fn primary_monitor(&self) -> Option<MonitorHandle> {
self.p
.primary_monitor()
.map(|inner| MonitorHandle { inner })
}
/// Change if or when [`DeviceEvent`]s are captured.
///
/// Since the [`DeviceEvent`] capture can lead to high CPU usage for unfocused windows, winit
/// will ignore them by default for unfocused windows on Linux/BSD. This method allows changing
/// this at runtime to explicitly capture them again.
///
/// ## Platform-specific
///
/// - **Wayland / macOS / iOS / Android / Orbital:** Unsupported.
///
/// [`DeviceEvent`]: crate::event::DeviceEvent
pub fn listen_device_events(&self, allowed: DeviceEvents) {
self.p.listen_device_events(allowed);
}
/// Sets the [`ControlFlow`].
pub fn set_control_flow(&self, control_flow: ControlFlow) {
self.p.set_control_flow(control_flow)
}
/// Gets the current [`ControlFlow`].
pub fn control_flow(&self) -> ControlFlow {
self.p.control_flow()
}
/// This exits the event loop.
///
/// See [`LoopExiting`](Event::LoopExiting).
pub fn exit(&self) {
self.p.exit()
}
/// Returns if the [`EventLoop`] is about to stop.
///
/// See [`exit()`](Self::exit).
pub fn exiting(&self) -> bool {
self.p.exiting()
}
}
#[cfg(feature = "rwh_06")]
impl<T> rwh_06::HasDisplayHandle for EventLoopWindowTarget<T> {
fn display_handle(&self) -> Result<rwh_06::DisplayHandle<'_>, rwh_06::HandleError> {
let raw = self.p.raw_display_handle_rwh_06()?;
// SAFETY: The display will never be deallocated while the event loop is alive.
Ok(unsafe { rwh_06::DisplayHandle::borrow_raw(raw) })
}
}
#[cfg(feature = "rwh_05")]
unsafe impl<T> rwh_05::HasRawDisplayHandle for EventLoopWindowTarget<T> {
/// Returns a [`rwh_05::RawDisplayHandle`] for the event loop.
fn raw_display_handle(&self) -> rwh_05::RawDisplayHandle {
self.p.raw_display_handle_rwh_05()
}
}
/// Used to send custom events to [`EventLoop`].
pub struct EventLoopProxy<T: 'static> {
event_loop_proxy: platform_impl::EventLoopProxy<T>,
}
impl<T: 'static> Clone for EventLoopProxy<T> {
fn clone(&self) -> Self {
Self {
event_loop_proxy: self.event_loop_proxy.clone(),
}
}
}
impl<T: 'static> EventLoopProxy<T> {
/// Send an event to the [`EventLoop`] from which this proxy was created. This emits a
/// `UserEvent(event)` event in the event loop, where `event` is the value passed to this
/// function.
///
/// Returns an `Err` if the associated [`EventLoop`] no longer exists.
///
/// [`UserEvent(event)`]: Event::UserEvent
pub fn send_event(&self, event: T) -> Result<(), EventLoopClosed<T>> {
self.event_loop_proxy.send_event(event)
}
}
impl<T: 'static> fmt::Debug for EventLoopProxy<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.pad("EventLoopProxy { .. }")
}
}
/// The error that is returned when an [`EventLoopProxy`] attempts to wake up an [`EventLoop`] that
/// no longer exists.
///
/// Contains the original event given to [`EventLoopProxy::send_event`].
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub struct EventLoopClosed<T>(pub T);
impl<T> fmt::Display for EventLoopClosed<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("Tried to wake up a closed `EventLoop`")
}
}
impl<T: fmt::Debug> error::Error for EventLoopClosed<T> {}
/// Control when device events are captured.
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default)]
pub enum DeviceEvents {
/// Report device events regardless of window focus.
Always,
/// Only capture device events while the window is focused.
#[default]
WhenFocused,
/// Never capture device events.
Never,
}
/// A unique identifier of the winit's async request.
///
/// This could be used to identify the async request once it's done
/// and a specific action must be taken.
///
/// One of the handling scenarious could be to maintain a working list
/// containing [`AsyncRequestSerial`] and some closure associated with it.
/// Then once event is arriving the working list is being traversed and a job
/// executed and removed from the list.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AsyncRequestSerial {
serial: usize,
}
impl AsyncRequestSerial {
// TODO(kchibisov): Remove `cfg` when the clipboard will be added.
#[allow(dead_code)]
pub(crate) fn get() -> Self {
static CURRENT_SERIAL: AtomicUsize = AtomicUsize::new(0);
// NOTE: We rely on wrap around here, while the user may just request
// in the loop usize::MAX times that's issue is considered on them.
let serial = CURRENT_SERIAL.fetch_add(1, Ordering::Relaxed);
Self { serial }
}
}
+125
View File
@@ -0,0 +1,125 @@
use crate::platform_impl::PlatformIcon;
use std::{error::Error, fmt, io, mem};
#[repr(C)]
#[derive(Debug)]
pub(crate) struct Pixel {
pub(crate) r: u8,
pub(crate) g: u8,
pub(crate) b: u8,
pub(crate) a: u8,
}
pub(crate) const PIXEL_SIZE: usize = mem::size_of::<Pixel>();
#[derive(Debug)]
/// An error produced when using [`Icon::from_rgba`] with invalid arguments.
pub enum BadIcon {
/// Produced when the length of the `rgba` argument isn't divisible by 4, thus `rgba` can't be
/// safely interpreted as 32bpp RGBA pixels.
ByteCountNotDivisibleBy4 { byte_count: usize },
/// Produced when the number of pixels (`rgba.len() / 4`) isn't equal to `width * height`.
/// At least one of your arguments is incorrect.
DimensionsVsPixelCount {
width: u32,
height: u32,
width_x_height: usize,
pixel_count: usize,
},
/// Produced when underlying OS functionality failed to create the icon
OsError(io::Error),
}
impl fmt::Display for BadIcon {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
BadIcon::ByteCountNotDivisibleBy4 { byte_count } => write!(f,
"The length of the `rgba` argument ({byte_count:?}) isn't divisible by 4, making it impossible to interpret as 32bpp RGBA pixels.",
),
BadIcon::DimensionsVsPixelCount {
width,
height,
width_x_height,
pixel_count,
} => write!(f,
"The specified dimensions ({width:?}x{height:?}) don't match the number of pixels supplied by the `rgba` argument ({pixel_count:?}). For those dimensions, the expected pixel count is {width_x_height:?}.",
),
BadIcon::OsError(e) => write!(f, "OS error when instantiating the icon: {e:?}"),
}
}
}
impl Error for BadIcon {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RgbaIcon {
pub(crate) rgba: Vec<u8>,
pub(crate) width: u32,
pub(crate) height: u32,
}
/// For platforms which don't have window icons (e.g. web)
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct NoIcon;
#[allow(dead_code)] // These are not used on every platform
mod constructors {
use super::*;
impl RgbaIcon {
pub fn from_rgba(rgba: Vec<u8>, width: u32, height: u32) -> Result<Self, BadIcon> {
if rgba.len() % PIXEL_SIZE != 0 {
return Err(BadIcon::ByteCountNotDivisibleBy4 {
byte_count: rgba.len(),
});
}
let pixel_count = rgba.len() / PIXEL_SIZE;
if pixel_count != (width * height) as usize {
Err(BadIcon::DimensionsVsPixelCount {
width,
height,
width_x_height: (width * height) as usize,
pixel_count,
})
} else {
Ok(RgbaIcon {
rgba,
width,
height,
})
}
}
}
impl NoIcon {
pub fn from_rgba(rgba: Vec<u8>, width: u32, height: u32) -> Result<Self, BadIcon> {
// Create the rgba icon anyway to validate the input
let _ = RgbaIcon::from_rgba(rgba, width, height)?;
Ok(NoIcon)
}
}
}
/// An icon used for the window titlebar, taskbar, etc.
#[derive(Clone)]
pub struct Icon {
pub(crate) inner: PlatformIcon,
}
impl fmt::Debug for Icon {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
fmt::Debug::fmt(&self.inner, formatter)
}
}
impl Icon {
/// Creates an icon from 32bpp RGBA data.
///
/// The length of `rgba` must be divisible by 4, and `width * height` must equal
/// `rgba.len() / 4`. Otherwise, this will return a `BadIcon` error.
pub fn from_rgba(rgba: Vec<u8>, width: u32, height: u32) -> Result<Self, BadIcon> {
Ok(Icon {
inner: PlatformIcon::from_rgba(rgba, width, height)?,
})
}
}
+1798
View File
File diff suppressed because it is too large. Load diff
+198
View File
@@ -0,0 +1,198 @@
//! Winit is a cross-platform window creation and event loop management library.
//!
//! # Building windows
//!
//! Before you can build a [`Window`], you first need to build an [`EventLoop`]. This is done with the
//! [`EventLoop::new()`] function.
//!
//! ```no_run
//! use winit::event_loop::EventLoop;
//! let event_loop = EventLoop::new().unwrap();
//! ```
//!
//! Once this is done, there are two ways to create a [`Window`]:
//!
//! - Calling [`Window::new(&event_loop)`][window_new].
//! - Calling [`let builder = WindowBuilder::new()`][window_builder_new] then [`builder.build(&event_loop)`][window_builder_build].
//!
//! The first method is the simplest and will give you default values for everything. The second
//! method allows you to customize the way your [`Window`] will look and behave by modifying the
//! fields of the [`WindowBuilder`] object before you create the [`Window`].
//!
//! # Event handling
//!
//! Once a [`Window`] has been created, it will generate different *events*. A [`Window`] object can
//! generate [`WindowEvent`]s when certain input events occur, such as a cursor moving over the
//! window or a key getting pressed while the window is focused. Devices can generate
//! [`DeviceEvent`]s, which contain unfiltered event data that isn't specific to a certain window.
//! Some user activity, like mouse movement, can generate both a [`WindowEvent`] *and* a
//! [`DeviceEvent`]. You can also create and handle your own custom [`Event::UserEvent`]s, if desired.
//!
//! You can retrieve events by calling [`EventLoop::run()`]. This function will
//! dispatch events for every [`Window`] that was created with that particular [`EventLoop`], and
//! will run until [`exit()`] is used, at which point [`Event::LoopExiting`].
//!
//! Winit no longer uses a `EventLoop::poll_events() -> impl Iterator<Event>`-based event loop
//! model, since that can't be implemented properly on some platforms (e.g web, iOS) and works poorly on
//! most other platforms. However, this model can be re-implemented to an extent with
#![cfg_attr(
any(
windows_platform,
macos_platform,
android_platform,
x11_platform,
wayland_platform
),
doc = "[`EventLoopExtPumpEvents::pump_events()`][platform::pump_events::EventLoopExtPumpEvents::pump_events()]"
)]
#![cfg_attr(
not(any(
windows_platform,
macos_platform,
android_platform,
x11_platform,
wayland_platform
)),
doc = "`EventLoopExtPumpEvents::pump_events()`"
)]
//! [^1]. See that method's documentation for more reasons about why
//! it's discouraged beyond compatibility reasons.
//!
//!
//! ```no_run
//! use winit::{
//! event::{Event, WindowEvent},
//! event_loop::{ControlFlow, EventLoop},
//! window::WindowBuilder,
//! };
//!
//! let event_loop = EventLoop::new().unwrap();
//! let window = WindowBuilder::new().build(&event_loop).unwrap();
//!
//! // ControlFlow::Poll continuously runs the event loop, even if the OS hasn't
//! // dispatched any events. This is ideal for games and similar applications.
//! event_loop.set_control_flow(ControlFlow::Poll);
//!
//! // ControlFlow::Wait pauses the event loop if no events are available to process.
//! // This is ideal for non-game applications that only update in response to user
//! // input, and uses significantly less power/CPU time than ControlFlow::Poll.
//! event_loop.set_control_flow(ControlFlow::Wait);
//!
//! event_loop.run(move |event, elwt| {
//! match event {
//! Event::WindowEvent {
//! event: WindowEvent::CloseRequested,
//! ..
//! } => {
//! println!("The close button was pressed; stopping");
//! elwt.exit();
//! },
//! Event::AboutToWait => {
//! // Application update code.
//!
//! // Queue a RedrawRequested event.
//! //
//! // You only need to call this if you've determined that you need to redraw in
//! // applications which do not always need to. Applications that redraw continuously
//! // can render here instead.
//! window.request_redraw();
//! },
//! Event::WindowEvent {
//! event: WindowEvent::RedrawRequested,
//! ..
//! } => {
//! // Redraw the application.
//! //
//! // It's preferable for applications that do not render continuously to render in
//! // this event rather than in AboutToWait, since rendering in here allows
//! // the program to gracefully handle redraws requested by the OS.
//! },
//! _ => ()
//! }
//! });
//! ```
//!
//! [`WindowEvent`] has a [`WindowId`] member. In multi-window environments, it should be
//! compared to the value returned by [`Window::id()`] to determine which [`Window`]
//! dispatched the event.
//!
//! # Drawing on the window
//!
//! Winit doesn't directly provide any methods for drawing on a [`Window`]. However, it allows you to
//! retrieve the raw handle of the window and display (see the [`platform`] module and/or the
//! [`raw_window_handle`] and [`raw_display_handle`] methods), which in turn allows
//! you to create an OpenGL/Vulkan/DirectX/Metal/etc. context that can be used to render graphics.
//!
//! Note that many platforms will display garbage data in the window's client area if the
//! application doesn't render anything to the window by the time the desktop compositor is ready to
//! display the window to the user. If you notice this happening, you should create the window with
//! [`visible` set to `false`](crate::window::WindowBuilder::with_visible) and explicitly make the
//! window visible only once you're ready to render into it.
//!
//! [`EventLoop`]: event_loop::EventLoop
//! [`EventLoop::new()`]: event_loop::EventLoop::new
//! [`EventLoop::run()`]: event_loop::EventLoop::run
//! [`exit()`]: event_loop::EventLoopWindowTarget::exit
//! [`Window`]: window::Window
//! [`WindowId`]: window::WindowId
//! [`WindowBuilder`]: window::WindowBuilder
//! [window_new]: window::Window::new
//! [window_builder_new]: window::WindowBuilder::new
//! [window_builder_build]: window::WindowBuilder::build
//! [`Window::id()`]: window::Window::id
//! [`WindowEvent`]: event::WindowEvent
//! [`DeviceEvent`]: event::DeviceEvent
//! [`Event::UserEvent`]: event::Event::UserEvent
//! [`Event::LoopExiting`]: event::Event::LoopExiting
//! [`raw_window_handle`]: ./window/struct.Window.html#method.raw_window_handle
//! [`raw_display_handle`]: ./window/struct.Window.html#method.raw_display_handle
//! [^1]: `EventLoopExtPumpEvents::pump_events()` is only available on Windows, macOS, Android, X11 and Wayland.
#![deny(rust_2018_idioms)]
#![deny(rustdoc::broken_intra_doc_links)]
#![deny(clippy::all)]
#![deny(unsafe_op_in_unsafe_fn)]
#![cfg_attr(feature = "cargo-clippy", deny(warnings))]
// Doc feature labels can be tested locally by running RUSTDOCFLAGS="--cfg=docsrs" cargo +nightly doc
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
#![allow(clippy::missing_safety_doc)]
#[cfg(feature = "rwh_06")]
pub use rwh_06 as raw_window_handle;
#[allow(unused_imports)]
#[macro_use]
extern crate log;
#[cfg(feature = "serde")]
#[macro_use]
extern crate serde;
#[macro_use]
extern crate bitflags;
pub mod dpi;
#[macro_use]
pub mod error;
pub mod event;
pub mod event_loop;
mod icon;
pub mod keyboard;
pub mod monitor;
mod platform_impl;
pub mod window;
pub mod platform;
/// Wrapper for objects which winit will access on the main thread so they are effectively `Send`
/// and `Sync`, since they always execute on a single thread.
///
/// # Safety
///
/// Winit can run only one event loop at a time, and the event loop itself is tied to some thread.
/// The objects could be sent across the threads, but once passed to winit, they execute on the
/// main thread if the platform demands it. Thus, marking such objects as `Send + Sync` is safe.
#[doc(hidden)]
#[derive(Clone, Debug)]
pub(crate) struct SendSyncWrapper<T>(pub(crate) T);
unsafe impl<T> Send for SendSyncWrapper<T> {}
unsafe impl<T> Sync for SendSyncWrapper<T> {}
+169
View File
@@ -0,0 +1,169 @@
//! Types useful for interacting with a user's monitors.
//!
//! If you want to get basic information about a monitor, you can use the
//! [`MonitorHandle`] type. This is retrieved from one of the following
//! methods, which return an iterator of [`MonitorHandle`]:
//! - [`EventLoopWindowTarget::available_monitors`](crate::event_loop::EventLoopWindowTarget::available_monitors).
//! - [`Window::available_monitors`](crate::window::Window::available_monitors).
use crate::{
dpi::{PhysicalPosition, PhysicalSize},
platform_impl,
};
/// Describes a fullscreen video mode of a monitor.
///
/// Can be acquired with [`MonitorHandle::video_modes`].
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct VideoMode {
pub(crate) video_mode: platform_impl::VideoMode,
}
impl std::fmt::Debug for VideoMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.video_mode.fmt(f)
}
}
impl PartialOrd for VideoMode {
fn partial_cmp(&self, other: &VideoMode) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for VideoMode {
fn cmp(&self, other: &VideoMode) -> std::cmp::Ordering {
self.monitor().cmp(&other.monitor()).then(
self.size()
.cmp(&other.size())
.then(
self.refresh_rate_millihertz()
.cmp(&other.refresh_rate_millihertz())
.then(self.bit_depth().cmp(&other.bit_depth())),
)
.reverse(),
)
}
}
impl VideoMode {
/// Returns the resolution of this video mode.
#[inline]
pub fn size(&self) -> PhysicalSize<u32> {
self.video_mode.size()
}
/// Returns the bit depth of this video mode, as in how many bits you have
/// available per color. This is generally 24 bits or 32 bits on modern
/// systems, depending on whether the alpha channel is counted or not.
///
/// ## Platform-specific
///
/// - **Wayland / Orbital:** Always returns 32.
/// - **iOS:** Always returns 32.
#[inline]
pub fn bit_depth(&self) -> u16 {
self.video_mode.bit_depth()
}
/// Returns the refresh rate of this video mode in mHz.
#[inline]
pub fn refresh_rate_millihertz(&self) -> u32 {
self.video_mode.refresh_rate_millihertz()
}
/// Returns the monitor that this video mode is valid for. Each monitor has
/// a separate set of valid video modes.
#[inline]
pub fn monitor(&self) -> MonitorHandle {
MonitorHandle {
inner: self.video_mode.monitor(),
}
}
}
impl std::fmt::Display for VideoMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}x{} @ {} mHz ({} bpp)",
self.size().width,
self.size().height,
self.refresh_rate_millihertz(),
self.bit_depth()
)
}
}
/// Handle to a monitor.
///
/// Allows you to retrieve information about a given monitor and can be used in [`Window`] creation.
///
/// [`Window`]: crate::window::Window
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct MonitorHandle {
pub(crate) inner: platform_impl::MonitorHandle,
}
impl MonitorHandle {
/// Returns a human-readable name of the monitor.
///
/// Returns `None` if the monitor doesn't exist anymore.
#[inline]
pub fn name(&self) -> Option<String> {
self.inner.name()
}
/// Returns the monitor's resolution.
#[inline]
pub fn size(&self) -> PhysicalSize<u32> {
self.inner.size()
}
/// Returns the top-left corner position of the monitor relative to the larger full
/// screen area.
#[inline]
pub fn position(&self) -> PhysicalPosition<i32> {
self.inner.position()
}
/// The monitor refresh rate used by the system.
///
/// Return `Some` if succeed, or `None` if failed, which usually happens when the monitor
/// the window is on is removed.
///
/// When using exclusive fullscreen, the refresh rate of the [`VideoMode`] that was used to
/// enter fullscreen should be used instead.
#[inline]
pub fn refresh_rate_millihertz(&self) -> Option<u32> {
self.inner.refresh_rate_millihertz()
}
/// Returns the scale factor of the underlying monitor. To map logical pixels to physical
/// pixels and vice versa, use [`Window::scale_factor`].
///
/// See the [`dpi`](crate::dpi) module for more information.
///
/// ## Platform-specific
///
/// - **X11:** Can be overridden using the `WINIT_X11_SCALE_FACTOR` environment variable.
/// - **Wayland:** May differ from [`Window::scale_factor`].
/// - **Android:** Always returns 1.0.
///
/// [`Window::scale_factor`]: crate::window::Window::scale_factor
#[inline]
pub fn scale_factor(&self) -> f64 {
self.inner.scale_factor()
}
/// Returns all fullscreen video modes supported by this monitor.
///
/// ## Platform-specific
///
/// - **Web:** Always returns an empty iterator
#[inline]
pub fn video_modes(&self) -> impl Iterator<Item = VideoMode> {
self.inner
.video_modes()
.map(|video_mode| VideoMode { video_mode })
}
}
+93
View File
@@ -0,0 +1,93 @@
use crate::{
event_loop::{EventLoop, EventLoopBuilder, EventLoopWindowTarget},
window::{Window, WindowBuilder},
};
use android_activity::{AndroidApp, ConfigurationRef, Rect};
/// Additional methods on [`EventLoop`] that are specific to Android.
pub trait EventLoopExtAndroid {}
impl<T> EventLoopExtAndroid for EventLoop<T> {}
/// Additional methods on [`EventLoopWindowTarget`] that are specific to Android.
pub trait EventLoopWindowTargetExtAndroid {}
/// Additional methods on [`Window`] that are specific to Android.
pub trait WindowExtAndroid {
fn content_rect(&self) -> Rect;
fn config(&self) -> ConfigurationRef;
}
impl WindowExtAndroid for Window {
fn content_rect(&self) -> Rect {
self.window.content_rect()
}
fn config(&self) -> ConfigurationRef {
self.window.config()
}
}
impl<T> EventLoopWindowTargetExtAndroid for EventLoopWindowTarget<T> {}
/// Additional methods on [`WindowBuilder`] that are specific to Android.
pub trait WindowBuilderExtAndroid {}
impl WindowBuilderExtAndroid for WindowBuilder {}
pub trait EventLoopBuilderExtAndroid {
/// Associates the `AndroidApp` that was passed to `android_main()` with the event loop
///
/// This must be called on Android since the `AndroidApp` is not global state.
fn with_android_app(&mut self, app: AndroidApp) -> &mut Self;
/// Calling this will mark the volume keys to be manually handled by the application
///
/// Default is to let the operating system handle the volume keys
fn handle_volume_keys(&mut self) -> &mut Self;
}
impl<T> EventLoopBuilderExtAndroid for EventLoopBuilder<T> {
fn with_android_app(&mut self, app: AndroidApp) -> &mut Self {
self.platform_specific.android_app = Some(app);
self
}
fn handle_volume_keys(&mut self) -> &mut Self {
self.platform_specific.ignore_volume_keys = false;
self
}
}
/// Re-export of the `android_activity` API
///
/// Winit re-exports the `android_activity` API for convenience so that most
/// applications can rely on the Winit crate to resolve the required version of
/// `android_activity` and avoid any chance of a conflict between Winit and the
/// application crate.
///
/// Unlike most libraries there can only be a single implementation
/// of the `android_activity` glue crate linked with an application because
/// it is responsible for the application's `android_main()` entry point.
///
/// Since Winit depends on a specific version of `android_activity` the simplest
/// way to avoid creating a conflict is for applications to avoid explicitly
/// depending on the `android_activity` crate, and instead consume the API that
/// is re-exported by Winit.
///
/// For compatibility applications should then import the `AndroidApp` type for
/// their `android_main(app: AndroidApp)` function like:
/// ```rust
/// #[cfg(target_os="android")]
/// use winit::platform::android::activity::AndroidApp;
/// ```
pub mod activity {
// We enable the `"native-activity"` feature just so that we can build the
// docs, but it'll be very confusing for users to see the docs with that
// feature enabled, so we avoid inlining it so that they're forced to view
// it on the crate's own docs.rs page.
#[doc(no_inline)]
pub use android_activity::*;
}
+308
View File
@@ -0,0 +1,308 @@
use std::os::raw::c_void;
use icrate::Foundation::MainThreadMarker;
use objc2::rc::Id;
use crate::{
event_loop::EventLoop,
monitor::{MonitorHandle, VideoMode},
window::{Window, WindowBuilder},
};
/// Additional methods on [`EventLoop`] that are specific to iOS.
pub trait EventLoopExtIOS {
/// Returns the [`Idiom`] (phone/tablet/tv/etc) for the current device.
fn idiom(&self) -> Idiom;
}
impl<T: 'static> EventLoopExtIOS for EventLoop<T> {
fn idiom(&self) -> Idiom {
self.event_loop.idiom()
}
}
/// Additional methods on [`Window`] that are specific to iOS.
pub trait WindowExtIOS {
/// Sets the [`contentScaleFactor`] of the underlying [`UIWindow`] to `scale_factor`.
///
/// The default value is device dependent, and it's recommended GLES or Metal applications set
/// this to [`MonitorHandle::scale_factor()`].
///
/// [`UIWindow`]: https://developer.apple.com/documentation/uikit/uiwindow?language=objc
/// [`contentScaleFactor`]: https://developer.apple.com/documentation/uikit/uiview/1622657-contentscalefactor?language=objc
fn set_scale_factor(&self, scale_factor: f64);
/// Sets the valid orientations for the [`Window`].
///
/// The default value is [`ValidOrientations::LandscapeAndPortrait`].
///
/// This changes the value returned by
/// [`-[UIViewController supportedInterfaceOrientations]`](https://developer.apple.com/documentation/uikit/uiviewcontroller/1621435-supportedinterfaceorientations?language=objc),
/// and then calls
/// [`-[UIViewController attemptRotationToDeviceOrientation]`](https://developer.apple.com/documentation/uikit/uiviewcontroller/1621400-attemptrotationtodeviceorientati?language=objc).
fn set_valid_orientations(&self, valid_orientations: ValidOrientations);
/// Sets whether the [`Window`] prefers the home indicator hidden.
///
/// The default is to prefer showing the home indicator.
///
/// This changes the value returned by
/// [`-[UIViewController prefersHomeIndicatorAutoHidden]`](https://developer.apple.com/documentation/uikit/uiviewcontroller/2887510-prefershomeindicatorautohidden?language=objc),
/// and then calls
/// [`-[UIViewController setNeedsUpdateOfHomeIndicatorAutoHidden]`](https://developer.apple.com/documentation/uikit/uiviewcontroller/2887509-setneedsupdateofhomeindicatoraut?language=objc).
///
/// This only has an effect on iOS 11.0+.
fn set_prefers_home_indicator_hidden(&self, hidden: bool);
/// Sets the screen edges for which the system gestures will take a lower priority than the
/// application's touch handling.
///
/// This changes the value returned by
/// [`-[UIViewController preferredScreenEdgesDeferringSystemGestures]`](https://developer.apple.com/documentation/uikit/uiviewcontroller/2887512-preferredscreenedgesdeferringsys?language=objc),
/// and then calls
/// [`-[UIViewController setNeedsUpdateOfScreenEdgesDeferringSystemGestures]`](https://developer.apple.com/documentation/uikit/uiviewcontroller/2887507-setneedsupdateofscreenedgesdefer?language=objc).
///
/// This only has an effect on iOS 11.0+.
fn set_preferred_screen_edges_deferring_system_gestures(&self, edges: ScreenEdge);
/// Sets whether the [`Window`] prefers the status bar hidden.
///
/// The default is to prefer showing the status bar.
///
/// This sets the value of the
/// [`prefersStatusBarHidden`](https://developer.apple.com/documentation/uikit/uiviewcontroller/1621440-prefersstatusbarhidden?language=objc)
/// property.
///
/// [`setNeedsStatusBarAppearanceUpdate()`](https://developer.apple.com/documentation/uikit/uiviewcontroller/1621354-setneedsstatusbarappearanceupdat?language=objc)
/// is also called for you.
fn set_prefers_status_bar_hidden(&self, hidden: bool);
/// Sets the preferred status bar style for the [`Window`].
///
/// The default is system-defined.
///
/// This sets the value of the
/// [`preferredStatusBarStyle`](https://developer.apple.com/documentation/uikit/uiviewcontroller/1621416-preferredstatusbarstyle?language=objc)
/// property.
///
/// [`setNeedsStatusBarAppearanceUpdate()`](https://developer.apple.com/documentation/uikit/uiviewcontroller/1621354-setneedsstatusbarappearanceupdat?language=objc)
/// is also called for you.
fn set_preferred_status_bar_style(&self, status_bar_style: StatusBarStyle);
}
impl WindowExtIOS for Window {
#[inline]
fn set_scale_factor(&self, scale_factor: f64) {
self.window
.maybe_queue_on_main(move |w| w.set_scale_factor(scale_factor))
}
#[inline]
fn set_valid_orientations(&self, valid_orientations: ValidOrientations) {
self.window
.maybe_queue_on_main(move |w| w.set_valid_orientations(valid_orientations))
}
#[inline]
fn set_prefers_home_indicator_hidden(&self, hidden: bool) {
self.window
.maybe_queue_on_main(move |w| w.set_prefers_home_indicator_hidden(hidden))
}
#[inline]
fn set_preferred_screen_edges_deferring_system_gestures(&self, edges: ScreenEdge) {
self.window.maybe_queue_on_main(move |w| {
w.set_preferred_screen_edges_deferring_system_gestures(edges)
})
}
#[inline]
fn set_prefers_status_bar_hidden(&self, hidden: bool) {
self.window
.maybe_queue_on_main(move |w| w.set_prefers_status_bar_hidden(hidden))
}
#[inline]
fn set_preferred_status_bar_style(&self, status_bar_style: StatusBarStyle) {
self.window
.maybe_queue_on_main(move |w| w.set_preferred_status_bar_style(status_bar_style))
}
}
/// Additional methods on [`WindowBuilder`] that are specific to iOS.
pub trait WindowBuilderExtIOS {
/// Sets the [`contentScaleFactor`] of the underlying [`UIWindow`] to `scale_factor`.
///
/// The default value is device dependent, and it's recommended GLES or Metal applications set
/// this to [`MonitorHandle::scale_factor()`].
///
/// [`UIWindow`]: https://developer.apple.com/documentation/uikit/uiwindow?language=objc
/// [`contentScaleFactor`]: https://developer.apple.com/documentation/uikit/uiview/1622657-contentscalefactor?language=objc
fn with_scale_factor(self, scale_factor: f64) -> Self;
/// Sets the valid orientations for the [`Window`].
///
/// The default value is [`ValidOrientations::LandscapeAndPortrait`].
///
/// This sets the initial value returned by
/// [`-[UIViewController supportedInterfaceOrientations]`](https://developer.apple.com/documentation/uikit/uiviewcontroller/1621435-supportedinterfaceorientations?language=objc).
fn with_valid_orientations(self, valid_orientations: ValidOrientations) -> Self;
/// Sets whether the [`Window`] prefers the home indicator hidden.
///
/// The default is to prefer showing the home indicator.
///
/// This sets the initial value returned by
/// [`-[UIViewController prefersHomeIndicatorAutoHidden]`](https://developer.apple.com/documentation/uikit/uiviewcontroller/2887510-prefershomeindicatorautohidden?language=objc).
///
/// This only has an effect on iOS 11.0+.
fn with_prefers_home_indicator_hidden(self, hidden: bool) -> Self;
/// Sets the screen edges for which the system gestures will take a lower priority than the
/// application's touch handling.
///
/// This sets the initial value returned by
/// [`-[UIViewController preferredScreenEdgesDeferringSystemGestures]`](https://developer.apple.com/documentation/uikit/uiviewcontroller/2887512-preferredscreenedgesdeferringsys?language=objc).
///
/// This only has an effect on iOS 11.0+.
fn with_preferred_screen_edges_deferring_system_gestures(self, edges: ScreenEdge) -> Self;
/// Sets whether the [`Window`] prefers the status bar hidden.
///
/// The default is to prefer showing the status bar.
///
/// This sets the initial value returned by
/// [`-[UIViewController prefersStatusBarHidden]`](https://developer.apple.com/documentation/uikit/uiviewcontroller/1621440-prefersstatusbarhidden?language=objc).
fn with_prefers_status_bar_hidden(self, hidden: bool) -> Self;
/// Sets the style of the [`Window`]'s status bar.
///
/// The default is system-defined.
///
/// This sets the initial value returned by
/// [`-[UIViewController preferredStatusBarStyle]`](https://developer.apple.com/documentation/uikit/uiviewcontroller/1621416-preferredstatusbarstyle?language=objc),
fn with_preferred_status_bar_style(self, status_bar_style: StatusBarStyle) -> Self;
}
impl WindowBuilderExtIOS for WindowBuilder {
#[inline]
fn with_scale_factor(mut self, scale_factor: f64) -> Self {
self.platform_specific.scale_factor = Some(scale_factor);
self
}
#[inline]
fn with_valid_orientations(mut self, valid_orientations: ValidOrientations) -> Self {
self.platform_specific.valid_orientations = valid_orientations;
self
}
#[inline]
fn with_prefers_home_indicator_hidden(mut self, hidden: bool) -> Self {
self.platform_specific.prefers_home_indicator_hidden = hidden;
self
}
#[inline]
fn with_preferred_screen_edges_deferring_system_gestures(mut self, edges: ScreenEdge) -> Self {
self.platform_specific
.preferred_screen_edges_deferring_system_gestures = edges;
self
}
#[inline]
fn with_prefers_status_bar_hidden(mut self, hidden: bool) -> Self {
self.platform_specific.prefers_status_bar_hidden = hidden;
self
}
#[inline]
fn with_preferred_status_bar_style(mut self, status_bar_style: StatusBarStyle) -> Self {
self.platform_specific.preferred_status_bar_style = status_bar_style;
self
}
}
/// Additional methods on [`MonitorHandle`] that are specific to iOS.
pub trait MonitorHandleExtIOS {
/// Returns a pointer to the [`UIScreen`] that is used by this monitor.
///
/// [`UIScreen`]: https://developer.apple.com/documentation/uikit/uiscreen?language=objc
fn ui_screen(&self) -> *mut c_void;
/// Returns the preferred [`VideoMode`] for this monitor.
///
/// This translates to a call to [`-[UIScreen preferredMode]`](https://developer.apple.com/documentation/uikit/uiscreen/1617823-preferredmode?language=objc).
fn preferred_video_mode(&self) -> VideoMode;
}
impl MonitorHandleExtIOS for MonitorHandle {
#[inline]
fn ui_screen(&self) -> *mut c_void {
// SAFETY: The marker is only used to get the pointer of the screen
let mtm = unsafe { MainThreadMarker::new_unchecked() };
Id::as_ptr(self.inner.ui_screen(mtm)) as *mut c_void
}
#[inline]
fn preferred_video_mode(&self) -> VideoMode {
VideoMode {
video_mode: self.inner.preferred_video_mode(),
}
}
}
/// Valid orientations for a particular [`Window`].
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ValidOrientations {
/// Excludes `PortraitUpsideDown` on iphone
#[default]
LandscapeAndPortrait,
Landscape,
/// Excludes `PortraitUpsideDown` on iphone
Portrait,
}
/// The device [idiom].
///
/// [idiom]: https://developer.apple.com/documentation/uikit/uidevice/1620037-userinterfaceidiom?language=objc
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Idiom {
Unspecified,
/// iPhone and iPod touch.
Phone,
/// iPad.
Pad,
/// tvOS and Apple TV.
TV,
CarPlay,
}
bitflags! {
/// The [edges] of a screen.
///
/// [edges]: https://developer.apple.com/documentation/uikit/uirectedge?language=objc
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ScreenEdge: u8 {
const NONE = 0;
const TOP = 1 << 0;
const LEFT = 1 << 1;
const BOTTOM = 1 << 2;
const RIGHT = 1 << 3;
const ALL = ScreenEdge::TOP.bits() | ScreenEdge::LEFT.bits()
| ScreenEdge::BOTTOM.bits() | ScreenEdge::RIGHT.bits();
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum StatusBarStyle {
#[default]
Default,
LightContent,
DarkContent,
}
+422
View File
@@ -0,0 +1,422 @@
use std::os::raw::c_void;
use objc2::rc::Id;
use crate::{
event_loop::{EventLoopBuilder, EventLoopWindowTarget},
monitor::MonitorHandle,
window::{Window, WindowBuilder},
};
/// Additional methods on [`Window`] that are specific to MacOS.
pub trait WindowExtMacOS {
/// Returns whether or not the window is in simple fullscreen mode.
fn simple_fullscreen(&self) -> bool;
/// Toggles a fullscreen mode that doesn't require a new macOS space.
/// Returns a boolean indicating whether the transition was successful (this
/// won't work if the window was already in the native fullscreen).
///
/// This is how fullscreen used to work on macOS in versions before Lion.
/// And allows the user to have a fullscreen window without using another
/// space or taking control over the entire monitor.
fn set_simple_fullscreen(&self, fullscreen: bool) -> bool;
/// Returns whether or not the window has shadow.
fn has_shadow(&self) -> bool;
/// Sets whether or not the window has shadow.
fn set_has_shadow(&self, has_shadow: bool);
/// Group windows together by using the same tabbing identifier.
///
/// <https://developer.apple.com/documentation/appkit/nswindow/1644704-tabbingidentifier>
fn set_tabbing_identifier(&self, identifier: &str);
/// Returns the window's tabbing identifier.
fn tabbing_identifier(&self) -> String;
/// Select next tab.
fn select_next_tab(&self);
/// Select previous tab.
fn select_previous_tab(&self);
/// Select the tab with the given index.
///
/// Will no-op when the index is out of bounds.
fn select_tab_at_index(&self, index: usize);
/// Get the number of tabs in the window tab group.
fn num_tabs(&self) -> usize;
/// Get the window's edit state.
///
/// # Examples
///
/// ```ignore
/// WindowEvent::CloseRequested => {
/// if window.is_document_edited() {
/// // Show the user a save pop-up or similar
/// } else {
/// // Close the window
/// drop(window);
/// }
/// }
/// ```
fn is_document_edited(&self) -> bool;
/// Put the window in a state which indicates a file save is required.
fn set_document_edited(&self, edited: bool);
/// Set option as alt behavior as described in [`OptionAsAlt`].
///
/// This will ignore diacritical marks and accent characters from
/// being processed as received characters. Instead, the input
/// device's raw character will be placed in event queues with the
/// Alt modifier set.
fn set_option_as_alt(&self, option_as_alt: OptionAsAlt);
/// Getter for the [`WindowExtMacOS::set_option_as_alt`].
fn option_as_alt(&self) -> OptionAsAlt;
}
impl WindowExtMacOS for Window {
#[inline]
fn simple_fullscreen(&self) -> bool {
self.window.maybe_wait_on_main(|w| w.simple_fullscreen())
}
#[inline]
fn set_simple_fullscreen(&self, fullscreen: bool) -> bool {
self.window
.maybe_wait_on_main(move |w| w.set_simple_fullscreen(fullscreen))
}
#[inline]
fn has_shadow(&self) -> bool {
self.window.maybe_wait_on_main(|w| w.has_shadow())
}
#[inline]
fn set_has_shadow(&self, has_shadow: bool) {
self.window
.maybe_queue_on_main(move |w| w.set_has_shadow(has_shadow))
}
#[inline]
fn set_tabbing_identifier(&self, identifier: &str) {
self.window
.maybe_wait_on_main(|w| w.set_tabbing_identifier(identifier))
}
#[inline]
fn tabbing_identifier(&self) -> String {
self.window.maybe_wait_on_main(|w| w.tabbing_identifier())
}
#[inline]
fn select_next_tab(&self) {
self.window.maybe_queue_on_main(|w| w.select_next_tab())
}
#[inline]
fn select_previous_tab(&self) {
self.window.maybe_queue_on_main(|w| w.select_previous_tab())
}
#[inline]
fn select_tab_at_index(&self, index: usize) {
self.window
.maybe_queue_on_main(move |w| w.select_tab_at_index(index))
}
#[inline]
fn num_tabs(&self) -> usize {
self.window.maybe_wait_on_main(|w| w.num_tabs())
}
#[inline]
fn is_document_edited(&self) -> bool {
self.window.maybe_wait_on_main(|w| w.is_document_edited())
}
#[inline]
fn set_document_edited(&self, edited: bool) {
self.window
.maybe_queue_on_main(move |w| w.set_document_edited(edited))
}
#[inline]
fn set_option_as_alt(&self, option_as_alt: OptionAsAlt) {
self.window
.maybe_queue_on_main(move |w| w.set_option_as_alt(option_as_alt))
}
#[inline]
fn option_as_alt(&self) -> OptionAsAlt {
self.window.maybe_wait_on_main(|w| w.option_as_alt())
}
}
/// Corresponds to `NSApplicationActivationPolicy`.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ActivationPolicy {
/// Corresponds to `NSApplicationActivationPolicyRegular`.
#[default]
Regular,
/// Corresponds to `NSApplicationActivationPolicyAccessory`.
Accessory,
/// Corresponds to `NSApplicationActivationPolicyProhibited`.
Prohibited,
}
/// Additional methods on [`WindowBuilder`] that are specific to MacOS.
///
/// **Note:** Properties dealing with the titlebar will be overwritten by the [`WindowBuilder::with_decorations`] method:
/// - `with_titlebar_transparent`
/// - `with_title_hidden`
/// - `with_titlebar_hidden`
/// - `with_titlebar_buttons_hidden`
/// - `with_fullsize_content_view`
pub trait WindowBuilderExtMacOS {
/// Enables click-and-drag behavior for the entire window, not just the titlebar.
fn with_movable_by_window_background(self, movable_by_window_background: bool) -> Self;
/// Makes the titlebar transparent and allows the content to appear behind it.
fn with_titlebar_transparent(self, titlebar_transparent: bool) -> Self;
/// Hides the window title.
fn with_title_hidden(self, title_hidden: bool) -> Self;
/// Hides the window titlebar.
fn with_titlebar_hidden(self, titlebar_hidden: bool) -> Self;
/// Hides the window titlebar buttons.
fn with_titlebar_buttons_hidden(self, titlebar_buttons_hidden: bool) -> Self;
/// Makes the window content appear behind the titlebar.
fn with_fullsize_content_view(self, fullsize_content_view: bool) -> Self;
fn with_disallow_hidpi(self, disallow_hidpi: bool) -> Self;
fn with_has_shadow(self, has_shadow: bool) -> Self;
/// Window accepts click-through mouse events.
fn with_accepts_first_mouse(self, accepts_first_mouse: bool) -> Self;
/// Defines the window tabbing identifier.
///
/// <https://developer.apple.com/documentation/appkit/nswindow/1644704-tabbingidentifier>
fn with_tabbing_identifier(self, identifier: &str) -> Self;
/// Set how the <kbd>Option</kbd> keys are interpreted.
///
/// See [`WindowExtMacOS::set_option_as_alt`] for details on what this means if set.
fn with_option_as_alt(self, option_as_alt: OptionAsAlt) -> Self;
}
impl WindowBuilderExtMacOS for WindowBuilder {
#[inline]
fn with_movable_by_window_background(mut self, movable_by_window_background: bool) -> Self {
self.platform_specific.movable_by_window_background = movable_by_window_background;
self
}
#[inline]
fn with_titlebar_transparent(mut self, titlebar_transparent: bool) -> Self {
self.platform_specific.titlebar_transparent = titlebar_transparent;
self
}
#[inline]
fn with_titlebar_hidden(mut self, titlebar_hidden: bool) -> Self {
self.platform_specific.titlebar_hidden = titlebar_hidden;
self
}
#[inline]
fn with_titlebar_buttons_hidden(mut self, titlebar_buttons_hidden: bool) -> Self {
self.platform_specific.titlebar_buttons_hidden = titlebar_buttons_hidden;
self
}
#[inline]
fn with_title_hidden(mut self, title_hidden: bool) -> Self {
self.platform_specific.title_hidden = title_hidden;
self
}
#[inline]
fn with_fullsize_content_view(mut self, fullsize_content_view: bool) -> Self {
self.platform_specific.fullsize_content_view = fullsize_content_view;
self
}
#[inline]
fn with_disallow_hidpi(mut self, disallow_hidpi: bool) -> Self {
self.platform_specific.disallow_hidpi = disallow_hidpi;
self
}
#[inline]
fn with_has_shadow(mut self, has_shadow: bool) -> Self {
self.platform_specific.has_shadow = has_shadow;
self
}
#[inline]
fn with_accepts_first_mouse(mut self, accepts_first_mouse: bool) -> Self {
self.platform_specific.accepts_first_mouse = accepts_first_mouse;
self
}
#[inline]
fn with_tabbing_identifier(mut self, tabbing_identifier: &str) -> Self {
self.platform_specific
.tabbing_identifier
.replace(tabbing_identifier.to_string());
self
}
#[inline]
fn with_option_as_alt(mut self, option_as_alt: OptionAsAlt) -> Self {
self.platform_specific.option_as_alt = option_as_alt;
self
}
}
pub trait EventLoopBuilderExtMacOS {
/// Sets the activation policy for the application.
///
/// It is set to [`ActivationPolicy::Regular`] by default.
///
/// # Example
///
/// Set the activation policy to "accessory".
///
/// ```
/// use winit::event_loop::EventLoopBuilder;
/// #[cfg(target_os = "macos")]
/// use winit::platform::macos::{EventLoopBuilderExtMacOS, ActivationPolicy};
///
/// let mut builder = EventLoopBuilder::new();
/// #[cfg(target_os = "macos")]
/// builder.with_activation_policy(ActivationPolicy::Accessory);
/// # if false { // We can't test this part
/// let event_loop = builder.build();
/// # }
/// ```
fn with_activation_policy(&mut self, activation_policy: ActivationPolicy) -> &mut Self;
/// Used to control whether a default menubar menu is created.
///
/// Menu creation is enabled by default.
///
/// # Example
///
/// Disable creating a default menubar.
///
/// ```
/// use winit::event_loop::EventLoopBuilder;
/// #[cfg(target_os = "macos")]
/// use winit::platform::macos::EventLoopBuilderExtMacOS;
///
/// let mut builder = EventLoopBuilder::new();
/// #[cfg(target_os = "macos")]
/// builder.with_default_menu(false);
/// # if false { // We can't test this part
/// let event_loop = builder.build();
/// # }
/// ```
fn with_default_menu(&mut self, enable: bool) -> &mut Self;
/// Used to prevent the application from automatically activating when launched if
/// another application is already active.
///
/// The default behavior is to ignore other applications and activate when launched.
fn with_activate_ignoring_other_apps(&mut self, ignore: bool) -> &mut Self;
}
impl<T> EventLoopBuilderExtMacOS for EventLoopBuilder<T> {
#[inline]
fn with_activation_policy(&mut self, activation_policy: ActivationPolicy) -> &mut Self {
self.platform_specific.activation_policy = activation_policy;
self
}
#[inline]
fn with_default_menu(&mut self, enable: bool) -> &mut Self {
self.platform_specific.default_menu = enable;
self
}
#[inline]
fn with_activate_ignoring_other_apps(&mut self, ignore: bool) -> &mut Self {
self.platform_specific.activate_ignoring_other_apps = ignore;
self
}
}
/// Additional methods on [`MonitorHandle`] that are specific to MacOS.
pub trait MonitorHandleExtMacOS {
/// Returns the identifier of the monitor for Cocoa.
fn native_id(&self) -> u32;
/// Returns a pointer to the NSScreen representing this monitor.
fn ns_screen(&self) -> Option<*mut c_void>;
}
impl MonitorHandleExtMacOS for MonitorHandle {
#[inline]
fn native_id(&self) -> u32 {
self.inner.native_identifier()
}
fn ns_screen(&self) -> Option<*mut c_void> {
self.inner.ns_screen().map(|s| Id::as_ptr(&s) as _)
}
}
/// Additional methods on [`EventLoopWindowTarget`] that are specific to macOS.
pub trait EventLoopWindowTargetExtMacOS {
/// Hide the entire application. In most applications this is typically triggered with Command-H.
fn hide_application(&self);
/// Hide the other applications. In most applications this is typically triggered with Command+Option-H.
fn hide_other_applications(&self);
/// Set whether the system can automatically organize windows into tabs.
///
/// <https://developer.apple.com/documentation/appkit/nswindow/1646657-allowsautomaticwindowtabbing>
fn set_allows_automatic_window_tabbing(&self, enabled: bool);
/// Returns whether the system can automatically organize windows into tabs.
fn allows_automatic_window_tabbing(&self) -> bool;
}
impl<T> EventLoopWindowTargetExtMacOS for EventLoopWindowTarget<T> {
fn hide_application(&self) {
self.p.hide_application()
}
fn hide_other_applications(&self) {
self.p.hide_other_applications()
}
fn set_allows_automatic_window_tabbing(&self, enabled: bool) {
self.p.set_allows_automatic_window_tabbing(enabled);
}
fn allows_automatic_window_tabbing(&self) -> bool {
self.p.allows_automatic_window_tabbing()
}
}
/// Option as alt behavior.
///
/// The default is `None`.
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum OptionAsAlt {
/// The left `Option` key is treated as `Alt`.
OnlyLeft,
/// The right `Option` key is treated as `Alt`.
OnlyRight,
/// Both `Option` keys are treated as `Alt`.
Both,
/// No special handling is applied for `Option` key.
#[default]
None,
}
+65
View File
@@ -0,0 +1,65 @@
//! Contains traits with platform-specific methods in them.
//!
//! Contains the follow OS-specific modules:
//!
//! - `android`
//! - `ios`
//! - `macos`
//! - `unix`
//! - `windows`
//! - `web`
//!
//! And the following platform-specific modules:
//!
//! - `run_on_demand` (available on `windows`, `unix`, `macos`, `android`)
//! - `pump_events` (available on `windows`, `unix`, `macos`, `android`)
//!
//! However only the module corresponding to the platform you're compiling to will be available.
#[cfg(android_platform)]
pub mod android;
#[cfg(ios_platform)]
pub mod ios;
#[cfg(macos_platform)]
pub mod macos;
#[cfg(orbital_platform)]
pub mod orbital;
#[cfg(any(x11_platform, wayland_platform))]
pub mod startup_notify;
#[cfg(wayland_platform)]
pub mod wayland;
#[cfg(wasm_platform)]
pub mod web;
#[cfg(windows_platform)]
pub mod windows;
#[cfg(x11_platform)]
pub mod x11;
#[cfg(any(
windows_platform,
macos_platform,
android_platform,
x11_platform,
wayland_platform
))]
pub mod run_on_demand;
#[cfg(any(
windows_platform,
macos_platform,
android_platform,
x11_platform,
wayland_platform
))]
pub mod pump_events;
#[cfg(any(
windows_platform,
macos_platform,
x11_platform,
wayland_platform,
orbital_platform,
docsrs
))]
pub mod modifier_supplement;
pub mod scancode;
+24
View File
@@ -0,0 +1,24 @@
#![cfg(any(windows_platform, macos_platform, x11_platform, wayland_platform))]
use crate::keyboard::Key;
/// Additional methods for the `KeyEvent` which cannot be implemented on all
/// platforms.
pub trait KeyEventExtModifierSupplement {
/// Identical to `KeyEvent::text` but this is affected by <kbd>Ctrl</kbd>.
///
/// For example, pressing <kbd>Ctrl</kbd>+<kbd>a</kbd> produces `Some("\x01")`.
fn text_with_all_modifiers(&self) -> Option<&str>;
/// This value ignores all modifiers including,
/// but not limited to <kbd>Shift</kbd>, <kbd>Caps Lock</kbd>,
/// and <kbd>Ctrl</kbd>. In most cases this means that the
/// unicode character in the resulting string is lowercase.
///
/// This is useful for key-bindings / shortcut key combinations.
///
/// In case `logical_key` reports `Dead`, this will still report the
/// key as `Character` according to the current keyboard layout. This value
/// cannot be `Dead`.
fn key_without_modifiers(&self) -> Key;
}
+1
View File
@@ -0,0 +1 @@
// There are no Orbital specific traits yet.
+189
View File
@@ -0,0 +1,189 @@
use std::time::Duration;
use crate::{
event::Event,
event_loop::{EventLoop, EventLoopWindowTarget},
};
/// The return status for `pump_events`
pub enum PumpStatus {
/// Continue running external loop.
Continue,
/// Exit external loop.
Exit(i32),
}
/// Additional methods on [`EventLoop`] for pumping events within an external event loop
pub trait EventLoopExtPumpEvents {
/// A type provided by the user that can be passed through [`Event::UserEvent`].
type UserEvent;
/// Pump the `EventLoop` to check for and dispatch pending events.
///
/// This API is designed to enable applications to integrate Winit into an
/// external event loop, for platforms that can support this.
///
/// The given `timeout` limits how long it may block waiting for new events.
///
/// Passing a `timeout` of `Some(Duration::ZERO)` would ensure your external
/// event loop is never blocked but you would likely need to consider how
/// to throttle your own external loop.
///
/// Passing a `timeout` of `None` means that it may wait indefinitely for new
/// events before returning control back to the external loop.
///
/// ## Example
///
/// ```rust,no_run
/// # // Copied from examples/window_pump_events.rs
/// # #[cfg(any(
/// # windows_platform,
/// # macos_platform,
/// # x11_platform,
/// # wayland_platform,
/// # android_platform,
/// # ))]
/// fn main() -> std::process::ExitCode {
/// # use std::{process::ExitCode, thread::sleep, time::Duration};
/// #
/// # use simple_logger::SimpleLogger;
/// # use winit::{
/// # event::{Event, WindowEvent},
/// # event_loop::EventLoop,
/// # platform::pump_events::{EventLoopExtPumpEvents, PumpStatus},
/// # window::WindowBuilder,
/// # };
/// let mut event_loop = EventLoop::new().unwrap();
/// #
/// # SimpleLogger::new().init().unwrap();
/// let window = WindowBuilder::new()
/// .with_title("A fantastic window!")
/// .build(&event_loop)
/// .unwrap();
///
/// 'main: loop {
/// let timeout = Some(Duration::ZERO);
/// let status = event_loop.pump_events(timeout, |event, elwt| {
/// # if let Event::WindowEvent { event, .. } = &event {
/// # // Print only Window events to reduce noise
/// # println!("{event:?}");
/// # }
/// #
/// match event {
/// Event::WindowEvent {
/// event: WindowEvent::CloseRequested,
/// window_id,
/// } if window_id == window.id() => elwt.exit(),
/// Event::AboutToWait => {
/// window.request_redraw();
/// }
/// _ => (),
/// }
/// });
/// if let PumpStatus::Exit(exit_code) = status {
/// break 'main ExitCode::from(exit_code as u8);
/// }
///
/// // Sleep for 1/60 second to simulate application work
/// //
/// // Since `pump_events` doesn't block it will be important to
/// // throttle the loop in the app somehow.
/// println!("Update()");
/// sleep(Duration::from_millis(16));
/// }
/// }
/// ```
///
/// **Note:** This is not a portable API, and its usage involves a number of
/// caveats and trade offs that should be considered before using this API!
///
/// You almost certainly shouldn't use this API, unless you absolutely know it's
/// the only practical option you have.
///
/// ## Synchronous events
///
/// Some events _must_ only be handled synchronously via the closure that
/// is passed to Winit so that the handler will also be synchronized with
/// the window system and operating system.
///
/// This is because some events are driven by a window system callback
/// where the window systems expects the application to have handled the
/// event before returning.
///
/// **These events can not be buffered and handled outside of the closure
/// passed to Winit.**
///
/// As a general rule it is not recommended to ever buffer events to handle
/// them outside of the closure passed to Winit since it's difficult to
/// provide guarantees about which events are safe to buffer across all
/// operating systems.
///
/// Notable events that will certainly create portability problems if
/// buffered and handled outside of Winit include:
/// - `RedrawRequested` events, used to schedule rendering.
///
/// macOS for example uses a `drawRect` callback to drive rendering
/// within applications and expects rendering to be finished before
/// the `drawRect` callback returns.
///
/// For portability it's strongly recommended that applications should
/// keep their rendering inside the closure provided to Winit.
/// - Any lifecycle events, such as `Suspended` / `Resumed`.
///
/// The handling of these events needs to be synchronized with the
/// operating system and it would never be appropriate to buffer a
/// notification that your application has been suspended or resumed and
/// then handled that later since there would always be a chance that
/// other lifecycle events occur while the event is buffered.
///
/// ## Supported Platforms
/// - Windows
/// - Linux
/// - MacOS
/// - Android
///
/// ## Unsupported Platforms
/// - **Web:** This API is fundamentally incompatible with the event-based way in which
/// Web browsers work because it's not possible to have a long-running external
/// loop that would block the browser and there is nothing that can be
/// polled to ask for new new events. Events are delivered via callbacks based
/// on an event loop that is internal to the browser itself.
/// - **iOS:** It's not possible to stop and start an `NSApplication` repeatedly on iOS so
/// there's no way to support the same approach to polling as on MacOS.
///
/// ## Platform-specific
/// - **Windows**: The implementation will use `PeekMessage` when checking for
/// window messages to avoid blocking your external event loop.
///
/// - **MacOS**: The implementation works in terms of stopping the global `NSApp`
/// whenever the application `RunLoop` indicates that it is preparing to block
/// and wait for new events.
///
/// This is very different to the polling APIs that are available on other
/// platforms (the lower level polling primitives on MacOS are private
/// implementation details for `NSApp` which aren't accessible to application
/// developers)
///
/// It's likely this will be less efficient than polling on other OSs and
/// it also means the `NSApp` is stopped while outside of the Winit
/// event loop - and that's observable (for example to crates like `rfd`)
/// because the `NSApp` is global state.
///
/// If you render outside of Winit you are likely to see window resizing artifacts
/// since MacOS expects applications to render synchronously during any `drawRect`
/// callback.
fn pump_events<F>(&mut self, timeout: Option<Duration>, event_handler: F) -> PumpStatus
where
F: FnMut(Event<Self::UserEvent>, &EventLoopWindowTarget<Self::UserEvent>);
}
impl<T> EventLoopExtPumpEvents for EventLoop<T> {
type UserEvent = T;
fn pump_events<F>(&mut self, timeout: Option<Duration>, event_handler: F) -> PumpStatus
where
F: FnMut(Event<Self::UserEvent>, &EventLoopWindowTarget<Self::UserEvent>),
{
self.event_loop.pump_events(timeout, event_handler)
}
}
+89
View File
@@ -0,0 +1,89 @@
use crate::{
error::EventLoopError,
event::Event,
event_loop::{EventLoop, EventLoopWindowTarget},
};
#[cfg(doc)]
use crate::{platform::pump_events::EventLoopExtPumpEvents, window::Window};
/// Additional methods on [`EventLoop`] to return control flow to the caller.
pub trait EventLoopExtRunOnDemand {
/// A type provided by the user that can be passed through [`Event::UserEvent`].
type UserEvent;
/// Runs the event loop in the calling thread and calls the given `event_handler` closure
/// to dispatch any window system events.
///
/// Unlike [`EventLoop::run`], this function accepts non-`'static` (i.e. non-`move`) closures
/// and it is possible to return control back to the caller without
/// consuming the `EventLoop` (by using [`exit()`]) and
/// so the event loop can be re-run after it has exit.
///
/// It's expected that each run of the loop will be for orthogonal instantiations of your
/// Winit application, but internally each instantiation may re-use some common window
/// system resources, such as a display server connection.
///
/// This API is not designed to run an event loop in bursts that you can exit from and return
/// to while maintaining the full state of your application. (If you need something like this
/// you can look at the [`EventLoopExtPumpEvents::pump_events()`] API)
///
/// Each time `run_on_demand` is called the `event_handler` can expect to receive a
/// `NewEvents(Init)` and `Resumed` event (even on platforms that have no suspend/resume
/// lifecycle) - which can be used to consistently initialize application state.
///
/// See the [`set_control_flow()`] docs on how to change the event loop's behavior.
///
/// # Caveats
/// - This extension isn't available on all platforms, since it's not always possible to return
/// to the caller (specifically this is impossible on iOS and Web - though with the Web
/// backend it is possible to use `EventLoopExtWebSys::spawn()`[^1] more than once instead).
/// - No [`Window`] state can be carried between separate runs of the event loop.
///
/// You are strongly encouraged to use [`EventLoop::run()`] for portability, unless you specifically need
/// the ability to re-run a single event loop more than once
///
/// # Supported Platforms
/// - Windows
/// - Linux
/// - macOS
/// - Android
///
/// # Unsupported Platforms
/// - **Web:** This API is fundamentally incompatible with the event-based way in which
/// Web browsers work because it's not possible to have a long-running external
/// loop that would block the browser and there is nothing that can be
/// polled to ask for new events. Events are delivered via callbacks based
/// on an event loop that is internal to the browser itself.
/// - **iOS:** It's not possible to stop and start an `NSApplication` repeatedly on iOS.
///
#[cfg_attr(
not(wasm_platform),
doc = "[^1]: `spawn()` is only available on `wasm` platforms."
)]
///
/// [`exit()`]: EventLoopWindowTarget::exit()
/// [`set_control_flow()`]: EventLoopWindowTarget::set_control_flow()
fn run_on_demand<F>(&mut self, event_handler: F) -> Result<(), EventLoopError>
where
F: FnMut(Event<Self::UserEvent>, &EventLoopWindowTarget<Self::UserEvent>);
}
impl<T> EventLoopExtRunOnDemand for EventLoop<T> {
type UserEvent = T;
fn run_on_demand<F>(&mut self, event_handler: F) -> Result<(), EventLoopError>
where
F: FnMut(Event<Self::UserEvent>, &EventLoopWindowTarget<Self::UserEvent>),
{
self.event_loop.window_target().clear_exit();
self.event_loop.run_on_demand(event_handler)
}
}
impl<T> EventLoopWindowTarget<T> {
/// Clear exit status.
pub(crate) fn clear_exit(&self) {
self.p.clear_exit()
}
}
+45
View File
@@ -0,0 +1,45 @@
#![cfg(any(windows_platform, macos_platform, x11_platform, wayland_platform))]
use crate::keyboard::{KeyCode, PhysicalKey};
// TODO: Describe what this value contains for each platform
/// Additional methods for the [`PhysicalKey`] type that allow the user to access the platform-specific
/// scancode.
///
/// [`PhysicalKey`]: crate::keyboard::PhysicalKey
pub trait PhysicalKeyExtScancode {
/// The raw value of the platform-specific physical key identifier.
///
/// Returns `Some(key_id)` if the conversion was succesful; returns `None` otherwise.
///
/// ## Platform-specific
/// - **Windows:** A 16bit extended scancode
/// - **Wayland/X11**: A 32-bit linux scancode, which is X11/Wayland keycode subtracted by 8.
fn to_scancode(self) -> Option<u32>;
/// Constructs a `PhysicalKey` from a platform-specific physical key identifier.
///
/// Note that this conversion may be lossy, i.e. converting the returned `PhysicalKey` back
/// using `to_scancode` might not yield the original value.
///
/// ## Platform-specific
/// - **Wayland/X11**: A 32-bit linux scancode. When building from X11/Wayland keycode subtract
/// `8` to get the value you wanted.
fn from_scancode(scancode: u32) -> PhysicalKey;
}
impl PhysicalKeyExtScancode for KeyCode
where
PhysicalKey: PhysicalKeyExtScancode,
{
#[inline]
fn from_scancode(scancode: u32) -> PhysicalKey {
<PhysicalKey as PhysicalKeyExtScancode>::from_scancode(scancode)
}
#[inline]
fn to_scancode(self) -> Option<u32> {
<PhysicalKey as PhysicalKeyExtScancode>::to_scancode(PhysicalKey::Code(self))
}
}
+99
View File
@@ -0,0 +1,99 @@
//! Window startup notification to handle window raising.
//!
//! The [`ActivationToken`] is essential to ensure that your newly
//! created window will obtain the focus, otherwise the user could
//! be requered to click on the window.
//!
//! Such token is usually delivered via the environment variable and
//! could be read from it with the [`EventLoopExtStartupNotify::read_token_from_env`].
//!
//! Such token must also be reset after reading it from your environment with
//! [`reset_activation_token_env`] otherwise child processes could inherit it.
//!
//! When starting a new child process with a newly obtained [`ActivationToken`] from
//! [`WindowExtStartupNotify::request_activation_token`] the [`set_activation_token_env`]
//! must be used to propagate it to the child
//!
//! To ensure the delivery of such token by other processes to you, the user should
//! set `StartupNotify=true` inside the `.desktop` file of their application.
//!
//! The specification could be found [`here`].
//!
//! [`here`]: https://specifications.freedesktop.org/startup-notification-spec/startup-notification-latest.txt
use std::env;
use crate::error::NotSupportedError;
use crate::event_loop::{AsyncRequestSerial, EventLoopWindowTarget};
use crate::window::{ActivationToken, Window, WindowBuilder};
/// The variable which is used mostly on X11.
const X11_VAR: &str = "DESKTOP_STARTUP_ID";
/// The variable which is used mostly on Wayland.
const WAYLAND_VAR: &str = "XDG_ACTIVATION_TOKEN";
pub trait EventLoopExtStartupNotify {
/// Read the token from the environment.
///
/// It's recommended **to unset** this environment variable for child processes.
fn read_token_from_env(&self) -> Option<ActivationToken>;
}
pub trait WindowExtStartupNotify {
/// Request a new activation token.
///
/// The token will be delivered inside
fn request_activation_token(&self) -> Result<AsyncRequestSerial, NotSupportedError>;
}
pub trait WindowBuilderExtStartupNotify {
/// Use this [`ActivationToken`] during window creation.
///
/// Not using such a token upon a window could make your window not gaining
/// focus until the user clicks on the window.
fn with_activation_token(self, token: ActivationToken) -> Self;
}
impl<T> EventLoopExtStartupNotify for EventLoopWindowTarget<T> {
fn read_token_from_env(&self) -> Option<ActivationToken> {
match self.p {
#[cfg(wayland_platform)]
crate::platform_impl::EventLoopWindowTarget::Wayland(_) => env::var(WAYLAND_VAR),
#[cfg(x11_platform)]
crate::platform_impl::EventLoopWindowTarget::X(_) => env::var(X11_VAR),
}
.ok()
.map(ActivationToken::_new)
}
}
impl WindowExtStartupNotify for Window {
fn request_activation_token(&self) -> Result<AsyncRequestSerial, NotSupportedError> {
self.window.request_activation_token()
}
}
impl WindowBuilderExtStartupNotify for WindowBuilder {
fn with_activation_token(mut self, token: ActivationToken) -> Self {
self.platform_specific.activation_token = Some(token);
self
}
}
/// Remove the activation environment variables from the current process.
///
/// This is wise to do before running child processes,
/// which may not to support the activation token.
pub fn reset_activation_token_env() {
env::remove_var(X11_VAR);
env::remove_var(WAYLAND_VAR);
}
/// Set environment variables responsible for activation token.
///
/// This could be used before running daemon processes.
pub fn set_activation_token_env(token: ActivationToken) {
env::set_var(X11_VAR, &token._token);
env::set_var(WAYLAND_VAR, token._token);
}
+106
View File
@@ -0,0 +1,106 @@
use crate::{
event_loop::{EventLoopBuilder, EventLoopWindowTarget},
monitor::MonitorHandle,
window::{Window, WindowBuilder},
};
use crate::platform_impl::{ApplicationName, Backend};
pub use crate::window::Theme;
/// ink-notes patch: touches on the client-side decorations that start before this instant
/// are ignored (palm rejection while a pen is near the screen); `None` accepts them.
static IGNORE_DECORATION_TOUCHES_UNTIL: std::sync::Mutex<Option<std::time::Instant>> =
std::sync::Mutex::new(None);
/// ink-notes patch: ignore touches on the client-side decorations until `until`.
pub fn ignore_decoration_touches_until(until: Option<std::time::Instant>) {
*IGNORE_DECORATION_TOUCHES_UNTIL
.lock()
.unwrap_or_else(|e| e.into_inner()) = until;
}
/// ink-notes patch: whether a touch on the decorations is ignored right now.
pub(crate) fn decoration_touches_ignored() -> bool {
IGNORE_DECORATION_TOUCHES_UNTIL
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_some_and(|until| std::time::Instant::now() < until)
}
/// Additional methods on [`EventLoopWindowTarget`] that are specific to Wayland.
pub trait EventLoopWindowTargetExtWayland {
/// True if the [`EventLoopWindowTarget`] uses Wayland.
fn is_wayland(&self) -> bool;
}
impl<T> EventLoopWindowTargetExtWayland for EventLoopWindowTarget<T> {
#[inline]
fn is_wayland(&self) -> bool {
self.p.is_wayland()
}
}
/// Additional methods on [`EventLoopBuilder`] that are specific to Wayland.
pub trait EventLoopBuilderExtWayland {
/// Force using Wayland.
fn with_wayland(&mut self) -> &mut Self;
/// Whether to allow the event loop to be created off of the main thread.
///
/// By default, the window is only allowed to be created on the main
/// thread, to make platform compatibility easier.
fn with_any_thread(&mut self, any_thread: bool) -> &mut Self;
}
impl<T> EventLoopBuilderExtWayland for EventLoopBuilder<T> {
#[inline]
fn with_wayland(&mut self) -> &mut Self {
self.platform_specific.forced_backend = Some(Backend::Wayland);
self
}
#[inline]
fn with_any_thread(&mut self, any_thread: bool) -> &mut Self {
self.platform_specific.any_thread = any_thread;
self
}
}
/// Additional methods on [`Window`] that are specific to Wayland.
pub trait WindowExtWayland {}
impl WindowExtWayland for Window {}
/// Additional methods on [`WindowBuilder`] that are specific to Wayland.
pub trait WindowBuilderExtWayland {
/// Build window with the given name.
///
/// The `general` name sets an application ID, which should match the `.desktop`
/// file destributed with your program. The `instance` is a `no-op`.
///
/// For details about application ID conventions, see the
/// [Desktop Entry Spec](https://specifications.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html#desktop-file-id)
fn with_name(self, general: impl Into<String>, instance: impl Into<String>) -> Self;
}
impl WindowBuilderExtWayland for WindowBuilder {
#[inline]
fn with_name(mut self, general: impl Into<String>, instance: impl Into<String>) -> Self {
self.platform_specific.name = Some(ApplicationName::new(general.into(), instance.into()));
self
}
}
/// Additional methods on `MonitorHandle` that are specific to Wayland.
pub trait MonitorHandleExtWayland {
/// Returns the inner identifier of the monitor.
fn native_id(&self) -> u32;
}
impl MonitorHandleExtWayland for MonitorHandle {
#[inline]
fn native_id(&self) -> u32 {
self.inner.native_identifier()
}
}
+202
View File
@@ -0,0 +1,202 @@
//! The web target does not automatically insert the canvas element object into the web page, to
//! allow end users to determine how the page should be laid out. Use the [`WindowExtWebSys`] trait
//! to retrieve the canvas from the Window. Alternatively, use the [`WindowBuilderExtWebSys`] trait
//! to provide your own canvas.
//!
//! It is recommended **not** to apply certain CSS properties to the canvas:
//! - [`transform`]
//! - [`border`]
//! - [`padding`]
//!
//! The following APIs can't take them into account and will therefore provide inaccurate results:
//! - [`WindowEvent::Resized`] and [`Window::(set_)inner_size()`]
//! - [`WindowEvent::Occluded`]
//! - [`WindowEvent::CursorMoved`], [`WindowEvent::CursorEntered`], [`WindowEvent::CursorLeft`],
//! and [`WindowEvent::Touch`].
//! - [`Window::set_outer_position()`]
//!
//! [`WindowEvent::Resized`]: crate::event::WindowEvent::Resized
//! [`Window::(set_)inner_size()`]: crate::window::Window::inner_size()
//! [`WindowEvent::Occluded`]: crate::event::WindowEvent::Occluded
//! [`WindowEvent::CursorMoved`]: crate::event::WindowEvent::CursorMoved
//! [`WindowEvent::CursorEntered`]: crate::event::WindowEvent::CursorEntered
//! [`WindowEvent::CursorLeft`]: crate::event::WindowEvent::CursorLeft
//! [`WindowEvent::Touch`]: crate::event::WindowEvent::Touch
//! [`Window::set_outer_position()`]: crate::window::Window::set_outer_position()
//! [`transform`]: https://developer.mozilla.org/en-US/docs/Web/CSS/transform
//! [`border`]: https://developer.mozilla.org/en-US/docs/Web/CSS/border
//! [`padding`]: https://developer.mozilla.org/en-US/docs/Web/CSS/padding
use crate::event::Event;
use crate::event_loop::EventLoop;
use crate::event_loop::EventLoopWindowTarget;
use crate::window::{Window, WindowBuilder};
use crate::SendSyncWrapper;
use web_sys::HtmlCanvasElement;
pub trait WindowExtWebSys {
/// Only returns the canvas if called from inside the window.
fn canvas(&self) -> Option<HtmlCanvasElement>;
}
impl WindowExtWebSys for Window {
#[inline]
fn canvas(&self) -> Option<HtmlCanvasElement> {
self.window.canvas()
}
}
pub trait WindowBuilderExtWebSys {
/// Pass an [`HtmlCanvasElement`] to be used for this [`Window`]. If [`None`],
/// [`WindowBuilder::build()`] will create one.
///
/// In any case, the canvas won't be automatically inserted into the web page.
///
/// [`None`] by default.
fn with_canvas(self, canvas: Option<HtmlCanvasElement>) -> Self;
/// Whether `event.preventDefault` should be automatically called to prevent event propagation
/// when appropriate.
///
/// For example, mouse wheel events are only handled by the canvas by default. This avoids
/// the default behavior of scrolling the page.
///
/// Some events are impossible to prevent. E.g. Firefox allows to access the native browser
/// context menu with Shift+Rightclick.
///
/// Enabled by default.
fn with_prevent_default(self, prevent_default: bool) -> Self;
/// Whether the canvas should be focusable using the tab key. This is necessary to capture
/// canvas keyboard events.
///
/// Enabled by default.
fn with_focusable(self, focusable: bool) -> Self;
/// On window creation, append the canvas element to the web page if it isn't already.
///
/// Disabled by default.
fn with_append(self, append: bool) -> Self;
}
impl WindowBuilderExtWebSys for WindowBuilder {
fn with_canvas(mut self, canvas: Option<HtmlCanvasElement>) -> Self {
self.platform_specific.canvas = SendSyncWrapper(canvas);
self
}
fn with_prevent_default(mut self, prevent_default: bool) -> Self {
self.platform_specific.prevent_default = prevent_default;
self
}
fn with_focusable(mut self, focusable: bool) -> Self {
self.platform_specific.focusable = focusable;
self
}
fn with_append(mut self, append: bool) -> Self {
self.platform_specific.append = append;
self
}
}
/// Additional methods on `EventLoop` that are specific to the web.
pub trait EventLoopExtWebSys {
/// A type provided by the user that can be passed through `Event::UserEvent`.
type UserEvent;
/// Initializes the winit event loop.
///
/// Unlike
#[cfg_attr(
all(wasm_platform, target_feature = "exception-handling"),
doc = "`run()`"
)]
#[cfg_attr(
not(all(wasm_platform, target_feature = "exception-handling")),
doc = "[`run()`]"
)]
/// [^1], this returns immediately, and doesn't throw an exception in order to
/// satisfy its [`!`] return type.
///
/// Once the event loop has been destroyed, it's possible to reinitialize another event loop
/// by calling this function again. This can be useful if you want to recreate the event loop
/// while the WebAssembly module is still loaded. For example, this can be used to recreate the
/// event loop when switching between tabs on a single page application.
///
#[cfg_attr(
not(all(wasm_platform, target_feature = "exception-handling")),
doc = "[`run()`]: EventLoop::run()"
)]
/// [^1]: `run()` is _not_ available on WASM when the target supports `exception-handling`.
fn spawn<F>(self, event_handler: F)
where
F: 'static + FnMut(Event<Self::UserEvent>, &EventLoopWindowTarget<Self::UserEvent>);
}
impl<T> EventLoopExtWebSys for EventLoop<T> {
type UserEvent = T;
fn spawn<F>(self, event_handler: F)
where
F: 'static + FnMut(Event<Self::UserEvent>, &EventLoopWindowTarget<Self::UserEvent>),
{
self.event_loop.spawn(event_handler)
}
}
pub trait EventLoopWindowTargetExtWebSys {
/// Sets the strategy for [`ControlFlow::Poll`].
///
/// See [`PollStrategy`].
///
/// [`ControlFlow::Poll`]: crate::event_loop::ControlFlow::Poll
fn set_poll_strategy(&self, strategy: PollStrategy);
/// Gets the strategy for [`ControlFlow::Poll`].
///
/// See [`PollStrategy`].
///
/// [`ControlFlow::Poll`]: crate::event_loop::ControlFlow::Poll
fn poll_strategy(&self) -> PollStrategy;
}
impl<T> EventLoopWindowTargetExtWebSys for EventLoopWindowTarget<T> {
#[inline]
fn set_poll_strategy(&self, strategy: PollStrategy) {
self.p.set_poll_strategy(strategy);
}
#[inline]
fn poll_strategy(&self) -> PollStrategy {
self.p.poll_strategy()
}
}
/// Strategy used for [`ControlFlow::Poll`](crate::event_loop::ControlFlow::Poll).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum PollStrategy {
/// Uses [`Window.requestIdleCallback()`] to queue the next event loop. If not available
/// this will fallback to [`setTimeout()`].
///
/// This strategy will wait for the browser to enter an idle period before running and might
/// be affected by browser throttling.
///
/// [`Window.requestIdleCallback()`]: https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback
/// [`setTimeout()`]: https://developer.mozilla.org/en-US/docs/Web/API/setTimeout
IdleCallback,
/// Uses the [Prioritized Task Scheduling API] to queue the next event loop. If not available
/// this will fallback to [`setTimeout()`].
///
/// This strategy will run as fast as possible without disturbing users from interacting with
/// the page and is not affected by browser throttling.
///
/// This is the default strategy.
///
/// [Prioritized Task Scheduling API]: https://developer.mozilla.org/en-US/docs/Web/API/Prioritized_Task_Scheduling_API
/// [`setTimeout()`]: https://developer.mozilla.org/en-US/docs/Web/API/setTimeout
#[default]
Scheduler,
}
+354
View File
@@ -0,0 +1,354 @@
use std::{ffi::c_void, path::Path};
use crate::{
dpi::PhysicalSize,
event::{DeviceId, KeyEvent},
event_loop::EventLoopBuilder,
keyboard::Key,
monitor::MonitorHandle,
platform::modifier_supplement::KeyEventExtModifierSupplement,
platform_impl::WinIcon,
window::{BadIcon, Icon, Window, WindowBuilder},
};
/// Window Handle type used by Win32 API
pub type HWND = isize;
/// Menu Handle type used by Win32 API
pub type HMENU = isize;
/// Monitor Handle type used by Win32 API
pub type HMONITOR = isize;
/// Additional methods on `EventLoop` that are specific to Windows.
pub trait EventLoopBuilderExtWindows {
/// Whether to allow the event loop to be created off of the main thread.
///
/// By default, the window is only allowed to be created on the main
/// thread, to make platform compatibility easier.
///
/// # `Window` caveats
///
/// Note that any `Window` created on the new thread will be destroyed when the thread
/// terminates. Attempting to use a `Window` after its parent thread terminates has
/// unspecified, although explicitly not undefined, behavior.
fn with_any_thread(&mut self, any_thread: bool) -> &mut Self;
/// Whether to enable process-wide DPI awareness.
///
/// By default, `winit` will attempt to enable process-wide DPI awareness. If
/// that's undesirable, you can disable it with this function.
///
/// # Example
///
/// Disable process-wide DPI awareness.
///
/// ```
/// use winit::event_loop::EventLoopBuilder;
/// #[cfg(target_os = "windows")]
/// use winit::platform::windows::EventLoopBuilderExtWindows;
///
/// let mut builder = EventLoopBuilder::new();
/// #[cfg(target_os = "windows")]
/// builder.with_dpi_aware(false);
/// # if false { // We can't test this part
/// let event_loop = builder.build();
/// # }
/// ```
fn with_dpi_aware(&mut self, dpi_aware: bool) -> &mut Self;
/// A callback to be executed before dispatching a win32 message to the window procedure.
/// Return true to disable winit's internal message dispatching.
///
/// # Example
///
/// ```
/// # use windows_sys::Win32::UI::WindowsAndMessaging::{ACCEL, CreateAcceleratorTableW, TranslateAcceleratorW, DispatchMessageW, TranslateMessage, MSG};
/// use winit::event_loop::EventLoopBuilder;
/// #[cfg(target_os = "windows")]
/// use winit::platform::windows::EventLoopBuilderExtWindows;
///
/// let mut builder = EventLoopBuilder::new();
/// #[cfg(target_os = "windows")]
/// builder.with_msg_hook(|msg|{
/// let msg = msg as *const MSG;
/// # let accels: Vec<ACCEL> = Vec::new();
/// let translated = unsafe {
/// TranslateAcceleratorW(
/// (*msg).hwnd,
/// CreateAcceleratorTableW(accels.as_ptr() as _, 1),
/// msg,
/// ) == 1
/// };
/// translated
/// });
/// ```
fn with_msg_hook<F>(&mut self, callback: F) -> &mut Self
where
F: FnMut(*const c_void) -> bool + 'static;
}
impl<T> EventLoopBuilderExtWindows for EventLoopBuilder<T> {
#[inline]
fn with_any_thread(&mut self, any_thread: bool) -> &mut Self {
self.platform_specific.any_thread = any_thread;
self
}
#[inline]
fn with_dpi_aware(&mut self, dpi_aware: bool) -> &mut Self {
self.platform_specific.dpi_aware = dpi_aware;
self
}
#[inline]
fn with_msg_hook<F>(&mut self, callback: F) -> &mut Self
where
F: FnMut(*const c_void) -> bool + 'static,
{
self.platform_specific.msg_hook = Some(Box::new(callback));
self
}
}
/// Additional methods on `Window` that are specific to Windows.
pub trait WindowExtWindows {
/// Enables or disables mouse and keyboard input to the specified window.
///
/// A window must be enabled before it can be activated.
/// If an application has create a modal dialog box by disabling its owner window
/// (as described in [`WindowBuilderExtWindows::with_owner_window`]), the application must enable
/// the owner window before destroying the dialog box.
/// Otherwise, another window will receive the keyboard focus and be activated.
///
/// If a child window is disabled, it is ignored when the system tries to determine which
/// window should receive mouse messages.
///
/// For more information, see <https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-enablewindow#remarks>
/// and <https://docs.microsoft.com/en-us/windows/win32/winmsg/window-features#disabled-windows>
fn set_enable(&self, enabled: bool);
/// This sets `ICON_BIG`. A good ceiling here is 256x256.
fn set_taskbar_icon(&self, taskbar_icon: Option<Icon>);
/// Whether to show or hide the window icon in the taskbar.
fn set_skip_taskbar(&self, skip: bool);
/// Shows or hides the background drop shadow for undecorated windows.
///
/// Enabling the shadow causes a thin 1px line to appear on the top of the window.
fn set_undecorated_shadow(&self, shadow: bool);
}
impl WindowExtWindows for Window {
#[inline]
fn set_enable(&self, enabled: bool) {
self.window.set_enable(enabled)
}
#[inline]
fn set_taskbar_icon(&self, taskbar_icon: Option<Icon>) {
self.window.set_taskbar_icon(taskbar_icon)
}
#[inline]
fn set_skip_taskbar(&self, skip: bool) {
self.window.set_skip_taskbar(skip)
}
#[inline]
fn set_undecorated_shadow(&self, shadow: bool) {
self.window.set_undecorated_shadow(shadow)
}
}
/// Additional methods on `WindowBuilder` that are specific to Windows.
#[allow(rustdoc::broken_intra_doc_links)]
pub trait WindowBuilderExtWindows {
/// Set an owner to the window to be created. Can be used to create a dialog box, for example.
/// This only works when [`WindowBuilder::with_parent_window`] isn't called or set to `None`.
/// Can be used in combination with [`WindowExtWindows::set_enable(false)`](WindowExtWindows::set_enable)
/// on the owner window to create a modal dialog box.
///
/// From MSDN:
/// - An owned window is always above its owner in the z-order.
/// - The system automatically destroys an owned window when its owner is destroyed.
/// - An owned window is hidden when its owner is minimized.
///
/// For more information, see <https://docs.microsoft.com/en-us/windows/win32/winmsg/window-features#owned-windows>
fn with_owner_window(self, parent: HWND) -> Self;
/// Sets a menu on the window to be created.
///
/// Parent and menu are mutually exclusive; a child window cannot have a menu!
///
/// The menu must have been manually created beforehand with [`CreateMenu`] or similar.
///
/// Note: Dark mode cannot be supported for win32 menus, it's simply not possible to change how the menus look.
/// If you use this, it is recommended that you combine it with `with_theme(Some(Theme::Light))` to avoid a jarring effect.
///
/// [`CreateMenu`]: windows_sys::Win32::UI::WindowsAndMessaging::CreateMenu
fn with_menu(self, menu: HMENU) -> Self;
/// This sets `ICON_BIG`. A good ceiling here is 256x256.
fn with_taskbar_icon(self, taskbar_icon: Option<Icon>) -> Self;
/// This sets `WS_EX_NOREDIRECTIONBITMAP`.
fn with_no_redirection_bitmap(self, flag: bool) -> Self;
/// Enables or disables drag and drop support (enabled by default). Will interfere with other crates
/// that use multi-threaded COM API (`CoInitializeEx` with `COINIT_MULTITHREADED` instead of
/// `COINIT_APARTMENTTHREADED`) on the same thread. Note that winit may still attempt to initialize
/// COM API regardless of this option. Currently only fullscreen mode does that, but there may be more in the future.
/// If you need COM API with `COINIT_MULTITHREADED` you must initialize it before calling any winit functions.
/// See <https://docs.microsoft.com/en-us/windows/win32/api/objbase/nf-objbase-coinitialize#remarks> for more information.
fn with_drag_and_drop(self, flag: bool) -> Self;
/// Whether show or hide the window icon in the taskbar.
fn with_skip_taskbar(self, skip: bool) -> Self;
/// Customize the window class name.
fn with_class_name<S: Into<String>>(self, class_name: S) -> Self;
/// Shows or hides the background drop shadow for undecorated windows.
///
/// The shadow is hidden by default.
/// Enabling the shadow causes a thin 1px line to appear on the top of the window.
fn with_undecorated_shadow(self, shadow: bool) -> Self;
}
impl WindowBuilderExtWindows for WindowBuilder {
#[inline]
fn with_owner_window(mut self, parent: HWND) -> Self {
self.platform_specific.owner = Some(parent);
self
}
#[inline]
fn with_menu(mut self, menu: HMENU) -> Self {
self.platform_specific.menu = Some(menu);
self
}
#[inline]
fn with_taskbar_icon(mut self, taskbar_icon: Option<Icon>) -> Self {
self.platform_specific.taskbar_icon = taskbar_icon;
self
}
#[inline]
fn with_no_redirection_bitmap(mut self, flag: bool) -> Self {
self.platform_specific.no_redirection_bitmap = flag;
self
}
#[inline]
fn with_drag_and_drop(mut self, flag: bool) -> Self {
self.platform_specific.drag_and_drop = flag;
self
}
#[inline]
fn with_skip_taskbar(mut self, skip: bool) -> Self {
self.platform_specific.skip_taskbar = skip;
self
}
#[inline]
fn with_class_name<S: Into<String>>(mut self, class_name: S) -> Self {
self.platform_specific.class_name = class_name.into();
self
}
#[inline]
fn with_undecorated_shadow(mut self, shadow: bool) -> Self {
self.platform_specific.decoration_shadow = shadow;
self
}
}
/// Additional methods on `MonitorHandle` that are specific to Windows.
pub trait MonitorHandleExtWindows {
/// Returns the name of the monitor adapter specific to the Win32 API.
fn native_id(&self) -> String;
/// Returns the handle of the monitor - `HMONITOR`.
fn hmonitor(&self) -> HMONITOR;
}
impl MonitorHandleExtWindows for MonitorHandle {
#[inline]
fn native_id(&self) -> String {
self.inner.native_identifier()
}
#[inline]
fn hmonitor(&self) -> HMONITOR {
self.inner.hmonitor()
}
}
/// Additional methods on `DeviceId` that are specific to Windows.
pub trait DeviceIdExtWindows {
/// Returns an identifier that persistently refers to this specific device.
///
/// Will return `None` if the device is no longer available.
fn persistent_identifier(&self) -> Option<String>;
}
impl DeviceIdExtWindows for DeviceId {
#[inline]
fn persistent_identifier(&self) -> Option<String> {
self.0.persistent_identifier()
}
}
/// Additional methods on `Icon` that are specific to Windows.
pub trait IconExtWindows: Sized {
/// Create an icon from a file path.
///
/// Specify `size` to load a specific icon size from the file, or `None` to load the default
/// icon size from the file.
///
/// In cases where the specified size does not exist in the file, Windows may perform scaling
/// to get an icon of the desired size.
fn from_path<P: AsRef<Path>>(path: P, size: Option<PhysicalSize<u32>>)
-> Result<Self, BadIcon>;
/// Create an icon from a resource embedded in this executable or library.
///
/// Specify `size` to load a specific icon size from the file, or `None` to load the default
/// icon size from the file.
///
/// In cases where the specified size does not exist in the file, Windows may perform scaling
/// to get an icon of the desired size.
fn from_resource(ordinal: u16, size: Option<PhysicalSize<u32>>) -> Result<Self, BadIcon>;
}
impl IconExtWindows for Icon {
fn from_path<P: AsRef<Path>>(
path: P,
size: Option<PhysicalSize<u32>>,
) -> Result<Self, BadIcon> {
let win_icon = WinIcon::from_path(path, size)?;
Ok(Icon { inner: win_icon })
}
fn from_resource(ordinal: u16, size: Option<PhysicalSize<u32>>) -> Result<Self, BadIcon> {
let win_icon = WinIcon::from_resource(ordinal, size)?;
Ok(Icon { inner: win_icon })
}
}
impl KeyEventExtModifierSupplement for KeyEvent {
#[inline]
fn text_with_all_modifiers(&self) -> Option<&str> {
self.platform_specific
.text_with_all_modifers
.as_ref()
.map(|s| s.as_str())
}
#[inline]
fn key_without_modifiers(&self) -> Key {
self.platform_specific.key_without_modifiers.clone()
}
}
+198
View File
@@ -0,0 +1,198 @@
use crate::{
event_loop::{EventLoopBuilder, EventLoopWindowTarget},
monitor::MonitorHandle,
window::{Window, WindowBuilder},
};
use crate::dpi::Size;
use crate::platform_impl::{ApplicationName, Backend, XLIB_ERROR_HOOKS};
pub use crate::platform_impl::{x11::util::WindowType as XWindowType, XNotSupported};
/// The first argument in the provided hook will be the pointer to `XDisplay`
/// and the second one the pointer to [`XErrorEvent`]. The returned `bool` is an
/// indicator whether the error was handled by the callback.
///
/// [`XErrorEvent`]: https://linux.die.net/man/3/xerrorevent
pub type XlibErrorHook =
Box<dyn Fn(*mut std::ffi::c_void, *mut std::ffi::c_void) -> bool + Send + Sync>;
/// A unique identifer for an X11 visual.
pub type XVisualID = u32;
/// A unique identifier for an X11 window.
pub type XWindow = u32;
/// Hook to winit's xlib error handling callback.
///
/// This method is provided as a safe way to handle the errors comming from X11
/// when using xlib in external crates, like glutin for GLX access. Trying to
/// handle errors by speculating with `XSetErrorHandler` is [`unsafe`].
///
/// **Be aware that your hook is always invoked and returning `true` from it will
/// prevent `winit` from getting the error itself. It's wise to always return
/// `false` if you're not initiated the `Sync`.**
///
/// [`unsafe`]: https://www.remlab.net/op/xlib.shtml
#[inline]
pub fn register_xlib_error_hook(hook: XlibErrorHook) {
// Append new hook.
unsafe {
XLIB_ERROR_HOOKS.lock().unwrap().push(hook);
}
}
/// Additional methods on [`EventLoopWindowTarget`] that are specific to X11.
pub trait EventLoopWindowTargetExtX11 {
/// True if the [`EventLoopWindowTarget`] uses X11.
fn is_x11(&self) -> bool;
}
impl<T> EventLoopWindowTargetExtX11 for EventLoopWindowTarget<T> {
#[inline]
fn is_x11(&self) -> bool {
!self.p.is_wayland()
}
}
/// Additional methods on [`EventLoopBuilder`] that are specific to X11.
pub trait EventLoopBuilderExtX11 {
/// Force using X11.
fn with_x11(&mut self) -> &mut Self;
/// Whether to allow the event loop to be created off of the main thread.
///
/// By default, the window is only allowed to be created on the main
/// thread, to make platform compatibility easier.
fn with_any_thread(&mut self, any_thread: bool) -> &mut Self;
}
impl<T> EventLoopBuilderExtX11 for EventLoopBuilder<T> {
#[inline]
fn with_x11(&mut self) -> &mut Self {
self.platform_specific.forced_backend = Some(Backend::X);
self
}
#[inline]
fn with_any_thread(&mut self, any_thread: bool) -> &mut Self {
self.platform_specific.any_thread = any_thread;
self
}
}
/// Additional methods on [`Window`] that are specific to X11.
pub trait WindowExtX11 {}
impl WindowExtX11 for Window {}
/// Additional methods on [`WindowBuilder`] that are specific to X11.
pub trait WindowBuilderExtX11 {
/// Create this window with a specific X11 visual.
fn with_x11_visual(self, visual_id: XVisualID) -> Self;
fn with_x11_screen(self, screen_id: i32) -> Self;
/// Build window with the given `general` and `instance` names.
///
/// The `general` sets general class of `WM_CLASS(STRING)`, while `instance` set the
/// instance part of it. The resulted property looks like `WM_CLASS(STRING) = "instance", "general"`.
///
/// For details about application ID conventions, see the
/// [Desktop Entry Spec](https://specifications.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html#desktop-file-id)
fn with_name(self, general: impl Into<String>, instance: impl Into<String>) -> Self;
/// Build window with override-redirect flag; defaults to false. Only relevant on X11.
fn with_override_redirect(self, override_redirect: bool) -> Self;
/// Build window with `_NET_WM_WINDOW_TYPE` hints; defaults to `Normal`. Only relevant on X11.
fn with_x11_window_type(self, x11_window_type: Vec<XWindowType>) -> Self;
/// Build window with base size hint. Only implemented on X11.
///
/// ```
/// # use winit::dpi::{LogicalSize, PhysicalSize};
/// # use winit::window::WindowBuilder;
/// # use winit::platform::x11::WindowBuilderExtX11;
/// // Specify the size in logical dimensions like this:
/// WindowBuilder::new().with_base_size(LogicalSize::new(400.0, 200.0));
///
/// // Or specify the size in physical dimensions like this:
/// WindowBuilder::new().with_base_size(PhysicalSize::new(400, 200));
/// ```
fn with_base_size<S: Into<Size>>(self, base_size: S) -> Self;
/// Embed this window into another parent window.
///
/// # Example
///
/// ```no_run
/// use winit::window::WindowBuilder;
/// use winit::platform::x11::{XWindow, WindowBuilderExtX11};
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let event_loop = winit::event_loop::EventLoop::new().unwrap();
/// let parent_window_id = std::env::args().nth(1).unwrap().parse::<XWindow>()?;
/// let window = WindowBuilder::new()
/// .with_embed_parent_window(parent_window_id)
/// .build(&event_loop)?;
/// # Ok(()) }
/// ```
fn with_embed_parent_window(self, parent_window_id: XWindow) -> Self;
}
impl WindowBuilderExtX11 for WindowBuilder {
#[inline]
fn with_x11_visual(mut self, visual_id: XVisualID) -> Self {
self.platform_specific.x11.visual_id = Some(visual_id);
self
}
#[inline]
fn with_x11_screen(mut self, screen_id: i32) -> Self {
self.platform_specific.x11.screen_id = Some(screen_id);
self
}
#[inline]
fn with_name(mut self, general: impl Into<String>, instance: impl Into<String>) -> Self {
self.platform_specific.name = Some(ApplicationName::new(general.into(), instance.into()));
self
}
#[inline]
fn with_override_redirect(mut self, override_redirect: bool) -> Self {
self.platform_specific.x11.override_redirect = override_redirect;
self
}
#[inline]
fn with_x11_window_type(mut self, x11_window_types: Vec<XWindowType>) -> Self {
self.platform_specific.x11.x11_window_types = x11_window_types;
self
}
#[inline]
fn with_base_size<S: Into<Size>>(mut self, base_size: S) -> Self {
self.platform_specific.x11.base_size = Some(base_size.into());
self
}
#[inline]
fn with_embed_parent_window(mut self, parent_window_id: XWindow) -> Self {
self.platform_specific.x11.embed_window = Some(parent_window_id);
self
}
}
/// Additional methods on `MonitorHandle` that are specific to X11.
pub trait MonitorHandleExtX11 {
/// Returns the inner identifier of the monitor.
fn native_id(&self) -> u32;
}
impl MonitorHandleExtX11 for MonitorHandle {
#[inline]
fn native_id(&self) -> u32 {
self.inner.native_identifier()
}
}
+607
View File
@@ -0,0 +1,607 @@
use android_activity::{
input::{KeyAction, KeyEvent, KeyMapChar, Keycode},
AndroidApp,
};
use crate::keyboard::{Key, KeyCode, KeyLocation, NamedKey, NativeKey, NativeKeyCode, PhysicalKey};
pub fn to_physical_key(keycode: Keycode) -> PhysicalKey {
PhysicalKey::Code(match keycode {
Keycode::A => KeyCode::KeyA,
Keycode::B => KeyCode::KeyB,
Keycode::C => KeyCode::KeyC,
Keycode::D => KeyCode::KeyD,
Keycode::E => KeyCode::KeyE,
Keycode::F => KeyCode::KeyF,
Keycode::G => KeyCode::KeyG,
Keycode::H => KeyCode::KeyH,
Keycode::I => KeyCode::KeyI,
Keycode::J => KeyCode::KeyJ,
Keycode::K => KeyCode::KeyK,
Keycode::L => KeyCode::KeyL,
Keycode::M => KeyCode::KeyM,
Keycode::N => KeyCode::KeyN,
Keycode::O => KeyCode::KeyO,
Keycode::P => KeyCode::KeyP,
Keycode::Q => KeyCode::KeyQ,
Keycode::R => KeyCode::KeyR,
Keycode::S => KeyCode::KeyS,
Keycode::T => KeyCode::KeyT,
Keycode::U => KeyCode::KeyU,
Keycode::V => KeyCode::KeyV,
Keycode::W => KeyCode::KeyW,
Keycode::X => KeyCode::KeyX,
Keycode::Y => KeyCode::KeyY,
Keycode::Z => KeyCode::KeyZ,
Keycode::Keycode0 => KeyCode::Digit0,
Keycode::Keycode1 => KeyCode::Digit1,
Keycode::Keycode2 => KeyCode::Digit2,
Keycode::Keycode3 => KeyCode::Digit3,
Keycode::Keycode4 => KeyCode::Digit4,
Keycode::Keycode5 => KeyCode::Digit5,
Keycode::Keycode6 => KeyCode::Digit6,
Keycode::Keycode7 => KeyCode::Digit7,
Keycode::Keycode8 => KeyCode::Digit8,
Keycode::Keycode9 => KeyCode::Digit9,
Keycode::Numpad0 => KeyCode::Numpad0,
Keycode::Numpad1 => KeyCode::Numpad1,
Keycode::Numpad2 => KeyCode::Numpad2,
Keycode::Numpad3 => KeyCode::Numpad3,
Keycode::Numpad4 => KeyCode::Numpad4,
Keycode::Numpad5 => KeyCode::Numpad5,
Keycode::Numpad6 => KeyCode::Numpad6,
Keycode::Numpad7 => KeyCode::Numpad7,
Keycode::Numpad8 => KeyCode::Numpad8,
Keycode::Numpad9 => KeyCode::Numpad9,
Keycode::NumpadAdd => KeyCode::NumpadAdd,
Keycode::NumpadSubtract => KeyCode::NumpadSubtract,
Keycode::NumpadMultiply => KeyCode::NumpadMultiply,
Keycode::NumpadDivide => KeyCode::NumpadDivide,
Keycode::NumpadEnter => KeyCode::NumpadEnter,
Keycode::NumpadEquals => KeyCode::NumpadEqual,
Keycode::NumpadComma => KeyCode::NumpadComma,
Keycode::NumpadDot => KeyCode::NumpadDecimal,
Keycode::NumLock => KeyCode::NumLock,
Keycode::DpadLeft => KeyCode::ArrowLeft,
Keycode::DpadRight => KeyCode::ArrowRight,
Keycode::DpadUp => KeyCode::ArrowUp,
Keycode::DpadDown => KeyCode::ArrowDown,
Keycode::F1 => KeyCode::F1,
Keycode::F2 => KeyCode::F2,
Keycode::F3 => KeyCode::F3,
Keycode::F4 => KeyCode::F4,
Keycode::F5 => KeyCode::F5,
Keycode::F6 => KeyCode::F6,
Keycode::F7 => KeyCode::F7,
Keycode::F8 => KeyCode::F8,
Keycode::F9 => KeyCode::F9,
Keycode::F10 => KeyCode::F10,
Keycode::F11 => KeyCode::F11,
Keycode::F12 => KeyCode::F12,
Keycode::Space => KeyCode::Space,
Keycode::Escape => KeyCode::Escape,
Keycode::Enter => KeyCode::Enter, // not on the Numpad
Keycode::Tab => KeyCode::Tab,
Keycode::PageUp => KeyCode::PageUp,
Keycode::PageDown => KeyCode::PageDown,
Keycode::MoveHome => KeyCode::Home,
Keycode::MoveEnd => KeyCode::End,
Keycode::Insert => KeyCode::Insert,
Keycode::Del => KeyCode::Backspace, // Backspace (above Enter)
Keycode::ForwardDel => KeyCode::Delete, // Delete (below Insert)
Keycode::Copy => KeyCode::Copy,
Keycode::Paste => KeyCode::Paste,
Keycode::Cut => KeyCode::Cut,
Keycode::VolumeUp => KeyCode::AudioVolumeUp,
Keycode::VolumeDown => KeyCode::AudioVolumeDown,
Keycode::VolumeMute => KeyCode::AudioVolumeMute,
//Keycode::Mute => None, // Microphone mute
Keycode::MediaPlayPause => KeyCode::MediaPlayPause,
Keycode::MediaStop => KeyCode::MediaStop,
Keycode::MediaNext => KeyCode::MediaTrackNext,
Keycode::MediaPrevious => KeyCode::MediaTrackPrevious,
Keycode::Plus => KeyCode::Equal,
Keycode::Minus => KeyCode::Minus,
// Winit doesn't differentiate both '+' and '=', considering they are usually
// on the same physical key
Keycode::Equals => KeyCode::Equal,
Keycode::Semicolon => KeyCode::Semicolon,
Keycode::Slash => KeyCode::Slash,
Keycode::Backslash => KeyCode::Backslash,
Keycode::Comma => KeyCode::Comma,
Keycode::Period => KeyCode::Period,
Keycode::Apostrophe => KeyCode::Quote,
Keycode::Grave => KeyCode::Backquote,
// Winit doesn't expose a SysRq code, so map to PrintScreen since it's
// usually the same physical key
Keycode::Sysrq => KeyCode::PrintScreen,
// These are usually the same (Pause/Break)
Keycode::Break => KeyCode::Pause,
// These are exactly the same
Keycode::ScrollLock => KeyCode::ScrollLock,
Keycode::Yen => KeyCode::IntlYen,
Keycode::Kana => KeyCode::Lang1,
Keycode::KatakanaHiragana => KeyCode::KanaMode,
Keycode::CtrlLeft => KeyCode::ControlLeft,
Keycode::CtrlRight => KeyCode::ControlRight,
Keycode::ShiftLeft => KeyCode::ShiftLeft,
Keycode::ShiftRight => KeyCode::ShiftRight,
Keycode::AltLeft => KeyCode::AltLeft,
Keycode::AltRight => KeyCode::AltRight,
Keycode::MetaLeft => KeyCode::SuperLeft,
Keycode::MetaRight => KeyCode::SuperRight,
Keycode::LeftBracket => KeyCode::BracketLeft,
Keycode::RightBracket => KeyCode::BracketRight,
Keycode::Power => KeyCode::Power,
Keycode::Sleep => KeyCode::Sleep, // what about SoftSleep?
Keycode::Wakeup => KeyCode::WakeUp,
keycode => return PhysicalKey::Unidentified(NativeKeyCode::Android(keycode.into())),
})
}
/// Tries to map the `key_event` to a `KeyMapChar` containing a unicode character or dead key accent
///
/// This takes a `KeyEvent` and looks up its corresponding `KeyCharacterMap` and
/// uses that to try and map the `key_code` + `meta_state` to a unicode
/// character or a dead key that can be combined with the next key press.
pub fn character_map_and_combine_key(
app: &AndroidApp,
key_event: &KeyEvent<'_>,
combining_accent: &mut Option<char>,
) -> Option<KeyMapChar> {
let device_id = key_event.device_id();
let key_map = match app.device_key_character_map(device_id) {
Ok(key_map) => key_map,
Err(err) => {
log::warn!("Failed to look up `KeyCharacterMap` for device {device_id}: {err:?}");
return None;
}
};
match key_map.get(key_event.key_code(), key_event.meta_state()) {
Ok(KeyMapChar::Unicode(unicode)) => {
// Only do dead key combining on key down
if key_event.action() == KeyAction::Down {
let combined_unicode = if let Some(accent) = combining_accent {
match key_map.get_dead_char(*accent, unicode) {
Ok(Some(key)) => Some(key),
Ok(None) => None,
Err(err) => {
log::warn!("KeyEvent: Failed to combine 'dead key' accent '{accent}' with '{unicode}': {err:?}");
None
}
}
} else {
Some(unicode)
};
*combining_accent = None;
combined_unicode.map(KeyMapChar::Unicode)
} else {
Some(KeyMapChar::Unicode(unicode))
}
}
Ok(KeyMapChar::CombiningAccent(accent)) => {
if key_event.action() == KeyAction::Down {
*combining_accent = Some(accent);
}
Some(KeyMapChar::CombiningAccent(accent))
}
Ok(KeyMapChar::None) => {
// Leave any combining_accent state in tact (seems to match how other
// Android apps work)
None
}
Err(err) => {
log::warn!("KeyEvent: Failed to get key map character: {err:?}");
*combining_accent = None;
None
}
}
}
pub fn to_logical(key_char: Option<KeyMapChar>, keycode: Keycode) -> Key {
use android_activity::input::Keycode::*;
let native = NativeKey::Android(keycode.into());
match key_char {
Some(KeyMapChar::Unicode(c)) => Key::Character(smol_str::SmolStr::from_iter([c])),
Some(KeyMapChar::CombiningAccent(c)) => Key::Dead(Some(c)),
None | Some(KeyMapChar::None) => match keycode {
// Using `BrowserHome` instead of `GoHome` according to
// https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values
Home => Key::Named(NamedKey::BrowserHome),
Back => Key::Named(NamedKey::BrowserBack),
Call => Key::Named(NamedKey::Call),
Endcall => Key::Named(NamedKey::EndCall),
//-------------------------------------------------------------------------------
// These should be redundant because they should have already been matched
// as `KeyMapChar::Unicode`, but also matched here as a fallback
Keycode0 => Key::Character("0".into()),
Keycode1 => Key::Character("1".into()),
Keycode2 => Key::Character("2".into()),
Keycode3 => Key::Character("3".into()),
Keycode4 => Key::Character("4".into()),
Keycode5 => Key::Character("5".into()),
Keycode6 => Key::Character("6".into()),
Keycode7 => Key::Character("7".into()),
Keycode8 => Key::Character("8".into()),
Keycode9 => Key::Character("9".into()),
Star => Key::Character("*".into()),
Pound => Key::Character("#".into()),
A => Key::Character("a".into()),
B => Key::Character("b".into()),
C => Key::Character("c".into()),
D => Key::Character("d".into()),
E => Key::Character("e".into()),
F => Key::Character("f".into()),
G => Key::Character("g".into()),
H => Key::Character("h".into()),
I => Key::Character("i".into()),
J => Key::Character("j".into()),
K => Key::Character("k".into()),
L => Key::Character("l".into()),
M => Key::Character("m".into()),
N => Key::Character("n".into()),
O => Key::Character("o".into()),
P => Key::Character("p".into()),
Q => Key::Character("q".into()),
R => Key::Character("r".into()),
S => Key::Character("s".into()),
T => Key::Character("t".into()),
U => Key::Character("u".into()),
V => Key::Character("v".into()),
W => Key::Character("w".into()),
X => Key::Character("x".into()),
Y => Key::Character("y".into()),
Z => Key::Character("z".into()),
Comma => Key::Character(",".into()),
Period => Key::Character(".".into()),
Grave => Key::Character("`".into()),
Minus => Key::Character("-".into()),
Equals => Key::Character("=".into()),
LeftBracket => Key::Character("[".into()),
RightBracket => Key::Character("]".into()),
Backslash => Key::Character("\\".into()),
Semicolon => Key::Character(";".into()),
Apostrophe => Key::Character("'".into()),
Slash => Key::Character("/".into()),
At => Key::Character("@".into()),
Plus => Key::Character("+".into()),
//-------------------------------------------------------------------------------
DpadUp => Key::Named(NamedKey::ArrowUp),
DpadDown => Key::Named(NamedKey::ArrowDown),
DpadLeft => Key::Named(NamedKey::ArrowLeft),
DpadRight => Key::Named(NamedKey::ArrowRight),
DpadCenter => Key::Named(NamedKey::Enter),
VolumeUp => Key::Named(NamedKey::AudioVolumeUp),
VolumeDown => Key::Named(NamedKey::AudioVolumeDown),
Power => Key::Named(NamedKey::Power),
Camera => Key::Named(NamedKey::Camera),
Clear => Key::Named(NamedKey::Clear),
AltLeft => Key::Named(NamedKey::Alt),
AltRight => Key::Named(NamedKey::Alt),
ShiftLeft => Key::Named(NamedKey::Shift),
ShiftRight => Key::Named(NamedKey::Shift),
Tab => Key::Named(NamedKey::Tab),
Space => Key::Named(NamedKey::Space),
Sym => Key::Named(NamedKey::Symbol),
Explorer => Key::Named(NamedKey::LaunchWebBrowser),
Envelope => Key::Named(NamedKey::LaunchMail),
Enter => Key::Named(NamedKey::Enter),
Del => Key::Named(NamedKey::Backspace),
// According to https://developer.android.com/reference/android/view/KeyEvent#KEYCODE_NUM
Num => Key::Named(NamedKey::Alt),
Headsethook => Key::Named(NamedKey::HeadsetHook),
Focus => Key::Named(NamedKey::CameraFocus),
Notification => Key::Named(NamedKey::Notification),
Search => Key::Named(NamedKey::BrowserSearch),
MediaPlayPause => Key::Named(NamedKey::MediaPlayPause),
MediaStop => Key::Named(NamedKey::MediaStop),
MediaNext => Key::Named(NamedKey::MediaTrackNext),
MediaPrevious => Key::Named(NamedKey::MediaTrackPrevious),
MediaRewind => Key::Named(NamedKey::MediaRewind),
MediaFastForward => Key::Named(NamedKey::MediaFastForward),
Mute => Key::Named(NamedKey::MicrophoneVolumeMute),
PageUp => Key::Named(NamedKey::PageUp),
PageDown => Key::Named(NamedKey::PageDown),
Escape => Key::Named(NamedKey::Escape),
ForwardDel => Key::Named(NamedKey::Delete),
CtrlLeft => Key::Named(NamedKey::Control),
CtrlRight => Key::Named(NamedKey::Control),
CapsLock => Key::Named(NamedKey::CapsLock),
ScrollLock => Key::Named(NamedKey::ScrollLock),
MetaLeft => Key::Named(NamedKey::Super),
MetaRight => Key::Named(NamedKey::Super),
Function => Key::Named(NamedKey::Fn),
Sysrq => Key::Named(NamedKey::PrintScreen),
Break => Key::Named(NamedKey::Pause),
MoveHome => Key::Named(NamedKey::Home),
MoveEnd => Key::Named(NamedKey::End),
Insert => Key::Named(NamedKey::Insert),
Forward => Key::Named(NamedKey::BrowserForward),
MediaPlay => Key::Named(NamedKey::MediaPlay),
MediaPause => Key::Named(NamedKey::MediaPause),
MediaClose => Key::Named(NamedKey::MediaClose),
MediaEject => Key::Named(NamedKey::Eject),
MediaRecord => Key::Named(NamedKey::MediaRecord),
F1 => Key::Named(NamedKey::F1),
F2 => Key::Named(NamedKey::F2),
F3 => Key::Named(NamedKey::F3),
F4 => Key::Named(NamedKey::F4),
F5 => Key::Named(NamedKey::F5),
F6 => Key::Named(NamedKey::F6),
F7 => Key::Named(NamedKey::F7),
F8 => Key::Named(NamedKey::F8),
F9 => Key::Named(NamedKey::F9),
F10 => Key::Named(NamedKey::F10),
F11 => Key::Named(NamedKey::F11),
F12 => Key::Named(NamedKey::F12),
NumLock => Key::Named(NamedKey::NumLock),
Numpad0 => Key::Character("0".into()),
Numpad1 => Key::Character("1".into()),
Numpad2 => Key::Character("2".into()),
Numpad3 => Key::Character("3".into()),
Numpad4 => Key::Character("4".into()),
Numpad5 => Key::Character("5".into()),
Numpad6 => Key::Character("6".into()),
Numpad7 => Key::Character("7".into()),
Numpad8 => Key::Character("8".into()),
Numpad9 => Key::Character("9".into()),
NumpadDivide => Key::Character("/".into()),
NumpadMultiply => Key::Character("*".into()),
NumpadSubtract => Key::Character("-".into()),
NumpadAdd => Key::Character("+".into()),
NumpadDot => Key::Character(".".into()),
NumpadComma => Key::Character(",".into()),
NumpadEnter => Key::Named(NamedKey::Enter),
NumpadEquals => Key::Character("=".into()),
NumpadLeftParen => Key::Character("(".into()),
NumpadRightParen => Key::Character(")".into()),
VolumeMute => Key::Named(NamedKey::AudioVolumeMute),
Info => Key::Named(NamedKey::Info),
ChannelUp => Key::Named(NamedKey::ChannelUp),
ChannelDown => Key::Named(NamedKey::ChannelDown),
ZoomIn => Key::Named(NamedKey::ZoomIn),
ZoomOut => Key::Named(NamedKey::ZoomOut),
Tv => Key::Named(NamedKey::TV),
Guide => Key::Named(NamedKey::Guide),
Dvr => Key::Named(NamedKey::DVR),
Bookmark => Key::Named(NamedKey::BrowserFavorites),
Captions => Key::Named(NamedKey::ClosedCaptionToggle),
Settings => Key::Named(NamedKey::Settings),
TvPower => Key::Named(NamedKey::TVPower),
TvInput => Key::Named(NamedKey::TVInput),
StbPower => Key::Named(NamedKey::STBPower),
StbInput => Key::Named(NamedKey::STBInput),
AvrPower => Key::Named(NamedKey::AVRPower),
AvrInput => Key::Named(NamedKey::AVRInput),
ProgRed => Key::Named(NamedKey::ColorF0Red),
ProgGreen => Key::Named(NamedKey::ColorF1Green),
ProgYellow => Key::Named(NamedKey::ColorF2Yellow),
ProgBlue => Key::Named(NamedKey::ColorF3Blue),
AppSwitch => Key::Named(NamedKey::AppSwitch),
LanguageSwitch => Key::Named(NamedKey::GroupNext),
MannerMode => Key::Named(NamedKey::MannerMode),
Keycode3dMode => Key::Named(NamedKey::TV3DMode),
Contacts => Key::Named(NamedKey::LaunchContacts),
Calendar => Key::Named(NamedKey::LaunchCalendar),
Music => Key::Named(NamedKey::LaunchMusicPlayer),
Calculator => Key::Named(NamedKey::LaunchApplication2),
ZenkakuHankaku => Key::Named(NamedKey::ZenkakuHankaku),
Eisu => Key::Named(NamedKey::Eisu),
Muhenkan => Key::Named(NamedKey::NonConvert),
Henkan => Key::Named(NamedKey::Convert),
KatakanaHiragana => Key::Named(NamedKey::HiraganaKatakana),
Kana => Key::Named(NamedKey::KanjiMode),
BrightnessDown => Key::Named(NamedKey::BrightnessDown),
BrightnessUp => Key::Named(NamedKey::BrightnessUp),
MediaAudioTrack => Key::Named(NamedKey::MediaAudioTrack),
Sleep => Key::Named(NamedKey::Standby),
Wakeup => Key::Named(NamedKey::WakeUp),
Pairing => Key::Named(NamedKey::Pairing),
MediaTopMenu => Key::Named(NamedKey::MediaTopMenu),
LastChannel => Key::Named(NamedKey::MediaLast),
TvDataService => Key::Named(NamedKey::TVDataService),
VoiceAssist => Key::Named(NamedKey::VoiceDial),
TvRadioService => Key::Named(NamedKey::TVRadioService),
TvTeletext => Key::Named(NamedKey::Teletext),
TvNumberEntry => Key::Named(NamedKey::TVNumberEntry),
TvTerrestrialAnalog => Key::Named(NamedKey::TVTerrestrialAnalog),
TvTerrestrialDigital => Key::Named(NamedKey::TVTerrestrialDigital),
TvSatellite => Key::Named(NamedKey::TVSatellite),
TvSatelliteBs => Key::Named(NamedKey::TVSatelliteBS),
TvSatelliteCs => Key::Named(NamedKey::TVSatelliteCS),
TvSatelliteService => Key::Named(NamedKey::TVSatelliteToggle),
TvNetwork => Key::Named(NamedKey::TVNetwork),
TvAntennaCable => Key::Named(NamedKey::TVAntennaCable),
TvInputHdmi1 => Key::Named(NamedKey::TVInputHDMI1),
TvInputHdmi2 => Key::Named(NamedKey::TVInputHDMI2),
TvInputHdmi3 => Key::Named(NamedKey::TVInputHDMI3),
TvInputHdmi4 => Key::Named(NamedKey::TVInputHDMI4),
TvInputComposite1 => Key::Named(NamedKey::TVInputComposite1),
TvInputComposite2 => Key::Named(NamedKey::TVInputComposite2),
TvInputComponent1 => Key::Named(NamedKey::TVInputComponent1),
TvInputComponent2 => Key::Named(NamedKey::TVInputComponent2),
TvInputVga1 => Key::Named(NamedKey::TVInputVGA1),
TvAudioDescription => Key::Named(NamedKey::TVAudioDescription),
TvAudioDescriptionMixUp => Key::Named(NamedKey::TVAudioDescriptionMixUp),
TvAudioDescriptionMixDown => Key::Named(NamedKey::TVAudioDescriptionMixDown),
TvZoomMode => Key::Named(NamedKey::ZoomToggle),
TvContentsMenu => Key::Named(NamedKey::TVContentsMenu),
TvMediaContextMenu => Key::Named(NamedKey::TVMediaContext),
TvTimerProgramming => Key::Named(NamedKey::TVTimer),
Help => Key::Named(NamedKey::Help),
NavigatePrevious => Key::Named(NamedKey::NavigatePrevious),
NavigateNext => Key::Named(NamedKey::NavigateNext),
NavigateIn => Key::Named(NamedKey::NavigateIn),
NavigateOut => Key::Named(NamedKey::NavigateOut),
MediaSkipForward => Key::Named(NamedKey::MediaSkipForward),
MediaSkipBackward => Key::Named(NamedKey::MediaSkipBackward),
MediaStepForward => Key::Named(NamedKey::MediaStepForward),
MediaStepBackward => Key::Named(NamedKey::MediaStepBackward),
Cut => Key::Named(NamedKey::Cut),
Copy => Key::Named(NamedKey::Copy),
Paste => Key::Named(NamedKey::Paste),
Refresh => Key::Named(NamedKey::BrowserRefresh),
// -----------------------------------------------------------------
// Keycodes that don't have a logical Key mapping
// -----------------------------------------------------------------
Unknown => Key::Unidentified(native),
// Can be added on demand
SoftLeft => Key::Unidentified(native),
SoftRight => Key::Unidentified(native),
Menu => Key::Unidentified(native),
Pictsymbols => Key::Unidentified(native),
SwitchCharset => Key::Unidentified(native),
// -----------------------------------------------------------------
// Gamepad events should be exposed through a separate API, not
// keyboard events
ButtonA => Key::Unidentified(native),
ButtonB => Key::Unidentified(native),
ButtonC => Key::Unidentified(native),
ButtonX => Key::Unidentified(native),
ButtonY => Key::Unidentified(native),
ButtonZ => Key::Unidentified(native),
ButtonL1 => Key::Unidentified(native),
ButtonR1 => Key::Unidentified(native),
ButtonL2 => Key::Unidentified(native),
ButtonR2 => Key::Unidentified(native),
ButtonThumbl => Key::Unidentified(native),
ButtonThumbr => Key::Unidentified(native),
ButtonStart => Key::Unidentified(native),
ButtonSelect => Key::Unidentified(native),
ButtonMode => Key::Unidentified(native),
// -----------------------------------------------------------------
Window => Key::Unidentified(native),
Button1 => Key::Unidentified(native),
Button2 => Key::Unidentified(native),
Button3 => Key::Unidentified(native),
Button4 => Key::Unidentified(native),
Button5 => Key::Unidentified(native),
Button6 => Key::Unidentified(native),
Button7 => Key::Unidentified(native),
Button8 => Key::Unidentified(native),
Button9 => Key::Unidentified(native),
Button10 => Key::Unidentified(native),
Button11 => Key::Unidentified(native),
Button12 => Key::Unidentified(native),
Button13 => Key::Unidentified(native),
Button14 => Key::Unidentified(native),
Button15 => Key::Unidentified(native),
Button16 => Key::Unidentified(native),
Yen => Key::Unidentified(native),
Ro => Key::Unidentified(native),
Assist => Key::Unidentified(native),
Keycode11 => Key::Unidentified(native),
Keycode12 => Key::Unidentified(native),
StemPrimary => Key::Unidentified(native),
Stem1 => Key::Unidentified(native),
Stem2 => Key::Unidentified(native),
Stem3 => Key::Unidentified(native),
DpadUpLeft => Key::Unidentified(native),
DpadDownLeft => Key::Unidentified(native),
DpadUpRight => Key::Unidentified(native),
DpadDownRight => Key::Unidentified(native),
SoftSleep => Key::Unidentified(native),
SystemNavigationUp => Key::Unidentified(native),
SystemNavigationDown => Key::Unidentified(native),
SystemNavigationLeft => Key::Unidentified(native),
SystemNavigationRight => Key::Unidentified(native),
AllApps => Key::Unidentified(native),
ThumbsUp => Key::Unidentified(native),
ThumbsDown => Key::Unidentified(native),
ProfileSwitch => Key::Unidentified(native),
// It's always possible that new versions of Android could introduce
// key codes we can't know about at compile time.
_ => Key::Unidentified(native),
},
}
}
pub fn to_location(keycode: Keycode) -> KeyLocation {
use android_activity::input::Keycode::*;
match keycode {
AltLeft => KeyLocation::Left,
AltRight => KeyLocation::Right,
ShiftLeft => KeyLocation::Left,
ShiftRight => KeyLocation::Right,
// According to https://developer.android.com/reference/android/view/KeyEvent#KEYCODE_NUM
Num => KeyLocation::Left,
CtrlLeft => KeyLocation::Left,
CtrlRight => KeyLocation::Right,
MetaLeft => KeyLocation::Left,
MetaRight => KeyLocation::Right,
NumLock => KeyLocation::Numpad,
Numpad0 => KeyLocation::Numpad,
Numpad1 => KeyLocation::Numpad,
Numpad2 => KeyLocation::Numpad,
Numpad3 => KeyLocation::Numpad,
Numpad4 => KeyLocation::Numpad,
Numpad5 => KeyLocation::Numpad,
Numpad6 => KeyLocation::Numpad,
Numpad7 => KeyLocation::Numpad,
Numpad8 => KeyLocation::Numpad,
Numpad9 => KeyLocation::Numpad,
NumpadDivide => KeyLocation::Numpad,
NumpadMultiply => KeyLocation::Numpad,
NumpadSubtract => KeyLocation::Numpad,
NumpadAdd => KeyLocation::Numpad,
NumpadDot => KeyLocation::Numpad,
NumpadComma => KeyLocation::Numpad,
NumpadEnter => KeyLocation::Numpad,
NumpadEquals => KeyLocation::Numpad,
NumpadLeftParen => KeyLocation::Numpad,
NumpadRightParen => KeyLocation::Numpad,
_ => KeyLocation::Standard,
}
}
File diff suppressed because it is too large. Load diff
+968
View File
@@ -0,0 +1,968 @@
#![deny(unused_results)]
use std::{
cell::{RefCell, RefMut},
collections::HashSet,
mem,
os::raw::c_void,
ptr,
sync::{Arc, Mutex},
time::Instant,
};
use core_foundation::base::CFRelease;
use core_foundation::date::CFAbsoluteTimeGetCurrent;
use core_foundation::runloop::{
kCFRunLoopCommonModes, CFRunLoopAddTimer, CFRunLoopGetMain, CFRunLoopRef, CFRunLoopTimerCreate,
CFRunLoopTimerInvalidate, CFRunLoopTimerRef, CFRunLoopTimerSetNextFireDate,
};
use icrate::Foundation::{
CGRect, CGSize, MainThreadMarker, NSInteger, NSOperatingSystemVersion, NSProcessInfo,
};
use objc2::rc::Id;
use objc2::runtime::AnyObject;
use objc2::{msg_send, sel};
use once_cell::sync::Lazy;
use super::event_loop::{EventHandler, Never};
use super::uikit::UIView;
use super::view::WinitUIWindow;
use crate::{
dpi::PhysicalSize,
event::{Event, InnerSizeWriter, StartCause, WindowEvent},
event_loop::ControlFlow,
window::WindowId as RootWindowId,
};
macro_rules! bug {
($($msg:tt)*) => {
panic!("winit iOS bug, file an issue: {}", format!($($msg)*))
};
}
macro_rules! bug_assert {
($test:expr, $($msg:tt)*) => {
assert!($test, "winit iOS bug, file an issue: {}", format!($($msg)*))
};
}
#[derive(Debug)]
pub enum EventWrapper {
StaticEvent(Event<Never>),
ScaleFactorChanged(ScaleFactorChanged),
}
#[derive(Debug)]
pub struct ScaleFactorChanged {
pub(super) window: Id<WinitUIWindow>,
pub(super) suggested_size: PhysicalSize<u32>,
pub(super) scale_factor: f64,
}
enum UserCallbackTransitionResult<'a> {
Success {
event_handler: Box<dyn EventHandler>,
active_control_flow: ControlFlow,
processing_redraws: bool,
},
ReentrancyPrevented {
queued_events: &'a mut Vec<EventWrapper>,
},
}
impl Event<Never> {
fn is_redraw(&self) -> bool {
matches!(
self,
Event::WindowEvent {
event: WindowEvent::RedrawRequested,
..
}
)
}
}
// this is the state machine for the app lifecycle
#[derive(Debug)]
#[must_use = "dropping `AppStateImpl` without inspecting it is probably a bug"]
enum AppStateImpl {
NotLaunched {
queued_windows: Vec<Id<WinitUIWindow>>,
queued_events: Vec<EventWrapper>,
queued_gpu_redraws: HashSet<Id<WinitUIWindow>>,
},
Launching {
queued_windows: Vec<Id<WinitUIWindow>>,
queued_events: Vec<EventWrapper>,
queued_event_handler: Box<dyn EventHandler>,
queued_gpu_redraws: HashSet<Id<WinitUIWindow>>,
},
ProcessingEvents {
event_handler: Box<dyn EventHandler>,
queued_gpu_redraws: HashSet<Id<WinitUIWindow>>,
active_control_flow: ControlFlow,
},
// special state to deal with reentrancy and prevent mutable aliasing.
InUserCallback {
queued_events: Vec<EventWrapper>,
queued_gpu_redraws: HashSet<Id<WinitUIWindow>>,
},
ProcessingRedraws {
event_handler: Box<dyn EventHandler>,
active_control_flow: ControlFlow,
},
Waiting {
waiting_event_handler: Box<dyn EventHandler>,
start: Instant,
},
PollFinished {
waiting_event_handler: Box<dyn EventHandler>,
},
Terminated,
}
pub(crate) struct AppState {
// This should never be `None`, except for briefly during a state transition.
app_state: Option<AppStateImpl>,
control_flow: ControlFlow,
waker: EventLoopWaker,
}
impl AppState {
pub(crate) fn get_mut(_mtm: MainThreadMarker) -> RefMut<'static, AppState> {
// basically everything in UIKit requires the main thread, so it's pointless to use the
// std::sync APIs.
// must be mut because plain `static` requires `Sync`
static mut APP_STATE: RefCell<Option<AppState>> = RefCell::new(None);
let mut guard = unsafe { APP_STATE.borrow_mut() };
if guard.is_none() {
#[inline(never)]
#[cold]
fn init_guard(guard: &mut RefMut<'static, Option<AppState>>) {
let waker = EventLoopWaker::new(unsafe { CFRunLoopGetMain() });
**guard = Some(AppState {
app_state: Some(AppStateImpl::NotLaunched {
queued_windows: Vec::new(),
queued_events: Vec::new(),
queued_gpu_redraws: HashSet::new(),
}),
control_flow: ControlFlow::default(),
waker,
});
}
init_guard(&mut guard);
}
RefMut::map(guard, |state| state.as_mut().unwrap())
}
fn state(&self) -> &AppStateImpl {
match &self.app_state {
Some(ref state) => state,
None => bug!("`AppState` previously failed a state transition"),
}
}
fn state_mut(&mut self) -> &mut AppStateImpl {
match &mut self.app_state {
Some(ref mut state) => state,
None => bug!("`AppState` previously failed a state transition"),
}
}
fn take_state(&mut self) -> AppStateImpl {
match self.app_state.take() {
Some(state) => state,
None => bug!("`AppState` previously failed a state transition"),
}
}
fn set_state(&mut self, new_state: AppStateImpl) {
bug_assert!(
self.app_state.is_none(),
"attempted to set an `AppState` without calling `take_state` first {:?}",
self.app_state
);
self.app_state = Some(new_state)
}
fn replace_state(&mut self, new_state: AppStateImpl) -> AppStateImpl {
match &mut self.app_state {
Some(ref mut state) => mem::replace(state, new_state),
None => bug!("`AppState` previously failed a state transition"),
}
}
fn has_launched(&self) -> bool {
!matches!(
self.state(),
AppStateImpl::NotLaunched { .. } | AppStateImpl::Launching { .. }
)
}
fn has_terminated(&self) -> bool {
matches!(self.state(), AppStateImpl::Terminated)
}
fn will_launch_transition(&mut self, queued_event_handler: Box<dyn EventHandler>) {
let (queued_windows, queued_events, queued_gpu_redraws) = match self.take_state() {
AppStateImpl::NotLaunched {
queued_windows,
queued_events,
queued_gpu_redraws,
} => (queued_windows, queued_events, queued_gpu_redraws),
s => bug!("unexpected state {:?}", s),
};
self.set_state(AppStateImpl::Launching {
queued_windows,
queued_events,
queued_event_handler,
queued_gpu_redraws,
});
}
fn did_finish_launching_transition(&mut self) -> (Vec<Id<WinitUIWindow>>, Vec<EventWrapper>) {
let (windows, events, event_handler, queued_gpu_redraws) = match self.take_state() {
AppStateImpl::Launching {
queued_windows,
queued_events,
queued_event_handler,
queued_gpu_redraws,
} => (
queued_windows,
queued_events,
queued_event_handler,
queued_gpu_redraws,
),
s => bug!("unexpected state {:?}", s),
};
self.set_state(AppStateImpl::ProcessingEvents {
event_handler,
active_control_flow: self.control_flow,
queued_gpu_redraws,
});
(windows, events)
}
fn wakeup_transition(&mut self) -> Option<EventWrapper> {
// before `AppState::did_finish_launching` is called, pretend there is no running
// event loop.
if !self.has_launched() || self.has_terminated() {
return None;
}
let (event_handler, event) = match (self.control_flow, self.take_state()) {
(
ControlFlow::Poll,
AppStateImpl::PollFinished {
waiting_event_handler,
},
) => (
waiting_event_handler,
EventWrapper::StaticEvent(Event::NewEvents(StartCause::Poll)),
),
(
ControlFlow::Wait,
AppStateImpl::Waiting {
waiting_event_handler,
start,
},
) => (
waiting_event_handler,
EventWrapper::StaticEvent(Event::NewEvents(StartCause::WaitCancelled {
start,
requested_resume: None,
})),
),
(
ControlFlow::WaitUntil(requested_resume),
AppStateImpl::Waiting {
waiting_event_handler,
start,
},
) => {
let event = if Instant::now() >= requested_resume {
EventWrapper::StaticEvent(Event::NewEvents(StartCause::ResumeTimeReached {
start,
requested_resume,
}))
} else {
EventWrapper::StaticEvent(Event::NewEvents(StartCause::WaitCancelled {
start,
requested_resume: Some(requested_resume),
}))
};
(waiting_event_handler, event)
}
s => bug!("`EventHandler` unexpectedly woke up {:?}", s),
};
self.set_state(AppStateImpl::ProcessingEvents {
event_handler,
queued_gpu_redraws: Default::default(),
active_control_flow: self.control_flow,
});
Some(event)
}
fn try_user_callback_transition(&mut self) -> UserCallbackTransitionResult<'_> {
// If we're not able to process an event due to recursion or `Init` not having been sent out
// yet, then queue the events up.
match self.state_mut() {
&mut AppStateImpl::Launching {
ref mut queued_events,
..
}
| &mut AppStateImpl::NotLaunched {
ref mut queued_events,
..
}
| &mut AppStateImpl::InUserCallback {
ref mut queued_events,
..
} => {
// A lifetime cast: early returns are not currently handled well with NLL, but
// polonius handles them well. This transmute is a safe workaround.
return unsafe {
mem::transmute::<
UserCallbackTransitionResult<'_>,
UserCallbackTransitionResult<'_>,
>(UserCallbackTransitionResult::ReentrancyPrevented {
queued_events,
})
};
}
&mut AppStateImpl::ProcessingEvents { .. }
| &mut AppStateImpl::ProcessingRedraws { .. } => {}
s @ &mut AppStateImpl::PollFinished { .. }
| s @ &mut AppStateImpl::Waiting { .. }
| s @ &mut AppStateImpl::Terminated => {
bug!("unexpected attempted to process an event {:?}", s)
}
}
let (event_handler, queued_gpu_redraws, active_control_flow, processing_redraws) =
match self.take_state() {
AppStateImpl::Launching { .. }
| AppStateImpl::NotLaunched { .. }
| AppStateImpl::InUserCallback { .. } => unreachable!(),
AppStateImpl::ProcessingEvents {
event_handler,
queued_gpu_redraws,
active_control_flow,
} => (
event_handler,
queued_gpu_redraws,
active_control_flow,
false,
),
AppStateImpl::ProcessingRedraws {
event_handler,
active_control_flow,
} => (event_handler, Default::default(), active_control_flow, true),
AppStateImpl::PollFinished { .. }
| AppStateImpl::Waiting { .. }
| AppStateImpl::Terminated => unreachable!(),
};
self.set_state(AppStateImpl::InUserCallback {
queued_events: Vec::new(),
queued_gpu_redraws,
});
UserCallbackTransitionResult::Success {
event_handler,
active_control_flow,
processing_redraws,
}
}
fn main_events_cleared_transition(&mut self) -> HashSet<Id<WinitUIWindow>> {
let (event_handler, queued_gpu_redraws, active_control_flow) = match self.take_state() {
AppStateImpl::ProcessingEvents {
event_handler,
queued_gpu_redraws,
active_control_flow,
} => (event_handler, queued_gpu_redraws, active_control_flow),
s => bug!("unexpected state {:?}", s),
};
self.set_state(AppStateImpl::ProcessingRedraws {
event_handler,
active_control_flow,
});
queued_gpu_redraws
}
fn events_cleared_transition(&mut self) {
if !self.has_launched() || self.has_terminated() {
return;
}
let (waiting_event_handler, old) = match self.take_state() {
AppStateImpl::ProcessingRedraws {
event_handler,
active_control_flow,
} => (event_handler, active_control_flow),
s => bug!("unexpected state {:?}", s),
};
let new = self.control_flow;
match (old, new) {
(ControlFlow::Wait, ControlFlow::Wait) => {
let start = Instant::now();
self.set_state(AppStateImpl::Waiting {
waiting_event_handler,
start,
});
}
(ControlFlow::WaitUntil(old_instant), ControlFlow::WaitUntil(new_instant))
if old_instant == new_instant =>
{
let start = Instant::now();
self.set_state(AppStateImpl::Waiting {
waiting_event_handler,
start,
});
}
(_, ControlFlow::Wait) => {
let start = Instant::now();
self.set_state(AppStateImpl::Waiting {
waiting_event_handler,
start,
});
self.waker.stop()
}
(_, ControlFlow::WaitUntil(new_instant)) => {
let start = Instant::now();
self.set_state(AppStateImpl::Waiting {
waiting_event_handler,
start,
});
self.waker.start_at(new_instant)
}
// Unlike on macOS, handle Poll to Poll transition here to call the waker
(_, ControlFlow::Poll) => {
self.set_state(AppStateImpl::PollFinished {
waiting_event_handler,
});
self.waker.start()
}
}
}
fn terminated_transition(&mut self) -> Box<dyn EventHandler> {
match self.replace_state(AppStateImpl::Terminated) {
AppStateImpl::ProcessingEvents { event_handler, .. } => event_handler,
s => bug!("`LoopExiting` happened while not processing events {:?}", s),
}
}
pub(crate) fn set_control_flow(&mut self, control_flow: ControlFlow) {
self.control_flow = control_flow;
}
pub(crate) fn control_flow(&self) -> ControlFlow {
self.control_flow
}
}
pub(crate) fn set_key_window(mtm: MainThreadMarker, window: &Id<WinitUIWindow>) {
let mut this = AppState::get_mut(mtm);
match this.state_mut() {
&mut AppStateImpl::NotLaunched {
ref mut queued_windows,
..
} => return queued_windows.push(window.clone()),
&mut AppStateImpl::ProcessingEvents { .. }
| &mut AppStateImpl::InUserCallback { .. }
| &mut AppStateImpl::ProcessingRedraws { .. } => {}
s @ &mut AppStateImpl::Launching { .. }
| s @ &mut AppStateImpl::Waiting { .. }
| s @ &mut AppStateImpl::PollFinished { .. } => bug!("unexpected state {:?}", s),
&mut AppStateImpl::Terminated => {
panic!("Attempt to create a `Window` after the app has terminated")
}
}
drop(this);
window.makeKeyAndVisible();
}
pub(crate) fn queue_gl_or_metal_redraw(mtm: MainThreadMarker, window: Id<WinitUIWindow>) {
let mut this = AppState::get_mut(mtm);
match this.state_mut() {
&mut AppStateImpl::NotLaunched {
ref mut queued_gpu_redraws,
..
}
| &mut AppStateImpl::Launching {
ref mut queued_gpu_redraws,
..
}
| &mut AppStateImpl::ProcessingEvents {
ref mut queued_gpu_redraws,
..
}
| &mut AppStateImpl::InUserCallback {
ref mut queued_gpu_redraws,
..
} => {
let _ = queued_gpu_redraws.insert(window);
}
s @ &mut AppStateImpl::ProcessingRedraws { .. }
| s @ &mut AppStateImpl::Waiting { .. }
| s @ &mut AppStateImpl::PollFinished { .. } => bug!("unexpected state {:?}", s),
&mut AppStateImpl::Terminated => {
panic!("Attempt to create a `Window` after the app has terminated")
}
}
}
pub fn will_launch(mtm: MainThreadMarker, queued_event_handler: Box<dyn EventHandler>) {
AppState::get_mut(mtm).will_launch_transition(queued_event_handler)
}
pub fn did_finish_launching(mtm: MainThreadMarker) {
let mut this = AppState::get_mut(mtm);
let windows = match this.state_mut() {
AppStateImpl::Launching { queued_windows, .. } => mem::take(queued_windows),
s => bug!("unexpected state {:?}", s),
};
this.waker.start();
// have to drop RefMut because the window setup code below can trigger new events
drop(this);
for window in windows {
// Do a little screen dance here to account for windows being created before
// `UIApplicationMain` is called. This fixes visual issues such as being
// offcenter and sized incorrectly. Additionally, to fix orientation issues, we
// gotta reset the `rootViewController`.
//
// relevant iOS log:
// ```
// [ApplicationLifecycle] Windows were created before application initialzation
// completed. This may result in incorrect visual appearance.
// ```
let screen = window.screen();
let _: () = unsafe { msg_send![&window, setScreen: ptr::null::<AnyObject>()] };
window.setScreen(&screen);
let controller = window.rootViewController();
window.setRootViewController(None);
window.setRootViewController(controller.as_deref());
window.makeKeyAndVisible();
}
let (windows, events) = AppState::get_mut(mtm).did_finish_launching_transition();
let events = std::iter::once(EventWrapper::StaticEvent(Event::NewEvents(
StartCause::Init,
)))
.chain(events);
handle_nonuser_events(mtm, events);
// the above window dance hack, could possibly trigger new windows to be created.
// we can just set those windows up normally, as they were created after didFinishLaunching
for window in windows {
window.makeKeyAndVisible();
}
}
// AppState::did_finish_launching handles the special transition `Init`
pub fn handle_wakeup_transition(mtm: MainThreadMarker) {
let mut this = AppState::get_mut(mtm);
let wakeup_event = match this.wakeup_transition() {
None => return,
Some(wakeup_event) => wakeup_event,
};
drop(this);
handle_nonuser_event(mtm, wakeup_event)
}
pub(crate) fn handle_nonuser_event(mtm: MainThreadMarker, event: EventWrapper) {
handle_nonuser_events(mtm, std::iter::once(event))
}
pub(crate) fn handle_nonuser_events<I: IntoIterator<Item = EventWrapper>>(
mtm: MainThreadMarker,
events: I,
) {
let mut this = AppState::get_mut(mtm);
if this.has_terminated() {
return;
}
let (mut event_handler, active_control_flow, processing_redraws) =
match this.try_user_callback_transition() {
UserCallbackTransitionResult::ReentrancyPrevented { queued_events } => {
queued_events.extend(events);
return;
}
UserCallbackTransitionResult::Success {
event_handler,
active_control_flow,
processing_redraws,
} => (event_handler, active_control_flow, processing_redraws),
};
drop(this);
for wrapper in events {
match wrapper {
EventWrapper::StaticEvent(event) => {
if !processing_redraws && event.is_redraw() {
log::info!("processing `RedrawRequested` during the main event loop");
} else if processing_redraws && !event.is_redraw() {
log::warn!(
"processing non `RedrawRequested` event after the main event loop: {:#?}",
event
);
}
event_handler.handle_nonuser_event(event)
}
EventWrapper::ScaleFactorChanged(event) => {
handle_hidpi_proxy(&mut event_handler, event)
}
}
}
loop {
let mut this = AppState::get_mut(mtm);
let queued_events = match this.state_mut() {
&mut AppStateImpl::InUserCallback {
ref mut queued_events,
queued_gpu_redraws: _,
} => mem::take(queued_events),
s => bug!("unexpected state {:?}", s),
};
if queued_events.is_empty() {
let queued_gpu_redraws = match this.take_state() {
AppStateImpl::InUserCallback {
queued_events: _,
queued_gpu_redraws,
} => queued_gpu_redraws,
_ => unreachable!(),
};
this.app_state = Some(if processing_redraws {
bug_assert!(
queued_gpu_redraws.is_empty(),
"redraw queued while processing redraws"
);
AppStateImpl::ProcessingRedraws {
event_handler,
active_control_flow,
}
} else {
AppStateImpl::ProcessingEvents {
event_handler,
queued_gpu_redraws,
active_control_flow,
}
});
break;
}
drop(this);
for wrapper in queued_events {
match wrapper {
EventWrapper::StaticEvent(event) => {
if !processing_redraws && event.is_redraw() {
log::info!("processing `RedrawRequested` during the main event loop");
} else if processing_redraws && !event.is_redraw() {
log::warn!(
"processing non-`RedrawRequested` event after the main event loop: {:#?}",
event
);
}
event_handler.handle_nonuser_event(event)
}
EventWrapper::ScaleFactorChanged(event) => {
handle_hidpi_proxy(&mut event_handler, event)
}
}
}
}
}
fn handle_user_events(mtm: MainThreadMarker) {
let mut this = AppState::get_mut(mtm);
let (mut event_handler, active_control_flow, processing_redraws) =
match this.try_user_callback_transition() {
UserCallbackTransitionResult::ReentrancyPrevented { .. } => {
bug!("unexpected attempted to process an event")
}
UserCallbackTransitionResult::Success {
event_handler,
active_control_flow,
processing_redraws,
} => (event_handler, active_control_flow, processing_redraws),
};
if processing_redraws {
bug!("user events attempted to be sent out while `ProcessingRedraws`");
}
drop(this);
event_handler.handle_user_events();
loop {
let mut this = AppState::get_mut(mtm);
let queued_events = match this.state_mut() {
&mut AppStateImpl::InUserCallback {
ref mut queued_events,
queued_gpu_redraws: _,
} => mem::take(queued_events),
s => bug!("unexpected state {:?}", s),
};
if queued_events.is_empty() {
let queued_gpu_redraws = match this.take_state() {
AppStateImpl::InUserCallback {
queued_events: _,
queued_gpu_redraws,
} => queued_gpu_redraws,
_ => unreachable!(),
};
this.app_state = Some(AppStateImpl::ProcessingEvents {
event_handler,
queued_gpu_redraws,
active_control_flow,
});
break;
}
drop(this);
for wrapper in queued_events {
match wrapper {
EventWrapper::StaticEvent(event) => event_handler.handle_nonuser_event(event),
EventWrapper::ScaleFactorChanged(event) => {
handle_hidpi_proxy(&mut event_handler, event)
}
}
}
event_handler.handle_user_events();
}
}
pub fn handle_main_events_cleared(mtm: MainThreadMarker) {
let mut this = AppState::get_mut(mtm);
if !this.has_launched() || this.has_terminated() {
return;
}
match this.state_mut() {
AppStateImpl::ProcessingEvents { .. } => {}
_ => bug!("`ProcessingRedraws` happened unexpectedly"),
};
drop(this);
handle_user_events(mtm);
let mut this = AppState::get_mut(mtm);
let redraw_events: Vec<EventWrapper> = this
.main_events_cleared_transition()
.into_iter()
.map(|window| {
EventWrapper::StaticEvent(Event::WindowEvent {
window_id: RootWindowId(window.id()),
event: WindowEvent::RedrawRequested,
})
})
.collect();
drop(this);
handle_nonuser_events(mtm, redraw_events);
handle_nonuser_event(mtm, EventWrapper::StaticEvent(Event::AboutToWait));
}
pub fn handle_events_cleared(mtm: MainThreadMarker) {
AppState::get_mut(mtm).events_cleared_transition();
}
pub fn terminated(mtm: MainThreadMarker) {
let mut this = AppState::get_mut(mtm);
let mut event_handler = this.terminated_transition();
drop(this);
event_handler.handle_nonuser_event(Event::LoopExiting)
}
fn handle_hidpi_proxy(event_handler: &mut Box<dyn EventHandler>, event: ScaleFactorChanged) {
let ScaleFactorChanged {
suggested_size,
scale_factor,
window,
} = event;
let new_inner_size = Arc::new(Mutex::new(suggested_size));
let event = Event::WindowEvent {
window_id: RootWindowId(window.id()),
event: WindowEvent::ScaleFactorChanged {
scale_factor,
inner_size_writer: InnerSizeWriter::new(Arc::downgrade(&new_inner_size)),
},
};
event_handler.handle_nonuser_event(event);
let (view, screen_frame) = get_view_and_screen_frame(&window);
let physical_size = *new_inner_size.lock().unwrap();
drop(new_inner_size);
let logical_size = physical_size.to_logical(scale_factor);
let size = CGSize::new(logical_size.width, logical_size.height);
let new_frame: CGRect = CGRect::new(screen_frame.origin, size);
view.setFrame(new_frame);
}
fn get_view_and_screen_frame(window: &WinitUIWindow) -> (Id<UIView>, CGRect) {
let view_controller = window.rootViewController().unwrap();
let view = view_controller.view().unwrap();
let bounds = window.bounds();
let screen = window.screen();
let screen_space = screen.coordinateSpace();
let screen_frame = window.convertRect_toCoordinateSpace(bounds, &screen_space);
(view, screen_frame)
}
struct EventLoopWaker {
timer: CFRunLoopTimerRef,
}
impl Drop for EventLoopWaker {
fn drop(&mut self) {
unsafe {
CFRunLoopTimerInvalidate(self.timer);
CFRelease(self.timer as _);
}
}
}
impl EventLoopWaker {
fn new(rl: CFRunLoopRef) -> EventLoopWaker {
extern "C" fn wakeup_main_loop(_timer: CFRunLoopTimerRef, _info: *mut c_void) {}
unsafe {
// Create a timer with a 0.1µs interval (1ns does not work) to mimic polling.
// It is initially setup with a first fire time really far into the
// future, but that gets changed to fire immediately in did_finish_launching
let timer = CFRunLoopTimerCreate(
ptr::null_mut(),
std::f64::MAX,
0.000_000_1,
0,
0,
wakeup_main_loop,
ptr::null_mut(),
);
CFRunLoopAddTimer(rl, timer, kCFRunLoopCommonModes);
EventLoopWaker { timer }
}
}
fn stop(&mut self) {
unsafe { CFRunLoopTimerSetNextFireDate(self.timer, std::f64::MAX) }
}
fn start(&mut self) {
unsafe { CFRunLoopTimerSetNextFireDate(self.timer, std::f64::MIN) }
}
fn start_at(&mut self, instant: Instant) {
let now = Instant::now();
if now >= instant {
self.start();
} else {
unsafe {
let current = CFAbsoluteTimeGetCurrent();
let duration = instant - now;
let fsecs =
duration.subsec_nanos() as f64 / 1_000_000_000.0 + duration.as_secs() as f64;
CFRunLoopTimerSetNextFireDate(self.timer, current + fsecs)
}
}
}
}
macro_rules! os_capabilities {
(
$(
$(#[$attr:meta])*
$error_name:ident: $objc_call:literal,
$name:ident: $major:literal-$minor:literal
),*
$(,)*
) => {
#[derive(Clone, Debug)]
pub struct OSCapabilities {
$(
pub $name: bool,
)*
os_version: NSOperatingSystemVersion,
}
impl From<NSOperatingSystemVersion> for OSCapabilities {
fn from(os_version: NSOperatingSystemVersion) -> OSCapabilities {
$(let $name = meets_requirements(os_version, $major, $minor);)*
OSCapabilities { $($name,)* os_version, }
}
}
impl OSCapabilities {$(
$(#[$attr])*
pub fn $error_name(&self, extra_msg: &str) {
log::warn!(
concat!("`", $objc_call, "` requires iOS {}.{}+. This device is running iOS {}.{}.{}. {}"),
$major, $minor, self.os_version.majorVersion, self.os_version.minorVersion, self.os_version.patchVersion,
extra_msg
)
}
)*}
};
}
os_capabilities! {
/// <https://developer.apple.com/documentation/uikit/uiview/2891103-safeareainsets?language=objc>
#[allow(unused)] // error message unused
safe_area_err_msg: "-[UIView safeAreaInsets]",
safe_area: 11-0,
/// <https://developer.apple.com/documentation/uikit/uiviewcontroller/2887509-setneedsupdateofhomeindicatoraut?language=objc>
home_indicator_hidden_err_msg: "-[UIViewController setNeedsUpdateOfHomeIndicatorAutoHidden]",
home_indicator_hidden: 11-0,
/// <https://developer.apple.com/documentation/uikit/uiviewcontroller/2887507-setneedsupdateofscreenedgesdefer?language=objc>
defer_system_gestures_err_msg: "-[UIViewController setNeedsUpdateOfScreenEdgesDeferringSystem]",
defer_system_gestures: 11-0,
/// <https://developer.apple.com/documentation/uikit/uiscreen/2806814-maximumframespersecond?language=objc>
maximum_frames_per_second_err_msg: "-[UIScreen maximumFramesPerSecond]",
maximum_frames_per_second: 10-3,
/// <https://developer.apple.com/documentation/uikit/uitouch/1618110-force?language=objc>
#[allow(unused)] // error message unused
force_touch_err_msg: "-[UITouch force]",
force_touch: 9-0,
}
fn meets_requirements(
version: NSOperatingSystemVersion,
required_major: NSInteger,
required_minor: NSInteger,
) -> bool {
(version.majorVersion, version.minorVersion) >= (required_major, required_minor)
}
pub fn os_capabilities() -> OSCapabilities {
static OS_CAPABILITIES: Lazy<OSCapabilities> = Lazy::new(|| {
let version: NSOperatingSystemVersion = unsafe {
let process_info = NSProcessInfo::processInfo();
let atleast_ios_8: bool = msg_send![
&process_info,
respondsToSelector: sel!(operatingSystemVersion)
];
// winit requires atleast iOS 8 because no one has put the time into supporting earlier os versions.
// Older iOS versions are increasingly difficult to test. For example, Xcode 11 does not support
// debugging on devices with an iOS version of less than 8. Another example, in order to use an iOS
// simulator older than iOS 8, you must download an older version of Xcode (<9), and at least Xcode 7
// has been tested to not even run on macOS 10.15 - Xcode 8 might?
//
// The minimum required iOS version is likely to grow in the future.
assert!(atleast_ios_8, "`winit` requires iOS version 8 or greater");
process_info.operatingSystemVersion()
};
version.into()
});
OS_CAPABILITIES.clone()
}
+380
View File
@@ -0,0 +1,380 @@
use std::{
collections::VecDeque,
ffi::c_void,
fmt::{self, Debug},
marker::PhantomData,
ptr,
sync::mpsc::{self, Receiver, Sender},
};
use core_foundation::base::{CFIndex, CFRelease};
use core_foundation::runloop::{
kCFRunLoopAfterWaiting, kCFRunLoopBeforeWaiting, kCFRunLoopCommonModes, kCFRunLoopDefaultMode,
kCFRunLoopExit, CFRunLoopActivity, CFRunLoopAddObserver, CFRunLoopAddSource, CFRunLoopGetMain,
CFRunLoopObserverCreate, CFRunLoopObserverRef, CFRunLoopSourceContext, CFRunLoopSourceCreate,
CFRunLoopSourceInvalidate, CFRunLoopSourceRef, CFRunLoopSourceSignal, CFRunLoopWakeUp,
};
use icrate::Foundation::{MainThreadMarker, NSString};
use objc2::ClassType;
use crate::{
error::EventLoopError,
event::Event,
event_loop::{
ControlFlow, DeviceEvents, EventLoopClosed,
EventLoopWindowTarget as RootEventLoopWindowTarget,
},
platform::ios::Idiom,
};
use super::{app_state, monitor, view, MonitorHandle};
use super::{
app_state::AppState,
uikit::{UIApplication, UIApplicationMain, UIDevice, UIScreen},
};
#[derive(Debug)]
pub struct EventLoopWindowTarget<T: 'static> {
pub(super) mtm: MainThreadMarker,
p: PhantomData<T>,
}
impl<T: 'static> EventLoopWindowTarget<T> {
pub fn available_monitors(&self) -> VecDeque<MonitorHandle> {
monitor::uiscreens(self.mtm)
}
pub fn primary_monitor(&self) -> Option<MonitorHandle> {
Some(MonitorHandle::new(UIScreen::main(self.mtm)))
}
#[inline]
pub fn listen_device_events(&self, _allowed: DeviceEvents) {}
#[cfg(feature = "rwh_05")]
#[inline]
pub fn raw_display_handle_rwh_05(&self) -> rwh_05::RawDisplayHandle {
rwh_05::RawDisplayHandle::UiKit(rwh_05::UiKitDisplayHandle::empty())
}
#[cfg(feature = "rwh_06")]
#[inline]
pub fn raw_display_handle_rwh_06(
&self,
) -> Result<rwh_06::RawDisplayHandle, rwh_06::HandleError> {
Ok(rwh_06::RawDisplayHandle::UiKit(
rwh_06::UiKitDisplayHandle::new(),
))
}
pub(crate) fn set_control_flow(&self, control_flow: ControlFlow) {
AppState::get_mut(self.mtm).set_control_flow(control_flow)
}
pub(crate) fn control_flow(&self) -> ControlFlow {
AppState::get_mut(self.mtm).control_flow()
}
pub(crate) fn exit(&self) {
// https://developer.apple.com/library/archive/qa/qa1561/_index.html
// it is not possible to quit an iOS app gracefully and programatically
warn!("`ControlFlow::Exit` ignored on iOS");
}
pub(crate) fn exiting(&self) -> bool {
false
}
}
pub struct EventLoop<T: 'static> {
mtm: MainThreadMarker,
sender: Sender<T>,
receiver: Receiver<T>,
window_target: RootEventLoopWindowTarget<T>,
}
#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash)]
pub(crate) struct PlatformSpecificEventLoopAttributes {}
impl<T: 'static> EventLoop<T> {
pub(crate) fn new(
_: &PlatformSpecificEventLoopAttributes,
) -> Result<EventLoop<T>, EventLoopError> {
let mtm = MainThreadMarker::new()
.expect("On iOS, `EventLoop` must be created on the main thread");
static mut SINGLETON_INIT: bool = false;
unsafe {
assert!(
!SINGLETON_INIT,
"Only one `EventLoop` is supported on iOS. \
`EventLoopProxy` might be helpful"
);
SINGLETON_INIT = true;
}
let (sender, receiver) = mpsc::channel();
// this line sets up the main run loop before `UIApplicationMain`
setup_control_flow_observers();
Ok(EventLoop {
mtm,
sender,
receiver,
window_target: RootEventLoopWindowTarget {
p: EventLoopWindowTarget {
mtm,
p: PhantomData,
},
_marker: PhantomData,
},
})
}
pub fn run<F>(self, event_handler: F) -> !
where
F: FnMut(Event<T>, &RootEventLoopWindowTarget<T>),
{
unsafe {
let application = UIApplication::shared(self.mtm);
assert!(
application.is_none(),
"\
`EventLoop` cannot be `run` after a call to `UIApplicationMain` on iOS\n\
Note: `EventLoop::run` calls `UIApplicationMain` on iOS",
);
let event_handler = std::mem::transmute::<
Box<dyn FnMut(Event<T>, &RootEventLoopWindowTarget<T>)>,
Box<EventHandlerCallback<T>>,
>(Box::new(event_handler));
let handler = EventLoopHandler {
f: event_handler,
receiver: self.receiver,
event_loop: self.window_target,
};
app_state::will_launch(self.mtm, Box::new(handler));
// Ensure application delegate is initialized
view::WinitApplicationDelegate::class();
UIApplicationMain(
0,
ptr::null(),
None,
Some(&NSString::from_str("WinitApplicationDelegate")),
);
unreachable!()
}
}
pub fn create_proxy(&self) -> EventLoopProxy<T> {
EventLoopProxy::new(self.sender.clone())
}
pub fn window_target(&self) -> &RootEventLoopWindowTarget<T> {
&self.window_target
}
}
// EventLoopExtIOS
impl<T: 'static> EventLoop<T> {
pub fn idiom(&self) -> Idiom {
UIDevice::current(self.mtm).userInterfaceIdiom().into()
}
}
pub struct EventLoopProxy<T> {
sender: Sender<T>,
source: CFRunLoopSourceRef,
}
unsafe impl<T: Send> Send for EventLoopProxy<T> {}
impl<T> Clone for EventLoopProxy<T> {
fn clone(&self) -> EventLoopProxy<T> {
EventLoopProxy::new(self.sender.clone())
}
}
impl<T> Drop for EventLoopProxy<T> {
fn drop(&mut self) {
unsafe {
CFRunLoopSourceInvalidate(self.source);
CFRelease(self.source as _);
}
}
}
impl<T> EventLoopProxy<T> {
fn new(sender: Sender<T>) -> EventLoopProxy<T> {
unsafe {
// just wake up the eventloop
extern "C" fn event_loop_proxy_handler(_: *const c_void) {}
// adding a Source to the main CFRunLoop lets us wake it up and
// process user events through the normal OS EventLoop mechanisms.
let rl = CFRunLoopGetMain();
let mut context = CFRunLoopSourceContext {
version: 0,
info: ptr::null_mut(),
retain: None,
release: None,
copyDescription: None,
equal: None,
hash: None,
schedule: None,
cancel: None,
perform: event_loop_proxy_handler,
};
let source =
CFRunLoopSourceCreate(ptr::null_mut(), CFIndex::max_value() - 1, &mut context);
CFRunLoopAddSource(rl, source, kCFRunLoopCommonModes);
CFRunLoopWakeUp(rl);
EventLoopProxy { sender, source }
}
}
pub fn send_event(&self, event: T) -> Result<(), EventLoopClosed<T>> {
self.sender
.send(event)
.map_err(|::std::sync::mpsc::SendError(x)| EventLoopClosed(x))?;
unsafe {
// let the main thread know there's a new event
CFRunLoopSourceSignal(self.source);
let rl = CFRunLoopGetMain();
CFRunLoopWakeUp(rl);
}
Ok(())
}
}
fn setup_control_flow_observers() {
unsafe {
// begin is queued with the highest priority to ensure it is processed before other observers
extern "C" fn control_flow_begin_handler(
_: CFRunLoopObserverRef,
activity: CFRunLoopActivity,
_: *mut c_void,
) {
let mtm = MainThreadMarker::new().unwrap();
#[allow(non_upper_case_globals)]
match activity {
kCFRunLoopAfterWaiting => app_state::handle_wakeup_transition(mtm),
_ => unreachable!(),
}
}
// Core Animation registers its `CFRunLoopObserver` that performs drawing operations in
// `CA::Transaction::ensure_implicit` with a priority of `0x1e8480`. We set the main_end
// priority to be 0, in order to send AboutToWait before RedrawRequested. This value was
// chosen conservatively to guard against apple using different priorities for their redraw
// observers in different OS's or on different devices. If it so happens that it's too
// conservative, the main symptom would be non-redraw events coming in after `AboutToWait`.
//
// The value of `0x1e8480` was determined by inspecting stack traces and the associated
// registers for every `CFRunLoopAddObserver` call on an iPad Air 2 running iOS 11.4.
//
// Also tested to be `0x1e8480` on iPhone 8, iOS 13 beta 4.
extern "C" fn control_flow_main_end_handler(
_: CFRunLoopObserverRef,
activity: CFRunLoopActivity,
_: *mut c_void,
) {
let mtm = MainThreadMarker::new().unwrap();
#[allow(non_upper_case_globals)]
match activity {
kCFRunLoopBeforeWaiting => app_state::handle_main_events_cleared(mtm),
kCFRunLoopExit => {} // may happen when running on macOS
_ => unreachable!(),
}
}
// end is queued with the lowest priority to ensure it is processed after other observers
extern "C" fn control_flow_end_handler(
_: CFRunLoopObserverRef,
activity: CFRunLoopActivity,
_: *mut c_void,
) {
let mtm = MainThreadMarker::new().unwrap();
#[allow(non_upper_case_globals)]
match activity {
kCFRunLoopBeforeWaiting => app_state::handle_events_cleared(mtm),
kCFRunLoopExit => {} // may happen when running on macOS
_ => unreachable!(),
}
}
let main_loop = CFRunLoopGetMain();
let begin_observer = CFRunLoopObserverCreate(
ptr::null_mut(),
kCFRunLoopAfterWaiting,
1, // repeat = true
CFIndex::min_value(),
control_flow_begin_handler,
ptr::null_mut(),
);
CFRunLoopAddObserver(main_loop, begin_observer, kCFRunLoopDefaultMode);
let main_end_observer = CFRunLoopObserverCreate(
ptr::null_mut(),
kCFRunLoopExit | kCFRunLoopBeforeWaiting,
1, // repeat = true
0, // see comment on `control_flow_main_end_handler`
control_flow_main_end_handler,
ptr::null_mut(),
);
CFRunLoopAddObserver(main_loop, main_end_observer, kCFRunLoopDefaultMode);
let end_observer = CFRunLoopObserverCreate(
ptr::null_mut(),
kCFRunLoopExit | kCFRunLoopBeforeWaiting,
1, // repeat = true
CFIndex::max_value(),
control_flow_end_handler,
ptr::null_mut(),
);
CFRunLoopAddObserver(main_loop, end_observer, kCFRunLoopDefaultMode);
}
}
#[derive(Debug)]
pub enum Never {}
type EventHandlerCallback<T> = dyn FnMut(Event<T>, &RootEventLoopWindowTarget<T>) + 'static;
pub trait EventHandler: Debug {
fn handle_nonuser_event(&mut self, event: Event<Never>);
fn handle_user_events(&mut self);
}
struct EventLoopHandler<T: 'static> {
f: Box<EventHandlerCallback<T>>,
receiver: Receiver<T>,
event_loop: RootEventLoopWindowTarget<T>,
}
impl<T: 'static> Debug for EventLoopHandler<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("EventLoopHandler")
.field("event_loop", &self.event_loop)
.finish()
}
}
impl<T: 'static> EventHandler for EventLoopHandler<T> {
fn handle_nonuser_event(&mut self, event: Event<Never>) {
(self.f)(event.map_nonuser_event().unwrap(), &self.event_loop);
}
fn handle_user_events(&mut self) {
for event in self.receiver.try_iter() {
(self.f)(Event::UserEvent(event), &self.event_loop);
}
}
}
+76
View File
@@ -0,0 +1,76 @@
#![allow(non_camel_case_types, non_snake_case, non_upper_case_globals)]
use icrate::Foundation::{NSInteger, NSUInteger};
use objc2::encode::{Encode, Encoding};
use crate::platform::ios::{Idiom, ScreenEdge};
#[repr(transparent)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct UIUserInterfaceIdiom(NSInteger);
unsafe impl Encode for UIUserInterfaceIdiom {
const ENCODING: Encoding = NSInteger::ENCODING;
}
impl UIUserInterfaceIdiom {
pub const Unspecified: UIUserInterfaceIdiom = UIUserInterfaceIdiom(-1);
pub const Phone: UIUserInterfaceIdiom = UIUserInterfaceIdiom(0);
pub const Pad: UIUserInterfaceIdiom = UIUserInterfaceIdiom(1);
pub const TV: UIUserInterfaceIdiom = UIUserInterfaceIdiom(2);
pub const CarPlay: UIUserInterfaceIdiom = UIUserInterfaceIdiom(3);
}
impl From<Idiom> for UIUserInterfaceIdiom {
fn from(idiom: Idiom) -> UIUserInterfaceIdiom {
match idiom {
Idiom::Unspecified => UIUserInterfaceIdiom::Unspecified,
Idiom::Phone => UIUserInterfaceIdiom::Phone,
Idiom::Pad => UIUserInterfaceIdiom::Pad,
Idiom::TV => UIUserInterfaceIdiom::TV,
Idiom::CarPlay => UIUserInterfaceIdiom::CarPlay,
}
}
}
impl From<UIUserInterfaceIdiom> for Idiom {
fn from(ui_idiom: UIUserInterfaceIdiom) -> Idiom {
match ui_idiom {
UIUserInterfaceIdiom::Unspecified => Idiom::Unspecified,
UIUserInterfaceIdiom::Phone => Idiom::Phone,
UIUserInterfaceIdiom::Pad => Idiom::Pad,
UIUserInterfaceIdiom::TV => Idiom::TV,
UIUserInterfaceIdiom::CarPlay => Idiom::CarPlay,
_ => unreachable!(),
}
}
}
#[repr(transparent)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct UIRectEdge(NSUInteger);
impl UIRectEdge {
pub(crate) const NONE: Self = Self(0);
}
unsafe impl Encode for UIRectEdge {
const ENCODING: Encoding = NSUInteger::ENCODING;
}
impl From<ScreenEdge> for UIRectEdge {
fn from(screen_edge: ScreenEdge) -> UIRectEdge {
assert_eq!(
screen_edge.bits() & !ScreenEdge::ALL.bits(),
0,
"invalid `ScreenEdge`"
);
UIRectEdge(screen_edge.bits().into())
}
}
impl From<UIRectEdge> for ScreenEdge {
fn from(ui_rect_edge: UIRectEdge) -> ScreenEdge {
let bits: u8 = ui_rect_edge.0.try_into().expect("invalid `UIRectEdge`");
ScreenEdge::from_bits(bits).expect("invalid `ScreenEdge`")
}
}
+109
View File
@@ -0,0 +1,109 @@
//! iOS support
//!
//! # Building app
//! To build ios app you will need rustc built for this targets:
//!
//! - armv7-apple-ios
//! - armv7s-apple-ios
//! - i386-apple-ios
//! - aarch64-apple-ios
//! - x86_64-apple-ios
//!
//! Then
//!
//! ```
//! cargo build --target=...
//! ```
//! The simplest way to integrate your app into xcode environment is to build it
//! as a static library. Wrap your main function and export it.
//!
//! ```rust, ignore
//! #[no_mangle]
//! pub extern fn start_winit_app() {
//! start_inner()
//! }
//!
//! fn start_inner() {
//! ...
//! }
//! ```
//!
//! Compile project and then drag resulting .a into Xcode project. Add winit.h to xcode.
//!
//! ```ignore
//! void start_winit_app();
//! ```
//!
//! Use start_winit_app inside your xcode's main function.
//!
//!
//! # App lifecycle and events
//!
//! iOS environment is very different from other platforms and you must be very
//! careful with it's events. Familiarize yourself with
//! [app lifecycle](https://developer.apple.com/library/ios/documentation/UIKit/Reference/UIApplicationDelegate_Protocol/).
//!
//!
//! This is how those event are represented in winit:
//!
//! - applicationDidBecomeActive is Resumed
//! - applicationWillResignActive is Suspended
//! - applicationWillTerminate is LoopExiting
//!
//! Keep in mind that after LoopExiting event is received every attempt to draw with
//! opengl will result in segfault.
//!
//! Also note that app may not receive the LoopExiting event if suspended; it might be SIGKILL'ed.
#![cfg(ios_platform)]
#![allow(clippy::let_unit_value)]
mod app_state;
mod event_loop;
mod ffi;
mod monitor;
mod uikit;
mod view;
mod window;
use std::fmt;
pub(crate) use self::{
event_loop::{
EventLoop, EventLoopProxy, EventLoopWindowTarget, PlatformSpecificEventLoopAttributes,
},
monitor::{MonitorHandle, VideoMode},
window::{PlatformSpecificWindowBuilderAttributes, Window, WindowId},
};
use self::uikit::UIScreen;
pub(crate) use crate::icon::NoIcon as PlatformIcon;
pub(crate) use crate::platform_impl::Fullscreen;
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct DeviceId {
uiscreen: *const UIScreen,
}
impl DeviceId {
pub const unsafe fn dummy() -> Self {
DeviceId {
uiscreen: std::ptr::null(),
}
}
}
unsafe impl Send for DeviceId {}
unsafe impl Sync for DeviceId {}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct KeyEventExtra {}
#[derive(Debug)]
pub enum OsError {}
impl fmt::Display for OsError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "os error")
}
}
+268
View File
@@ -0,0 +1,268 @@
#![allow(clippy::unnecessary_cast)]
use std::{
collections::{BTreeSet, VecDeque},
fmt, hash, ptr,
};
use icrate::Foundation::{MainThreadBound, MainThreadMarker, NSInteger};
use objc2::mutability::IsRetainable;
use objc2::rc::Id;
use super::uikit::{UIScreen, UIScreenMode};
use crate::{
dpi::{PhysicalPosition, PhysicalSize},
monitor::VideoMode as RootVideoMode,
platform_impl::platform::app_state,
};
// Workaround for `MainThreadBound` implementing almost no traits
#[derive(Debug)]
struct MainThreadBoundDelegateImpls<T>(MainThreadBound<Id<T>>);
impl<T: IsRetainable> Clone for MainThreadBoundDelegateImpls<T> {
fn clone(&self) -> Self {
Self(
self.0
.get_on_main(|inner, mtm| MainThreadBound::new(Id::clone(inner), mtm)),
)
}
}
impl<T: IsRetainable> hash::Hash for MainThreadBoundDelegateImpls<T> {
fn hash<H: hash::Hasher>(&self, state: &mut H) {
// SAFETY: Marker only used to get the pointer
let mtm = unsafe { MainThreadMarker::new_unchecked() };
Id::as_ptr(self.0.get(mtm)).hash(state);
}
}
impl<T: IsRetainable> PartialEq for MainThreadBoundDelegateImpls<T> {
fn eq(&self, other: &Self) -> bool {
// SAFETY: Marker only used to get the pointer
let mtm = unsafe { MainThreadMarker::new_unchecked() };
Id::as_ptr(self.0.get(mtm)) == Id::as_ptr(other.0.get(mtm))
}
}
impl<T: IsRetainable> Eq for MainThreadBoundDelegateImpls<T> {}
#[derive(Debug, PartialEq, Eq, Hash, Clone)]
pub struct VideoMode {
pub(crate) size: (u32, u32),
pub(crate) bit_depth: u16,
pub(crate) refresh_rate_millihertz: u32,
screen_mode: MainThreadBoundDelegateImpls<UIScreenMode>,
pub(crate) monitor: MonitorHandle,
}
impl VideoMode {
fn new(
uiscreen: Id<UIScreen>,
screen_mode: Id<UIScreenMode>,
mtm: MainThreadMarker,
) -> VideoMode {
let refresh_rate_millihertz = refresh_rate_millihertz(&uiscreen);
let size = screen_mode.size();
VideoMode {
size: (size.width as u32, size.height as u32),
bit_depth: 32,
refresh_rate_millihertz,
screen_mode: MainThreadBoundDelegateImpls(MainThreadBound::new(screen_mode, mtm)),
monitor: MonitorHandle::new(uiscreen),
}
}
pub fn size(&self) -> PhysicalSize<u32> {
self.size.into()
}
pub fn bit_depth(&self) -> u16 {
self.bit_depth
}
pub fn refresh_rate_millihertz(&self) -> u32 {
self.refresh_rate_millihertz
}
pub fn monitor(&self) -> MonitorHandle {
self.monitor.clone()
}
pub(super) fn screen_mode(&self, mtm: MainThreadMarker) -> &Id<UIScreenMode> {
self.screen_mode.0.get(mtm)
}
}
pub struct MonitorHandle {
ui_screen: MainThreadBound<Id<UIScreen>>,
}
impl Clone for MonitorHandle {
fn clone(&self) -> Self {
Self {
ui_screen: self
.ui_screen
.get_on_main(|inner, mtm| MainThreadBound::new(inner.clone(), mtm)),
}
}
}
impl hash::Hash for MonitorHandle {
fn hash<H: hash::Hasher>(&self, state: &mut H) {
(self as *const Self).hash(state);
}
}
impl PartialEq for MonitorHandle {
fn eq(&self, other: &Self) -> bool {
ptr::eq(self, other)
}
}
impl Eq for MonitorHandle {}
impl PartialOrd for MonitorHandle {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for MonitorHandle {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
// TODO: Make a better ordering
(self as *const Self).cmp(&(other as *const Self))
}
}
impl fmt::Debug for MonitorHandle {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// TODO: Do this using the proper fmt API
#[derive(Debug)]
#[allow(dead_code)]
struct MonitorHandle {
name: Option<String>,
size: PhysicalSize<u32>,
position: PhysicalPosition<i32>,
scale_factor: f64,
}
let monitor_id_proxy = MonitorHandle {
name: self.name(),
size: self.size(),
position: self.position(),
scale_factor: self.scale_factor(),
};
monitor_id_proxy.fmt(f)
}
}
impl MonitorHandle {
pub(crate) fn new(ui_screen: Id<UIScreen>) -> Self {
// Holding `Id<UIScreen>` implies we're on the main thread.
let mtm = MainThreadMarker::new().unwrap();
Self {
ui_screen: MainThreadBound::new(ui_screen, mtm),
}
}
pub fn name(&self) -> Option<String> {
self.ui_screen.get_on_main(|ui_screen, mtm| {
let main = UIScreen::main(mtm);
if *ui_screen == main {
Some("Primary".to_string())
} else if *ui_screen == main.mirroredScreen() {
Some("Mirrored".to_string())
} else {
UIScreen::screens(mtm)
.iter()
.position(|rhs| rhs == &**ui_screen)
.map(|idx| idx.to_string())
}
})
}
pub fn size(&self) -> PhysicalSize<u32> {
let bounds = self
.ui_screen
.get_on_main(|ui_screen, _| ui_screen.nativeBounds());
PhysicalSize::new(bounds.size.width as u32, bounds.size.height as u32)
}
pub fn position(&self) -> PhysicalPosition<i32> {
let bounds = self
.ui_screen
.get_on_main(|ui_screen, _| ui_screen.nativeBounds());
(bounds.origin.x as f64, bounds.origin.y as f64).into()
}
pub fn scale_factor(&self) -> f64 {
self.ui_screen
.get_on_main(|ui_screen, _| ui_screen.nativeScale()) as f64
}
pub fn refresh_rate_millihertz(&self) -> Option<u32> {
Some(
self.ui_screen
.get_on_main(|ui_screen, _| refresh_rate_millihertz(ui_screen)),
)
}
pub fn video_modes(&self) -> impl Iterator<Item = VideoMode> {
self.ui_screen.get_on_main(|ui_screen, mtm| {
// Use Ord impl of RootVideoMode
let modes: BTreeSet<_> = ui_screen
.availableModes()
.into_iter()
.map(|mode| RootVideoMode {
video_mode: VideoMode::new(ui_screen.clone(), mode, mtm),
})
.collect();
modes.into_iter().map(|mode| mode.video_mode)
})
}
pub(crate) fn ui_screen(&self, mtm: MainThreadMarker) -> &Id<UIScreen> {
self.ui_screen.get(mtm)
}
pub fn preferred_video_mode(&self) -> VideoMode {
self.ui_screen.get_on_main(|ui_screen, mtm| {
VideoMode::new(ui_screen.clone(), ui_screen.preferredMode().unwrap(), mtm)
})
}
}
fn refresh_rate_millihertz(uiscreen: &UIScreen) -> u32 {
let refresh_rate_millihertz: NSInteger = {
let os_capabilities = app_state::os_capabilities();
if os_capabilities.maximum_frames_per_second {
uiscreen.maximumFramesPerSecond()
} else {
// https://developer.apple.com/library/archive/technotes/tn2460/_index.html
// https://en.wikipedia.org/wiki/IPad_Pro#Model_comparison
//
// All iOS devices support 60 fps, and on devices where `maximumFramesPerSecond` is not
// supported, they are all guaranteed to have 60hz refresh rates. This does not
// correctly handle external displays. ProMotion displays support 120fps, but they were
// introduced at the same time as the `maximumFramesPerSecond` API.
//
// FIXME: earlier OSs could calculate the refresh rate using
// `-[CADisplayLink duration]`.
os_capabilities.maximum_frames_per_second_err_msg("defaulting to 60 fps");
60
}
};
refresh_rate_millihertz as u32 * 1000
}
pub fn uiscreens(mtm: MainThreadMarker) -> VecDeque<MonitorHandle> {
UIScreen::screens(mtm)
.into_iter()
.map(MonitorHandle::new)
.collect()
}
+31
View File
@@ -0,0 +1,31 @@
use icrate::Foundation::{CGRect, MainThreadMarker, NSArray, NSObject};
use objc2::rc::Id;
use objc2::{extern_class, extern_methods, msg_send_id, mutability, ClassType};
use super::{UIResponder, UIWindow};
extern_class!(
#[derive(Debug, PartialEq, Eq, Hash)]
pub(crate) struct UIApplication;
unsafe impl ClassType for UIApplication {
#[inherits(NSObject)]
type Super = UIResponder;
type Mutability = mutability::InteriorMutable;
}
);
extern_methods!(
unsafe impl UIApplication {
pub fn shared(_mtm: MainThreadMarker) -> Option<Id<Self>> {
unsafe { msg_send_id![Self::class(), sharedApplication] }
}
pub fn windows(&self) -> Id<NSArray<UIWindow>> {
unsafe { msg_send_id![self, windows] }
}
#[method(statusBarFrame)]
pub fn statusBarFrame(&self) -> CGRect;
}
);
@@ -0,0 +1,12 @@
use icrate::Foundation::NSObject;
use objc2::{extern_class, mutability, ClassType};
extern_class!(
#[derive(Debug, PartialEq, Eq, Hash)]
pub(crate) struct UICoordinateSpace;
unsafe impl ClassType for UICoordinateSpace {
type Super = NSObject;
type Mutability = mutability::InteriorMutable;
}
);
+26
View File
@@ -0,0 +1,26 @@
use icrate::Foundation::{MainThreadMarker, NSObject};
use objc2::rc::Id;
use objc2::{extern_class, extern_methods, msg_send_id, mutability, ClassType};
use super::super::ffi::UIUserInterfaceIdiom;
extern_class!(
#[derive(Debug, PartialEq, Eq, Hash)]
pub(crate) struct UIDevice;
unsafe impl ClassType for UIDevice {
type Super = NSObject;
type Mutability = mutability::InteriorMutable;
}
);
extern_methods!(
unsafe impl UIDevice {
pub fn current(_mtm: MainThreadMarker) -> Id<Self> {
unsafe { msg_send_id![Self::class(), currentDevice] }
}
#[method(userInterfaceIdiom)]
pub fn userInterfaceIdiom(&self) -> UIUserInterfaceIdiom;
}
);
+12
View File
@@ -0,0 +1,12 @@
use icrate::Foundation::NSObject;
use objc2::{extern_class, mutability, ClassType};
extern_class!(
#[derive(Debug, PartialEq, Eq, Hash)]
pub(crate) struct UIEvent;
unsafe impl ClassType for UIEvent {
type Super = NSObject;
type Mutability = mutability::InteriorMutable;
}
);
+45
View File
@@ -0,0 +1,45 @@
#![allow(non_snake_case)]
#![allow(non_upper_case_globals)]
use std::os::raw::{c_char, c_int};
use icrate::Foundation::NSString;
mod application;
mod coordinate_space;
mod device;
mod event;
mod responder;
mod screen;
mod screen_mode;
mod status_bar_style;
mod touch;
mod trait_collection;
mod view;
mod view_controller;
mod window;
pub(crate) use self::application::UIApplication;
pub(crate) use self::coordinate_space::UICoordinateSpace;
pub(crate) use self::device::UIDevice;
pub(crate) use self::event::UIEvent;
pub(crate) use self::responder::UIResponder;
pub(crate) use self::screen::{UIScreen, UIScreenOverscanCompensation};
pub(crate) use self::screen_mode::UIScreenMode;
pub(crate) use self::status_bar_style::UIStatusBarStyle;
pub(crate) use self::touch::{UITouch, UITouchPhase, UITouchType};
pub(crate) use self::trait_collection::{UIForceTouchCapability, UITraitCollection};
#[allow(unused_imports)]
pub(crate) use self::view::{UIEdgeInsets, UIView};
pub(crate) use self::view_controller::{UIInterfaceOrientationMask, UIViewController};
pub(crate) use self::window::UIWindow;
#[link(name = "UIKit", kind = "framework")]
extern "C" {
pub fn UIApplicationMain(
argc: c_int,
argv: *const c_char,
principalClassName: Option<&NSString>,
delegateClassName: Option<&NSString>,
) -> c_int;
}
+12
View File
@@ -0,0 +1,12 @@
use icrate::Foundation::NSObject;
use objc2::{extern_class, mutability, ClassType};
extern_class!(
#[derive(Debug, PartialEq, Eq, Hash)]
pub(crate) struct UIResponder;
unsafe impl ClassType for UIResponder {
type Super = NSObject;
type Mutability = mutability::InteriorMutable;
}
);
+80
View File
@@ -0,0 +1,80 @@
use icrate::Foundation::{CGFloat, CGRect, MainThreadMarker, NSArray, NSInteger, NSObject};
use objc2::encode::{Encode, Encoding};
use objc2::rc::Id;
use objc2::{extern_class, extern_methods, msg_send_id, mutability, ClassType};
use super::{UICoordinateSpace, UIScreenMode};
extern_class!(
#[derive(Debug, PartialEq, Eq, Hash)]
pub(crate) struct UIScreen;
unsafe impl ClassType for UIScreen {
type Super = NSObject;
type Mutability = mutability::InteriorMutable;
}
);
extern_methods!(
unsafe impl UIScreen {
pub fn main(_mtm: MainThreadMarker) -> Id<Self> {
unsafe { msg_send_id![Self::class(), mainScreen] }
}
pub fn screens(_mtm: MainThreadMarker) -> Id<NSArray<Self>> {
unsafe { msg_send_id![Self::class(), screens] }
}
#[method(bounds)]
pub fn bounds(&self) -> CGRect;
#[method(scale)]
pub fn scale(&self) -> CGFloat;
#[method(nativeBounds)]
pub fn nativeBounds(&self) -> CGRect;
#[method(nativeScale)]
pub fn nativeScale(&self) -> CGFloat;
#[method(maximumFramesPerSecond)]
pub fn maximumFramesPerSecond(&self) -> NSInteger;
pub fn mirroredScreen(&self) -> Id<Self> {
unsafe { msg_send_id![Self::class(), mirroredScreen] }
}
pub fn preferredMode(&self) -> Option<Id<UIScreenMode>> {
unsafe { msg_send_id![self, preferredMode] }
}
#[method(setCurrentMode:)]
pub fn setCurrentMode(&self, mode: Option<&UIScreenMode>);
pub fn availableModes(&self) -> Id<NSArray<UIScreenMode>> {
unsafe { msg_send_id![self, availableModes] }
}
#[method(setOverscanCompensation:)]
pub fn setOverscanCompensation(&self, overscanCompensation: UIScreenOverscanCompensation);
pub fn coordinateSpace(&self) -> Id<UICoordinateSpace> {
unsafe { msg_send_id![self, coordinateSpace] }
}
}
);
#[repr(transparent)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct UIScreenOverscanCompensation(NSInteger);
unsafe impl Encode for UIScreenOverscanCompensation {
const ENCODING: Encoding = NSInteger::ENCODING;
}
#[allow(dead_code)]
impl UIScreenOverscanCompensation {
pub const Scale: Self = Self(0);
pub const InsetBounds: Self = Self(1);
pub const None: Self = Self(2);
}
+19
View File
@@ -0,0 +1,19 @@
use icrate::Foundation::{CGSize, NSObject};
use objc2::{extern_class, extern_methods, mutability, ClassType};
extern_class!(
#[derive(Debug, PartialEq, Eq, Hash)]
pub(crate) struct UIScreenMode;
unsafe impl ClassType for UIScreenMode {
type Super = NSObject;
type Mutability = mutability::InteriorMutable;
}
);
extern_methods!(
unsafe impl UIScreenMode {
#[method(size)]
pub fn size(&self) -> CGSize;
}
);
@@ -0,0 +1,27 @@
use crate::platform::ios::StatusBarStyle;
use icrate::Foundation::NSInteger;
use objc2::encode::{Encode, Encoding};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[allow(dead_code)]
#[repr(isize)]
pub enum UIStatusBarStyle {
#[default]
Default = 0,
LightContent = 1,
DarkContent = 3,
}
impl From<StatusBarStyle> for UIStatusBarStyle {
fn from(value: StatusBarStyle) -> Self {
match value {
StatusBarStyle::Default => Self::Default,
StatusBarStyle::LightContent => Self::LightContent,
StatusBarStyle::DarkContent => Self::DarkContent,
}
}
}
unsafe impl Encode for UIStatusBarStyle {
const ENCODING: Encoding = NSInteger::ENCODING;
}
+65
View File
@@ -0,0 +1,65 @@
use icrate::Foundation::{CGFloat, CGPoint, NSInteger, NSObject};
use objc2::encode::{Encode, Encoding};
use objc2::{extern_class, extern_methods, mutability, ClassType};
use super::UIView;
extern_class!(
#[derive(Debug, PartialEq, Eq, Hash)]
pub(crate) struct UITouch;
unsafe impl ClassType for UITouch {
type Super = NSObject;
type Mutability = mutability::InteriorMutable;
}
);
extern_methods!(
unsafe impl UITouch {
#[method(locationInView:)]
pub fn locationInView(&self, view: Option<&UIView>) -> CGPoint;
#[method(type)]
pub fn type_(&self) -> UITouchType;
#[method(force)]
pub fn force(&self) -> CGFloat;
#[method(maximumPossibleForce)]
pub fn maximumPossibleForce(&self) -> CGFloat;
#[method(altitudeAngle)]
pub fn altitudeAngle(&self) -> CGFloat;
#[method(phase)]
pub fn phase(&self) -> UITouchPhase;
}
);
#[derive(Debug, PartialEq, Eq)]
#[allow(dead_code)]
#[repr(isize)]
pub enum UITouchType {
Direct = 0,
Indirect,
Pencil,
}
unsafe impl Encode for UITouchType {
const ENCODING: Encoding = NSInteger::ENCODING;
}
#[derive(Debug)]
#[allow(dead_code)]
#[repr(isize)]
pub enum UITouchPhase {
Began = 0,
Moved,
Stationary,
Ended,
Cancelled,
}
unsafe impl Encode for UITouchPhase {
const ENCODING: Encoding = NSInteger::ENCODING;
}
@@ -0,0 +1,33 @@
use icrate::Foundation::{NSInteger, NSObject};
use objc2::encode::{Encode, Encoding};
use objc2::{extern_class, extern_methods, mutability, ClassType};
extern_class!(
#[derive(Debug, PartialEq, Eq, Hash)]
pub(crate) struct UITraitCollection;
unsafe impl ClassType for UITraitCollection {
type Super = NSObject;
type Mutability = mutability::InteriorMutable;
}
);
extern_methods!(
unsafe impl UITraitCollection {
#[method(forceTouchCapability)]
pub fn forceTouchCapability(&self) -> UIForceTouchCapability;
}
);
#[derive(Debug, PartialEq, Eq)]
#[allow(dead_code)]
#[repr(isize)]
pub enum UIForceTouchCapability {
Unknown = 0,
Unavailable,
Available,
}
unsafe impl Encode for UIForceTouchCapability {
const ENCODING: Encoding = NSInteger::ENCODING;
}
+90
View File
@@ -0,0 +1,90 @@
use icrate::Foundation::{CGFloat, CGRect, NSObject};
use objc2::encode::{Encode, Encoding};
use objc2::rc::Id;
use objc2::{extern_class, extern_methods, msg_send_id, mutability, ClassType};
use super::{UICoordinateSpace, UIResponder, UIViewController};
extern_class!(
#[derive(Debug, PartialEq, Eq, Hash)]
pub(crate) struct UIView;
unsafe impl ClassType for UIView {
#[inherits(NSObject)]
type Super = UIResponder;
type Mutability = mutability::InteriorMutable;
}
);
extern_methods!(
unsafe impl UIView {
#[method(bounds)]
pub fn bounds(&self) -> CGRect;
#[method(setBounds:)]
pub fn setBounds(&self, value: CGRect);
#[method(frame)]
pub fn frame(&self) -> CGRect;
#[method(setFrame:)]
pub fn setFrame(&self, value: CGRect);
#[method(contentScaleFactor)]
pub fn contentScaleFactor(&self) -> CGFloat;
#[method(setContentScaleFactor:)]
pub fn setContentScaleFactor(&self, val: CGFloat);
#[method(setMultipleTouchEnabled:)]
pub fn setMultipleTouchEnabled(&self, val: bool);
pub fn rootViewController(&self) -> Option<Id<UIViewController>> {
unsafe { msg_send_id![self, rootViewController] }
}
#[method(setRootViewController:)]
pub fn setRootViewController(&self, rootViewController: Option<&UIViewController>);
#[method(convertRect:toCoordinateSpace:)]
pub fn convertRect_toCoordinateSpace(
&self,
rect: CGRect,
coordinateSpace: &UICoordinateSpace,
) -> CGRect;
#[method(convertRect:fromCoordinateSpace:)]
pub fn convertRect_fromCoordinateSpace(
&self,
rect: CGRect,
coordinateSpace: &UICoordinateSpace,
) -> CGRect;
#[method(safeAreaInsets)]
pub fn safeAreaInsets(&self) -> UIEdgeInsets;
#[method(setNeedsDisplay)]
pub fn setNeedsDisplay(&self);
}
);
#[repr(C)]
#[derive(Debug, Clone)]
pub struct UIEdgeInsets {
pub top: CGFloat,
pub left: CGFloat,
pub bottom: CGFloat,
pub right: CGFloat,
}
unsafe impl Encode for UIEdgeInsets {
const ENCODING: Encoding = Encoding::Struct(
"UIEdgeInsets",
&[
CGFloat::ENCODING,
CGFloat::ENCODING,
CGFloat::ENCODING,
CGFloat::ENCODING,
],
);
}
@@ -0,0 +1,57 @@
use icrate::Foundation::{NSObject, NSUInteger};
use objc2::encode::{Encode, Encoding};
use objc2::rc::Id;
use objc2::{extern_class, extern_methods, msg_send_id, mutability, ClassType};
use super::{UIResponder, UIView};
extern_class!(
#[derive(Debug, PartialEq, Eq, Hash)]
pub(crate) struct UIViewController;
unsafe impl ClassType for UIViewController {
#[inherits(NSObject)]
type Super = UIResponder;
type Mutability = mutability::InteriorMutable;
}
);
extern_methods!(
unsafe impl UIViewController {
#[method(attemptRotationToDeviceOrientation)]
pub fn attemptRotationToDeviceOrientation();
#[method(setNeedsStatusBarAppearanceUpdate)]
pub fn setNeedsStatusBarAppearanceUpdate(&self);
#[method(setNeedsUpdateOfHomeIndicatorAutoHidden)]
pub fn setNeedsUpdateOfHomeIndicatorAutoHidden(&self);
#[method(setNeedsUpdateOfScreenEdgesDeferringSystemGestures)]
pub fn setNeedsUpdateOfScreenEdgesDeferringSystemGestures(&self);
pub fn view(&self) -> Option<Id<UIView>> {
unsafe { msg_send_id![self, view] }
}
#[method(setView:)]
pub fn setView(&self, view: Option<&UIView>);
}
);
bitflags! {
#[derive(Clone, Copy)]
pub struct UIInterfaceOrientationMask: NSUInteger {
const Portrait = 1 << 1;
const PortraitUpsideDown = 1 << 2;
const LandscapeRight = 1 << 3;
const LandscapeLeft = 1 << 4;
const Landscape = Self::LandscapeLeft.bits() | Self::LandscapeRight.bits();
const AllButUpsideDown = Self::Landscape.bits() | Self::Portrait.bits();
const All = Self::AllButUpsideDown.bits() | Self::PortraitUpsideDown.bits();
}
}
unsafe impl Encode for UIInterfaceOrientationMask {
const ENCODING: Encoding = NSUInteger::ENCODING;
}
+36
View File
@@ -0,0 +1,36 @@
use icrate::Foundation::NSObject;
use objc2::rc::Id;
use objc2::{extern_class, extern_methods, msg_send_id, mutability, ClassType};
use super::{UIResponder, UIScreen, UIView};
extern_class!(
#[derive(Debug, PartialEq, Eq, Hash)]
pub(crate) struct UIWindow;
unsafe impl ClassType for UIWindow {
#[inherits(UIResponder, NSObject)]
type Super = UIView;
type Mutability = mutability::InteriorMutable;
}
);
extern_methods!(
unsafe impl UIWindow {
pub fn screen(&self) -> Id<UIScreen> {
unsafe { msg_send_id![self, screen] }
}
#[method(setScreen:)]
pub fn setScreen(&self, screen: &UIScreen);
#[method(setHidden:)]
pub fn setHidden(&self, flag: bool);
#[method(makeKeyAndVisible)]
pub fn makeKeyAndVisible(&self);
#[method(isKeyWindow)]
pub fn isKeyWindow(&self) -> bool;
}
);
+601
View File
@@ -0,0 +1,601 @@
#![allow(clippy::unnecessary_cast)]
use std::cell::Cell;
use std::ptr::NonNull;
use icrate::Foundation::{CGFloat, CGRect, MainThreadMarker, NSObject, NSObjectProtocol, NSSet};
use objc2::declare::{Ivar, IvarDrop};
use objc2::rc::Id;
use objc2::runtime::AnyClass;
use objc2::{declare_class, extern_methods, msg_send, msg_send_id, mutability, ClassType};
use super::app_state::{self, EventWrapper};
use super::uikit::{
UIApplication, UIDevice, UIEvent, UIForceTouchCapability, UIInterfaceOrientationMask,
UIResponder, UIStatusBarStyle, UITouch, UITouchPhase, UITouchType, UITraitCollection, UIView,
UIViewController, UIWindow,
};
use super::window::WindowId;
use crate::{
dpi::PhysicalPosition,
event::{DeviceId as RootDeviceId, Event, Force, Touch, TouchPhase, WindowEvent},
platform::ios::ValidOrientations,
platform_impl::platform::{
ffi::{UIRectEdge, UIUserInterfaceIdiom},
window::PlatformSpecificWindowBuilderAttributes,
DeviceId, Fullscreen,
},
window::{WindowAttributes, WindowId as RootWindowId},
};
declare_class!(
pub(crate) struct WinitView;
unsafe impl ClassType for WinitView {
#[inherits(UIResponder, NSObject)]
type Super = UIView;
type Mutability = mutability::InteriorMutable;
const NAME: &'static str = "WinitUIView";
}
unsafe impl WinitView {
#[method(drawRect:)]
fn draw_rect(&self, rect: CGRect) {
let mtm = MainThreadMarker::new().unwrap();
let window = self.window().unwrap();
app_state::handle_nonuser_event(
mtm,
EventWrapper::StaticEvent(Event::WindowEvent {
window_id: RootWindowId(window.id()),
event: WindowEvent::RedrawRequested,
}),
);
let _: () = unsafe { msg_send![super(self), drawRect: rect] };
}
#[method(layoutSubviews)]
fn layout_subviews(&self) {
let mtm = MainThreadMarker::new().unwrap();
let _: () = unsafe { msg_send![super(self), layoutSubviews] };
let window = self.window().unwrap();
let window_bounds = window.bounds();
let screen = window.screen();
let screen_space = screen.coordinateSpace();
let screen_frame = self.convertRect_toCoordinateSpace(window_bounds, &screen_space);
let scale_factor = screen.scale();
let size = crate::dpi::LogicalSize {
width: screen_frame.size.width as f64,
height: screen_frame.size.height as f64,
}
.to_physical(scale_factor as f64);
// If the app is started in landscape, the view frame and window bounds can be mismatched.
// The view frame will be in portrait and the window bounds in landscape. So apply the
// window bounds to the view frame to make it consistent.
let view_frame = self.frame();
if view_frame != window_bounds {
self.setFrame(window_bounds);
}
app_state::handle_nonuser_event(
mtm,
EventWrapper::StaticEvent(Event::WindowEvent {
window_id: RootWindowId(window.id()),
event: WindowEvent::Resized(size),
}),
);
}
#[method(setContentScaleFactor:)]
fn set_content_scale_factor(&self, untrusted_scale_factor: CGFloat) {
let mtm = MainThreadMarker::new().unwrap();
let _: () =
unsafe { msg_send![super(self), setContentScaleFactor: untrusted_scale_factor] };
// `window` is null when `setContentScaleFactor` is invoked prior to `[UIWindow
// makeKeyAndVisible]` at window creation time (either manually or internally by
// UIKit when the `UIView` is first created), in which case we send no events here
let window = match self.window() {
Some(window) => window,
None => return,
};
// `setContentScaleFactor` may be called with a value of 0, which means "reset the
// content scale factor to a device-specific default value", so we can't use the
// parameter here. We can query the actual factor using the getter
let scale_factor = self.contentScaleFactor();
assert!(
!scale_factor.is_nan()
&& scale_factor.is_finite()
&& scale_factor.is_sign_positive()
&& scale_factor > 0.0,
"invalid scale_factor set on UIView",
);
let scale_factor = scale_factor as f64;
let bounds = self.bounds();
let screen = window.screen();
let screen_space = screen.coordinateSpace();
let screen_frame = self.convertRect_toCoordinateSpace(bounds, &screen_space);
let size = crate::dpi::LogicalSize {
width: screen_frame.size.width as f64,
height: screen_frame.size.height as f64,
};
let window_id = RootWindowId(window.id());
app_state::handle_nonuser_events(
mtm,
std::iter::once(EventWrapper::ScaleFactorChanged(
app_state::ScaleFactorChanged {
window,
scale_factor,
suggested_size: size.to_physical(scale_factor),
},
))
.chain(std::iter::once(EventWrapper::StaticEvent(
Event::WindowEvent {
window_id,
event: WindowEvent::Resized(size.to_physical(scale_factor)),
},
))),
);
}
#[method(touchesBegan:withEvent:)]
fn touches_began(&self, touches: &NSSet<UITouch>, _event: Option<&UIEvent>) {
self.handle_touches(touches)
}
#[method(touchesMoved:withEvent:)]
fn touches_moved(&self, touches: &NSSet<UITouch>, _event: Option<&UIEvent>) {
self.handle_touches(touches)
}
#[method(touchesEnded:withEvent:)]
fn touches_ended(&self, touches: &NSSet<UITouch>, _event: Option<&UIEvent>) {
self.handle_touches(touches)
}
#[method(touchesCancelled:withEvent:)]
fn touches_cancelled(&self, touches: &NSSet<UITouch>, _event: Option<&UIEvent>) {
self.handle_touches(touches)
}
}
);
extern_methods!(
#[allow(non_snake_case)]
unsafe impl WinitView {
fn window(&self) -> Option<Id<WinitUIWindow>> {
unsafe { msg_send_id![self, window] }
}
unsafe fn traitCollection(&self) -> Id<UITraitCollection> {
msg_send_id![self, traitCollection]
}
// TODO: Allow the user to customize this
#[method(layerClass)]
pub(crate) fn layerClass() -> &'static AnyClass;
}
);
impl WinitView {
pub(crate) fn new(
_mtm: MainThreadMarker,
_window_attributes: &WindowAttributes,
platform_attributes: &PlatformSpecificWindowBuilderAttributes,
frame: CGRect,
) -> Id<Self> {
let this: Id<Self> = unsafe { msg_send_id![Self::alloc(), initWithFrame: frame] };
this.setMultipleTouchEnabled(true);
if let Some(scale_factor) = platform_attributes.scale_factor {
this.setContentScaleFactor(scale_factor as _);
}
this
}
fn handle_touches(&self, touches: &NSSet<UITouch>) {
let window = self.window().unwrap();
let uiscreen = window.screen();
let mut touch_events = Vec::new();
let os_supports_force = app_state::os_capabilities().force_touch;
for touch in touches {
let logical_location = touch.locationInView(None);
let touch_type = touch.type_();
let force = if os_supports_force {
let trait_collection = unsafe { self.traitCollection() };
let touch_capability = trait_collection.forceTouchCapability();
// Both the OS _and_ the device need to be checked for force touch support.
if touch_capability == UIForceTouchCapability::Available
|| touch_type == UITouchType::Pencil
{
let force = touch.force();
let max_possible_force = touch.maximumPossibleForce();
let altitude_angle: Option<f64> = if touch_type == UITouchType::Pencil {
let angle = touch.altitudeAngle();
Some(angle as _)
} else {
None
};
Some(Force::Calibrated {
force: force as _,
max_possible_force: max_possible_force as _,
altitude_angle,
})
} else {
None
}
} else {
None
};
let touch_id = touch as *const UITouch as u64;
let phase = touch.phase();
let phase = match phase {
UITouchPhase::Began => TouchPhase::Started,
UITouchPhase::Moved => TouchPhase::Moved,
// 2 is UITouchPhase::Stationary and is not expected here
UITouchPhase::Ended => TouchPhase::Ended,
UITouchPhase::Cancelled => TouchPhase::Cancelled,
_ => panic!("unexpected touch phase: {:?}", phase as i32),
};
let physical_location = {
let scale_factor = self.contentScaleFactor();
PhysicalPosition::from_logical::<(f64, f64), f64>(
(logical_location.x as _, logical_location.y as _),
scale_factor as f64,
)
};
touch_events.push(EventWrapper::StaticEvent(Event::WindowEvent {
window_id: RootWindowId(window.id()),
event: WindowEvent::Touch(Touch {
device_id: RootDeviceId(DeviceId {
uiscreen: Id::as_ptr(&uiscreen),
}),
id: touch_id,
location: physical_location,
force,
phase,
}),
}));
}
let mtm = MainThreadMarker::new().unwrap();
app_state::handle_nonuser_events(mtm, touch_events);
}
}
pub struct ViewControllerState {
prefers_status_bar_hidden: Cell<bool>,
preferred_status_bar_style: Cell<UIStatusBarStyle>,
prefers_home_indicator_auto_hidden: Cell<bool>,
supported_orientations: Cell<UIInterfaceOrientationMask>,
preferred_screen_edges_deferring_system_gestures: Cell<UIRectEdge>,
}
declare_class!(
pub(crate) struct WinitViewController {
state: IvarDrop<Box<ViewControllerState>, "_state">,
}
mod view_controller_ivars;
unsafe impl ClassType for WinitViewController {
#[inherits(UIResponder, NSObject)]
type Super = UIViewController;
type Mutability = mutability::InteriorMutable;
const NAME: &'static str = "WinitUIViewController";
}
unsafe impl WinitViewController {
#[method(init)]
unsafe fn init(this: *mut Self) -> Option<NonNull<Self>> {
let this: Option<&mut Self> = msg_send![super(this), init];
this.map(|this| {
// These are set in WinitViewController::new, it's just to set them
// to _something_.
Ivar::write(
&mut this.state,
Box::new(ViewControllerState {
prefers_status_bar_hidden: Cell::new(false),
preferred_status_bar_style: Cell::new(UIStatusBarStyle::Default),
prefers_home_indicator_auto_hidden: Cell::new(false),
supported_orientations: Cell::new(UIInterfaceOrientationMask::All),
preferred_screen_edges_deferring_system_gestures: Cell::new(
UIRectEdge::NONE,
),
}),
);
NonNull::from(this)
})
}
}
unsafe impl WinitViewController {
#[method(shouldAutorotate)]
fn should_autorotate(&self) -> bool {
true
}
#[method(prefersStatusBarHidden)]
fn prefers_status_bar_hidden(&self) -> bool {
self.state.prefers_status_bar_hidden.get()
}
#[method(preferredStatusBarStyle)]
fn preferred_status_bar_style(&self) -> UIStatusBarStyle {
self.state.preferred_status_bar_style.get()
}
#[method(prefersHomeIndicatorAutoHidden)]
fn prefers_home_indicator_auto_hidden(&self) -> bool {
self.state.prefers_home_indicator_auto_hidden.get()
}
#[method(supportedInterfaceOrientations)]
fn supported_orientations(&self) -> UIInterfaceOrientationMask {
self.state.supported_orientations.get()
}
#[method(preferredScreenEdgesDeferringSystemGestures)]
fn preferred_screen_edges_deferring_system_gestures(&self) -> UIRectEdge {
self.state
.preferred_screen_edges_deferring_system_gestures
.get()
}
}
);
impl WinitViewController {
pub(crate) fn set_prefers_status_bar_hidden(&self, val: bool) {
self.state.prefers_status_bar_hidden.set(val);
self.setNeedsStatusBarAppearanceUpdate();
}
pub(crate) fn set_preferred_status_bar_style(&self, val: UIStatusBarStyle) {
self.state.preferred_status_bar_style.set(val);
self.setNeedsStatusBarAppearanceUpdate();
}
pub(crate) fn set_prefers_home_indicator_auto_hidden(&self, val: bool) {
self.state.prefers_home_indicator_auto_hidden.set(val);
let os_capabilities = app_state::os_capabilities();
if os_capabilities.home_indicator_hidden {
self.setNeedsUpdateOfHomeIndicatorAutoHidden();
} else {
os_capabilities.home_indicator_hidden_err_msg("ignoring")
}
}
pub(crate) fn set_preferred_screen_edges_deferring_system_gestures(&self, val: UIRectEdge) {
self.state
.preferred_screen_edges_deferring_system_gestures
.set(val);
let os_capabilities = app_state::os_capabilities();
if os_capabilities.defer_system_gestures {
self.setNeedsUpdateOfScreenEdgesDeferringSystemGestures();
} else {
os_capabilities.defer_system_gestures_err_msg("ignoring")
}
}
pub(crate) fn set_supported_interface_orientations(
&self,
mtm: MainThreadMarker,
valid_orientations: ValidOrientations,
) {
let mask = match (
valid_orientations,
UIDevice::current(mtm).userInterfaceIdiom(),
) {
(ValidOrientations::LandscapeAndPortrait, UIUserInterfaceIdiom::Phone) => {
UIInterfaceOrientationMask::AllButUpsideDown
}
(ValidOrientations::LandscapeAndPortrait, _) => UIInterfaceOrientationMask::All,
(ValidOrientations::Landscape, _) => UIInterfaceOrientationMask::Landscape,
(ValidOrientations::Portrait, UIUserInterfaceIdiom::Phone) => {
UIInterfaceOrientationMask::Portrait
}
(ValidOrientations::Portrait, _) => {
UIInterfaceOrientationMask::Portrait
| UIInterfaceOrientationMask::PortraitUpsideDown
}
};
self.state.supported_orientations.set(mask);
UIViewController::attemptRotationToDeviceOrientation();
}
pub(crate) fn new(
mtm: MainThreadMarker,
_window_attributes: &WindowAttributes,
platform_attributes: &PlatformSpecificWindowBuilderAttributes,
view: &UIView,
) -> Id<Self> {
let this: Id<Self> = unsafe { msg_send_id![Self::alloc(), init] };
this.set_prefers_status_bar_hidden(platform_attributes.prefers_status_bar_hidden);
this.set_preferred_status_bar_style(platform_attributes.preferred_status_bar_style.into());
this.set_supported_interface_orientations(mtm, platform_attributes.valid_orientations);
this.set_prefers_home_indicator_auto_hidden(
platform_attributes.prefers_home_indicator_hidden,
);
this.set_preferred_screen_edges_deferring_system_gestures(
platform_attributes
.preferred_screen_edges_deferring_system_gestures
.into(),
);
this.setView(Some(view));
this
}
}
declare_class!(
#[derive(Debug, PartialEq, Eq, Hash)]
pub(crate) struct WinitUIWindow;
unsafe impl ClassType for WinitUIWindow {
#[inherits(UIResponder, NSObject)]
type Super = UIWindow;
type Mutability = mutability::InteriorMutable;
const NAME: &'static str = "WinitUIWindow";
}
unsafe impl WinitUIWindow {
#[method(becomeKeyWindow)]
fn become_key_window(&self) {
let mtm = MainThreadMarker::new().unwrap();
app_state::handle_nonuser_event(
mtm,
EventWrapper::StaticEvent(Event::WindowEvent {
window_id: RootWindowId(self.id()),
event: WindowEvent::Focused(true),
}),
);
let _: () = unsafe { msg_send![super(self), becomeKeyWindow] };
}
#[method(resignKeyWindow)]
fn resign_key_window(&self) {
let mtm = MainThreadMarker::new().unwrap();
app_state::handle_nonuser_event(
mtm,
EventWrapper::StaticEvent(Event::WindowEvent {
window_id: RootWindowId(self.id()),
event: WindowEvent::Focused(false),
}),
);
let _: () = unsafe { msg_send![super(self), resignKeyWindow] };
}
}
);
impl WinitUIWindow {
pub(crate) fn new(
mtm: MainThreadMarker,
window_attributes: &WindowAttributes,
_platform_attributes: &PlatformSpecificWindowBuilderAttributes,
frame: CGRect,
view_controller: &UIViewController,
) -> Id<Self> {
let this: Id<Self> = unsafe { msg_send_id![Self::alloc(), initWithFrame: frame] };
this.setRootViewController(Some(view_controller));
match window_attributes.fullscreen.0.clone().map(Into::into) {
Some(Fullscreen::Exclusive(ref video_mode)) => {
let monitor = video_mode.monitor();
let screen = monitor.ui_screen(mtm);
screen.setCurrentMode(Some(video_mode.screen_mode(mtm)));
this.setScreen(screen);
}
Some(Fullscreen::Borderless(Some(ref monitor))) => {
let screen = monitor.ui_screen(mtm);
this.setScreen(screen);
}
_ => (),
}
this
}
pub(crate) fn id(&self) -> WindowId {
(self as *const Self as usize as u64).into()
}
}
declare_class!(
pub struct WinitApplicationDelegate;
unsafe impl ClassType for WinitApplicationDelegate {
type Super = NSObject;
type Mutability = mutability::InteriorMutable;
const NAME: &'static str = "WinitApplicationDelegate";
}
// UIApplicationDelegate protocol
unsafe impl WinitApplicationDelegate {
#[method(application:didFinishLaunchingWithOptions:)]
fn did_finish_launching(&self, _application: &UIApplication, _: *mut NSObject) -> bool {
app_state::did_finish_launching(MainThreadMarker::new().unwrap());
true
}
#[method(applicationDidBecomeActive:)]
fn did_become_active(&self, _application: &UIApplication) {
let mtm = MainThreadMarker::new().unwrap();
app_state::handle_nonuser_event(mtm, EventWrapper::StaticEvent(Event::Resumed))
}
#[method(applicationWillResignActive:)]
fn will_resign_active(&self, _application: &UIApplication) {
let mtm = MainThreadMarker::new().unwrap();
app_state::handle_nonuser_event(mtm, EventWrapper::StaticEvent(Event::Suspended))
}
#[method(applicationWillEnterForeground:)]
fn will_enter_foreground(&self, application: &UIApplication) {
self.send_occluded_event_for_all_windows(application, false);
}
#[method(applicationDidEnterBackground:)]
fn did_enter_background(&self, application: &UIApplication) {
self.send_occluded_event_for_all_windows(application, true);
}
#[method(applicationWillTerminate:)]
fn will_terminate(&self, application: &UIApplication) {
let mut events = Vec::new();
for window in application.windows().iter() {
if window.is_kind_of::<WinitUIWindow>() {
// SAFETY: We just checked that the window is a `winit` window
let window = unsafe {
let ptr: *const UIWindow = window;
let ptr: *const WinitUIWindow = ptr.cast();
&*ptr
};
events.push(EventWrapper::StaticEvent(Event::WindowEvent {
window_id: RootWindowId(window.id()),
event: WindowEvent::Destroyed,
}));
}
}
let mtm = MainThreadMarker::new().unwrap();
app_state::handle_nonuser_events(mtm, events);
app_state::terminated(mtm);
}
#[method(applicationDidReceiveMemoryWarning:)]
fn did_receive_memory_warning(&self, _application: &UIApplication) {
let mtm = MainThreadMarker::new().unwrap();
app_state::handle_nonuser_event(mtm, EventWrapper::StaticEvent(Event::MemoryWarning))
}
}
);
impl WinitApplicationDelegate {
fn send_occluded_event_for_all_windows(&self, application: &UIApplication, occluded: bool) {
let mut events = Vec::new();
for window in application.windows().iter() {
if window.is_kind_of::<WinitUIWindow>() {
// SAFETY: We just checked that the window is a `winit` window
let window = unsafe {
let ptr: *const UIWindow = window;
let ptr: *const WinitUIWindow = ptr.cast();
&*ptr
};
events.push(EventWrapper::StaticEvent(Event::WindowEvent {
window_id: RootWindowId(window.id()),
event: WindowEvent::Occluded(occluded),
}));
}
}
let mtm = MainThreadMarker::new().unwrap();
app_state::handle_nonuser_events(mtm, events);
}
}
+669
View File
@@ -0,0 +1,669 @@
#![allow(clippy::unnecessary_cast)]
use std::collections::VecDeque;
use icrate::Foundation::{CGFloat, CGPoint, CGRect, CGSize, MainThreadBound, MainThreadMarker};
use objc2::rc::Id;
use objc2::runtime::AnyObject;
use objc2::{class, msg_send};
use super::app_state::EventWrapper;
use super::uikit::{UIApplication, UIScreen, UIScreenOverscanCompensation};
use super::view::{WinitUIWindow, WinitView, WinitViewController};
use crate::{
dpi::{self, LogicalPosition, LogicalSize, PhysicalPosition, PhysicalSize, Position, Size},
error::{ExternalError, NotSupportedError, OsError as RootOsError},
event::{Event, WindowEvent},
icon::Icon,
platform::ios::{ScreenEdge, StatusBarStyle, ValidOrientations},
platform_impl::platform::{
app_state, monitor, EventLoopWindowTarget, Fullscreen, MonitorHandle,
},
window::{
CursorGrabMode, CursorIcon, ImePurpose, ResizeDirection, Theme, UserAttentionType,
WindowAttributes, WindowButtons, WindowId as RootWindowId, WindowLevel,
},
};
pub struct Inner {
window: Id<WinitUIWindow>,
view_controller: Id<WinitViewController>,
view: Id<WinitView>,
gl_or_metal_backed: bool,
}
impl Inner {
pub fn set_title(&self, _title: &str) {
debug!("`Window::set_title` is ignored on iOS")
}
pub fn set_transparent(&self, _transparent: bool) {
debug!("`Window::set_transparent` is ignored on iOS")
}
pub fn set_blur(&self, _blur: bool) {
debug!("`Window::set_blur` is ignored on iOS")
}
pub fn set_visible(&self, visible: bool) {
self.window.setHidden(!visible)
}
pub fn is_visible(&self) -> Option<bool> {
warn!("`Window::is_visible` is ignored on iOS");
None
}
pub fn request_redraw(&self) {
if self.gl_or_metal_backed {
let mtm = MainThreadMarker::new().unwrap();
// `setNeedsDisplay` does nothing on UIViews which are directly backed by CAEAGLLayer or CAMetalLayer.
// Ordinarily the OS sets up a bunch of UIKit state before calling drawRect: on a UIView, but when using
// raw or gl/metal for drawing this work is completely avoided.
//
// The docs for `setNeedsDisplay` don't mention `CAMetalLayer`; however, this has been confirmed via
// testing.
//
// https://developer.apple.com/documentation/uikit/uiview/1622437-setneedsdisplay?language=objc
app_state::queue_gl_or_metal_redraw(mtm, self.window.clone());
} else {
self.view.setNeedsDisplay();
}
}
pub fn pre_present_notify(&self) {}
pub fn inner_position(&self) -> Result<PhysicalPosition<i32>, NotSupportedError> {
let safe_area = self.safe_area_screen_space();
let position = LogicalPosition {
x: safe_area.origin.x as f64,
y: safe_area.origin.y as f64,
};
let scale_factor = self.scale_factor();
Ok(position.to_physical(scale_factor))
}
pub fn outer_position(&self) -> Result<PhysicalPosition<i32>, NotSupportedError> {
let screen_frame = self.screen_frame();
let position = LogicalPosition {
x: screen_frame.origin.x as f64,
y: screen_frame.origin.y as f64,
};
let scale_factor = self.scale_factor();
Ok(position.to_physical(scale_factor))
}
pub fn set_outer_position(&self, physical_position: Position) {
let scale_factor = self.scale_factor();
let position = physical_position.to_logical::<f64>(scale_factor);
let screen_frame = self.screen_frame();
let new_screen_frame = CGRect {
origin: CGPoint {
x: position.x as _,
y: position.y as _,
},
size: screen_frame.size,
};
let bounds = self.rect_from_screen_space(new_screen_frame);
self.window.setBounds(bounds);
}
pub fn inner_size(&self) -> PhysicalSize<u32> {
let scale_factor = self.scale_factor();
let safe_area = self.safe_area_screen_space();
let size = LogicalSize {
width: safe_area.size.width as f64,
height: safe_area.size.height as f64,
};
size.to_physical(scale_factor)
}
pub fn outer_size(&self) -> PhysicalSize<u32> {
let scale_factor = self.scale_factor();
let screen_frame = self.screen_frame();
let size = LogicalSize {
width: screen_frame.size.width as f64,
height: screen_frame.size.height as f64,
};
size.to_physical(scale_factor)
}
pub fn request_inner_size(&self, _size: Size) -> Option<PhysicalSize<u32>> {
Some(self.inner_size())
}
pub fn set_min_inner_size(&self, _dimensions: Option<Size>) {
warn!("`Window::set_min_inner_size` is ignored on iOS")
}
pub fn set_max_inner_size(&self, _dimensions: Option<Size>) {
warn!("`Window::set_max_inner_size` is ignored on iOS")
}
pub fn resize_increments(&self) -> Option<PhysicalSize<u32>> {
None
}
#[inline]
pub fn set_resize_increments(&self, _increments: Option<Size>) {
warn!("`Window::set_resize_increments` is ignored on iOS")
}
pub fn set_resizable(&self, _resizable: bool) {
warn!("`Window::set_resizable` is ignored on iOS")
}
pub fn is_resizable(&self) -> bool {
warn!("`Window::is_resizable` is ignored on iOS");
false
}
#[inline]
pub fn set_enabled_buttons(&self, _buttons: WindowButtons) {
warn!("`Window::set_enabled_buttons` is ignored on iOS");
}
#[inline]
pub fn enabled_buttons(&self) -> WindowButtons {
warn!("`Window::enabled_buttons` is ignored on iOS");
WindowButtons::all()
}
pub fn scale_factor(&self) -> f64 {
self.view.contentScaleFactor() as _
}
pub fn set_cursor_icon(&self, _cursor: CursorIcon) {
debug!("`Window::set_cursor_icon` ignored on iOS")
}
pub fn set_cursor_position(&self, _position: Position) -> Result<(), ExternalError> {
Err(ExternalError::NotSupported(NotSupportedError::new()))
}
pub fn set_cursor_grab(&self, _: CursorGrabMode) -> Result<(), ExternalError> {
Err(ExternalError::NotSupported(NotSupportedError::new()))
}
pub fn set_cursor_visible(&self, _visible: bool) {
debug!("`Window::set_cursor_visible` is ignored on iOS")
}
pub fn drag_window(&self) -> Result<(), ExternalError> {
Err(ExternalError::NotSupported(NotSupportedError::new()))
}
pub fn drag_resize_window(&self, _direction: ResizeDirection) -> Result<(), ExternalError> {
Err(ExternalError::NotSupported(NotSupportedError::new()))
}
#[inline]
pub fn show_window_menu(&self, _position: Position) {}
pub fn set_cursor_hittest(&self, _hittest: bool) -> Result<(), ExternalError> {
Err(ExternalError::NotSupported(NotSupportedError::new()))
}
pub fn set_minimized(&self, _minimized: bool) {
warn!("`Window::set_minimized` is ignored on iOS")
}
pub fn is_minimized(&self) -> Option<bool> {
warn!("`Window::is_minimized` is ignored on iOS");
None
}
pub fn set_maximized(&self, _maximized: bool) {
warn!("`Window::set_maximized` is ignored on iOS")
}
pub fn is_maximized(&self) -> bool {
warn!("`Window::is_maximized` is ignored on iOS");
false
}
pub(crate) fn set_fullscreen(&self, monitor: Option<Fullscreen>) {
let mtm = MainThreadMarker::new().unwrap();
let uiscreen = match &monitor {
Some(Fullscreen::Exclusive(video_mode)) => {
let uiscreen = video_mode.monitor.ui_screen(mtm);
uiscreen.setCurrentMode(Some(video_mode.screen_mode(mtm)));
uiscreen.clone()
}
Some(Fullscreen::Borderless(Some(monitor))) => monitor.ui_screen(mtm).clone(),
Some(Fullscreen::Borderless(None)) => {
self.current_monitor_inner().ui_screen(mtm).clone()
}
None => {
warn!("`Window::set_fullscreen(None)` ignored on iOS");
return;
}
};
// this is pretty slow on iOS, so avoid doing it if we can
let current = self.window.screen();
if uiscreen != current {
self.window.setScreen(&uiscreen);
}
let bounds = uiscreen.bounds();
self.window.setFrame(bounds);
// For external displays, we must disable overscan compensation or
// the displayed image will have giant black bars surrounding it on
// each side
uiscreen.setOverscanCompensation(UIScreenOverscanCompensation::None);
}
pub(crate) fn fullscreen(&self) -> Option<Fullscreen> {
let mtm = MainThreadMarker::new().unwrap();
let monitor = self.current_monitor_inner();
let uiscreen = monitor.ui_screen(mtm);
let screen_space_bounds = self.screen_frame();
let screen_bounds = uiscreen.bounds();
// TODO: track fullscreen instead of relying on brittle float comparisons
if screen_space_bounds.origin.x == screen_bounds.origin.x
&& screen_space_bounds.origin.y == screen_bounds.origin.y
&& screen_space_bounds.size.width == screen_bounds.size.width
&& screen_space_bounds.size.height == screen_bounds.size.height
{
Some(Fullscreen::Borderless(Some(monitor)))
} else {
None
}
}
pub fn set_decorations(&self, _decorations: bool) {}
pub fn is_decorated(&self) -> bool {
true
}
pub fn set_window_level(&self, _level: WindowLevel) {
warn!("`Window::set_window_level` is ignored on iOS")
}
pub fn set_window_icon(&self, _icon: Option<Icon>) {
warn!("`Window::set_window_icon` is ignored on iOS")
}
pub fn set_ime_cursor_area(&self, _position: Position, _size: Size) {
warn!("`Window::set_ime_cursor_area` is ignored on iOS")
}
pub fn set_ime_allowed(&self, _allowed: bool) {
warn!("`Window::set_ime_allowed` is ignored on iOS")
}
pub fn set_ime_purpose(&self, _purpose: ImePurpose) {
warn!("`Window::set_ime_allowed` is ignored on iOS")
}
pub fn focus_window(&self) {
warn!("`Window::set_focus` is ignored on iOS")
}
pub fn request_user_attention(&self, _request_type: Option<UserAttentionType>) {
warn!("`Window::request_user_attention` is ignored on iOS")
}
// Allow directly accessing the current monitor internally without unwrapping.
fn current_monitor_inner(&self) -> MonitorHandle {
MonitorHandle::new(self.window.screen())
}
pub fn current_monitor(&self) -> Option<MonitorHandle> {
Some(self.current_monitor_inner())
}
pub fn available_monitors(&self) -> VecDeque<MonitorHandle> {
monitor::uiscreens(MainThreadMarker::new().unwrap())
}
pub fn primary_monitor(&self) -> Option<MonitorHandle> {
Some(MonitorHandle::new(UIScreen::main(
MainThreadMarker::new().unwrap(),
)))
}
pub fn id(&self) -> WindowId {
self.window.id()
}
#[cfg(feature = "rwh_04")]
pub fn raw_window_handle_rwh_04(&self) -> rwh_04::RawWindowHandle {
let mut window_handle = rwh_04::UiKitHandle::empty();
window_handle.ui_window = Id::as_ptr(&self.window) as _;
window_handle.ui_view = Id::as_ptr(&self.view) as _;
window_handle.ui_view_controller = Id::as_ptr(&self.view_controller) as _;
rwh_04::RawWindowHandle::UiKit(window_handle)
}
#[cfg(feature = "rwh_05")]
pub fn raw_window_handle_rwh_05(&self) -> rwh_05::RawWindowHandle {
let mut window_handle = rwh_05::UiKitWindowHandle::empty();
window_handle.ui_window = Id::as_ptr(&self.window) as _;
window_handle.ui_view = Id::as_ptr(&self.view) as _;
window_handle.ui_view_controller = Id::as_ptr(&self.view_controller) as _;
rwh_05::RawWindowHandle::UiKit(window_handle)
}
#[cfg(feature = "rwh_05")]
pub fn raw_display_handle_rwh_05(&self) -> rwh_05::RawDisplayHandle {
rwh_05::RawDisplayHandle::UiKit(rwh_05::UiKitDisplayHandle::empty())
}
#[cfg(feature = "rwh_06")]
pub fn raw_window_handle_rwh_06(&self) -> Result<rwh_06::RawWindowHandle, rwh_06::HandleError> {
let mut window_handle = rwh_06::UiKitWindowHandle::new({
let ui_view = Id::as_ptr(&self.view) as _;
std::ptr::NonNull::new(ui_view).expect("Id<T> should never be null")
});
window_handle.ui_view_controller =
std::ptr::NonNull::new(Id::as_ptr(&self.view_controller) as _);
Ok(rwh_06::RawWindowHandle::UiKit(window_handle))
}
#[cfg(feature = "rwh_06")]
pub fn raw_display_handle_rwh_06(
&self,
) -> Result<rwh_06::RawDisplayHandle, rwh_06::HandleError> {
Ok(rwh_06::RawDisplayHandle::UiKit(
rwh_06::UiKitDisplayHandle::new(),
))
}
pub fn theme(&self) -> Option<Theme> {
warn!("`Window::theme` is ignored on iOS");
None
}
pub fn set_content_protected(&self, _protected: bool) {}
pub fn has_focus(&self) -> bool {
self.window.isKeyWindow()
}
#[inline]
pub fn set_theme(&self, _theme: Option<Theme>) {
warn!("`Window::set_theme` is ignored on iOS");
}
pub fn title(&self) -> String {
warn!("`Window::title` is ignored on iOS");
String::new()
}
pub fn reset_dead_keys(&self) {
// Noop
}
}
pub struct Window {
inner: MainThreadBound<Inner>,
}
impl Window {
pub(crate) fn new<T>(
event_loop: &EventLoopWindowTarget<T>,
window_attributes: WindowAttributes,
platform_attributes: PlatformSpecificWindowBuilderAttributes,
) -> Result<Window, RootOsError> {
let mtm = event_loop.mtm;
if window_attributes.min_inner_size.is_some() {
warn!("`WindowAttributes::min_inner_size` is ignored on iOS");
}
if window_attributes.max_inner_size.is_some() {
warn!("`WindowAttributes::max_inner_size` is ignored on iOS");
}
// TODO: transparency, visible
let main_screen = UIScreen::main(mtm);
let fullscreen = window_attributes.fullscreen.0.clone().map(Into::into);
let screen = match fullscreen {
Some(Fullscreen::Exclusive(ref video_mode)) => video_mode.monitor.ui_screen(mtm),
Some(Fullscreen::Borderless(Some(ref monitor))) => monitor.ui_screen(mtm),
Some(Fullscreen::Borderless(None)) | None => &main_screen,
};
let screen_bounds = screen.bounds();
let frame = match window_attributes.inner_size {
Some(dim) => {
let scale_factor = screen.scale();
let size = dim.to_logical::<f64>(scale_factor as f64);
CGRect {
origin: screen_bounds.origin,
size: CGSize {
width: size.width as _,
height: size.height as _,
},
}
}
None => screen_bounds,
};
let view = WinitView::new(mtm, &window_attributes, &platform_attributes, frame);
let gl_or_metal_backed = unsafe {
let layer_class = WinitView::layerClass();
let is_metal = msg_send![layer_class, isSubclassOfClass: class!(CAMetalLayer)];
let is_gl = msg_send![layer_class, isSubclassOfClass: class!(CAEAGLLayer)];
is_metal || is_gl
};
let view_controller =
WinitViewController::new(mtm, &window_attributes, &platform_attributes, &view);
let window = WinitUIWindow::new(
mtm,
&window_attributes,
&platform_attributes,
frame,
&view_controller,
);
app_state::set_key_window(mtm, &window);
// Like the Windows and macOS backends, we send a `ScaleFactorChanged` and `Resized`
// event on window creation if the DPI factor != 1.0
let scale_factor = view.contentScaleFactor();
let scale_factor = scale_factor as f64;
if scale_factor != 1.0 {
let bounds = view.bounds();
let screen = window.screen();
let screen_space = screen.coordinateSpace();
let screen_frame = view.convertRect_toCoordinateSpace(bounds, &screen_space);
let size = crate::dpi::LogicalSize {
width: screen_frame.size.width as f64,
height: screen_frame.size.height as f64,
};
let window_id = RootWindowId(window.id());
app_state::handle_nonuser_events(
mtm,
std::iter::once(EventWrapper::ScaleFactorChanged(
app_state::ScaleFactorChanged {
window: window.clone(),
scale_factor,
suggested_size: size.to_physical(scale_factor),
},
))
.chain(std::iter::once(EventWrapper::StaticEvent(
Event::WindowEvent {
window_id,
event: WindowEvent::Resized(size.to_physical(scale_factor)),
},
))),
);
}
let inner = Inner {
window,
view_controller,
view,
gl_or_metal_backed,
};
Ok(Window {
inner: MainThreadBound::new(inner, mtm),
})
}
pub(crate) fn maybe_queue_on_main(&self, f: impl FnOnce(&Inner) + Send + 'static) {
// For now, don't actually do queuing, since it may be less predictable
self.maybe_wait_on_main(f)
}
pub(crate) fn maybe_wait_on_main<R: Send>(&self, f: impl FnOnce(&Inner) -> R + Send) -> R {
self.inner.get_on_main(|inner, _mtm| f(inner))
}
}
// WindowExtIOS
impl Inner {
pub fn set_scale_factor(&self, scale_factor: f64) {
assert!(
dpi::validate_scale_factor(scale_factor),
"`WindowExtIOS::set_scale_factor` received an invalid hidpi factor"
);
let scale_factor = scale_factor as CGFloat;
self.view.setContentScaleFactor(scale_factor);
}
pub fn set_valid_orientations(&self, valid_orientations: ValidOrientations) {
self.view_controller.set_supported_interface_orientations(
MainThreadMarker::new().unwrap(),
valid_orientations,
);
}
pub fn set_prefers_home_indicator_hidden(&self, hidden: bool) {
self.view_controller
.set_prefers_home_indicator_auto_hidden(hidden);
}
pub fn set_preferred_screen_edges_deferring_system_gestures(&self, edges: ScreenEdge) {
self.view_controller
.set_preferred_screen_edges_deferring_system_gestures(edges.into());
}
pub fn set_prefers_status_bar_hidden(&self, hidden: bool) {
self.view_controller.set_prefers_status_bar_hidden(hidden);
}
pub fn set_preferred_status_bar_style(&self, status_bar_style: StatusBarStyle) {
self.view_controller
.set_preferred_status_bar_style(status_bar_style.into());
}
}
impl Inner {
fn screen_frame(&self) -> CGRect {
self.rect_to_screen_space(self.window.bounds())
}
fn rect_to_screen_space(&self, rect: CGRect) -> CGRect {
let screen_space = self.window.screen().coordinateSpace();
self.window
.convertRect_toCoordinateSpace(rect, &screen_space)
}
fn rect_from_screen_space(&self, rect: CGRect) -> CGRect {
let screen_space = self.window.screen().coordinateSpace();
self.window
.convertRect_fromCoordinateSpace(rect, &screen_space)
}
fn safe_area_screen_space(&self) -> CGRect {
let bounds = self.window.bounds();
if app_state::os_capabilities().safe_area {
let safe_area = self.window.safeAreaInsets();
let safe_bounds = CGRect {
origin: CGPoint {
x: bounds.origin.x + safe_area.left,
y: bounds.origin.y + safe_area.top,
},
size: CGSize {
width: bounds.size.width - safe_area.left - safe_area.right,
height: bounds.size.height - safe_area.top - safe_area.bottom,
},
};
self.rect_to_screen_space(safe_bounds)
} else {
let screen_frame = self.rect_to_screen_space(bounds);
let status_bar_frame = {
let app = UIApplication::shared(MainThreadMarker::new().unwrap()).expect(
"`Window::get_inner_position` cannot be called before `EventLoop::run` on iOS",
);
app.statusBarFrame()
};
let (y, height) = if screen_frame.origin.y > status_bar_frame.size.height {
(screen_frame.origin.y, screen_frame.size.height)
} else {
let y = status_bar_frame.size.height;
let height = screen_frame.size.height
- (status_bar_frame.size.height - screen_frame.origin.y);
(y, height)
};
CGRect {
origin: CGPoint {
x: screen_frame.origin.x,
y,
},
size: CGSize {
width: screen_frame.size.width,
height,
},
}
}
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct WindowId {
window: *mut WinitUIWindow,
}
impl WindowId {
pub const unsafe fn dummy() -> Self {
WindowId {
window: std::ptr::null_mut(),
}
}
}
impl From<WindowId> for u64 {
fn from(window_id: WindowId) -> Self {
window_id.window as u64
}
}
impl From<u64> for WindowId {
fn from(raw_id: u64) -> Self {
Self {
window: raw_id as _,
}
}
}
unsafe impl Send for WindowId {}
unsafe impl Sync for WindowId {}
impl From<&AnyObject> for WindowId {
fn from(window: &AnyObject) -> WindowId {
WindowId {
window: window as *const _ as _,
}
}
}
#[derive(Clone, Default)]
pub struct PlatformSpecificWindowBuilderAttributes {
pub scale_factor: Option<f64>,
pub valid_orientations: ValidOrientations,
pub prefers_home_indicator_hidden: bool,
pub prefers_status_bar_hidden: bool,
pub preferred_status_bar_style: StatusBarStyle,
pub preferred_screen_edges_deferring_system_gestures: ScreenEdge,
}
+1
View File
@@ -0,0 +1 @@
pub mod xkb;
@@ -0,0 +1,124 @@
//! XKB compose handling.
use std::env;
use std::ffi::CString;
use std::ops::Deref;
use std::os::unix::ffi::OsStringExt;
use std::ptr::NonNull;
use super::XkbContext;
use super::XKBCH;
use smol_str::SmolStr;
use xkbcommon_dl::{
xkb_compose_compile_flags, xkb_compose_feed_result, xkb_compose_state, xkb_compose_state_flags,
xkb_compose_status, xkb_compose_table, xkb_keysym_t,
};
#[derive(Debug)]
pub struct XkbComposeTable {
table: NonNull<xkb_compose_table>,
}
impl XkbComposeTable {
pub fn new(context: &XkbContext) -> Option<Self> {
let locale = env::var_os("LC_ALL")
.and_then(|v| if v.is_empty() { None } else { Some(v) })
.or_else(|| env::var_os("LC_CTYPE"))
.and_then(|v| if v.is_empty() { None } else { Some(v) })
.or_else(|| env::var_os("LANG"))
.and_then(|v| if v.is_empty() { None } else { Some(v) })
.unwrap_or_else(|| "C".into());
let locale = CString::new(locale.into_vec()).unwrap();
let table = unsafe {
(XKBCH.xkb_compose_table_new_from_locale)(
context.as_ptr(),
locale.as_ptr(),
xkb_compose_compile_flags::XKB_COMPOSE_COMPILE_NO_FLAGS,
)
};
let table = NonNull::new(table)?;
Some(Self { table })
}
/// Create new state with the given compose table.
pub fn new_state(&self) -> Option<XkbComposeState> {
let state = unsafe {
(XKBCH.xkb_compose_state_new)(
self.table.as_ptr(),
xkb_compose_state_flags::XKB_COMPOSE_STATE_NO_FLAGS,
)
};
let state = NonNull::new(state)?;
Some(XkbComposeState { state })
}
}
impl Deref for XkbComposeTable {
type Target = NonNull<xkb_compose_table>;
fn deref(&self) -> &Self::Target {
&self.table
}
}
impl Drop for XkbComposeTable {
fn drop(&mut self) {
unsafe {
(XKBCH.xkb_compose_table_unref)(self.table.as_ptr());
}
}
}
#[derive(Debug)]
pub struct XkbComposeState {
state: NonNull<xkb_compose_state>,
}
impl XkbComposeState {
pub fn get_string(&mut self, scratch_buffer: &mut Vec<u8>) -> Option<SmolStr> {
super::make_string_with(scratch_buffer, |ptr, len| unsafe {
(XKBCH.xkb_compose_state_get_utf8)(self.state.as_ptr(), ptr, len)
})
}
#[inline]
pub fn feed(&mut self, keysym: xkb_keysym_t) -> ComposeStatus {
let feed_result = unsafe { (XKBCH.xkb_compose_state_feed)(self.state.as_ptr(), keysym) };
match feed_result {
xkb_compose_feed_result::XKB_COMPOSE_FEED_IGNORED => ComposeStatus::Ignored,
xkb_compose_feed_result::XKB_COMPOSE_FEED_ACCEPTED => {
ComposeStatus::Accepted(self.status())
}
}
}
#[inline]
pub fn reset(&mut self) {
unsafe {
(XKBCH.xkb_compose_state_reset)(self.state.as_ptr());
}
}
#[inline]
pub fn status(&mut self) -> xkb_compose_status {
unsafe { (XKBCH.xkb_compose_state_get_status)(self.state.as_ptr()) }
}
}
impl Drop for XkbComposeState {
fn drop(&mut self) {
unsafe {
(XKBCH.xkb_compose_state_unref)(self.state.as_ptr());
};
}
}
#[derive(Copy, Clone, Debug)]
pub enum ComposeStatus {
Accepted(xkb_compose_status),
Ignored,
None,
}
File diff suppressed because it is too large. Load diff
+465
View File
@@ -0,0 +1,465 @@
use std::ops::Deref;
use std::os::raw::c_char;
use std::ptr::{self, NonNull};
use std::sync::atomic::{AtomicBool, Ordering};
use log::warn;
use once_cell::sync::Lazy;
use smol_str::SmolStr;
#[cfg(wayland_platform)]
use std::os::unix::io::OwnedFd;
use xkbcommon_dl::{
self as xkb, xkb_compose_status, xkb_context, xkb_context_flags, xkbcommon_compose_handle,
xkbcommon_handle, XkbCommon, XkbCommonCompose,
};
#[cfg(x11_platform)]
use {x11_dl::xlib_xcb::xcb_connection_t, xkbcommon_dl::x11::xkbcommon_x11_handle};
use crate::event::ElementState;
use crate::event::KeyEvent;
use crate::keyboard::{Key, KeyLocation};
use crate::platform_impl::KeyEventExtra;
mod compose;
mod keymap;
mod state;
use compose::{ComposeStatus, XkbComposeState, XkbComposeTable};
use keymap::XkbKeymap;
#[cfg(x11_platform)]
pub use keymap::raw_keycode_to_physicalkey;
pub use keymap::{physicalkey_to_scancode, scancode_to_keycode};
pub use state::XkbState;
// TODO: Wire this up without using a static `AtomicBool`.
static RESET_DEAD_KEYS: AtomicBool = AtomicBool::new(false);
static XKBH: Lazy<&'static XkbCommon> = Lazy::new(xkbcommon_handle);
static XKBCH: Lazy<&'static XkbCommonCompose> = Lazy::new(xkbcommon_compose_handle);
#[cfg(feature = "x11")]
static XKBXH: Lazy<&'static xkb::x11::XkbCommonX11> = Lazy::new(xkbcommon_x11_handle);
#[inline(always)]
pub fn reset_dead_keys() {
RESET_DEAD_KEYS.store(true, Ordering::SeqCst);
}
#[derive(Debug)]
pub enum Error {
/// libxkbcommon is not available
XKBNotFound,
}
#[derive(Debug)]
pub struct Context {
// NOTE: field order matters.
#[cfg(x11_platform)]
pub core_keyboard_id: i32,
state: Option<XkbState>,
keymap: Option<XkbKeymap>,
compose_state1: Option<XkbComposeState>,
compose_state2: Option<XkbComposeState>,
_compose_table: Option<XkbComposeTable>,
context: XkbContext,
scratch_buffer: Vec<u8>,
}
impl Context {
pub fn new() -> Result<Self, Error> {
if xkb::xkbcommon_option().is_none() {
return Err(Error::XKBNotFound);
}
let context = XkbContext::new()?;
let mut compose_table = XkbComposeTable::new(&context);
let mut compose_state1 = compose_table.as_ref().and_then(|table| table.new_state());
let mut compose_state2 = compose_table.as_ref().and_then(|table| table.new_state());
// Disable compose if anything compose related failed to initialize.
if compose_table.is_none() || compose_state1.is_none() || compose_state2.is_none() {
compose_state2 = None;
compose_state1 = None;
compose_table = None;
}
Ok(Self {
state: None,
keymap: None,
compose_state1,
compose_state2,
#[cfg(x11_platform)]
core_keyboard_id: 0,
_compose_table: compose_table,
context,
scratch_buffer: Vec::with_capacity(8),
})
}
#[cfg(feature = "x11")]
pub fn from_x11_xkb(xcb: *mut xcb_connection_t) -> Result<Self, Error> {
let result = unsafe {
(XKBXH.xkb_x11_setup_xkb_extension)(
xcb,
1,
2,
xkbcommon_dl::x11::xkb_x11_setup_xkb_extension_flags::XKB_X11_SETUP_XKB_EXTENSION_NO_FLAGS,
ptr::null_mut(),
ptr::null_mut(),
ptr::null_mut(),
ptr::null_mut(),
)
};
if result != 1 {
return Err(Error::XKBNotFound);
}
let mut this = Self::new()?;
this.core_keyboard_id = unsafe { (XKBXH.xkb_x11_get_core_keyboard_device_id)(xcb) };
this.set_keymap_from_x11(xcb);
Ok(this)
}
pub fn state_mut(&mut self) -> Option<&mut XkbState> {
self.state.as_mut()
}
pub fn keymap_mut(&mut self) -> Option<&mut XkbKeymap> {
self.keymap.as_mut()
}
#[cfg(wayland_platform)]
pub fn set_keymap_from_fd(&mut self, fd: OwnedFd, size: usize) {
let keymap = XkbKeymap::from_fd(&self.context, fd, size);
let state = keymap.as_ref().and_then(XkbState::new_wayland);
if keymap.is_none() || state.is_none() {
warn!("failed to update xkb keymap");
}
self.state = state;
self.keymap = keymap;
}
#[cfg(x11_platform)]
pub fn set_keymap_from_x11(&mut self, xcb: *mut xcb_connection_t) {
let keymap = XkbKeymap::from_x11_keymap(&self.context, xcb, self.core_keyboard_id);
let state = keymap
.as_ref()
.and_then(|keymap| XkbState::new_x11(xcb, keymap));
if keymap.is_none() || state.is_none() {
warn!("failed to update xkb keymap");
}
self.state = state;
self.keymap = keymap;
}
/// Key builder context with the user provided xkb state.
pub fn key_context(&mut self) -> Option<KeyContext<'_>> {
let state = self.state.as_mut()?;
let keymap = self.keymap.as_mut()?;
let compose_state1 = self.compose_state1.as_mut();
let compose_state2 = self.compose_state2.as_mut();
let scratch_buffer = &mut self.scratch_buffer;
Some(KeyContext {
state,
keymap,
compose_state1,
compose_state2,
scratch_buffer,
})
}
/// Key builder context with the user provided xkb state.
///
/// Should be used when the original context must not be altered.
#[cfg(x11_platform)]
pub fn key_context_with_state<'a>(
&'a mut self,
state: &'a mut XkbState,
) -> Option<KeyContext<'a>> {
let keymap = self.keymap.as_mut()?;
let compose_state1 = self.compose_state1.as_mut();
let compose_state2 = self.compose_state2.as_mut();
let scratch_buffer = &mut self.scratch_buffer;
Some(KeyContext {
state,
keymap,
compose_state1,
compose_state2,
scratch_buffer,
})
}
}
pub struct KeyContext<'a> {
pub state: &'a mut XkbState,
pub keymap: &'a mut XkbKeymap,
compose_state1: Option<&'a mut XkbComposeState>,
compose_state2: Option<&'a mut XkbComposeState>,
scratch_buffer: &'a mut Vec<u8>,
}
impl<'a> KeyContext<'a> {
pub fn process_key_event(
&mut self,
keycode: u32,
state: ElementState,
repeat: bool,
) -> KeyEvent {
let mut event =
KeyEventResults::new(self, keycode, !repeat && state == ElementState::Pressed);
let physical_key = keymap::raw_keycode_to_physicalkey(keycode);
let (logical_key, location) = event.key();
let text = event.text();
let (key_without_modifiers, _) = event.key_without_modifiers();
let text_with_all_modifiers = event.text_with_all_modifiers();
let platform_specific = KeyEventExtra {
text_with_all_modifiers,
key_without_modifiers,
};
KeyEvent {
physical_key,
logical_key,
text,
location,
state,
repeat,
platform_specific,
}
}
fn keysym_to_utf8_raw(&mut self, keysym: u32) -> Option<SmolStr> {
self.scratch_buffer.clear();
self.scratch_buffer.reserve(8);
loop {
let bytes_written = unsafe {
(XKBH.xkb_keysym_to_utf8)(
keysym,
self.scratch_buffer.as_mut_ptr().cast(),
self.scratch_buffer.capacity(),
)
};
if bytes_written == 0 {
return None;
} else if bytes_written == -1 {
self.scratch_buffer.reserve(8);
} else {
unsafe {
self.scratch_buffer
.set_len(bytes_written.try_into().unwrap())
};
break;
}
}
// Remove the null-terminator
self.scratch_buffer.pop();
byte_slice_to_smol_str(self.scratch_buffer)
}
}
struct KeyEventResults<'a, 'b> {
context: &'a mut KeyContext<'b>,
keycode: u32,
keysym: u32,
compose: ComposeStatus,
}
impl<'a, 'b> KeyEventResults<'a, 'b> {
fn new(context: &'a mut KeyContext<'b>, keycode: u32, compose: bool) -> Self {
let keysym = context.state.get_one_sym_raw(keycode);
let compose = if let Some(state) = context.compose_state1.as_mut().filter(|_| compose) {
if RESET_DEAD_KEYS.swap(false, Ordering::SeqCst) {
state.reset();
context.compose_state2.as_mut().unwrap().reset();
}
state.feed(keysym)
} else {
ComposeStatus::None
};
KeyEventResults {
context,
keycode,
keysym,
compose,
}
}
pub fn key(&mut self) -> (Key, KeyLocation) {
let (key, location) = match self.keysym_to_key(self.keysym) {
Ok(known) => return known,
Err(undefined) => undefined,
};
if let ComposeStatus::Accepted(xkb_compose_status::XKB_COMPOSE_COMPOSING) = self.compose {
let compose_state = self.context.compose_state2.as_mut().unwrap();
// When pressing a dead key twice, the non-combining variant of that character will
// be produced. Since this function only concerns itself with a single keypress, we
// simulate this double press here by feeding the keysym to the compose state
// twice.
compose_state.feed(self.keysym);
if matches!(compose_state.feed(self.keysym), ComposeStatus::Accepted(_)) {
// Extracting only a single `char` here *should* be fine, assuming that no
// dead key's non-combining variant ever occupies more than one `char`.
let text = compose_state.get_string(self.context.scratch_buffer);
let key = Key::Dead(text.and_then(|s| s.chars().next()));
(key, location)
} else {
(key, location)
}
} else {
let key = self
.composed_text()
.unwrap_or_else(|_| self.context.keysym_to_utf8_raw(self.keysym))
.map(Key::Character)
.unwrap_or(key);
(key, location)
}
}
pub fn key_without_modifiers(&mut self) -> (Key, KeyLocation) {
// This will become a pointer to an array which libxkbcommon owns, so we don't need to deallocate it.
let layout = self.context.state.layout(self.keycode);
let keysym = self
.context
.keymap
.first_keysym_by_level(layout, self.keycode);
match self.keysym_to_key(keysym) {
Ok((key, location)) => (key, location),
Err((key, location)) => {
let key = self
.context
.keysym_to_utf8_raw(keysym)
.map(Key::Character)
.unwrap_or(key);
(key, location)
}
}
}
fn keysym_to_key(&self, keysym: u32) -> Result<(Key, KeyLocation), (Key, KeyLocation)> {
let location = keymap::keysym_location(keysym);
let key = keymap::keysym_to_key(keysym);
if matches!(key, Key::Unidentified(_)) {
Err((key, location))
} else {
Ok((key, location))
}
}
pub fn text(&mut self) -> Option<SmolStr> {
self.composed_text()
.unwrap_or_else(|_| self.context.keysym_to_utf8_raw(self.keysym))
}
// The current behaviour makes it so composing a character overrides attempts to input a
// control character with the `Ctrl` key. We can potentially add a configuration option
// if someone specifically wants the oppsite behaviour.
pub fn text_with_all_modifiers(&mut self) -> Option<SmolStr> {
match self.composed_text() {
Ok(text) => text,
Err(_) => self
.context
.state
.get_utf8_raw(self.keycode, self.context.scratch_buffer),
}
}
fn composed_text(&mut self) -> Result<Option<SmolStr>, ()> {
match self.compose {
ComposeStatus::Accepted(status) => match status {
xkb_compose_status::XKB_COMPOSE_COMPOSED => {
let state = self.context.compose_state1.as_mut().unwrap();
Ok(state.get_string(self.context.scratch_buffer))
}
xkb_compose_status::XKB_COMPOSE_COMPOSING
| xkb_compose_status::XKB_COMPOSE_CANCELLED => Ok(None),
xkb_compose_status::XKB_COMPOSE_NOTHING => Err(()),
},
_ => Err(()),
}
}
}
#[derive(Debug)]
pub struct XkbContext {
context: NonNull<xkb_context>,
}
impl XkbContext {
pub fn new() -> Result<Self, Error> {
let context = unsafe { (XKBH.xkb_context_new)(xkb_context_flags::XKB_CONTEXT_NO_FLAGS) };
let context = match NonNull::new(context) {
Some(context) => context,
None => return Err(Error::XKBNotFound),
};
Ok(Self { context })
}
}
impl Drop for XkbContext {
fn drop(&mut self) {
unsafe {
(XKBH.xkb_context_unref)(self.context.as_ptr());
}
}
}
impl Deref for XkbContext {
type Target = NonNull<xkb_context>;
fn deref(&self) -> &Self::Target {
&self.context
}
}
/// Shared logic for constructing a string with `xkb_compose_state_get_utf8` and
/// `xkb_state_key_get_utf8`.
fn make_string_with<F>(scratch_buffer: &mut Vec<u8>, mut f: F) -> Option<SmolStr>
where
F: FnMut(*mut c_char, usize) -> i32,
{
let size = f(ptr::null_mut(), 0);
if size == 0 {
return None;
}
let size = usize::try_from(size).unwrap();
scratch_buffer.clear();
// The allocated buffer must include space for the null-terminator.
scratch_buffer.reserve(size + 1);
unsafe {
let written = f(
scratch_buffer.as_mut_ptr().cast(),
scratch_buffer.capacity(),
);
if usize::try_from(written).unwrap() != size {
// This will likely never happen.
return None;
}
scratch_buffer.set_len(size);
};
byte_slice_to_smol_str(scratch_buffer)
}
// NOTE: This is track_caller so we can have more informative line numbers when logging
#[track_caller]
fn byte_slice_to_smol_str(bytes: &[u8]) -> Option<SmolStr> {
std::str::from_utf8(bytes)
.map(SmolStr::new)
.map_err(|e| {
log::warn!(
"UTF-8 received from libxkbcommon ({:?}) was invalid: {e}",
bytes
)
})
.ok()
}
+189
View File
@@ -0,0 +1,189 @@
//! XKB state.
use std::os::raw::c_char;
use std::ptr::NonNull;
use smol_str::SmolStr;
#[cfg(x11_platform)]
use x11_dl::xlib_xcb::xcb_connection_t;
use xkbcommon_dl::{
self as xkb, xkb_keycode_t, xkb_keysym_t, xkb_layout_index_t, xkb_state, xkb_state_component,
};
use crate::platform_impl::common::xkb::keymap::XkbKeymap;
#[cfg(x11_platform)]
use crate::platform_impl::common::xkb::XKBXH;
use crate::platform_impl::common::xkb::{make_string_with, XKBH};
#[derive(Debug)]
pub struct XkbState {
state: NonNull<xkb_state>,
modifiers: ModifiersState,
}
impl XkbState {
#[cfg(wayland_platform)]
pub fn new_wayland(keymap: &XkbKeymap) -> Option<Self> {
let state = NonNull::new(unsafe { (XKBH.xkb_state_new)(keymap.as_ptr()) })?;
Some(Self::new_inner(state))
}
#[cfg(x11_platform)]
pub fn new_x11(xcb: *mut xcb_connection_t, keymap: &XkbKeymap) -> Option<Self> {
let state = unsafe {
(XKBXH.xkb_x11_state_new_from_device)(keymap.as_ptr(), xcb, keymap._core_keyboard_id)
};
let state = NonNull::new(state)?;
Some(Self::new_inner(state))
}
fn new_inner(state: NonNull<xkb_state>) -> Self {
let modifiers = ModifiersState::default();
let mut this = Self { state, modifiers };
this.reload_modifiers();
this
}
pub fn get_one_sym_raw(&mut self, keycode: xkb_keycode_t) -> xkb_keysym_t {
unsafe { (XKBH.xkb_state_key_get_one_sym)(self.state.as_ptr(), keycode) }
}
pub fn layout(&mut self, key: xkb_keycode_t) -> xkb_layout_index_t {
unsafe { (XKBH.xkb_state_key_get_layout)(self.state.as_ptr(), key) }
}
#[cfg(x11_platform)]
pub fn depressed_modifiers(&mut self) -> xkb::xkb_mod_mask_t {
unsafe {
(XKBH.xkb_state_serialize_mods)(
self.state.as_ptr(),
xkb_state_component::XKB_STATE_MODS_DEPRESSED,
)
}
}
#[cfg(x11_platform)]
pub fn latched_modifiers(&mut self) -> xkb::xkb_mod_mask_t {
unsafe {
(XKBH.xkb_state_serialize_mods)(
self.state.as_ptr(),
xkb_state_component::XKB_STATE_MODS_LATCHED,
)
}
}
#[cfg(x11_platform)]
pub fn locked_modifiers(&mut self) -> xkb::xkb_mod_mask_t {
unsafe {
(XKBH.xkb_state_serialize_mods)(
self.state.as_ptr(),
xkb_state_component::XKB_STATE_MODS_LOCKED,
)
}
}
pub fn get_utf8_raw(
&mut self,
keycode: xkb_keycode_t,
scratch_buffer: &mut Vec<u8>,
) -> Option<SmolStr> {
make_string_with(scratch_buffer, |ptr, len| unsafe {
(XKBH.xkb_state_key_get_utf8)(self.state.as_ptr(), keycode, ptr, len)
})
}
pub fn modifiers(&self) -> ModifiersState {
self.modifiers
}
pub fn update_modifiers(
&mut self,
mods_depressed: u32,
mods_latched: u32,
mods_locked: u32,
depressed_group: u32,
latched_group: u32,
locked_group: u32,
) {
let mask = unsafe {
(XKBH.xkb_state_update_mask)(
self.state.as_ptr(),
mods_depressed,
mods_latched,
mods_locked,
depressed_group,
latched_group,
locked_group,
)
};
if mask.contains(xkb_state_component::XKB_STATE_MODS_EFFECTIVE) {
// Effective value of mods have changed, we need to update our state.
self.reload_modifiers();
}
}
/// Reload the modifiers.
fn reload_modifiers(&mut self) {
self.modifiers.ctrl = self.mod_name_is_active(xkb::XKB_MOD_NAME_CTRL);
self.modifiers.alt = self.mod_name_is_active(xkb::XKB_MOD_NAME_ALT);
self.modifiers.shift = self.mod_name_is_active(xkb::XKB_MOD_NAME_SHIFT);
self.modifiers.caps_lock = self.mod_name_is_active(xkb::XKB_MOD_NAME_CAPS);
self.modifiers.logo = self.mod_name_is_active(xkb::XKB_MOD_NAME_LOGO);
self.modifiers.num_lock = self.mod_name_is_active(xkb::XKB_MOD_NAME_NUM);
}
/// Check if the modifier is active within xkb.
fn mod_name_is_active(&mut self, name: &[u8]) -> bool {
unsafe {
(XKBH.xkb_state_mod_name_is_active)(
self.state.as_ptr(),
name.as_ptr() as *const c_char,
xkb_state_component::XKB_STATE_MODS_EFFECTIVE,
) > 0
}
}
}
impl Drop for XkbState {
fn drop(&mut self) {
unsafe {
(XKBH.xkb_state_unref)(self.state.as_ptr());
}
}
}
/// Represents the current state of the keyboard modifiers
///
/// Each field of this struct represents a modifier and is `true` if this modifier is active.
///
/// For some modifiers, this means that the key is currently pressed, others are toggled
/// (like caps lock).
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub struct ModifiersState {
/// The "control" key
pub ctrl: bool,
/// The "alt" key
pub alt: bool,
/// The "shift" key
pub shift: bool,
/// The "Caps lock" key
pub caps_lock: bool,
/// The "logo" key
///
/// Also known as the "windows" key on most keyboards
pub logo: bool,
/// The "Num lock" key
pub num_lock: bool,
}
impl From<ModifiersState> for crate::keyboard::ModifiersState {
fn from(mods: ModifiersState) -> crate::keyboard::ModifiersState {
let mut to_mods = crate::keyboard::ModifiersState::empty();
to_mods.set(crate::keyboard::ModifiersState::SHIFT, mods.shift);
to_mods.set(crate::keyboard::ModifiersState::CONTROL, mods.ctrl);
to_mods.set(crate::keyboard::ModifiersState::ALT, mods.alt);
to_mods.set(crate::keyboard::ModifiersState::SUPER, mods.logo);
to_mods
}
}
+692
View File
@@ -0,0 +1,692 @@
use std::convert::TryInto;
use std::env;
use std::ffi::CString;
use std::os::raw::c_char;
use std::os::unix::ffi::OsStringExt;
use std::ptr;
use std::sync::atomic::{AtomicBool, Ordering};
use once_cell::sync::Lazy;
use smol_str::SmolStr;
use xkbcommon_dl::{
self as ffi, xkb_state_component, xkbcommon_compose_handle, xkbcommon_handle, XkbCommon,
XkbCommonCompose,
};
#[cfg(feature = "wayland")]
use {memmap2::MmapOptions, std::os::unix::io::OwnedFd};
#[cfg(feature = "x11")]
use {x11_dl::xlib_xcb::xcb_connection_t, xkbcommon_dl::x11::xkbcommon_x11_handle};
use crate::event::KeyEvent;
use crate::platform_impl::common::keymap;
use crate::platform_impl::KeyEventExtra;
use crate::{
event::ElementState,
keyboard::{Key, KeyLocation, PhysicalKey},
};
// TODO: Wire this up without using a static `AtomicBool`.
static RESET_DEAD_KEYS: AtomicBool = AtomicBool::new(false);
#[inline(always)]
pub fn reset_dead_keys() {
RESET_DEAD_KEYS.store(true, Ordering::SeqCst);
}
static XKBH: Lazy<&'static XkbCommon> = Lazy::new(xkbcommon_handle);
static XKBCH: Lazy<&'static XkbCommonCompose> = Lazy::new(xkbcommon_compose_handle);
#[cfg(feature = "x11")]
static XKBXH: Lazy<&'static ffi::x11::XkbCommonX11> = Lazy::new(xkbcommon_x11_handle);
#[derive(Debug)]
pub struct KbdState {
#[cfg(feature = "x11")]
xcb_connection: *mut xcb_connection_t,
xkb_context: *mut ffi::xkb_context,
xkb_keymap: *mut ffi::xkb_keymap,
xkb_state: *mut ffi::xkb_state,
xkb_compose_table: *mut ffi::xkb_compose_table,
xkb_compose_state: *mut ffi::xkb_compose_state,
xkb_compose_state_2: *mut ffi::xkb_compose_state,
mods_state: ModifiersState,
#[cfg(feature = "x11")]
pub core_keyboard_id: i32,
scratch_buffer: Vec<u8>,
}
impl KbdState {
pub fn update_modifiers(
&mut self,
mods_depressed: u32,
mods_latched: u32,
mods_locked: u32,
depressed_group: u32,
latched_group: u32,
locked_group: u32,
) {
if !self.ready() {
return;
}
let mask = unsafe {
(XKBH.xkb_state_update_mask)(
self.xkb_state,
mods_depressed,
mods_latched,
mods_locked,
depressed_group,
latched_group,
locked_group,
)
};
if mask.contains(xkb_state_component::XKB_STATE_MODS_EFFECTIVE) {
// effective value of mods have changed, we need to update our state
self.mods_state.update_with(self.xkb_state);
}
}
pub fn get_one_sym_raw(&mut self, keycode: u32) -> u32 {
if !self.ready() {
return 0;
}
unsafe { (XKBH.xkb_state_key_get_one_sym)(self.xkb_state, keycode) }
}
pub fn get_utf8_raw(&mut self, keycode: u32) -> Option<SmolStr> {
if !self.ready() {
return None;
}
let xkb_state = self.xkb_state;
self.make_string_with({
|ptr, len| unsafe { (XKBH.xkb_state_key_get_utf8)(xkb_state, keycode, ptr, len) }
})
}
fn compose_feed_normal(&mut self, keysym: u32) -> Option<ffi::xkb_compose_feed_result> {
self.compose_feed(self.xkb_compose_state, keysym)
}
fn compose_feed_2(&mut self, keysym: u32) -> Option<ffi::xkb_compose_feed_result> {
self.compose_feed(self.xkb_compose_state_2, keysym)
}
fn compose_feed(
&mut self,
xkb_compose_state: *mut ffi::xkb_compose_state,
keysym: u32,
) -> Option<ffi::xkb_compose_feed_result> {
if !self.ready() || self.xkb_compose_state.is_null() {
return None;
}
if RESET_DEAD_KEYS.swap(false, Ordering::SeqCst) {
unsafe { self.init_compose() };
}
Some(unsafe { (XKBCH.xkb_compose_state_feed)(xkb_compose_state, keysym) })
}
fn compose_status_normal(&mut self) -> Option<ffi::xkb_compose_status> {
self.compose_status(self.xkb_compose_state)
}
fn compose_status(
&mut self,
xkb_compose_state: *mut ffi::xkb_compose_state,
) -> Option<ffi::xkb_compose_status> {
if !self.ready() || xkb_compose_state.is_null() {
return None;
}
Some(unsafe { (XKBCH.xkb_compose_state_get_status)(xkb_compose_state) })
}
fn compose_get_utf8_normal(&mut self) -> Option<SmolStr> {
self.compose_get_utf8(self.xkb_compose_state)
}
fn compose_get_utf8_2(&mut self) -> Option<SmolStr> {
self.compose_get_utf8(self.xkb_compose_state_2)
}
fn compose_get_utf8(
&mut self,
xkb_compose_state: *mut ffi::xkb_compose_state,
) -> Option<SmolStr> {
if !self.ready() || xkb_compose_state.is_null() {
return None;
}
self.make_string_with(|ptr, len| unsafe {
(XKBCH.xkb_compose_state_get_utf8)(xkb_compose_state, ptr, len)
})
}
/// Shared logic for constructing a string with `xkb_compose_state_get_utf8` and
/// `xkb_state_key_get_utf8`.
fn make_string_with<F>(&mut self, mut f: F) -> Option<SmolStr>
where
F: FnMut(*mut c_char, usize) -> i32,
{
let size = f(ptr::null_mut(), 0);
if size == 0 {
return None;
}
let size = usize::try_from(size).unwrap();
self.scratch_buffer.clear();
// The allocated buffer must include space for the null-terminator
self.scratch_buffer.reserve(size + 1);
unsafe {
let written = f(
self.scratch_buffer.as_mut_ptr().cast(),
self.scratch_buffer.capacity(),
);
if usize::try_from(written).unwrap() != size {
// This will likely never happen
return None;
}
self.scratch_buffer.set_len(size);
};
byte_slice_to_smol_str(&self.scratch_buffer)
}
pub fn new() -> Result<Self, Error> {
if ffi::xkbcommon_option().is_none() {
return Err(Error::XKBNotFound);
}
let context =
unsafe { (XKBH.xkb_context_new)(ffi::xkb_context_flags::XKB_CONTEXT_NO_FLAGS) };
if context.is_null() {
return Err(Error::XKBNotFound);
}
let mut me = Self {
#[cfg(feature = "x11")]
xcb_connection: ptr::null_mut(),
xkb_context: context,
xkb_keymap: ptr::null_mut(),
xkb_state: ptr::null_mut(),
xkb_compose_table: ptr::null_mut(),
xkb_compose_state: ptr::null_mut(),
xkb_compose_state_2: ptr::null_mut(),
mods_state: ModifiersState::new(),
#[cfg(feature = "x11")]
core_keyboard_id: 0,
scratch_buffer: Vec::new(),
};
unsafe { me.init_compose() };
Ok(me)
}
#[cfg(feature = "x11")]
pub fn from_x11_xkb(connection: *mut xcb_connection_t) -> Result<Self, Error> {
let mut me = Self::new()?;
me.xcb_connection = connection;
let result = unsafe {
(XKBXH.xkb_x11_setup_xkb_extension)(
connection,
1,
2,
xkbcommon_dl::x11::xkb_x11_setup_xkb_extension_flags::XKB_X11_SETUP_XKB_EXTENSION_NO_FLAGS,
ptr::null_mut(),
ptr::null_mut(),
ptr::null_mut(),
ptr::null_mut(),
)
};
assert_eq!(result, 1, "Failed to initialize libxkbcommon");
unsafe { me.init_with_x11_keymap() };
Ok(me)
}
unsafe fn init_compose(&mut self) {
let locale = env::var_os("LC_ALL")
.and_then(|v| if v.is_empty() { None } else { Some(v) })
.or_else(|| env::var_os("LC_CTYPE"))
.and_then(|v| if v.is_empty() { None } else { Some(v) })
.or_else(|| env::var_os("LANG"))
.and_then(|v| if v.is_empty() { None } else { Some(v) })
.unwrap_or_else(|| "C".into());
let locale = CString::new(locale.into_vec()).unwrap();
let compose_table = unsafe {
(XKBCH.xkb_compose_table_new_from_locale)(
self.xkb_context,
locale.as_ptr(),
ffi::xkb_compose_compile_flags::XKB_COMPOSE_COMPILE_NO_FLAGS,
)
};
if compose_table.is_null() {
// init of compose table failed, continue without compose
return;
}
let compose_state = unsafe {
(XKBCH.xkb_compose_state_new)(
compose_table,
ffi::xkb_compose_state_flags::XKB_COMPOSE_STATE_NO_FLAGS,
)
};
if compose_state.is_null() {
// init of compose state failed, continue without compose
unsafe { (XKBCH.xkb_compose_table_unref)(compose_table) };
return;
}
let compose_state_2 = unsafe {
(XKBCH.xkb_compose_state_new)(
compose_table,
ffi::xkb_compose_state_flags::XKB_COMPOSE_STATE_NO_FLAGS,
)
};
if compose_state_2.is_null() {
// init of compose state failed, continue without compose
unsafe { (XKBCH.xkb_compose_table_unref)(compose_table) };
unsafe { (XKBCH.xkb_compose_state_unref)(compose_state) };
return;
}
self.xkb_compose_table = compose_table;
self.xkb_compose_state = compose_state;
self.xkb_compose_state_2 = compose_state_2;
}
unsafe fn post_init(&mut self, state: *mut ffi::xkb_state, keymap: *mut ffi::xkb_keymap) {
self.xkb_keymap = keymap;
self.xkb_state = state;
self.mods_state.update_with(state);
}
unsafe fn de_init(&mut self) {
unsafe { (XKBH.xkb_state_unref)(self.xkb_state) };
self.xkb_state = ptr::null_mut();
unsafe { (XKBH.xkb_keymap_unref)(self.xkb_keymap) };
self.xkb_keymap = ptr::null_mut();
}
#[cfg(feature = "x11")]
pub unsafe fn init_with_x11_keymap(&mut self) {
if !self.xkb_keymap.is_null() {
unsafe { self.de_init() };
}
// TODO: Support keyboards other than the "virtual core keyboard device".
self.core_keyboard_id =
unsafe { (XKBXH.xkb_x11_get_core_keyboard_device_id)(self.xcb_connection) };
let keymap = unsafe {
(XKBXH.xkb_x11_keymap_new_from_device)(
self.xkb_context,
self.xcb_connection,
self.core_keyboard_id,
xkbcommon_dl::xkb_keymap_compile_flags::XKB_KEYMAP_COMPILE_NO_FLAGS,
)
};
if keymap.is_null() {
panic!("Failed to get keymap from X11 server.");
}
let state = unsafe {
(XKBXH.xkb_x11_state_new_from_device)(
keymap,
self.xcb_connection,
self.core_keyboard_id,
)
};
unsafe { self.post_init(state, keymap) };
}
#[cfg(feature = "wayland")]
pub unsafe fn init_with_fd(&mut self, fd: OwnedFd, size: usize) {
if !self.xkb_keymap.is_null() {
unsafe { self.de_init() };
}
let map = unsafe {
MmapOptions::new()
.len(size)
.map_copy_read_only(&fd)
.unwrap()
};
let keymap = unsafe {
(XKBH.xkb_keymap_new_from_string)(
self.xkb_context,
map.as_ptr() as *const _,
ffi::xkb_keymap_format::XKB_KEYMAP_FORMAT_TEXT_V1,
ffi::xkb_keymap_compile_flags::XKB_KEYMAP_COMPILE_NO_FLAGS,
)
};
if keymap.is_null() {
panic!("Received invalid keymap from compositor.");
}
let state = unsafe { (XKBH.xkb_state_new)(keymap) };
unsafe { self.post_init(state, keymap) };
}
pub fn key_repeats(&mut self, keycode: ffi::xkb_keycode_t) -> bool {
unsafe { (XKBH.xkb_keymap_key_repeats)(self.xkb_keymap, keycode) == 1 }
}
#[inline]
pub fn ready(&self) -> bool {
!self.xkb_state.is_null()
}
#[inline]
pub fn mods_state(&self) -> ModifiersState {
self.mods_state
}
pub fn process_key_event(
&mut self,
keycode: u32,
state: ElementState,
repeat: bool,
) -> KeyEvent {
let mut event =
KeyEventResults::new(self, keycode, !repeat && state == ElementState::Pressed);
let physical_key = event.physical_key();
let (logical_key, location) = event.key();
let text = event.text();
let (key_without_modifiers, _) = event.key_without_modifiers();
let text_with_all_modifiers = event.text_with_all_modifiers();
let platform_specific = KeyEventExtra {
key_without_modifiers,
text_with_all_modifiers,
};
KeyEvent {
physical_key,
logical_key,
text,
location,
state,
repeat,
platform_specific,
}
}
fn keysym_to_utf8_raw(&mut self, keysym: u32) -> Option<SmolStr> {
self.scratch_buffer.clear();
self.scratch_buffer.reserve(8);
loop {
let bytes_written = unsafe {
(XKBH.xkb_keysym_to_utf8)(
keysym,
self.scratch_buffer.as_mut_ptr().cast(),
self.scratch_buffer.capacity(),
)
};
if bytes_written == 0 {
return None;
} else if bytes_written == -1 {
self.scratch_buffer.reserve(8);
} else {
unsafe {
self.scratch_buffer
.set_len(bytes_written.try_into().unwrap())
};
break;
}
}
// Remove the null-terminator
self.scratch_buffer.pop();
byte_slice_to_smol_str(&self.scratch_buffer)
}
}
impl Drop for KbdState {
fn drop(&mut self) {
unsafe {
if !self.xkb_compose_state.is_null() {
(XKBCH.xkb_compose_state_unref)(self.xkb_compose_state);
}
if !self.xkb_compose_state_2.is_null() {
(XKBCH.xkb_compose_state_unref)(self.xkb_compose_state_2);
}
if !self.xkb_compose_table.is_null() {
(XKBCH.xkb_compose_table_unref)(self.xkb_compose_table);
}
if !self.xkb_state.is_null() {
(XKBH.xkb_state_unref)(self.xkb_state);
}
if !self.xkb_keymap.is_null() {
(XKBH.xkb_keymap_unref)(self.xkb_keymap);
}
(XKBH.xkb_context_unref)(self.xkb_context);
}
}
}
struct KeyEventResults<'a> {
state: &'a mut KbdState,
keycode: u32,
keysym: u32,
compose: Option<XkbCompose>,
}
impl<'a> KeyEventResults<'a> {
fn new(state: &'a mut KbdState, keycode: u32, compose: bool) -> Self {
let keysym = state.get_one_sym_raw(keycode);
let compose = if compose {
Some(match state.compose_feed_normal(keysym) {
Some(ffi::xkb_compose_feed_result::XKB_COMPOSE_FEED_ACCEPTED) => {
// Unwrapping is safe here, as `compose_feed` returns `None` when composition is uninitialized.
XkbCompose::Accepted(state.compose_status_normal().unwrap())
}
Some(ffi::xkb_compose_feed_result::XKB_COMPOSE_FEED_IGNORED) => XkbCompose::Ignored,
None => XkbCompose::Uninitialized,
})
} else {
None
};
KeyEventResults {
state,
keycode,
keysym,
compose,
}
}
fn physical_key(&self) -> PhysicalKey {
keymap::raw_keycode_to_physicalkey(self.keycode)
}
pub fn key(&mut self) -> (Key, KeyLocation) {
self.keysym_to_key(self.keysym)
.unwrap_or_else(|(key, location)| match self.compose {
Some(XkbCompose::Accepted(ffi::xkb_compose_status::XKB_COMPOSE_COMPOSING)) => {
// When pressing a dead key twice, the non-combining variant of that character will be
// produced. Since this function only concerns itself with a single keypress, we simulate
// this double press here by feeding the keysym to the compose state twice.
self.state.compose_feed_2(self.keysym);
match self.state.compose_feed_2(self.keysym) {
Some(ffi::xkb_compose_feed_result::XKB_COMPOSE_FEED_ACCEPTED) => (
// Extracting only a single `char` here *should* be fine, assuming that no dead
// key's non-combining variant ever occupies more than one `char`.
Key::Dead(
self.state
.compose_get_utf8_2()
.map(|s| s.chars().next().unwrap()),
),
location,
),
_ => (key, location),
}
}
_ => (
self.composed_text()
.unwrap_or_else(|_| self.state.keysym_to_utf8_raw(self.keysym))
.map(Key::Character)
.unwrap_or(key),
location,
),
})
}
pub fn key_without_modifiers(&mut self) -> (Key, KeyLocation) {
// This will become a pointer to an array which libxkbcommon owns, so we don't need to deallocate it.
let mut keysyms = ptr::null();
let keysym_count = unsafe {
let layout = (XKBH.xkb_state_key_get_layout)(self.state.xkb_state, self.keycode);
(XKBH.xkb_keymap_key_get_syms_by_level)(
self.state.xkb_keymap,
self.keycode,
layout,
// NOTE: The level should be zero to ignore modifiers.
0,
&mut keysyms,
)
};
let keysym = if keysym_count == 1 {
unsafe { *keysyms }
} else {
0
};
self.keysym_to_key(keysym)
.unwrap_or_else(|(key, location)| {
(
self.state
.keysym_to_utf8_raw(keysym)
.map(Key::Character)
.unwrap_or(key),
location,
)
})
}
fn keysym_to_key(&self, keysym: u32) -> Result<(Key, KeyLocation), (Key, KeyLocation)> {
let location = super::keymap::keysym_location(keysym);
let key = super::keymap::keysym_to_key(keysym);
if matches!(key, Key::Unidentified(_)) {
Err((key, location))
} else {
Ok((key, location))
}
}
pub fn text(&mut self) -> Option<SmolStr> {
self.composed_text()
.unwrap_or_else(|_| self.state.keysym_to_utf8_raw(self.keysym))
}
pub fn text_with_all_modifiers(&mut self) -> Option<SmolStr> {
// The current behaviour makes it so composing a character overrides attempts to input a
// control character with the `Ctrl` key. We can potentially add a configuration option
// if someone specifically wants the oppsite behaviour.
self.composed_text()
.unwrap_or_else(|_| self.state.get_utf8_raw(self.keycode))
}
fn composed_text(&mut self) -> Result<Option<SmolStr>, ()> {
if let Some(compose) = &self.compose {
match compose {
XkbCompose::Accepted(status) => match status {
ffi::xkb_compose_status::XKB_COMPOSE_COMPOSED => {
Ok(self.state.compose_get_utf8_normal())
}
ffi::xkb_compose_status::XKB_COMPOSE_NOTHING => Err(()),
_ => Ok(None),
},
XkbCompose::Ignored | XkbCompose::Uninitialized => Err(()),
}
} else {
Err(())
}
}
}
/// Represents the current state of the keyboard modifiers
///
/// Each field of this struct represents a modifier and is `true` if this modifier is active.
///
/// For some modifiers, this means that the key is currently pressed, others are toggled
/// (like caps lock).
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub struct ModifiersState {
/// The "control" key
pub ctrl: bool,
/// The "alt" key
pub alt: bool,
/// The "shift" key
pub shift: bool,
/// The "Caps lock" key
pub caps_lock: bool,
/// The "logo" key
///
/// Also known as the "windows" key on most keyboards
pub logo: bool,
/// The "Num lock" key
pub num_lock: bool,
}
impl ModifiersState {
fn new() -> Self {
Self::default()
}
fn update_with(&mut self, state: *mut ffi::xkb_state) {
let mod_name_is_active = |mod_name: &[u8]| unsafe {
(XKBH.xkb_state_mod_name_is_active)(
state,
mod_name.as_ptr() as *const c_char,
xkb_state_component::XKB_STATE_MODS_EFFECTIVE,
) > 0
};
self.ctrl = mod_name_is_active(ffi::XKB_MOD_NAME_CTRL);
self.alt = mod_name_is_active(ffi::XKB_MOD_NAME_ALT);
self.shift = mod_name_is_active(ffi::XKB_MOD_NAME_SHIFT);
self.caps_lock = mod_name_is_active(ffi::XKB_MOD_NAME_CAPS);
self.logo = mod_name_is_active(ffi::XKB_MOD_NAME_LOGO);
self.num_lock = mod_name_is_active(ffi::XKB_MOD_NAME_NUM);
}
}
impl From<ModifiersState> for crate::keyboard::ModifiersState {
fn from(mods: ModifiersState) -> crate::keyboard::ModifiersState {
let mut to_mods = crate::keyboard::ModifiersState::empty();
to_mods.set(crate::keyboard::ModifiersState::SHIFT, mods.shift);
to_mods.set(crate::keyboard::ModifiersState::CONTROL, mods.ctrl);
to_mods.set(crate::keyboard::ModifiersState::ALT, mods.alt);
to_mods.set(crate::keyboard::ModifiersState::SUPER, mods.logo);
to_mods
}
}
#[derive(Debug)]
pub enum Error {
/// libxkbcommon is not available
XKBNotFound,
}
#[derive(Copy, Clone, Debug)]
enum XkbCompose {
Accepted(ffi::xkb_compose_status),
Ignored,
Uninitialized,
}
// Note: This is track_caller so we can have more informative line numbers when logging
#[track_caller]
fn byte_slice_to_smol_str(bytes: &[u8]) -> Option<SmolStr> {
std::str::from_utf8(bytes)
.map(SmolStr::new)
.map_err(|e| {
warn!(
"UTF-8 received from libxkbcommon ({:?}) was invalid: {e}",
bytes
)
})
.ok()
}
+976
View File
@@ -0,0 +1,976 @@
#![cfg(free_unix)]
#[cfg(all(not(x11_platform), not(wayland_platform)))]
compile_error!("Please select a feature to build for unix: `x11`, `wayland`");
use std::os::unix::io::{AsFd, AsRawFd, BorrowedFd, RawFd};
use std::sync::Arc;
use std::time::Duration;
use std::{collections::VecDeque, env, fmt};
#[cfg(x11_platform)]
use std::{ffi::CStr, mem::MaybeUninit, os::raw::*, sync::Mutex};
#[cfg(x11_platform)]
use once_cell::sync::Lazy;
use smol_str::SmolStr;
#[cfg(x11_platform)]
use crate::platform::x11::XlibErrorHook;
use crate::{
dpi::{PhysicalPosition, PhysicalSize, Position, Size},
error::{EventLoopError, ExternalError, NotSupportedError, OsError as RootOsError},
event::KeyEvent,
event_loop::{
AsyncRequestSerial, ControlFlow, DeviceEvents, EventLoopClosed,
EventLoopWindowTarget as RootELW,
},
icon::Icon,
keyboard::{Key, PhysicalKey},
platform::{
modifier_supplement::KeyEventExtModifierSupplement, pump_events::PumpStatus,
scancode::PhysicalKeyExtScancode,
},
window::{
ActivationToken, CursorGrabMode, CursorIcon, ImePurpose, ResizeDirection, Theme,
UserAttentionType, WindowAttributes, WindowButtons, WindowLevel,
},
};
#[cfg(x11_platform)]
pub use x11::XNotSupported;
#[cfg(x11_platform)]
use x11::{util::WindowType as XWindowType, X11Error, XConnection, XError};
pub(crate) use crate::icon::RgbaIcon as PlatformIcon;
pub(crate) use crate::platform_impl::Fullscreen;
pub mod common;
#[cfg(wayland_platform)]
pub mod wayland;
#[cfg(x11_platform)]
pub mod x11;
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
pub(crate) enum Backend {
#[cfg(x11_platform)]
X,
#[cfg(wayland_platform)]
Wayland,
}
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Hash)]
pub(crate) struct PlatformSpecificEventLoopAttributes {
pub(crate) forced_backend: Option<Backend>,
pub(crate) any_thread: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApplicationName {
pub general: String,
pub instance: String,
}
impl ApplicationName {
pub fn new(general: String, instance: String) -> Self {
Self { general, instance }
}
}
#[derive(Clone)]
pub struct PlatformSpecificWindowBuilderAttributes {
pub name: Option<ApplicationName>,
pub activation_token: Option<ActivationToken>,
#[cfg(x11_platform)]
pub x11: X11WindowBuilderAttributes,
}
#[derive(Clone)]
#[cfg(x11_platform)]
pub struct X11WindowBuilderAttributes {
pub visual_id: Option<x11rb::protocol::xproto::Visualid>,
pub screen_id: Option<i32>,
pub base_size: Option<Size>,
pub override_redirect: bool,
pub x11_window_types: Vec<XWindowType>,
/// The parent window to embed this window into.
pub embed_window: Option<x11rb::protocol::xproto::Window>,
}
impl Default for PlatformSpecificWindowBuilderAttributes {
fn default() -> Self {
Self {
name: None,
activation_token: None,
#[cfg(x11_platform)]
x11: X11WindowBuilderAttributes {
visual_id: None,
screen_id: None,
base_size: None,
override_redirect: false,
x11_window_types: vec![XWindowType::Normal],
embed_window: None,
},
}
}
}
#[cfg(x11_platform)]
pub(crate) static X11_BACKEND: Lazy<Mutex<Result<Arc<XConnection>, XNotSupported>>> =
Lazy::new(|| Mutex::new(XConnection::new(Some(x_error_callback)).map(Arc::new)));
#[derive(Debug, Clone)]
pub enum OsError {
Misc(&'static str),
#[cfg(x11_platform)]
XError(Arc<X11Error>),
#[cfg(wayland_platform)]
WaylandError(Arc<wayland::WaylandError>),
}
impl fmt::Display for OsError {
fn fmt(&self, _f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
match *self {
OsError::Misc(e) => _f.pad(e),
#[cfg(x11_platform)]
OsError::XError(ref e) => fmt::Display::fmt(e, _f),
#[cfg(wayland_platform)]
OsError::WaylandError(ref e) => fmt::Display::fmt(e, _f),
}
}
}
pub(crate) enum Window {
#[cfg(x11_platform)]
X(x11::Window),
#[cfg(wayland_platform)]
Wayland(wayland::Window),
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct WindowId(u64);
impl From<WindowId> for u64 {
fn from(window_id: WindowId) -> Self {
window_id.0
}
}
impl From<u64> for WindowId {
fn from(raw_id: u64) -> Self {
Self(raw_id)
}
}
impl WindowId {
pub const unsafe fn dummy() -> Self {
Self(0)
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum DeviceId {
#[cfg(x11_platform)]
X(x11::DeviceId),
#[cfg(wayland_platform)]
Wayland(wayland::DeviceId),
}
impl DeviceId {
pub const unsafe fn dummy() -> Self {
#[cfg(wayland_platform)]
return DeviceId::Wayland(unsafe { wayland::DeviceId::dummy() });
#[cfg(all(not(wayland_platform), x11_platform))]
return DeviceId::X(unsafe { x11::DeviceId::dummy() });
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum MonitorHandle {
#[cfg(x11_platform)]
X(x11::MonitorHandle),
#[cfg(wayland_platform)]
Wayland(wayland::MonitorHandle),
}
/// `x11_or_wayland!(match expr; Enum(foo) => foo.something())`
/// expands to the equivalent of
/// ```ignore
/// match self {
/// Enum::X(foo) => foo.something(),
/// Enum::Wayland(foo) => foo.something(),
/// }
/// ```
/// The result can be converted to another enum by adding `; as AnotherEnum`
macro_rules! x11_or_wayland {
(match $what:expr; $enum:ident ( $($c1:tt)* ) => $x:expr; as $enum2:ident ) => {
match $what {
#[cfg(x11_platform)]
$enum::X($($c1)*) => $enum2::X($x),
#[cfg(wayland_platform)]
$enum::Wayland($($c1)*) => $enum2::Wayland($x),
}
};
(match $what:expr; $enum:ident ( $($c1:tt)* ) => $x:expr) => {
match $what {
#[cfg(x11_platform)]
$enum::X($($c1)*) => $x,
#[cfg(wayland_platform)]
$enum::Wayland($($c1)*) => $x,
}
};
}
impl MonitorHandle {
#[inline]
pub fn name(&self) -> Option<String> {
x11_or_wayland!(match self; MonitorHandle(m) => m.name())
}
#[inline]
pub fn native_identifier(&self) -> u32 {
x11_or_wayland!(match self; MonitorHandle(m) => m.native_identifier())
}
#[inline]
pub fn size(&self) -> PhysicalSize<u32> {
x11_or_wayland!(match self; MonitorHandle(m) => m.size())
}
#[inline]
pub fn position(&self) -> PhysicalPosition<i32> {
x11_or_wayland!(match self; MonitorHandle(m) => m.position())
}
#[inline]
pub fn refresh_rate_millihertz(&self) -> Option<u32> {
x11_or_wayland!(match self; MonitorHandle(m) => m.refresh_rate_millihertz())
}
#[inline]
pub fn scale_factor(&self) -> f64 {
x11_or_wayland!(match self; MonitorHandle(m) => m.scale_factor() as _)
}
#[inline]
pub fn video_modes(&self) -> Box<dyn Iterator<Item = VideoMode>> {
x11_or_wayland!(match self; MonitorHandle(m) => Box::new(m.video_modes()))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum VideoMode {
#[cfg(x11_platform)]
X(x11::VideoMode),
#[cfg(wayland_platform)]
Wayland(wayland::VideoMode),
}
impl VideoMode {
#[inline]
pub fn size(&self) -> PhysicalSize<u32> {
x11_or_wayland!(match self; VideoMode(m) => m.size())
}
#[inline]
pub fn bit_depth(&self) -> u16 {
x11_or_wayland!(match self; VideoMode(m) => m.bit_depth())
}
#[inline]
pub fn refresh_rate_millihertz(&self) -> u32 {
x11_or_wayland!(match self; VideoMode(m) => m.refresh_rate_millihertz())
}
#[inline]
pub fn monitor(&self) -> MonitorHandle {
x11_or_wayland!(match self; VideoMode(m) => m.monitor(); as MonitorHandle)
}
}
impl Window {
#[inline]
pub(crate) fn new<T>(
window_target: &EventLoopWindowTarget<T>,
attribs: WindowAttributes,
pl_attribs: PlatformSpecificWindowBuilderAttributes,
) -> Result<Self, RootOsError> {
match *window_target {
#[cfg(wayland_platform)]
EventLoopWindowTarget::Wayland(ref window_target) => {
wayland::Window::new(window_target, attribs, pl_attribs).map(Window::Wayland)
}
#[cfg(x11_platform)]
EventLoopWindowTarget::X(ref window_target) => {
x11::Window::new(window_target, attribs, pl_attribs).map(Window::X)
}
}
}
pub(crate) fn maybe_queue_on_main(&self, f: impl FnOnce(&Self) + Send + 'static) {
f(self)
}
pub(crate) fn maybe_wait_on_main<R: Send>(&self, f: impl FnOnce(&Self) -> R + Send) -> R {
f(self)
}
#[inline]
pub fn id(&self) -> WindowId {
x11_or_wayland!(match self; Window(w) => w.id())
}
#[inline]
pub fn set_title(&self, title: &str) {
x11_or_wayland!(match self; Window(w) => w.set_title(title));
}
#[inline]
pub fn set_transparent(&self, transparent: bool) {
x11_or_wayland!(match self; Window(w) => w.set_transparent(transparent));
}
#[inline]
pub fn set_blur(&self, blur: bool) {
x11_or_wayland!(match self; Window(w) => w.set_blur(blur));
}
#[inline]
pub fn set_visible(&self, visible: bool) {
x11_or_wayland!(match self; Window(w) => w.set_visible(visible))
}
#[inline]
pub fn is_visible(&self) -> Option<bool> {
x11_or_wayland!(match self; Window(w) => w.is_visible())
}
#[inline]
pub fn outer_position(&self) -> Result<PhysicalPosition<i32>, NotSupportedError> {
x11_or_wayland!(match self; Window(w) => w.outer_position())
}
#[inline]
pub fn inner_position(&self) -> Result<PhysicalPosition<i32>, NotSupportedError> {
x11_or_wayland!(match self; Window(w) => w.inner_position())
}
#[inline]
pub fn set_outer_position(&self, position: Position) {
x11_or_wayland!(match self; Window(w) => w.set_outer_position(position))
}
#[inline]
pub fn inner_size(&self) -> PhysicalSize<u32> {
x11_or_wayland!(match self; Window(w) => w.inner_size())
}
#[inline]
pub fn outer_size(&self) -> PhysicalSize<u32> {
x11_or_wayland!(match self; Window(w) => w.outer_size())
}
#[inline]
pub fn request_inner_size(&self, size: Size) -> Option<PhysicalSize<u32>> {
x11_or_wayland!(match self; Window(w) => w.request_inner_size(size))
}
#[inline]
pub(crate) fn request_activation_token(&self) -> Result<AsyncRequestSerial, NotSupportedError> {
x11_or_wayland!(match self; Window(w) => w.request_activation_token())
}
#[inline]
pub fn set_min_inner_size(&self, dimensions: Option<Size>) {
x11_or_wayland!(match self; Window(w) => w.set_min_inner_size(dimensions))
}
#[inline]
pub fn set_max_inner_size(&self, dimensions: Option<Size>) {
x11_or_wayland!(match self; Window(w) => w.set_max_inner_size(dimensions))
}
#[inline]
pub fn resize_increments(&self) -> Option<PhysicalSize<u32>> {
x11_or_wayland!(match self; Window(w) => w.resize_increments())
}
#[inline]
pub fn set_resize_increments(&self, increments: Option<Size>) {
x11_or_wayland!(match self; Window(w) => w.set_resize_increments(increments))
}
#[inline]
pub fn set_resizable(&self, resizable: bool) {
x11_or_wayland!(match self; Window(w) => w.set_resizable(resizable))
}
#[inline]
pub fn is_resizable(&self) -> bool {
x11_or_wayland!(match self; Window(w) => w.is_resizable())
}
#[inline]
pub fn set_enabled_buttons(&self, buttons: WindowButtons) {
x11_or_wayland!(match self; Window(w) => w.set_enabled_buttons(buttons))
}
#[inline]
pub fn enabled_buttons(&self) -> WindowButtons {
x11_or_wayland!(match self; Window(w) => w.enabled_buttons())
}
#[inline]
pub fn set_cursor_icon(&self, cursor: CursorIcon) {
x11_or_wayland!(match self; Window(w) => w.set_cursor_icon(cursor))
}
#[inline]
pub fn set_cursor_grab(&self, mode: CursorGrabMode) -> Result<(), ExternalError> {
x11_or_wayland!(match self; Window(window) => window.set_cursor_grab(mode))
}
#[inline]
pub fn set_cursor_visible(&self, visible: bool) {
x11_or_wayland!(match self; Window(window) => window.set_cursor_visible(visible))
}
#[inline]
pub fn drag_window(&self) -> Result<(), ExternalError> {
x11_or_wayland!(match self; Window(window) => window.drag_window())
}
#[inline]
pub fn drag_resize_window(&self, direction: ResizeDirection) -> Result<(), ExternalError> {
x11_or_wayland!(match self; Window(window) => window.drag_resize_window(direction))
}
#[inline]
pub fn show_window_menu(&self, position: Position) {
x11_or_wayland!(match self; Window(w) => w.show_window_menu(position))
}
#[inline]
pub fn set_cursor_hittest(&self, hittest: bool) -> Result<(), ExternalError> {
x11_or_wayland!(match self; Window(w) => w.set_cursor_hittest(hittest))
}
#[inline]
pub fn scale_factor(&self) -> f64 {
x11_or_wayland!(match self; Window(w) => w.scale_factor())
}
#[inline]
pub fn set_cursor_position(&self, position: Position) -> Result<(), ExternalError> {
x11_or_wayland!(match self; Window(w) => w.set_cursor_position(position))
}
#[inline]
pub fn set_maximized(&self, maximized: bool) {
x11_or_wayland!(match self; Window(w) => w.set_maximized(maximized))
}
#[inline]
pub fn is_maximized(&self) -> bool {
x11_or_wayland!(match self; Window(w) => w.is_maximized())
}
#[inline]
pub fn set_minimized(&self, minimized: bool) {
x11_or_wayland!(match self; Window(w) => w.set_minimized(minimized))
}
#[inline]
pub fn is_minimized(&self) -> Option<bool> {
x11_or_wayland!(match self; Window(w) => w.is_minimized())
}
#[inline]
pub(crate) fn fullscreen(&self) -> Option<Fullscreen> {
x11_or_wayland!(match self; Window(w) => w.fullscreen())
}
#[inline]
pub(crate) fn set_fullscreen(&self, monitor: Option<Fullscreen>) {
x11_or_wayland!(match self; Window(w) => w.set_fullscreen(monitor))
}
#[inline]
pub fn set_decorations(&self, decorations: bool) {
x11_or_wayland!(match self; Window(w) => w.set_decorations(decorations))
}
#[inline]
pub fn is_decorated(&self) -> bool {
x11_or_wayland!(match self; Window(w) => w.is_decorated())
}
#[inline]
pub fn set_window_level(&self, level: WindowLevel) {
x11_or_wayland!(match self; Window(w) => w.set_window_level(level))
}
#[inline]
pub fn set_window_icon(&self, window_icon: Option<Icon>) {
x11_or_wayland!(match self; Window(w) => w.set_window_icon(window_icon.map(|icon| icon.inner)))
}
#[inline]
pub fn set_ime_cursor_area(&self, position: Position, size: Size) {
x11_or_wayland!(match self; Window(w) => w.set_ime_cursor_area(position, size))
}
#[inline]
pub fn reset_dead_keys(&self) {
common::xkb::reset_dead_keys()
}
#[inline]
pub fn set_ime_allowed(&self, allowed: bool) {
x11_or_wayland!(match self; Window(w) => w.set_ime_allowed(allowed))
}
#[inline]
pub fn set_ime_purpose(&self, purpose: ImePurpose) {
x11_or_wayland!(match self; Window(w) => w.set_ime_purpose(purpose))
}
#[inline]
pub fn focus_window(&self) {
x11_or_wayland!(match self; Window(w) => w.focus_window())
}
pub fn request_user_attention(&self, request_type: Option<UserAttentionType>) {
x11_or_wayland!(match self; Window(w) => w.request_user_attention(request_type))
}
#[inline]
pub fn request_redraw(&self) {
x11_or_wayland!(match self; Window(w) => w.request_redraw())
}
#[inline]
pub fn pre_present_notify(&self) {
x11_or_wayland!(match self; Window(w) => w.pre_present_notify())
}
#[inline]
pub fn current_monitor(&self) -> Option<MonitorHandle> {
Some(x11_or_wayland!(match self; Window(w) => w.current_monitor()?; as MonitorHandle))
}
#[inline]
pub fn available_monitors(&self) -> VecDeque<MonitorHandle> {
match self {
#[cfg(x11_platform)]
Window::X(ref window) => window
.available_monitors()
.into_iter()
.map(MonitorHandle::X)
.collect(),
#[cfg(wayland_platform)]
Window::Wayland(ref window) => window
.available_monitors()
.into_iter()
.map(MonitorHandle::Wayland)
.collect(),
}
}
#[inline]
pub fn primary_monitor(&self) -> Option<MonitorHandle> {
Some(x11_or_wayland!(match self; Window(w) => w.primary_monitor()?; as MonitorHandle))
}
#[cfg(feature = "rwh_04")]
#[inline]
pub fn raw_window_handle_rwh_04(&self) -> rwh_04::RawWindowHandle {
x11_or_wayland!(match self; Window(window) => window.raw_window_handle_rwh_04())
}
#[cfg(feature = "rwh_05")]
#[inline]
pub fn raw_window_handle_rwh_05(&self) -> rwh_05::RawWindowHandle {
x11_or_wayland!(match self; Window(window) => window.raw_window_handle_rwh_05())
}
#[cfg(feature = "rwh_05")]
#[inline]
pub fn raw_display_handle_rwh_05(&self) -> rwh_05::RawDisplayHandle {
x11_or_wayland!(match self; Window(window) => window.raw_display_handle_rwh_05())
}
#[cfg(feature = "rwh_06")]
#[inline]
pub fn raw_window_handle_rwh_06(&self) -> Result<rwh_06::RawWindowHandle, rwh_06::HandleError> {
x11_or_wayland!(match self; Window(window) => window.raw_window_handle_rwh_06())
}
#[cfg(feature = "rwh_06")]
#[inline]
pub fn raw_display_handle_rwh_06(
&self,
) -> Result<rwh_06::RawDisplayHandle, rwh_06::HandleError> {
x11_or_wayland!(match self; Window(window) => window.raw_display_handle_rwh_06())
}
#[inline]
pub fn set_theme(&self, theme: Option<Theme>) {
x11_or_wayland!(match self; Window(window) => window.set_theme(theme))
}
#[inline]
pub fn theme(&self) -> Option<Theme> {
x11_or_wayland!(match self; Window(window) => window.theme())
}
pub fn set_content_protected(&self, protected: bool) {
x11_or_wayland!(match self; Window(window) => window.set_content_protected(protected))
}
#[inline]
pub fn has_focus(&self) -> bool {
x11_or_wayland!(match self; Window(window) => window.has_focus())
}
pub fn title(&self) -> String {
x11_or_wayland!(match self; Window(window) => window.title())
}
}
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub struct KeyEventExtra {
pub key_without_modifiers: Key,
pub text_with_all_modifiers: Option<SmolStr>,
}
impl KeyEventExtModifierSupplement for KeyEvent {
#[inline]
fn text_with_all_modifiers(&self) -> Option<&str> {
self.platform_specific
.text_with_all_modifiers
.as_ref()
.map(|s| s.as_str())
}
#[inline]
fn key_without_modifiers(&self) -> Key {
self.platform_specific.key_without_modifiers.clone()
}
}
impl PhysicalKeyExtScancode for PhysicalKey {
fn from_scancode(scancode: u32) -> PhysicalKey {
common::xkb::scancode_to_keycode(scancode)
}
fn to_scancode(self) -> Option<u32> {
common::xkb::physicalkey_to_scancode(self)
}
}
/// Hooks for X11 errors.
#[cfg(x11_platform)]
pub(crate) static mut XLIB_ERROR_HOOKS: Lazy<Mutex<Vec<XlibErrorHook>>> =
Lazy::new(|| Mutex::new(Vec::new()));
#[cfg(x11_platform)]
unsafe extern "C" fn x_error_callback(
display: *mut x11::ffi::Display,
event: *mut x11::ffi::XErrorEvent,
) -> c_int {
let xconn_lock = X11_BACKEND.lock().unwrap();
if let Ok(ref xconn) = *xconn_lock {
// Call all the hooks.
let mut error_handled = false;
for hook in unsafe { XLIB_ERROR_HOOKS.lock() }.unwrap().iter() {
error_handled |= hook(display as *mut _, event as *mut _);
}
// `assume_init` is safe here because the array consists of `MaybeUninit` values,
// which do not require initialization.
let mut buf: [MaybeUninit<c_char>; 1024] = unsafe { MaybeUninit::uninit().assume_init() };
unsafe {
(xconn.xlib.XGetErrorText)(
display,
(*event).error_code as c_int,
buf.as_mut_ptr() as *mut c_char,
buf.len() as c_int,
)
};
let description =
unsafe { CStr::from_ptr(buf.as_ptr() as *const c_char) }.to_string_lossy();
let error = unsafe {
XError {
description: description.into_owned(),
error_code: (*event).error_code,
request_code: (*event).request_code,
minor_code: (*event).minor_code,
}
};
// Don't log error.
if !error_handled {
error!("X11 error: {:#?}", error);
// XXX only update the error, if it wasn't handled by any of the hooks.
*xconn.latest_error.lock().unwrap() = Some(error);
}
}
// Fun fact: this return value is completely ignored.
0
}
pub enum EventLoop<T: 'static> {
#[cfg(wayland_platform)]
Wayland(Box<wayland::EventLoop<T>>),
#[cfg(x11_platform)]
X(x11::EventLoop<T>),
}
pub enum EventLoopProxy<T: 'static> {
#[cfg(x11_platform)]
X(x11::EventLoopProxy<T>),
#[cfg(wayland_platform)]
Wayland(wayland::EventLoopProxy<T>),
}
impl<T: 'static> Clone for EventLoopProxy<T> {
fn clone(&self) -> Self {
x11_or_wayland!(match self; EventLoopProxy(proxy) => proxy.clone(); as EventLoopProxy)
}
}
impl<T: 'static> EventLoop<T> {
pub(crate) fn new(
attributes: &PlatformSpecificEventLoopAttributes,
) -> Result<Self, EventLoopError> {
if !attributes.any_thread && !is_main_thread() {
panic!(
"Initializing the event loop outside of the main thread is a significant \
cross-platform compatibility hazard. If you absolutely need to create an \
EventLoop on a different thread, you can use the \
`EventLoopBuilderExtUnix::any_thread` function."
);
}
// NOTE: Wayland first because of X11 could be present under Wayland as well. Empty
// variables are also treated as not set.
let backend = match (
attributes.forced_backend,
env::var("WAYLAND_DISPLAY")
.ok()
.filter(|var| !var.is_empty())
.or_else(|| env::var("WAYLAND_SOCKET").ok())
.filter(|var| !var.is_empty())
.is_some(),
env::var("DISPLAY")
.map(|var| !var.is_empty())
.unwrap_or(false),
) {
// User is forcing a backend.
(Some(backend), _, _) => backend,
// Wayland is present.
#[cfg(wayland_platform)]
(None, true, _) => Backend::Wayland,
// X11 is present.
#[cfg(x11_platform)]
(None, _, true) => Backend::X,
// No backend is present.
(_, wayland_display, x11_display) => {
let msg = if wayland_display && !cfg!(wayland_platform) {
"DISPLAY is not set; note: enable the `winit/wayland` feature to support Wayland"
} else if x11_display && !cfg!(x11_platform) {
"neither WAYLAND_DISPLAY nor WAYLAND_SOCKET is set; note: enable the `winit/x11` feature to support X11"
} else {
"neither WAYLAND_DISPLAY nor WAYLAND_SOCKET nor DISPLAY is set."
};
return Err(EventLoopError::Os(os_error!(OsError::Misc(msg))));
}
};
// Create the display based on the backend.
match backend {
#[cfg(wayland_platform)]
Backend::Wayland => EventLoop::new_wayland_any_thread().map_err(Into::into),
#[cfg(x11_platform)]
Backend::X => EventLoop::new_x11_any_thread().map_err(Into::into),
}
}
#[cfg(wayland_platform)]
fn new_wayland_any_thread() -> Result<EventLoop<T>, EventLoopError> {
wayland::EventLoop::new().map(|evlp| EventLoop::Wayland(Box::new(evlp)))
}
#[cfg(x11_platform)]
fn new_x11_any_thread() -> Result<EventLoop<T>, EventLoopError> {
let xconn = match X11_BACKEND.lock().unwrap().as_ref() {
Ok(xconn) => xconn.clone(),
Err(_) => return Err(EventLoopError::NotSupported(NotSupportedError::new())),
};
Ok(EventLoop::X(x11::EventLoop::new(xconn)))
}
pub fn create_proxy(&self) -> EventLoopProxy<T> {
x11_or_wayland!(match self; EventLoop(evlp) => evlp.create_proxy(); as EventLoopProxy)
}
pub fn run<F>(mut self, callback: F) -> Result<(), EventLoopError>
where
F: FnMut(crate::event::Event<T>, &RootELW<T>),
{
self.run_on_demand(callback)
}
pub fn run_on_demand<F>(&mut self, callback: F) -> Result<(), EventLoopError>
where
F: FnMut(crate::event::Event<T>, &RootELW<T>),
{
x11_or_wayland!(match self; EventLoop(evlp) => evlp.run_on_demand(callback))
}
pub fn pump_events<F>(&mut self, timeout: Option<Duration>, callback: F) -> PumpStatus
where
F: FnMut(crate::event::Event<T>, &RootELW<T>),
{
x11_or_wayland!(match self; EventLoop(evlp) => evlp.pump_events(timeout, callback))
}
pub fn window_target(&self) -> &crate::event_loop::EventLoopWindowTarget<T> {
x11_or_wayland!(match self; EventLoop(evlp) => evlp.window_target())
}
}
impl<T> AsFd for EventLoop<T> {
fn as_fd(&self) -> BorrowedFd<'_> {
x11_or_wayland!(match self; EventLoop(evlp) => evlp.as_fd())
}
}
impl<T> AsRawFd for EventLoop<T> {
fn as_raw_fd(&self) -> RawFd {
x11_or_wayland!(match self; EventLoop(evlp) => evlp.as_raw_fd())
}
}
impl<T: 'static> EventLoopProxy<T> {
pub fn send_event(&self, event: T) -> Result<(), EventLoopClosed<T>> {
x11_or_wayland!(match self; EventLoopProxy(proxy) => proxy.send_event(event))
}
}
pub enum EventLoopWindowTarget<T> {
#[cfg(wayland_platform)]
Wayland(wayland::EventLoopWindowTarget<T>),
#[cfg(x11_platform)]
X(x11::EventLoopWindowTarget<T>),
}
impl<T> EventLoopWindowTarget<T> {
#[inline]
pub fn is_wayland(&self) -> bool {
match *self {
#[cfg(wayland_platform)]
EventLoopWindowTarget::Wayland(_) => true,
#[cfg(x11_platform)]
_ => false,
}
}
#[inline]
pub fn available_monitors(&self) -> VecDeque<MonitorHandle> {
match *self {
#[cfg(wayland_platform)]
EventLoopWindowTarget::Wayland(ref evlp) => evlp
.available_monitors()
.map(MonitorHandle::Wayland)
.collect(),
#[cfg(x11_platform)]
EventLoopWindowTarget::X(ref evlp) => {
evlp.available_monitors().map(MonitorHandle::X).collect()
}
}
}
#[inline]
pub fn primary_monitor(&self) -> Option<MonitorHandle> {
Some(
x11_or_wayland!(match self; EventLoopWindowTarget(evlp) => evlp.primary_monitor()?; as MonitorHandle),
)
}
#[inline]
pub fn listen_device_events(&self, allowed: DeviceEvents) {
x11_or_wayland!(match self; Self(evlp) => evlp.listen_device_events(allowed))
}
#[cfg(feature = "rwh_05")]
#[inline]
pub fn raw_display_handle_rwh_05(&self) -> rwh_05::RawDisplayHandle {
x11_or_wayland!(match self; Self(evlp) => evlp.raw_display_handle_rwh_05())
}
#[cfg(feature = "rwh_06")]
#[inline]
pub fn raw_display_handle_rwh_06(
&self,
) -> Result<rwh_06::RawDisplayHandle, rwh_06::HandleError> {
x11_or_wayland!(match self; Self(evlp) => evlp.raw_display_handle_rwh_06())
}
pub(crate) fn set_control_flow(&self, control_flow: ControlFlow) {
x11_or_wayland!(match self; Self(evlp) => evlp.set_control_flow(control_flow))
}
pub(crate) fn control_flow(&self) -> ControlFlow {
x11_or_wayland!(match self; Self(evlp) => evlp.control_flow())
}
pub(crate) fn clear_exit(&self) {
x11_or_wayland!(match self; Self(evlp) => evlp.clear_exit())
}
pub(crate) fn exit(&self) {
x11_or_wayland!(match self; Self(evlp) => evlp.exit())
}
pub(crate) fn exiting(&self) -> bool {
x11_or_wayland!(match self; Self(evlp) => evlp.exiting())
}
#[allow(dead_code)]
fn set_exit_code(&self, code: i32) {
x11_or_wayland!(match self; Self(evlp) => evlp.set_exit_code(code))
}
#[allow(dead_code)]
fn exit_code(&self) -> Option<i32> {
x11_or_wayland!(match self; Self(evlp) => evlp.exit_code())
}
}
/// Returns the minimum `Option<Duration>`, taking into account that `None`
/// equates to an infinite timeout, not a zero timeout (so can't just use
/// `Option::min`)
fn min_timeout(a: Option<Duration>, b: Option<Duration>) -> Option<Duration> {
a.map_or(b, |a_timeout| {
b.map_or(Some(a_timeout), |b_timeout| Some(a_timeout.min(b_timeout)))
})
}
#[cfg(target_os = "linux")]
fn is_main_thread() -> bool {
rustix::thread::gettid() == rustix::process::getpid()
}
#[cfg(any(target_os = "dragonfly", target_os = "freebsd", target_os = "openbsd"))]
fn is_main_thread() -> bool {
use libc::pthread_main_np;
unsafe { pthread_main_np() == 1 }
}
#[cfg(target_os = "netbsd")]
fn is_main_thread() -> bool {
std::thread::current().name() == Some("main")
}
@@ -0,0 +1,721 @@
//! The event-loop routines.
use std::cell::{Cell, RefCell};
use std::io::Result as IOResult;
use std::marker::PhantomData;
use std::mem;
use std::os::unix::io::{AsFd, AsRawFd, BorrowedFd, RawFd};
use std::rc::Rc;
use std::sync::atomic::Ordering;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use sctk::reexports::calloop::Error as CalloopError;
use sctk::reexports::calloop_wayland_source::WaylandSource;
use sctk::reexports::client::globals;
use sctk::reexports::client::{Connection, QueueHandle};
use crate::dpi::LogicalSize;
use crate::error::{EventLoopError, OsError as RootOsError};
use crate::event::{Event, InnerSizeWriter, StartCause, WindowEvent};
use crate::event_loop::{
ControlFlow, DeviceEvents, EventLoopWindowTarget as RootEventLoopWindowTarget,
};
use crate::platform::pump_events::PumpStatus;
use crate::platform_impl::platform::min_timeout;
use crate::platform_impl::{EventLoopWindowTarget as PlatformEventLoopWindowTarget, OsError};
mod proxy;
pub mod sink;
pub use proxy::EventLoopProxy;
use sink::EventSink;
use super::state::{WindowCompositorUpdate, WinitState};
use super::window::state::FrameCallbackState;
use super::{logical_to_physical_rounded, DeviceId, WaylandError, WindowId};
type WaylandDispatcher = calloop::Dispatcher<'static, WaylandSource<WinitState>, WinitState>;
/// The Wayland event loop.
pub struct EventLoop<T: 'static> {
/// Has `run` or `run_on_demand` been called or a call to `pump_events` that starts the loop
loop_running: bool,
buffer_sink: EventSink,
compositor_updates: Vec<WindowCompositorUpdate>,
window_ids: Vec<WindowId>,
/// Sender of user events.
user_events_sender: calloop::channel::Sender<T>,
// XXX can't remove RefCell out of here, unless we can plumb generics into the `Window`, which
// we don't really want, since it'll break public API by a lot.
/// Pending events from the user.
pending_user_events: Rc<RefCell<Vec<T>>>,
/// The Wayland dispatcher to has raw access to the queue when needed, such as
/// when creating a new window.
wayland_dispatcher: WaylandDispatcher,
/// Connection to the wayland server.
connection: Connection,
/// Event loop window target.
window_target: RootEventLoopWindowTarget<T>,
// XXX drop after everything else, just to be safe.
/// Calloop's event loop.
event_loop: calloop::EventLoop<'static, WinitState>,
}
impl<T: 'static> EventLoop<T> {
pub fn new() -> Result<EventLoop<T>, EventLoopError> {
macro_rules! map_err {
($e:expr, $err:expr) => {
$e.map_err(|error| os_error!($err(error).into()))
};
}
let connection = map_err!(Connection::connect_to_env(), WaylandError::Connection)?;
let (globals, mut event_queue) = map_err!(
globals::registry_queue_init(&connection),
WaylandError::Global
)?;
let queue_handle = event_queue.handle();
let event_loop = map_err!(
calloop::EventLoop::<WinitState>::try_new(),
WaylandError::Calloop
)?;
let mut winit_state = WinitState::new(&globals, &queue_handle, event_loop.handle())
.map_err(|error| os_error!(error))?;
// NOTE: do a roundtrip after binding the globals to prevent potential
// races with the server.
map_err!(
event_queue.roundtrip(&mut winit_state),
WaylandError::Dispatch
)?;
// Register Wayland source.
let wayland_source = WaylandSource::new(connection.clone(), event_queue);
let wayland_dispatcher =
calloop::Dispatcher::new(wayland_source, |_, queue, winit_state: &mut WinitState| {
let result = queue.dispatch_pending(winit_state);
if result.is_ok()
&& (!winit_state.events_sink.is_empty()
|| !winit_state.window_compositor_updates.is_empty())
{
winit_state.dispatched_events = true;
}
result
});
map_err!(
event_loop
.handle()
.register_dispatcher(wayland_dispatcher.clone()),
WaylandError::Calloop
)?;
// Setup the user proxy.
let pending_user_events = Rc::new(RefCell::new(Vec::new()));
let pending_user_events_clone = pending_user_events.clone();
let (user_events_sender, user_events_channel) = calloop::channel::channel();
let result = event_loop
.handle()
.insert_source(
user_events_channel,
move |event, _, winit_state: &mut WinitState| {
if let calloop::channel::Event::Msg(msg) = event {
winit_state.dispatched_events = true;
pending_user_events_clone.borrow_mut().push(msg);
}
},
)
.map_err(|error| error.error);
map_err!(result, WaylandError::Calloop)?;
// An event's loop awakener to wake up for window events from winit's windows.
let (event_loop_awakener, event_loop_awakener_source) = map_err!(
calloop::ping::make_ping()
.map_err(|error| CalloopError::OtherError(Box::new(error).into())),
WaylandError::Calloop
)?;
let result = event_loop
.handle()
.insert_source(
event_loop_awakener_source,
move |_, _, winit_state: &mut WinitState| {
// Mark that we have something to dispatch.
winit_state.dispatched_events = true;
},
)
.map_err(|error| error.error);
map_err!(result, WaylandError::Calloop)?;
let window_target = EventLoopWindowTarget {
connection: connection.clone(),
wayland_dispatcher: wayland_dispatcher.clone(),
event_loop_awakener,
queue_handle,
control_flow: Cell::new(ControlFlow::default()),
exit: Cell::new(None),
state: RefCell::new(winit_state),
_marker: PhantomData,
};
let event_loop = Self {
loop_running: false,
compositor_updates: Vec::new(),
buffer_sink: EventSink::default(),
window_ids: Vec::new(),
connection,
wayland_dispatcher,
user_events_sender,
pending_user_events,
event_loop,
window_target: RootEventLoopWindowTarget {
p: PlatformEventLoopWindowTarget::Wayland(window_target),
_marker: PhantomData,
},
};
Ok(event_loop)
}
pub fn run_on_demand<F>(&mut self, mut event_handler: F) -> Result<(), EventLoopError>
where
F: FnMut(Event<T>, &RootEventLoopWindowTarget<T>),
{
if self.loop_running {
return Err(EventLoopError::AlreadyRunning);
}
let exit = loop {
match self.pump_events(None, &mut event_handler) {
PumpStatus::Exit(0) => {
break Ok(());
}
PumpStatus::Exit(code) => {
break Err(EventLoopError::ExitFailure(code));
}
_ => {
continue;
}
}
};
// Applications aren't allowed to carry windows between separate
// `run_on_demand` calls but if they have only just dropped their
// windows we need to make sure those last requests are sent to the
// compositor.
let _ = self.roundtrip().map_err(EventLoopError::Os);
exit
}
pub fn pump_events<F>(&mut self, timeout: Option<Duration>, mut callback: F) -> PumpStatus
where
F: FnMut(Event<T>, &RootEventLoopWindowTarget<T>),
{
if !self.loop_running {
self.loop_running = true;
// Run the initial loop iteration.
self.single_iteration(&mut callback, StartCause::Init);
}
// Consider the possibility that the `StartCause::Init` iteration could
// request to Exit.
if !self.exiting() {
self.poll_events_with_timeout(timeout, &mut callback);
}
if let Some(code) = self.exit_code() {
self.loop_running = false;
callback(Event::LoopExiting, self.window_target());
PumpStatus::Exit(code)
} else {
PumpStatus::Continue
}
}
pub fn poll_events_with_timeout<F>(&mut self, mut timeout: Option<Duration>, mut callback: F)
where
F: FnMut(Event<T>, &RootEventLoopWindowTarget<T>),
{
let cause = loop {
let start = Instant::now();
timeout = {
let control_flow_timeout = match self.control_flow() {
ControlFlow::Wait => None,
ControlFlow::Poll => Some(Duration::ZERO),
ControlFlow::WaitUntil(wait_deadline) => {
Some(wait_deadline.saturating_duration_since(start))
}
};
min_timeout(control_flow_timeout, timeout)
};
// NOTE Ideally we should flush as the last thing we do before polling
// to wait for events, and this should be done by the calloop
// WaylandSource but we currently need to flush writes manually.
//
// Checking for flush error is essential to perform an exit with error, since
// once we have a protocol error, we could get stuck retrying...
if self.connection.flush().is_err() {
self.set_exit_code(1);
return;
}
if let Err(error) = self.loop_dispatch(timeout) {
// NOTE We exit on errors from dispatches, since if we've got protocol error
// libwayland-client/wayland-rs will inform us anyway, but crashing downstream is not
// really an option. Instead we inform that the event loop got destroyed. We may
// communicate an error that something was terminated, but winit doesn't provide us
// with an API to do that via some event.
// Still, we set the exit code to the error's OS error code, or to 1 if not possible.
let exit_code = error.raw_os_error().unwrap_or(1);
self.set_exit_code(exit_code);
return;
}
// NB: `StartCause::Init` is handled as a special case and doesn't need
// to be considered here
let cause = match self.control_flow() {
ControlFlow::Poll => StartCause::Poll,
ControlFlow::Wait => StartCause::WaitCancelled {
start,
requested_resume: None,
},
ControlFlow::WaitUntil(deadline) => {
if Instant::now() < deadline {
StartCause::WaitCancelled {
start,
requested_resume: Some(deadline),
}
} else {
StartCause::ResumeTimeReached {
start,
requested_resume: deadline,
}
}
}
};
// Reduce spurious wake-ups.
let dispatched_events = self.with_state(|state| state.dispatched_events);
if matches!(cause, StartCause::WaitCancelled { .. }) && !dispatched_events {
continue;
}
break cause;
};
self.single_iteration(&mut callback, cause);
}
fn single_iteration<F>(&mut self, callback: &mut F, cause: StartCause)
where
F: FnMut(Event<T>, &RootEventLoopWindowTarget<T>),
{
// NOTE currently just indented to simplify the diff
// We retain these grow-only scratch buffers as part of the EventLoop
// for the sake of avoiding lots of reallocs. We take them here to avoid
// trying to mutably borrow `self` more than once and we swap them back
// when finished.
let mut compositor_updates = std::mem::take(&mut self.compositor_updates);
let mut buffer_sink = std::mem::take(&mut self.buffer_sink);
let mut window_ids = std::mem::take(&mut self.window_ids);
callback(Event::NewEvents(cause), &self.window_target);
// NB: For consistency all platforms must emit a 'resumed' event even though Wayland
// applications don't themselves have a formal suspend/resume lifecycle.
if cause == StartCause::Init {
callback(Event::Resumed, &self.window_target);
}
// Handle pending user events. We don't need back buffer, since we can't dispatch
// user events indirectly via callback to the user.
for user_event in self.pending_user_events.borrow_mut().drain(..) {
callback(Event::UserEvent(user_event), &self.window_target);
}
// Drain the pending compositor updates.
self.with_state(|state| compositor_updates.append(&mut state.window_compositor_updates));
for mut compositor_update in compositor_updates.drain(..) {
let window_id = compositor_update.window_id;
if compositor_update.scale_changed {
let (physical_size, scale_factor) = self.with_state(|state| {
let windows = state.windows.get_mut();
let window = windows.get(&window_id).unwrap().lock().unwrap();
let scale_factor = window.scale_factor();
let size = logical_to_physical_rounded(window.inner_size(), scale_factor);
(size, scale_factor)
});
// Stash the old window size.
let old_physical_size = physical_size;
let new_inner_size = Arc::new(Mutex::new(physical_size));
callback(
Event::WindowEvent {
window_id: crate::window::WindowId(window_id),
event: WindowEvent::ScaleFactorChanged {
scale_factor,
inner_size_writer: InnerSizeWriter::new(Arc::downgrade(
&new_inner_size,
)),
},
},
&self.window_target,
);
let physical_size = *new_inner_size.lock().unwrap();
drop(new_inner_size);
// Resize the window when user altered the size.
if old_physical_size != physical_size {
self.with_state(|state| {
let windows = state.windows.get_mut();
let mut window = windows.get(&window_id).unwrap().lock().unwrap();
let new_logical_size: LogicalSize<f64> =
physical_size.to_logical(scale_factor);
window.request_inner_size(new_logical_size.into());
});
// Make it queue resize.
compositor_update.resized = true;
}
}
// NOTE: Rescale changed the physical size which winit operates in, thus we should
// resize.
if compositor_update.resized || compositor_update.scale_changed {
let physical_size = self.with_state(|state| {
let windows = state.windows.get_mut();
let window = windows.get(&window_id).unwrap().lock().unwrap();
let scale_factor = window.scale_factor();
let size = logical_to_physical_rounded(window.inner_size(), scale_factor);
// Mark the window as needed a redraw.
state
.window_requests
.get_mut()
.get_mut(&window_id)
.unwrap()
.redraw_requested
.store(true, Ordering::Relaxed);
size
});
callback(
Event::WindowEvent {
window_id: crate::window::WindowId(window_id),
event: WindowEvent::Resized(physical_size),
},
&self.window_target,
);
}
if compositor_update.close_window {
callback(
Event::WindowEvent {
window_id: crate::window::WindowId(window_id),
event: WindowEvent::CloseRequested,
},
&self.window_target,
);
}
}
// Push the events directly from the window.
self.with_state(|state| {
buffer_sink.append(&mut state.window_events_sink.lock().unwrap());
});
for event in buffer_sink.drain() {
let event = event.map_nonuser_event().unwrap();
callback(event, &self.window_target);
}
// Handle non-synthetic events.
self.with_state(|state| {
buffer_sink.append(&mut state.events_sink);
});
for event in buffer_sink.drain() {
let event = event.map_nonuser_event().unwrap();
callback(event, &self.window_target);
}
// Collect the window ids
self.with_state(|state| {
window_ids.extend(state.window_requests.get_mut().keys());
});
for window_id in window_ids.iter() {
let event = self.with_state(|state| {
let window_requests = state.window_requests.get_mut();
if window_requests.get(window_id).unwrap().take_closed() {
mem::drop(window_requests.remove(window_id));
mem::drop(state.windows.get_mut().remove(window_id));
return Some(WindowEvent::Destroyed);
}
let mut window = state
.windows
.get_mut()
.get_mut(window_id)
.unwrap()
.lock()
.unwrap();
if window.frame_callback_state() == FrameCallbackState::Requested {
return None;
}
// Reset the frame callbacks state.
window.frame_callback_reset();
let mut redraw_requested = window_requests
.get(window_id)
.unwrap()
.take_redraw_requested();
// Redraw the frame while at it.
redraw_requested |= window.refresh_frame();
redraw_requested.then_some(WindowEvent::RedrawRequested)
});
if let Some(event) = event {
callback(
Event::WindowEvent {
window_id: crate::window::WindowId(*window_id),
event,
},
&self.window_target,
);
}
}
// Reset the hint that we've dispatched events.
self.with_state(|state| {
state.dispatched_events = false;
});
// This is always the last event we dispatch before poll again
callback(Event::AboutToWait, &self.window_target);
// Update the window frames and schedule redraws.
let mut wake_up = false;
for window_id in window_ids.drain(..) {
wake_up |= self.with_state(|state| match state.windows.get_mut().get_mut(&window_id) {
Some(window) => {
let refresh = window.lock().unwrap().refresh_frame();
if refresh {
state
.window_requests
.get_mut()
.get_mut(&window_id)
.unwrap()
.redraw_requested
.store(true, Ordering::Relaxed);
}
refresh
}
None => false,
});
}
// Wakeup event loop if needed.
//
// If the user draws from the `AboutToWait` this is likely not required, however
// we can't do much about it.
if wake_up {
match &self.window_target.p {
PlatformEventLoopWindowTarget::Wayland(window_target) => {
window_target.event_loop_awakener.ping();
}
#[cfg(x11_platform)]
PlatformEventLoopWindowTarget::X(_) => unreachable!(),
}
}
std::mem::swap(&mut self.compositor_updates, &mut compositor_updates);
std::mem::swap(&mut self.buffer_sink, &mut buffer_sink);
std::mem::swap(&mut self.window_ids, &mut window_ids);
}
#[inline]
pub fn create_proxy(&self) -> EventLoopProxy<T> {
EventLoopProxy::new(self.user_events_sender.clone())
}
#[inline]
pub fn window_target(&self) -> &RootEventLoopWindowTarget<T> {
&self.window_target
}
fn with_state<'a, U: 'a, F: FnOnce(&'a mut WinitState) -> U>(&'a mut self, callback: F) -> U {
let state = match &mut self.window_target.p {
PlatformEventLoopWindowTarget::Wayland(window_target) => window_target.state.get_mut(),
#[cfg(x11_platform)]
_ => unreachable!(),
};
callback(state)
}
fn loop_dispatch<D: Into<Option<std::time::Duration>>>(&mut self, timeout: D) -> IOResult<()> {
let state = match &mut self.window_target.p {
PlatformEventLoopWindowTarget::Wayland(window_target) => window_target.state.get_mut(),
#[cfg(feature = "x11")]
_ => unreachable!(),
};
self.event_loop.dispatch(timeout, state).map_err(|error| {
error!("Error dispatching event loop: {}", error);
error.into()
})
}
fn roundtrip(&mut self) -> Result<usize, RootOsError> {
let state = match &mut self.window_target.p {
PlatformEventLoopWindowTarget::Wayland(window_target) => window_target.state.get_mut(),
#[cfg(feature = "x11")]
_ => unreachable!(),
};
let mut wayland_source = self.wayland_dispatcher.as_source_mut();
let event_queue = wayland_source.queue();
event_queue.roundtrip(state).map_err(|error| {
os_error!(OsError::WaylandError(Arc::new(WaylandError::Dispatch(
error
))))
})
}
fn control_flow(&self) -> ControlFlow {
self.window_target.p.control_flow()
}
fn exiting(&self) -> bool {
self.window_target.p.exiting()
}
fn set_exit_code(&self, code: i32) {
self.window_target.p.set_exit_code(code)
}
fn exit_code(&self) -> Option<i32> {
self.window_target.p.exit_code()
}
}
impl<T> AsFd for EventLoop<T> {
fn as_fd(&self) -> BorrowedFd<'_> {
self.event_loop.as_fd()
}
}
impl<T> AsRawFd for EventLoop<T> {
fn as_raw_fd(&self) -> RawFd {
self.event_loop.as_raw_fd()
}
}
pub struct EventLoopWindowTarget<T> {
/// The event loop wakeup source.
pub event_loop_awakener: calloop::ping::Ping,
/// The main queue used by the event loop.
pub queue_handle: QueueHandle<WinitState>,
/// The application's latest control_flow state
pub(crate) control_flow: Cell<ControlFlow>,
/// The application's exit state.
pub(crate) exit: Cell<Option<i32>>,
// TODO remove that RefCell once we can pass `&mut` in `Window::new`.
/// Winit state.
pub state: RefCell<WinitState>,
/// Dispatcher of Wayland events.
pub wayland_dispatcher: WaylandDispatcher,
/// Connection to the wayland server.
pub connection: Connection,
_marker: std::marker::PhantomData<T>,
}
impl<T> EventLoopWindowTarget<T> {
pub(crate) fn set_control_flow(&self, control_flow: ControlFlow) {
self.control_flow.set(control_flow)
}
pub(crate) fn control_flow(&self) -> ControlFlow {
self.control_flow.get()
}
pub(crate) fn exit(&self) {
self.exit.set(Some(0))
}
pub(crate) fn clear_exit(&self) {
self.exit.set(None)
}
pub(crate) fn exiting(&self) -> bool {
self.exit.get().is_some()
}
pub(crate) fn set_exit_code(&self, code: i32) {
self.exit.set(Some(code))
}
pub(crate) fn exit_code(&self) -> Option<i32> {
self.exit.get()
}
#[inline]
pub fn listen_device_events(&self, _allowed: DeviceEvents) {}
#[cfg(feature = "rwh_05")]
#[inline]
pub fn raw_display_handle_rwh_05(&self) -> rwh_05::RawDisplayHandle {
use sctk::reexports::client::Proxy;
let mut display_handle = rwh_05::WaylandDisplayHandle::empty();
display_handle.display = self.connection.display().id().as_ptr() as *mut _;
rwh_05::RawDisplayHandle::Wayland(display_handle)
}
#[cfg(feature = "rwh_06")]
#[inline]
pub fn raw_display_handle_rwh_06(
&self,
) -> Result<rwh_06::RawDisplayHandle, rwh_06::HandleError> {
use sctk::reexports::client::Proxy;
Ok(rwh_06::WaylandDisplayHandle::new({
let ptr = self.connection.display().id().as_ptr();
std::ptr::NonNull::new(ptr as *mut _).expect("wl_display should never be null")
})
.into())
}
}
@@ -0,0 +1,32 @@
//! An event loop proxy.
use std::sync::mpsc::SendError;
use sctk::reexports::calloop::channel::Sender;
use crate::event_loop::EventLoopClosed;
/// A handle that can be sent across the threads and used to wake up the `EventLoop`.
pub struct EventLoopProxy<T: 'static> {
user_events_sender: Sender<T>,
}
impl<T: 'static> Clone for EventLoopProxy<T> {
fn clone(&self) -> Self {
EventLoopProxy {
user_events_sender: self.user_events_sender.clone(),
}
}
}
impl<T: 'static> EventLoopProxy<T> {
pub fn new(user_events_sender: Sender<T>) -> Self {
Self { user_events_sender }
}
pub fn send_event(&self, event: T) -> Result<(), EventLoopClosed<T>> {
self.user_events_sender
.send(event)
.map_err(|SendError(error)| EventLoopClosed(error))
}
}
@@ -0,0 +1,56 @@
//! An event loop's sink to deliver events from the Wayland event callbacks.
use std::vec::Drain;
use crate::event::{DeviceEvent, DeviceId as RootDeviceId, Event, WindowEvent};
use crate::platform_impl::platform::DeviceId as PlatformDeviceId;
use crate::window::WindowId as RootWindowId;
use super::{DeviceId, WindowId};
/// An event loop's sink to deliver events from the Wayland event callbacks
/// to the winit's user.
#[derive(Default)]
pub struct EventSink {
pub window_events: Vec<Event<()>>,
}
impl EventSink {
pub fn new() -> Self {
Default::default()
}
/// Return `true` if there're pending events.
#[inline]
pub fn is_empty(&self) -> bool {
self.window_events.is_empty()
}
/// Add new device event to a queue.
#[inline]
pub fn push_device_event(&mut self, event: DeviceEvent, device_id: DeviceId) {
self.window_events.push(Event::DeviceEvent {
event,
device_id: RootDeviceId(PlatformDeviceId::Wayland(device_id)),
});
}
/// Add new window event to a queue.
#[inline]
pub fn push_window_event(&mut self, event: WindowEvent, window_id: WindowId) {
self.window_events.push(Event::WindowEvent {
event,
window_id: RootWindowId(window_id),
});
}
#[inline]
pub fn append(&mut self, other: &mut Self) {
self.window_events.append(&mut other.window_events);
}
#[inline]
pub fn drain(&mut self) -> Drain<'_, Event<()>> {
self.window_events.drain(..)
}
}
+86
View File
@@ -0,0 +1,86 @@
#![cfg(wayland_platform)]
//! Winit's Wayland backend.
use std::fmt::Display;
use std::sync::Arc;
use sctk::reexports::client::globals::{BindError, GlobalError};
use sctk::reexports::client::protocol::wl_surface::WlSurface;
use sctk::reexports::client::{self, ConnectError, DispatchError, Proxy};
use crate::dpi::{LogicalSize, PhysicalSize};
pub use crate::platform_impl::platform::{OsError, WindowId};
pub use event_loop::{EventLoop, EventLoopProxy, EventLoopWindowTarget};
pub use output::{MonitorHandle, VideoMode};
pub use window::Window;
mod event_loop;
mod output;
mod seat;
mod state;
mod types;
mod window;
#[derive(Debug)]
pub enum WaylandError {
/// Error connecting to the socket.
Connection(ConnectError),
/// Error binding the global.
Global(GlobalError),
// Bind error.
Bind(BindError),
/// Error during the dispatching the event queue.
Dispatch(DispatchError),
/// Calloop error.
Calloop(calloop::Error),
/// Wayland
Wire(client::backend::WaylandError),
}
impl Display for WaylandError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
WaylandError::Connection(error) => error.fmt(f),
WaylandError::Global(error) => error.fmt(f),
WaylandError::Bind(error) => error.fmt(f),
WaylandError::Dispatch(error) => error.fmt(f),
WaylandError::Calloop(error) => error.fmt(f),
WaylandError::Wire(error) => error.fmt(f),
}
}
}
impl From<WaylandError> for OsError {
fn from(value: WaylandError) -> Self {
Self::WaylandError(Arc::new(value))
}
}
/// Dummy device id, since Wayland doesn't have device events.
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct DeviceId;
impl DeviceId {
pub const unsafe fn dummy() -> Self {
DeviceId
}
}
/// Get the WindowId out of the surface.
#[inline]
fn make_wid(surface: &WlSurface) -> WindowId {
WindowId(surface.id().as_ptr() as u64)
}
/// The default routine does floor, but we need round on Wayland.
fn logical_to_physical_rounded(size: LogicalSize<u32>, scale_factor: f64) -> PhysicalSize<u32> {
let width = size.width as f64 * scale_factor;
let height = size.height as f64 * scale_factor;
(width.round(), height.round()).into()
}
+171
View File
@@ -0,0 +1,171 @@
use sctk::reexports::client::protocol::wl_output::WlOutput;
use sctk::reexports::client::Proxy;
use sctk::output::OutputData;
use crate::dpi::{LogicalPosition, PhysicalPosition, PhysicalSize};
use crate::platform_impl::platform::VideoMode as PlatformVideoMode;
use super::event_loop::EventLoopWindowTarget;
impl<T> EventLoopWindowTarget<T> {
#[inline]
pub fn available_monitors(&self) -> impl Iterator<Item = MonitorHandle> {
self.state
.borrow()
.output_state
.outputs()
.map(MonitorHandle::new)
}
#[inline]
pub fn primary_monitor(&self) -> Option<MonitorHandle> {
// There's no primary monitor on Wayland.
None
}
}
#[derive(Clone, Debug)]
pub struct MonitorHandle {
pub(crate) proxy: WlOutput,
}
impl MonitorHandle {
#[inline]
pub(crate) fn new(proxy: WlOutput) -> Self {
Self { proxy }
}
#[inline]
pub fn name(&self) -> Option<String> {
let output_data = self.proxy.data::<OutputData>().unwrap();
output_data.with_output_info(|info| info.name.clone())
}
#[inline]
pub fn native_identifier(&self) -> u32 {
let output_data = self.proxy.data::<OutputData>().unwrap();
output_data.with_output_info(|info| info.id)
}
#[inline]
pub fn size(&self) -> PhysicalSize<u32> {
let output_data = self.proxy.data::<OutputData>().unwrap();
let dimensions = output_data.with_output_info(|info| {
info.modes
.iter()
.find_map(|mode| mode.current.then_some(mode.dimensions))
});
match dimensions {
Some((width, height)) => (width as u32, height as u32),
_ => (0, 0),
}
.into()
}
#[inline]
pub fn position(&self) -> PhysicalPosition<i32> {
let output_data = self.proxy.data::<OutputData>().unwrap();
output_data.with_output_info(|info| {
info.logical_position.map_or_else(
|| {
LogicalPosition::<i32>::from(info.location)
.to_physical(info.scale_factor as f64)
},
|logical_position| {
LogicalPosition::<i32>::from(logical_position)
.to_physical(info.scale_factor as f64)
},
)
})
}
#[inline]
pub fn refresh_rate_millihertz(&self) -> Option<u32> {
let output_data = self.proxy.data::<OutputData>().unwrap();
output_data.with_output_info(|info| {
info.modes
.iter()
.find_map(|mode| mode.current.then_some(mode.refresh_rate as u32))
})
}
#[inline]
pub fn scale_factor(&self) -> i32 {
let output_data = self.proxy.data::<OutputData>().unwrap();
output_data.scale_factor()
}
#[inline]
pub fn video_modes(&self) -> impl Iterator<Item = PlatformVideoMode> {
let output_data = self.proxy.data::<OutputData>().unwrap();
let modes = output_data.with_output_info(|info| info.modes.clone());
let monitor = self.clone();
modes.into_iter().map(move |mode| {
PlatformVideoMode::Wayland(VideoMode {
size: (mode.dimensions.0 as u32, mode.dimensions.1 as u32).into(),
refresh_rate_millihertz: mode.refresh_rate as u32,
bit_depth: 32,
monitor: monitor.clone(),
})
})
}
}
impl PartialEq for MonitorHandle {
fn eq(&self, other: &Self) -> bool {
self.native_identifier() == other.native_identifier()
}
}
impl Eq for MonitorHandle {}
impl PartialOrd for MonitorHandle {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for MonitorHandle {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.native_identifier().cmp(&other.native_identifier())
}
}
impl std::hash::Hash for MonitorHandle {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.native_identifier().hash(state);
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct VideoMode {
pub(crate) size: PhysicalSize<u32>,
pub(crate) bit_depth: u16,
pub(crate) refresh_rate_millihertz: u32,
pub(crate) monitor: MonitorHandle,
}
impl VideoMode {
#[inline]
pub fn size(&self) -> PhysicalSize<u32> {
self.size
}
#[inline]
pub fn bit_depth(&self) -> u16 {
self.bit_depth
}
#[inline]
pub fn refresh_rate_millihertz(&self) -> u32 {
self.refresh_rate_millihertz
}
pub fn monitor(&self) -> MonitorHandle {
self.monitor.clone()
}
}
@@ -0,0 +1,407 @@
//! The keyboard input handling.
use std::sync::Mutex;
use std::time::Duration;
use calloop::timer::{TimeoutAction, Timer};
use calloop::{LoopHandle, RegistrationToken};
use log::warn;
use sctk::reexports::client::protocol::wl_keyboard::WlKeyboard;
use sctk::reexports::client::protocol::wl_keyboard::{
Event as WlKeyboardEvent, KeyState as WlKeyState, KeymapFormat as WlKeymapFormat,
};
use sctk::reexports::client::protocol::wl_seat::WlSeat;
use sctk::reexports::client::{Connection, Dispatch, Proxy, QueueHandle, WEnum};
use crate::event::{ElementState, WindowEvent};
use crate::keyboard::ModifiersState;
use crate::platform_impl::common::xkb::Context;
use crate::platform_impl::wayland::event_loop::sink::EventSink;
use crate::platform_impl::wayland::seat::WinitSeatState;
use crate::platform_impl::wayland::state::WinitState;
use crate::platform_impl::wayland::{self, DeviceId, WindowId};
impl Dispatch<WlKeyboard, KeyboardData, WinitState> for WinitState {
fn event(
state: &mut WinitState,
wl_keyboard: &WlKeyboard,
event: <WlKeyboard as Proxy>::Event,
data: &KeyboardData,
_: &Connection,
_: &QueueHandle<WinitState>,
) {
let seat_state = match state.seats.get_mut(&data.seat.id()) {
Some(seat_state) => seat_state,
None => return,
};
match event {
WlKeyboardEvent::Keymap { format, fd, size } => match format {
WEnum::Value(format) => match format {
WlKeymapFormat::NoKeymap => {
warn!("non-xkb compatible keymap")
}
WlKeymapFormat::XkbV1 => {
let context = &mut seat_state.keyboard_state.as_mut().unwrap().xkb_context;
context.set_keymap_from_fd(fd, size as usize);
}
_ => unreachable!(),
},
WEnum::Unknown(value) => {
warn!("unknown keymap format 0x{:x}", value)
}
},
WlKeyboardEvent::Enter { surface, .. } => {
let window_id = wayland::make_wid(&surface);
// Mark the window as focused.
let was_unfocused = match state.windows.get_mut().get(&window_id) {
Some(window) => {
let mut window = window.lock().unwrap();
let was_unfocused = !window.has_focus();
window.add_seat_focus(data.seat.id());
was_unfocused
}
None => return,
};
// Drop the repeat, if there were any.
let keyboard_state = seat_state.keyboard_state.as_mut().unwrap();
keyboard_state.current_repeat = None;
if let Some(token) = keyboard_state.repeat_token.take() {
keyboard_state.loop_handle.remove(token);
}
*data.window_id.lock().unwrap() = Some(window_id);
// The keyboard focus is considered as general focus.
if was_unfocused {
state
.events_sink
.push_window_event(WindowEvent::Focused(true), window_id);
}
// HACK: this is just for GNOME not fixing their ordering issue of modifiers.
if std::mem::take(&mut seat_state.modifiers_pending) {
state.events_sink.push_window_event(
WindowEvent::ModifiersChanged(seat_state.modifiers.into()),
window_id,
);
}
}
WlKeyboardEvent::Leave { surface, .. } => {
let window_id = wayland::make_wid(&surface);
// NOTE: we should drop the repeat regardless whethere it was for the present
// window of for the window which just went gone.
let keyboard_state = seat_state.keyboard_state.as_mut().unwrap();
keyboard_state.current_repeat = None;
if let Some(token) = keyboard_state.repeat_token.take() {
keyboard_state.loop_handle.remove(token);
}
// NOTE: The check whether the window exists is essential as we might get a
// nil surface, regardless of what protocol says.
let focused = match state.windows.get_mut().get(&window_id) {
Some(window) => {
let mut window = window.lock().unwrap();
window.remove_seat_focus(&data.seat.id());
window.has_focus()
}
None => return,
};
// We don't need to update it above, because the next `Enter` will overwrite
// anyway.
*data.window_id.lock().unwrap() = None;
if !focused {
// Notify that no modifiers are being pressed.
state.events_sink.push_window_event(
WindowEvent::ModifiersChanged(ModifiersState::empty().into()),
window_id,
);
state
.events_sink
.push_window_event(WindowEvent::Focused(false), window_id);
}
}
WlKeyboardEvent::Key {
key,
state: WEnum::Value(WlKeyState::Pressed),
..
} => {
let key = key + 8;
key_input(
seat_state,
&mut state.events_sink,
data,
key,
ElementState::Pressed,
false,
);
let keyboard_state = seat_state.keyboard_state.as_mut().unwrap();
let delay = match keyboard_state.repeat_info {
RepeatInfo::Repeat { delay, .. } => delay,
RepeatInfo::Disable => return,
};
if !keyboard_state
.xkb_context
.keymap_mut()
.unwrap()
.key_repeats(key)
{
return;
}
keyboard_state.current_repeat = Some(key);
// NOTE terminate ongoing timer and start a new timer.
if let Some(token) = keyboard_state.repeat_token.take() {
keyboard_state.loop_handle.remove(token);
}
let timer = Timer::from_duration(delay);
let wl_keyboard = wl_keyboard.clone();
keyboard_state.repeat_token = keyboard_state
.loop_handle
.insert_source(timer, move |_, _, state| {
// Required to handle the wakeups from the repeat sources.
state.dispatched_events = true;
let data = wl_keyboard.data::<KeyboardData>().unwrap();
let seat_state = state.seats.get_mut(&data.seat.id()).unwrap();
// NOTE: The removed on event source is batched, but key change to
// `None` is instant.
let repeat_keycode =
match seat_state.keyboard_state.as_ref().unwrap().current_repeat {
Some(repeat_keycode) => repeat_keycode,
None => return TimeoutAction::Drop,
};
key_input(
seat_state,
&mut state.events_sink,
data,
repeat_keycode,
ElementState::Pressed,
true,
);
// NOTE: the gap could change dynamically while repeat is going.
match seat_state.keyboard_state.as_ref().unwrap().repeat_info {
RepeatInfo::Repeat { gap, .. } => TimeoutAction::ToDuration(gap),
RepeatInfo::Disable => TimeoutAction::Drop,
}
})
.ok();
}
WlKeyboardEvent::Key {
key,
state: WEnum::Value(WlKeyState::Released),
..
} => {
let key = key + 8;
key_input(
seat_state,
&mut state.events_sink,
data,
key,
ElementState::Released,
false,
);
let keyboard_state = seat_state.keyboard_state.as_mut().unwrap();
if keyboard_state.repeat_info != RepeatInfo::Disable
&& keyboard_state
.xkb_context
.keymap_mut()
.unwrap()
.key_repeats(key)
&& Some(key) == keyboard_state.current_repeat
{
keyboard_state.current_repeat = None;
if let Some(token) = keyboard_state.repeat_token.take() {
keyboard_state.loop_handle.remove(token);
}
}
}
WlKeyboardEvent::Modifiers {
mods_depressed,
mods_latched,
mods_locked,
group,
..
} => {
let xkb_context = &mut seat_state.keyboard_state.as_mut().unwrap().xkb_context;
let xkb_state = match xkb_context.state_mut() {
Some(state) => state,
None => return,
};
xkb_state.update_modifiers(mods_depressed, mods_latched, mods_locked, 0, 0, group);
seat_state.modifiers = xkb_state.modifiers().into();
// HACK: part of the workaround from `WlKeyboardEvent::Enter`.
let window_id = match *data.window_id.lock().unwrap() {
Some(window_id) => window_id,
None => {
seat_state.modifiers_pending = true;
return;
}
};
state.events_sink.push_window_event(
WindowEvent::ModifiersChanged(seat_state.modifiers.into()),
window_id,
);
}
WlKeyboardEvent::RepeatInfo { rate, delay } => {
let keyboard_state = seat_state.keyboard_state.as_mut().unwrap();
keyboard_state.repeat_info = if rate == 0 {
// Stop the repeat once we get a disable event.
keyboard_state.current_repeat = None;
if let Some(repeat_token) = keyboard_state.repeat_token.take() {
keyboard_state.loop_handle.remove(repeat_token);
}
RepeatInfo::Disable
} else {
let gap = Duration::from_micros(1_000_000 / rate as u64);
let delay = Duration::from_millis(delay as u64);
RepeatInfo::Repeat { gap, delay }
};
}
_ => unreachable!(),
}
}
}
/// The state of the keyboard on the current seat.
#[derive(Debug)]
pub struct KeyboardState {
/// The underlying WlKeyboard.
pub keyboard: WlKeyboard,
/// Loop handle to handle key repeat.
pub loop_handle: LoopHandle<'static, WinitState>,
/// The state of the keyboard.
pub xkb_context: Context,
/// The information about the repeat rate obtained from the compositor.
pub repeat_info: RepeatInfo,
/// The token of the current handle inside the calloop's event loop.
pub repeat_token: Option<RegistrationToken>,
/// The current repeat raw key.
pub current_repeat: Option<u32>,
}
impl KeyboardState {
pub fn new(keyboard: WlKeyboard, loop_handle: LoopHandle<'static, WinitState>) -> Self {
Self {
keyboard,
loop_handle,
xkb_context: Context::new().unwrap(),
repeat_info: RepeatInfo::default(),
repeat_token: None,
current_repeat: None,
}
}
}
impl Drop for KeyboardState {
fn drop(&mut self) {
if self.keyboard.version() >= 3 {
self.keyboard.release();
}
if let Some(token) = self.repeat_token.take() {
self.loop_handle.remove(token);
}
}
}
/// The rate at which a pressed key is repeated.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RepeatInfo {
/// Keys will be repeated at the specified rate and delay.
Repeat {
/// The time between the key repeats.
gap: Duration,
/// Delay (in milliseconds) between a key press and the start of repetition.
delay: Duration,
},
/// Keys should not be repeated.
Disable,
}
impl Default for RepeatInfo {
/// The default repeat rate is 25 keys per second with the delay of 200ms.
///
/// The values are picked based on the default in various compositors and Xorg.
fn default() -> Self {
Self::Repeat {
gap: Duration::from_millis(40),
delay: Duration::from_millis(200),
}
}
}
/// Keyboard user data.
#[derive(Debug)]
pub struct KeyboardData {
/// The currently focused window surface. Could be `None` on bugged compositors, like mutter.
window_id: Mutex<Option<WindowId>>,
/// The seat used to create this keyboard.
seat: WlSeat,
}
impl KeyboardData {
pub fn new(seat: WlSeat) -> Self {
Self {
window_id: Default::default(),
seat,
}
}
}
fn key_input(
seat_state: &mut WinitSeatState,
event_sink: &mut EventSink,
data: &KeyboardData,
keycode: u32,
state: ElementState,
repeat: bool,
) {
let window_id = match *data.window_id.lock().unwrap() {
Some(window_id) => window_id,
None => return,
};
let keyboard_state = seat_state.keyboard_state.as_mut().unwrap();
let device_id = crate::event::DeviceId(crate::platform_impl::DeviceId::Wayland(DeviceId));
if let Some(mut key_context) = keyboard_state.xkb_context.key_context() {
let event = key_context.process_key_event(keycode, state, repeat);
let event = WindowEvent::KeyboardInput {
device_id,
event,
is_synthetic: false,
};
event_sink.push_window_event(event, window_id);
}
}
+223
View File
@@ -0,0 +1,223 @@
//! Seat handling.
use std::sync::Arc;
use ahash::AHashMap;
use sctk::reexports::client::backend::ObjectId;
use sctk::reexports::client::protocol::wl_seat::WlSeat;
use sctk::reexports::client::protocol::wl_touch::WlTouch;
use sctk::reexports::client::{Connection, Proxy, QueueHandle};
use sctk::reexports::protocols::wp::relative_pointer::zv1::client::zwp_relative_pointer_v1::ZwpRelativePointerV1;
use sctk::reexports::protocols::wp::text_input::zv3::client::zwp_text_input_v3::ZwpTextInputV3;
use sctk::seat::pointer::{ThemeSpec, ThemedPointer};
use sctk::seat::{Capability as SeatCapability, SeatHandler, SeatState};
use crate::event::WindowEvent;
use crate::keyboard::ModifiersState;
use crate::platform_impl::wayland::state::WinitState;
mod keyboard;
mod pointer;
mod text_input;
mod touch;
pub use pointer::relative_pointer::RelativePointerState;
pub use pointer::{PointerConstraintsState, WinitPointerData, WinitPointerDataExt};
pub use text_input::{TextInputState, ZwpTextInputV3Ext};
use keyboard::{KeyboardData, KeyboardState};
use text_input::TextInputData;
use touch::TouchPoint;
#[derive(Debug, Default)]
pub struct WinitSeatState {
/// The pointer bound on the seat.
pointer: Option<Arc<ThemedPointer<WinitPointerData>>>,
/// The touch bound on the seat.
touch: Option<WlTouch>,
/// The mapping from touched points to the surfaces they're present.
touch_map: AHashMap<i32, TouchPoint>,
/// The text input bound on the seat.
text_input: Option<Arc<ZwpTextInputV3>>,
/// The relative pointer bound on the seat.
relative_pointer: Option<ZwpRelativePointerV1>,
/// The keyboard bound on the seat.
keyboard_state: Option<KeyboardState>,
/// The current modifiers state on the seat.
modifiers: ModifiersState,
/// Wether we have pending modifiers.
modifiers_pending: bool,
}
impl WinitSeatState {
pub fn new() -> Self {
Default::default()
}
}
impl SeatHandler for WinitState {
fn seat_state(&mut self) -> &mut SeatState {
&mut self.seat_state
}
fn new_capability(
&mut self,
_: &Connection,
queue_handle: &QueueHandle<Self>,
seat: WlSeat,
capability: SeatCapability,
) {
let seat_state = self.seats.get_mut(&seat.id()).unwrap();
match capability {
SeatCapability::Touch if seat_state.touch.is_none() => {
seat_state.touch = self.seat_state.get_touch(queue_handle, &seat).ok();
}
SeatCapability::Keyboard if seat_state.keyboard_state.is_none() => {
let keyboard = seat.get_keyboard(queue_handle, KeyboardData::new(seat.clone()));
seat_state.keyboard_state =
Some(KeyboardState::new(keyboard, self.loop_handle.clone()));
}
SeatCapability::Pointer if seat_state.pointer.is_none() => {
let surface = self.compositor_state.create_surface(queue_handle);
let surface_id = surface.id();
let pointer_data = WinitPointerData::new(seat.clone());
let themed_pointer = self
.seat_state
.get_pointer_with_theme_and_data(
queue_handle,
&seat,
self.shm.wl_shm(),
surface,
ThemeSpec::System,
pointer_data,
)
.expect("failed to create pointer with present capability.");
seat_state.relative_pointer = self.relative_pointer.as_ref().map(|manager| {
manager.get_relative_pointer(
themed_pointer.pointer(),
queue_handle,
sctk::globals::GlobalData,
)
});
let themed_pointer = Arc::new(themed_pointer);
// Register cursor surface.
self.pointer_surfaces
.insert(surface_id, themed_pointer.clone());
seat_state.pointer = Some(themed_pointer);
}
_ => (),
}
if let Some(text_input_state) = seat_state
.text_input
.is_none()
.then_some(self.text_input_state.as_ref())
.flatten()
{
seat_state.text_input = Some(Arc::new(text_input_state.get_text_input(
&seat,
queue_handle,
TextInputData::default(),
)));
}
}
fn remove_capability(
&mut self,
_: &Connection,
_queue_handle: &QueueHandle<Self>,
seat: WlSeat,
capability: SeatCapability,
) {
let seat_state = self.seats.get_mut(&seat.id()).unwrap();
if let Some(text_input) = seat_state.text_input.take() {
text_input.destroy();
}
match capability {
SeatCapability::Touch => {
if let Some(touch) = seat_state.touch.take() {
if touch.version() >= 3 {
touch.release();
}
}
}
SeatCapability::Pointer => {
if let Some(relative_pointer) = seat_state.relative_pointer.take() {
relative_pointer.destroy();
}
if let Some(pointer) = seat_state.pointer.take() {
let pointer_data = pointer.pointer().winit_data();
// Remove the cursor from the mapping.
let surface_id = pointer.surface().id();
let _ = self.pointer_surfaces.remove(&surface_id);
// Remove the inner locks/confines before dropping the pointer.
pointer_data.unlock_pointer();
pointer_data.unconfine_pointer();
if pointer.pointer().version() >= 3 {
pointer.pointer().release();
}
}
}
SeatCapability::Keyboard => {
seat_state.keyboard_state = None;
self.on_keyboard_destroy(&seat.id());
}
_ => (),
}
}
fn new_seat(
&mut self,
_connection: &Connection,
_queue_handle: &QueueHandle<Self>,
seat: WlSeat,
) {
self.seats.insert(seat.id(), WinitSeatState::new());
}
fn remove_seat(
&mut self,
_connection: &Connection,
_queue_handle: &QueueHandle<Self>,
seat: WlSeat,
) {
let _ = self.seats.remove(&seat.id());
self.on_keyboard_destroy(&seat.id());
}
}
impl WinitState {
fn on_keyboard_destroy(&mut self, seat: &ObjectId) {
for (window_id, window) in self.windows.get_mut() {
let mut window = window.lock().unwrap();
let had_focus = window.has_focus();
window.remove_seat_focus(seat);
if had_focus != window.has_focus() {
self.events_sink
.push_window_event(WindowEvent::Focused(false), *window_id);
}
}
}
}
sctk::delegate_seat!(WinitState);
@@ -0,0 +1,505 @@
//! The pointer events.
use std::ops::Deref;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use sctk::reexports::client::delegate_dispatch;
use sctk::reexports::client::protocol::wl_pointer::WlPointer;
use sctk::reexports::client::protocol::wl_seat::WlSeat;
use sctk::reexports::client::protocol::wl_surface::WlSurface;
use sctk::reexports::client::{Connection, Proxy, QueueHandle, Dispatch};
use sctk::reexports::protocols::wp::pointer_constraints::zv1::client::zwp_confined_pointer_v1::ZwpConfinedPointerV1;
use sctk::reexports::protocols::wp::pointer_constraints::zv1::client::zwp_locked_pointer_v1::ZwpLockedPointerV1;
use sctk::reexports::protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::WpCursorShapeDeviceV1;
use sctk::reexports::protocols::wp::cursor_shape::v1::client::wp_cursor_shape_manager_v1::WpCursorShapeManagerV1;
use sctk::reexports::protocols::wp::pointer_constraints::zv1::client::zwp_pointer_constraints_v1::{Lifetime, ZwpPointerConstraintsV1};
use sctk::reexports::client::globals::{BindError, GlobalList};
use sctk::reexports::csd_frame::FrameClick;
use sctk::compositor::SurfaceData;
use sctk::globals::GlobalData;
use sctk::seat::pointer::{PointerData, PointerDataExt};
use sctk::seat::pointer::{PointerEvent, PointerEventKind, PointerHandler};
use sctk::seat::SeatState;
use crate::dpi::{LogicalPosition, PhysicalPosition};
use crate::event::{ElementState, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent};
use crate::platform_impl::wayland::state::WinitState;
use crate::platform_impl::wayland::{self, DeviceId, WindowId};
pub mod relative_pointer;
impl PointerHandler for WinitState {
fn pointer_frame(
&mut self,
connection: &Connection,
_: &QueueHandle<Self>,
pointer: &WlPointer,
events: &[PointerEvent],
) {
let seat = pointer.winit_data().seat();
let seat_state = self.seats.get(&seat.id()).unwrap();
let device_id = crate::event::DeviceId(crate::platform_impl::DeviceId::Wayland(DeviceId));
for event in events {
let surface = &event.surface;
// The parent surface.
let parent_surface = match event.surface.data::<SurfaceData>() {
Some(data) => data.parent_surface().unwrap_or(surface),
None => continue,
};
let window_id = wayland::make_wid(parent_surface);
// Ensure that window exists.
let mut window = match self.windows.get_mut().get_mut(&window_id) {
Some(window) => window.lock().unwrap(),
None => continue,
};
let scale_factor = window.scale_factor();
let position: PhysicalPosition<f64> =
LogicalPosition::new(event.position.0, event.position.1).to_physical(scale_factor);
match event.kind {
// Pointer movements on decorations.
PointerEventKind::Enter { .. } | PointerEventKind::Motion { .. }
if parent_surface != surface =>
{
if let Some(icon) = window.frame_point_moved(
seat,
surface,
Duration::ZERO,
event.position.0,
event.position.1,
) {
if let Some(pointer) = seat_state.pointer.as_ref() {
let _ = pointer.set_cursor(connection, icon);
}
}
}
PointerEventKind::Leave { .. } if parent_surface != surface => {
window.frame_point_left();
}
ref kind @ PointerEventKind::Press {
button,
serial,
time,
}
| ref kind @ PointerEventKind::Release {
button,
serial,
time,
} if parent_surface != surface => {
let click = match wayland_button_to_winit(button) {
MouseButton::Left => FrameClick::Normal,
MouseButton::Right => FrameClick::Alternate,
_ => continue,
};
let pressed = matches!(kind, PointerEventKind::Press { .. });
// Emulate click on the frame.
window.frame_click(
click,
pressed,
seat,
serial,
Duration::from_millis(time as u64),
window_id,
&mut self.window_compositor_updates,
);
}
// Regular events on the main surface.
PointerEventKind::Enter { .. } => {
self.events_sink
.push_window_event(WindowEvent::CursorEntered { device_id }, window_id);
if let Some(pointer) = seat_state.pointer.as_ref().map(Arc::downgrade) {
window.pointer_entered(pointer);
}
// Set the currently focused surface.
pointer.winit_data().inner.lock().unwrap().surface = Some(window_id);
self.events_sink.push_window_event(
WindowEvent::CursorMoved {
device_id,
position,
},
window_id,
);
}
PointerEventKind::Leave { .. } => {
if let Some(pointer) = seat_state.pointer.as_ref().map(Arc::downgrade) {
window.pointer_left(pointer);
}
// Remove the active surface.
pointer.winit_data().inner.lock().unwrap().surface = None;
self.events_sink
.push_window_event(WindowEvent::CursorLeft { device_id }, window_id);
}
PointerEventKind::Motion { .. } => {
self.events_sink.push_window_event(
WindowEvent::CursorMoved {
device_id,
position,
},
window_id,
);
}
ref kind @ PointerEventKind::Press { button, serial, .. }
| ref kind @ PointerEventKind::Release { button, serial, .. } => {
// Update the last button serial.
pointer
.winit_data()
.inner
.lock()
.unwrap()
.latest_button_serial = serial;
let button = wayland_button_to_winit(button);
let state = if matches!(kind, PointerEventKind::Press { .. }) {
ElementState::Pressed
} else {
ElementState::Released
};
self.events_sink.push_window_event(
WindowEvent::MouseInput {
device_id,
state,
button,
},
window_id,
);
}
PointerEventKind::Axis {
horizontal,
vertical,
..
} => {
// Get the current phase.
let mut pointer_data = pointer.winit_data().inner.lock().unwrap();
let has_discrete_scroll = horizontal.discrete != 0 || vertical.discrete != 0;
// Figure out what to do about start/ended phases here.
//
// Figure out how to deal with `Started`. Also the `Ended` is not guaranteed
// to be sent for mouse wheels.
let phase = if horizontal.stop || vertical.stop {
TouchPhase::Ended
} else {
match pointer_data.phase {
// Descrete scroll only results in moved events.
_ if has_discrete_scroll => TouchPhase::Moved,
TouchPhase::Started | TouchPhase::Moved => TouchPhase::Moved,
_ => TouchPhase::Started,
}
};
// Update the phase.
pointer_data.phase = phase;
// Mice events have both pixel and discrete delta's at the same time. So prefer
// the descrite values if they are present.
let delta = if has_discrete_scroll {
// XXX Wayland sign convention is the inverse of winit.
MouseScrollDelta::LineDelta(
(-horizontal.discrete) as f32,
(-vertical.discrete) as f32,
)
} else {
// XXX Wayland sign convention is the inverse of winit.
MouseScrollDelta::PixelDelta(
LogicalPosition::new(-horizontal.absolute, -vertical.absolute)
.to_physical(scale_factor),
)
};
self.events_sink.push_window_event(
WindowEvent::MouseWheel {
device_id,
delta,
phase,
},
window_id,
)
}
}
}
}
}
#[derive(Debug)]
pub struct WinitPointerData {
/// The inner winit data associated with the pointer.
inner: Mutex<WinitPointerDataInner>,
/// The data required by the sctk.
sctk_data: PointerData,
}
impl WinitPointerData {
pub fn new(seat: WlSeat) -> Self {
Self {
inner: Mutex::new(WinitPointerDataInner::default()),
sctk_data: PointerData::new(seat),
}
}
pub fn lock_pointer(
&self,
pointer_constraints: &PointerConstraintsState,
surface: &WlSurface,
pointer: &WlPointer,
queue_handle: &QueueHandle<WinitState>,
) {
let mut inner = self.inner.lock().unwrap();
if inner.locked_pointer.is_none() {
inner.locked_pointer = Some(pointer_constraints.lock_pointer(
surface,
pointer,
None,
Lifetime::Persistent,
queue_handle,
GlobalData,
));
}
}
pub fn unlock_pointer(&self) {
let mut inner = self.inner.lock().unwrap();
if let Some(locked_pointer) = inner.locked_pointer.take() {
locked_pointer.destroy();
}
}
pub fn confine_pointer(
&self,
pointer_constraints: &PointerConstraintsState,
surface: &WlSurface,
pointer: &WlPointer,
queue_handle: &QueueHandle<WinitState>,
) {
self.inner.lock().unwrap().confined_pointer = Some(pointer_constraints.confine_pointer(
surface,
pointer,
None,
Lifetime::Persistent,
queue_handle,
GlobalData,
));
}
pub fn unconfine_pointer(&self) {
let inner = self.inner.lock().unwrap();
if let Some(confined_pointer) = inner.confined_pointer.as_ref() {
confined_pointer.destroy();
}
}
/// Seat associated with this pointer.
pub fn seat(&self) -> &WlSeat {
self.sctk_data.seat()
}
/// Active window.
pub fn focused_window(&self) -> Option<WindowId> {
self.inner.lock().unwrap().surface
}
/// Last button serial.
pub fn latest_button_serial(&self) -> u32 {
self.sctk_data.latest_button_serial().unwrap_or_default()
}
/// Last enter serial.
pub fn latest_enter_serial(&self) -> u32 {
self.sctk_data.latest_enter_serial().unwrap_or_default()
}
pub fn set_locked_cursor_position(&self, surface_x: f64, surface_y: f64) {
let inner = self.inner.lock().unwrap();
if let Some(locked_pointer) = inner.locked_pointer.as_ref() {
locked_pointer.set_cursor_position_hint(surface_x, surface_y);
}
}
}
impl PointerDataExt for WinitPointerData {
fn pointer_data(&self) -> &PointerData {
&self.sctk_data
}
}
#[derive(Debug)]
pub struct WinitPointerDataInner {
/// The associated locked pointer.
locked_pointer: Option<ZwpLockedPointerV1>,
/// The associated confined pointer.
confined_pointer: Option<ZwpConfinedPointerV1>,
/// Serial of the last button event.
latest_button_serial: u32,
/// Currently focused window.
surface: Option<WindowId>,
/// Current axis phase.
phase: TouchPhase,
}
impl Drop for WinitPointerDataInner {
fn drop(&mut self) {
if let Some(locked_pointer) = self.locked_pointer.take() {
locked_pointer.destroy();
}
if let Some(confined_pointer) = self.confined_pointer.take() {
confined_pointer.destroy();
}
}
}
impl Default for WinitPointerDataInner {
fn default() -> Self {
Self {
surface: None,
locked_pointer: None,
confined_pointer: None,
latest_button_serial: 0,
phase: TouchPhase::Ended,
}
}
}
/// Convert the Wayland button into winit.
fn wayland_button_to_winit(button: u32) -> MouseButton {
// These values are coming from <linux/input-event-codes.h>.
const BTN_LEFT: u32 = 0x110;
const BTN_RIGHT: u32 = 0x111;
const BTN_MIDDLE: u32 = 0x112;
const BTN_SIDE: u32 = 0x113;
const BTN_EXTRA: u32 = 0x114;
const BTN_FORWARD: u32 = 0x115;
const BTN_BACK: u32 = 0x116;
match button {
BTN_LEFT => MouseButton::Left,
BTN_RIGHT => MouseButton::Right,
BTN_MIDDLE => MouseButton::Middle,
BTN_BACK | BTN_SIDE => MouseButton::Back,
BTN_FORWARD | BTN_EXTRA => MouseButton::Forward,
button => MouseButton::Other(button as u16),
}
}
pub trait WinitPointerDataExt {
fn winit_data(&self) -> &WinitPointerData;
}
impl WinitPointerDataExt for WlPointer {
fn winit_data(&self) -> &WinitPointerData {
self.data::<WinitPointerData>()
.expect("failed to get pointer data.")
}
}
pub struct PointerConstraintsState {
pointer_constraints: ZwpPointerConstraintsV1,
}
impl PointerConstraintsState {
pub fn new(
globals: &GlobalList,
queue_handle: &QueueHandle<WinitState>,
) -> Result<Self, BindError> {
let pointer_constraints = globals.bind(queue_handle, 1..=1, GlobalData)?;
Ok(Self {
pointer_constraints,
})
}
}
impl Deref for PointerConstraintsState {
type Target = ZwpPointerConstraintsV1;
fn deref(&self) -> &Self::Target {
&self.pointer_constraints
}
}
impl Dispatch<ZwpPointerConstraintsV1, GlobalData, WinitState> for PointerConstraintsState {
fn event(
_state: &mut WinitState,
_proxy: &ZwpPointerConstraintsV1,
_event: <ZwpPointerConstraintsV1 as wayland_client::Proxy>::Event,
_data: &GlobalData,
_conn: &Connection,
_qhandle: &QueueHandle<WinitState>,
) {
}
}
impl Dispatch<ZwpLockedPointerV1, GlobalData, WinitState> for PointerConstraintsState {
fn event(
_state: &mut WinitState,
_proxy: &ZwpLockedPointerV1,
_event: <ZwpLockedPointerV1 as wayland_client::Proxy>::Event,
_data: &GlobalData,
_conn: &Connection,
_qhandle: &QueueHandle<WinitState>,
) {
}
}
impl Dispatch<ZwpConfinedPointerV1, GlobalData, WinitState> for PointerConstraintsState {
fn event(
_state: &mut WinitState,
_proxy: &ZwpConfinedPointerV1,
_event: <ZwpConfinedPointerV1 as wayland_client::Proxy>::Event,
_data: &GlobalData,
_conn: &Connection,
_qhandle: &QueueHandle<WinitState>,
) {
}
}
impl Dispatch<WpCursorShapeDeviceV1, GlobalData, WinitState> for SeatState {
fn event(
_: &mut WinitState,
_: &WpCursorShapeDeviceV1,
_: <WpCursorShapeDeviceV1 as Proxy>::Event,
_: &GlobalData,
_: &Connection,
_: &QueueHandle<WinitState>,
) {
unreachable!("wp_cursor_shape_manager has no events")
}
}
impl Dispatch<WpCursorShapeManagerV1, GlobalData, WinitState> for SeatState {
fn event(
_: &mut WinitState,
_: &WpCursorShapeManagerV1,
_: <WpCursorShapeManagerV1 as Proxy>::Event,
_: &GlobalData,
_: &Connection,
_: &QueueHandle<WinitState>,
) {
unreachable!("wp_cursor_device_manager has no events")
}
}
delegate_dispatch!(WinitState: [ WlPointer: WinitPointerData] => SeatState);
delegate_dispatch!(WinitState: [ WpCursorShapeManagerV1: GlobalData] => SeatState);
delegate_dispatch!(WinitState: [ WpCursorShapeDeviceV1: GlobalData] => SeatState);
delegate_dispatch!(WinitState: [ZwpPointerConstraintsV1: GlobalData] => PointerConstraintsState);
delegate_dispatch!(WinitState: [ZwpLockedPointerV1: GlobalData] => PointerConstraintsState);
delegate_dispatch!(WinitState: [ZwpConfinedPointerV1: GlobalData] => PointerConstraintsState);
@@ -0,0 +1,95 @@
//! Relative pointer.
use std::ops::Deref;
use sctk::reexports::client::globals::{BindError, GlobalList};
use sctk::reexports::client::{delegate_dispatch, Dispatch};
use sctk::reexports::client::{Connection, QueueHandle};
use sctk::reexports::protocols::wp::relative_pointer::zv1::{
client::zwp_relative_pointer_manager_v1::ZwpRelativePointerManagerV1,
client::zwp_relative_pointer_v1::{self, ZwpRelativePointerV1},
};
use sctk::globals::GlobalData;
use crate::event::DeviceEvent;
use crate::platform_impl::wayland::state::WinitState;
/// Wrapper around the relative pointer.
pub struct RelativePointerState {
manager: ZwpRelativePointerManagerV1,
}
impl RelativePointerState {
/// Create new relative pointer manager.
pub fn new(
globals: &GlobalList,
queue_handle: &QueueHandle<WinitState>,
) -> Result<Self, BindError> {
let manager = globals.bind(queue_handle, 1..=1, GlobalData)?;
Ok(Self { manager })
}
}
impl Deref for RelativePointerState {
type Target = ZwpRelativePointerManagerV1;
fn deref(&self) -> &Self::Target {
&self.manager
}
}
impl Dispatch<ZwpRelativePointerManagerV1, GlobalData, WinitState> for RelativePointerState {
fn event(
_state: &mut WinitState,
_proxy: &ZwpRelativePointerManagerV1,
_event: <ZwpRelativePointerManagerV1 as wayland_client::Proxy>::Event,
_data: &GlobalData,
_conn: &Connection,
_qhandle: &QueueHandle<WinitState>,
) {
}
}
impl Dispatch<ZwpRelativePointerV1, GlobalData, WinitState> for RelativePointerState {
fn event(
state: &mut WinitState,
_proxy: &ZwpRelativePointerV1,
event: <ZwpRelativePointerV1 as wayland_client::Proxy>::Event,
_data: &GlobalData,
_conn: &Connection,
_qhandle: &QueueHandle<WinitState>,
) {
let (dx_unaccel, dy_unaccel) = match event {
zwp_relative_pointer_v1::Event::RelativeMotion {
dx_unaccel,
dy_unaccel,
..
} => (dx_unaccel, dy_unaccel),
_ => return,
};
state.events_sink.push_device_event(
DeviceEvent::Motion {
axis: 0,
value: dx_unaccel,
},
super::DeviceId,
);
state.events_sink.push_device_event(
DeviceEvent::Motion {
axis: 1,
value: dy_unaccel,
},
super::DeviceId,
);
state.events_sink.push_device_event(
DeviceEvent::MouseMotion {
delta: (dx_unaccel, dy_unaccel),
},
super::DeviceId,
);
}
}
delegate_dispatch!(WinitState: [ZwpRelativePointerV1: GlobalData] => RelativePointerState);
delegate_dispatch!(WinitState: [ZwpRelativePointerManagerV1: GlobalData] => RelativePointerState);
@@ -0,0 +1,212 @@
use std::ops::Deref;
use sctk::globals::GlobalData;
use sctk::reexports::client::{Connection, Proxy, QueueHandle};
use sctk::reexports::client::delegate_dispatch;
use sctk::reexports::client::globals::{BindError, GlobalList};
use sctk::reexports::client::protocol::wl_surface::WlSurface;
use sctk::reexports::client::Dispatch;
use sctk::reexports::protocols::wp::text_input::zv3::client::zwp_text_input_manager_v3::ZwpTextInputManagerV3;
use sctk::reexports::protocols::wp::text_input::zv3::client::zwp_text_input_v3::Event as TextInputEvent;
use sctk::reexports::protocols::wp::text_input::zv3::client::zwp_text_input_v3::{
ContentHint, ContentPurpose, ZwpTextInputV3,
};
use crate::event::{Ime, WindowEvent};
use crate::platform_impl::wayland;
use crate::platform_impl::wayland::state::WinitState;
use crate::window::ImePurpose;
pub struct TextInputState {
text_input_manager: ZwpTextInputManagerV3,
}
impl TextInputState {
pub fn new(
globals: &GlobalList,
queue_handle: &QueueHandle<WinitState>,
) -> Result<Self, BindError> {
let text_input_manager = globals.bind(queue_handle, 1..=1, GlobalData)?;
Ok(Self { text_input_manager })
}
}
impl Deref for TextInputState {
type Target = ZwpTextInputManagerV3;
fn deref(&self) -> &Self::Target {
&self.text_input_manager
}
}
impl Dispatch<ZwpTextInputManagerV3, GlobalData, WinitState> for TextInputState {
fn event(
_state: &mut WinitState,
_proxy: &ZwpTextInputManagerV3,
_event: <ZwpTextInputManagerV3 as Proxy>::Event,
_data: &GlobalData,
_conn: &Connection,
_qhandle: &QueueHandle<WinitState>,
) {
}
}
impl Dispatch<ZwpTextInputV3, TextInputData, WinitState> for TextInputState {
fn event(
state: &mut WinitState,
text_input: &ZwpTextInputV3,
event: <ZwpTextInputV3 as Proxy>::Event,
data: &TextInputData,
_conn: &Connection,
_qhandle: &QueueHandle<WinitState>,
) {
let windows = state.windows.get_mut();
let mut text_input_data = data.inner.lock().unwrap();
match event {
TextInputEvent::Enter { surface } => {
let window_id = wayland::make_wid(&surface);
text_input_data.surface = Some(surface);
let mut window = match windows.get(&window_id) {
Some(window) => window.lock().unwrap(),
None => return,
};
if window.ime_allowed() {
text_input.enable();
text_input.set_content_type_by_purpose(window.ime_purpose());
text_input.commit();
state
.events_sink
.push_window_event(WindowEvent::Ime(Ime::Enabled), window_id);
}
window.text_input_entered(text_input);
}
TextInputEvent::Leave { surface } => {
text_input_data.surface = None;
// Always issue a disable.
text_input.disable();
text_input.commit();
let window_id = wayland::make_wid(&surface);
// XXX this check is essential, because `leave` could have a
// refence to nil surface...
let mut window = match windows.get(&window_id) {
Some(window) => window.lock().unwrap(),
None => return,
};
window.text_input_left(text_input);
state
.events_sink
.push_window_event(WindowEvent::Ime(Ime::Disabled), window_id);
}
TextInputEvent::PreeditString {
text,
cursor_begin,
cursor_end,
} => {
let text = text.unwrap_or_default();
let cursor_begin = usize::try_from(cursor_begin)
.ok()
.and_then(|idx| text.is_char_boundary(idx).then_some(idx));
let cursor_end = usize::try_from(cursor_end)
.ok()
.and_then(|idx| text.is_char_boundary(idx).then_some(idx));
text_input_data.pending_preedit = Some(Preedit {
text,
cursor_begin,
cursor_end,
})
}
TextInputEvent::CommitString { text } => {
text_input_data.pending_preedit = None;
text_input_data.pending_commit = text;
}
TextInputEvent::Done { .. } => {
let window_id = match text_input_data.surface.as_ref() {
Some(surface) => wayland::make_wid(surface),
None => return,
};
// Clear preedit at the start of `Done`.
state.events_sink.push_window_event(
WindowEvent::Ime(Ime::Preedit(String::new(), None)),
window_id,
);
// Send `Commit`.
if let Some(text) = text_input_data.pending_commit.take() {
state
.events_sink
.push_window_event(WindowEvent::Ime(Ime::Commit(text)), window_id);
}
// Send preedit.
if let Some(preedit) = text_input_data.pending_preedit.take() {
let cursor_range = preedit
.cursor_begin
.map(|b| (b, preedit.cursor_end.unwrap_or(b)));
state.events_sink.push_window_event(
WindowEvent::Ime(Ime::Preedit(preedit.text, cursor_range)),
window_id,
);
}
}
TextInputEvent::DeleteSurroundingText { .. } => {
// Not handled.
}
_ => {}
}
}
}
pub trait ZwpTextInputV3Ext {
fn set_content_type_by_purpose(&self, purpose: ImePurpose);
}
impl ZwpTextInputV3Ext for ZwpTextInputV3 {
fn set_content_type_by_purpose(&self, purpose: ImePurpose) {
let (hint, purpose) = match purpose {
ImePurpose::Normal => (ContentHint::None, ContentPurpose::Normal),
ImePurpose::Password => (ContentHint::SensitiveData, ContentPurpose::Password),
ImePurpose::Terminal => (ContentHint::None, ContentPurpose::Terminal),
};
self.set_content_type(hint, purpose);
}
}
/// The Data associated with the text input.
#[derive(Default)]
pub struct TextInputData {
inner: std::sync::Mutex<TextInputDataInner>,
}
#[derive(Default)]
pub struct TextInputDataInner {
/// The `WlSurface` we're performing input to.
surface: Option<WlSurface>,
/// The commit to submit on `done`.
pending_commit: Option<String>,
/// The preedit to submit on `done`.
pending_preedit: Option<Preedit>,
}
/// The state of the preedit.
struct Preedit {
text: String,
cursor_begin: Option<usize>,
cursor_end: Option<usize>,
}
delegate_dispatch!(WinitState: [ZwpTextInputManagerV3: GlobalData] => TextInputState);
delegate_dispatch!(WinitState: [ZwpTextInputV3: TextInputData] => TextInputState);
Loaded 100 of 224 files, more files were not shown because too many files have changed in this diff. Show more