Compare commits
34 Commits
4a80165428
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ce893ac776 | |||
| d4d7ee742e | |||
| fb3552e9f7 | |||
|
|
8809313be1 | ||
| 588a3f7e36 | |||
| f8c45f0e30 | |||
| b00cf460bb | |||
| df8fcbf757 | |||
| cfa0faff26 | |||
|
|
68c6a87bf6 | ||
| ae8380b1ac | |||
|
|
e40ab637a6 | ||
| d221d5a0cd | |||
| b8a32cfb96 | |||
|
|
34b3e76ac1 | ||
| 48c5a89712 | |||
| 582f9e1a37 | |||
| 042652ec73 | |||
| 9ca3b60f3a | |||
| 3d8523533c | |||
| 1e2cb836f4 | |||
| fc045a9c22 | |||
| cff4b12a4a | |||
|
|
f7ef932976 | ||
| 24f4d2703a | |||
| c553c4df4b | |||
| 88385e14b2 | |||
| 0e52274053 | |||
| 78a7ae151c | |||
| 757063694e | |||
| 640db23de2 | |||
| a8bbd1e35c | |||
|
|
9cc65e70ea | ||
|
|
750eae7a93 |
34
CHANGELOG.md
Normal file
34
CHANGELOG.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to `ltk` are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.2.0] — 2026-06-25
|
||||||
|
|
||||||
|
This release adds the primitives an embedder needs to drive ltk as the render backend for a retained, externally-owned widget tree (for example projecting an Android view hierarchy onto an ltk surface). Each is kept general rather than tied to one consumer.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`Canvas::set_clip_path`** — anti-aliased clipping to an arbitrary vector path (`&[PathCmd]`) on both backends. The software backend installs a tiny-skia coverage mask; the GLES backend captures the clipped draws into an offscreen layer and composites them back through an anti-aliased coverage mask. Complements the existing rect clip (`set_clip_rects`) for shaped clips such as a circular avatar, a rounded card or a `VectorDrawable` mask. See `examples/clip_path.rs`.
|
||||||
|
- **`Canvas::fill_path` / `Canvas::stroke_path`** over a new `PathCmd` command list (`MoveTo` / `LineTo` / `QuadTo` / `CubicTo` / `Close`, in surface coordinates) — renders an arbitrary vector path, a `Path`, a `VectorDrawable` or a Lottie frame. The software backend rasterises directly with tiny-skia; the GLES backend rasterises into a tiny-skia pixmap and uploads it, so both backends share the same path rasteriser and stay at visual parity.
|
||||||
|
- **`Canvas::read_rgba_pixels`** — reads any canvas into tightly packed straight-alpha RGBA8 (top-left row first), on both the GLES and software backends (the software path un-premultiplies its pixmap). **`Canvas::is_software`** lets a caller branch on the backend (e.g. honour a real path clip on software but only a bounding rect on GLES).
|
||||||
|
- **`measure_text( text, size )`** (re-exported at the crate root) — measures one line with the default UI font and the system fallback chain, returning `(width, line_height)` in pixels without a live `Canvas`, for an embedder's measure pass that must match the renderer's metrics. Backed by a process-wide cached primary-font handle.
|
||||||
|
- **`Stack::push_placed`** — appends a child at an exact rect, bypassing alignment and intrinsic sizing, so a view tree whose geometry is computed elsewhere can be projected onto a Stack in paint order. **`Stack::push_placed_clipped`** additionally clips the child's subtree to a rect (Android's `clipChildren`): overflowing content is not painted.
|
||||||
|
- **`ExternalSource::Cpu`** and the **`External::cpu`** constructor — an immediate-mode CPU drawing closure invoked once per frame with the canvas and the widget's laid-out rect, working on both backends. Hosts a custom `View.onDraw` straight onto the ltk canvas without a GL texture round-trip, unlike the existing `Texture` source which only renders on GLES.
|
||||||
|
- **`RichText`** widget — wrapped paragraph text carrying a `Msg` per clickable link range; the layout pass emits one hit rect per link line so taps land on the link rather than the whole paragraph. The ltk side of an Android `Spanned` carrying `URLSpan` / `ClickableSpan`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- GLES clip-layer composite no longer renders path-clipped content vertically flipped: the offscreen layer was sampled at the inverted screen Y. The software backend was unaffected.
|
||||||
|
- Gesture: the horizontal pager is driven only once the swipe axis locks horizontal. Previously the pre-lock lateral drift of a vertical gesture could arm the consumer's pager without a matching release event, which could freeze the surface until an unrelated gesture reset it.
|
||||||
|
- Software/GLES parity: the software backend now snaps glyph pen positions and image destinations to integer pixels (rounding to nearest), matching the GLES backend. Previously it truncated, so text and 1:1 images could land up to half a pixel off between backends and sample ~1 px softer.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Default theme launcher SVGs replaced with renderer-compatible versions.
|
||||||
|
|
||||||
|
## [0.1.0] — 2026-03-10
|
||||||
|
|
||||||
|
- Initial release.
|
||||||
|
|
||||||
|
[0.2.0]: https://github.com/liberux/ltk/releases/tag/v0.2.0
|
||||||
|
[0.1.0]: https://github.com/liberux/ltk/releases/tag/v0.1.0
|
||||||
11
Cargo.toml
11
Cargo.toml
@@ -1,8 +1,10 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ltk"
|
name = "ltk"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.85"
|
rust-version = "1.85"
|
||||||
|
# MSRV-aware resolver: keep a fresh resolve on deps compatible with rust-version (Debian stable = 1.85).
|
||||||
|
resolver = "3"
|
||||||
license = "LGPL-2.1-only"
|
license = "LGPL-2.1-only"
|
||||||
description = "Lightweight declarative Wayland UI toolkit. Elm-shaped App / view / update model with GLES and software rendering backends, layer-shell support, runtime theming and a runtime-free core surface for embedding."
|
description = "Lightweight declarative Wayland UI toolkit. Elm-shaped App / view / update model with GLES and software rendering backends, layer-shell support, runtime theming and a runtime-free core surface for embedding."
|
||||||
repository = "https://github.com/liberux/ltk"
|
repository = "https://github.com/liberux/ltk"
|
||||||
@@ -20,6 +22,13 @@ exclude = [
|
|||||||
"TODO",
|
"TODO",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# Exposes `ltk::test_support` — internal helpers (widget handlers, laid-out
|
||||||
|
# widget lookup, slider math) used only by ltk's own integration tests. Not
|
||||||
|
# part of the stable public API; off by default so third-party builds never
|
||||||
|
# see it. `make test` enables it.
|
||||||
|
test-support = []
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
chrono = { version = "0.4", features = ["clock"] }
|
chrono = { version = "0.4", features = ["clock"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
|||||||
6
Makefile
6
Makefile
@@ -11,7 +11,7 @@ all:
|
|||||||
cargo build --release
|
cargo build --release
|
||||||
|
|
||||||
test:
|
test:
|
||||||
cargo test
|
cargo test --features test-support
|
||||||
|
|
||||||
# Typecheck the `no_run` snippets in docs/*.md by feeding each markdown
|
# Typecheck the `no_run` snippets in docs/*.md by feeding each markdown
|
||||||
# file to `rustdoc --test`. Catches API drift in cookbook + widget
|
# file to `rustdoc --test`. Catches API drift in cookbook + widget
|
||||||
@@ -29,13 +29,14 @@ doc:
|
|||||||
|
|
||||||
install: doc
|
install: doc
|
||||||
install -d $(REGISTRY)
|
install -d $(REGISTRY)
|
||||||
cp -r src Cargo.toml liberux.toml $(REGISTRY)/
|
cp -r src benches locales Cargo.toml liberux.toml $(REGISTRY)/
|
||||||
cp debian/cargo-checksum.json $(REGISTRY)/.cargo-checksum.json
|
cp debian/cargo-checksum.json $(REGISTRY)/.cargo-checksum.json
|
||||||
install -d $(DOCDIR)
|
install -d $(DOCDIR)
|
||||||
cp -r target/doc/* $(DOCDIR)/
|
cp -r target/doc/* $(DOCDIR)/
|
||||||
|
|
||||||
examples:
|
examples:
|
||||||
LTK_THEMES_DIR=themes cargo run --release --example showcase
|
LTK_THEMES_DIR=themes cargo run --release --example showcase
|
||||||
|
LTK_THEMES_DIR=themes cargo run --release --example responsive
|
||||||
LTK_THEMES_DIR=themes cargo run --release --example inputs
|
LTK_THEMES_DIR=themes cargo run --release --example inputs
|
||||||
LTK_THEMES_DIR=themes cargo run --release --example scroll
|
LTK_THEMES_DIR=themes cargo run --release --example scroll
|
||||||
LTK_THEMES_DIR=themes cargo run --release --example sliders
|
LTK_THEMES_DIR=themes cargo run --release --example sliders
|
||||||
@@ -44,6 +45,7 @@ examples:
|
|||||||
LTK_THEMES_DIR=themes cargo run --release --example widgets
|
LTK_THEMES_DIR=themes cargo run --release --example widgets
|
||||||
LTK_THEMES_DIR=themes cargo run --release --example pickers
|
LTK_THEMES_DIR=themes cargo run --release --example pickers
|
||||||
LTK_THEMES_DIR=themes cargo run --release --example dialog
|
LTK_THEMES_DIR=themes cargo run --release --example dialog
|
||||||
|
LTK_THEMES_DIR=themes cargo run --release --example carousel
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
dh_clean
|
dh_clean
|
||||||
|
|||||||
40
README.md
40
README.md
@@ -66,7 +66,7 @@ model is "public Wayland toolkit with production consumers" rather than
|
|||||||
- low idle wakeups and event-driven redraws
|
- low idle wakeups and event-driven redraws
|
||||||
- partial redraws and damage tracking
|
- partial redraws and damage tracking
|
||||||
- a simple declarative tree instead of retained widgets
|
- a simple declarative tree instead of retained widgets
|
||||||
- direct support for normal windows and layer-shell surfaces
|
- direct support for normal windows, layer-shell, and ext-session-lock surfaces
|
||||||
- a runtime-free core (`ltk::core::UiSurface`) for embedding layout and drawing
|
- a runtime-free core (`ltk::core::UiSurface`) for embedding layout and drawing
|
||||||
without `ltk::run()`
|
without `ltk::run()`
|
||||||
|
|
||||||
@@ -175,6 +175,7 @@ cargo run --example showcase
|
|||||||
Useful entry points in this repository:
|
Useful entry points in this repository:
|
||||||
|
|
||||||
- `cargo run --example showcase`
|
- `cargo run --example showcase`
|
||||||
|
- `cargo run --example responsive`
|
||||||
- `cargo run --example widgets`
|
- `cargo run --example widgets`
|
||||||
- `cargo run --example inputs`
|
- `cargo run --example inputs`
|
||||||
- `cargo run --example scroll`
|
- `cargo run --example scroll`
|
||||||
@@ -187,6 +188,7 @@ Useful entry points in this repository:
|
|||||||
In general:
|
In general:
|
||||||
|
|
||||||
- start with `showcase` for a regular app window
|
- start with `showcase` for a regular app window
|
||||||
|
- use `responsive` to see the fluid vs physical modes on stock widgets
|
||||||
- use `widgets` to see the core controls
|
- use `widgets` to see the core controls
|
||||||
- use `mini_shell` if you need overlays, theme switching, or shell-style
|
- use `mini_shell` if you need overlays, theme switching, or shell-style
|
||||||
composition
|
composition
|
||||||
@@ -211,6 +213,25 @@ More advanced APIs are available when needed:
|
|||||||
- `core::UiSurface`
|
- `core::UiSurface`
|
||||||
- runtime theme APIs
|
- runtime theme APIs
|
||||||
|
|
||||||
|
## Responsive Design
|
||||||
|
|
||||||
|
`ltk` offers **two** first-class ways to make an interface adapt to the
|
||||||
|
display, chosen per value or per process:
|
||||||
|
|
||||||
|
- **Fluid** — sizes are a fraction of the surface, tracking the short side
|
||||||
|
(width in portrait, height in landscape). Best for full-screen system
|
||||||
|
surfaces. Written with `Length::vmin` / `orient` / `fluid` and bounded with
|
||||||
|
`.clamp`.
|
||||||
|
- **Physical** — sizes stay a constant real-world size across displays (the
|
||||||
|
mainstream HiDPI `dp` model). Best for conventional windowed apps. Written
|
||||||
|
with `Length::dp` plus `set_density`.
|
||||||
|
|
||||||
|
Stock widgets follow the process-wide mode (`set_widget_scaling`, fluid by
|
||||||
|
default); an explicit `Length` on a widget always overrides it. The full
|
||||||
|
mechanics live in [`docs/architecture.md`](docs/architecture.md#responsive-sizing),
|
||||||
|
a walkthrough in [`docs/onboarding.md`](docs/onboarding.md), and the per-item
|
||||||
|
reference in the `Length` / `WidgetScaling` rustdoc.
|
||||||
|
|
||||||
## Windows and Shell Surfaces
|
## Windows and Shell Surfaces
|
||||||
|
|
||||||
By default, `ltk` creates a regular `xdg-shell` window.
|
By default, `ltk` creates a regular `xdg-shell` window.
|
||||||
@@ -230,6 +251,11 @@ Switch to layer-shell only when you are building shell surfaces such as:
|
|||||||
- greeters
|
- greeters
|
||||||
- lock screens
|
- lock screens
|
||||||
|
|
||||||
|
For a screen locker, use `ShellMode::SessionLock` instead of layer-shell: it
|
||||||
|
presents an `ext-session-lock-v1` surface that the compositor keeps on top of
|
||||||
|
everything until the app returns `true` from `requested_exit()`, which makes
|
||||||
|
the runtime call `unlock` and lift the lock.
|
||||||
|
|
||||||
## Performance Notes
|
## Performance Notes
|
||||||
|
|
||||||
`ltk` is designed to sleep when idle and redraw only on real work.
|
`ltk` is designed to sleep when idle and redraw only on real work.
|
||||||
@@ -252,13 +278,17 @@ The library already provides:
|
|||||||
## Backend Differences
|
## Backend Differences
|
||||||
|
|
||||||
The public API is the same across backends, but visual parity is not perfect
|
The public API is the same across backends, but visual parity is not perfect
|
||||||
yet. The widget tree, layout, hit-testing, text, images, fills, strokes,
|
yet. The widget tree, layout, hit-testing, text, images, fills, strokes and
|
||||||
clipping and gradients all paint identically on both paths. The gap is in the
|
clipping all paint identically on both paths. The gaps are in gradients and the
|
||||||
shadow / backdrop pipeline.
|
shadow / backdrop pipeline.
|
||||||
|
|
||||||
Effects that currently render only on the **GLES** backend, and are silent
|
Effects that currently render only on the **GLES** backend, and degrade on the
|
||||||
no-ops on the **Software** backend:
|
**Software** backend:
|
||||||
|
|
||||||
|
- **Gradients** (linear and radial, via `Canvas::fill_paint_rect`) — rendered with
|
||||||
|
dedicated shaders on GLES; on software they collapse to a flat fill from the
|
||||||
|
first stop (tiny-skia can render gradients natively, but that is not wired up
|
||||||
|
yet).
|
||||||
- **Outer drop shadows** (`Canvas::fill_shadow_outer`) — themed surfaces that
|
- **Outer drop shadows** (`Canvas::fill_shadow_outer`) — themed surfaces that
|
||||||
declare a `Shadow` slot show the soft halo on GLES and a flat fill on
|
declare a `Shadow` slot show the soft halo on GLES and a flat fill on
|
||||||
software.
|
software.
|
||||||
|
|||||||
@@ -71,8 +71,13 @@ message and we will reply with it before sharing any sensitive details.
|
|||||||
replaces the previous frame's snapshot, so the typical lifecycle is
|
replaces the previous frame's snapshot, so the typical lifecycle is
|
||||||
"one frame on the heap, then overwritten on drop". Single-line and
|
"one frame on the heap, then overwritten on drop". Single-line and
|
||||||
multiline are mutually exclusive with secure (passwords have no line
|
multiline are mutually exclusive with secure (passwords have no line
|
||||||
breaks); the text-input-v3 IME path is also skipped in secure mode so
|
breaks). Note: secure does **not** skip the text-input-v3 path — the
|
||||||
preedit / commit strings never reach the compositor's IME stack.
|
field still activates it (so the on-screen keyboard works on password
|
||||||
|
fields) but is flagged `Password` with `SensitiveData | HiddenText`,
|
||||||
|
which asks the IME / OSK not to predict, autocorrect or store the
|
||||||
|
value. The value can still reach a trusted compositor/IME; closing
|
||||||
|
that path would mean not activating text-input there (and losing the
|
||||||
|
OSK on the field).
|
||||||
|
|
||||||
### What `TextEdit::secure` does **not** cover
|
### What `TextEdit::secure` does **not** cover
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ fn build_widgets( n: usize ) -> Vec<LaidOutWidget<()>>
|
|||||||
keyboard_focusable: true,
|
keyboard_focusable: true,
|
||||||
cursor: ltk::CursorShape::Default,
|
cursor: ltk::CursorShape::Default,
|
||||||
tooltip: None,
|
tooltip: None,
|
||||||
|
accessible_label: None,
|
||||||
|
is_live_region: false,
|
||||||
}
|
}
|
||||||
} ).collect()
|
} ).collect()
|
||||||
}
|
}
|
||||||
|
|||||||
6
debian/changelog
vendored
6
debian/changelog
vendored
@@ -1,3 +1,9 @@
|
|||||||
|
ltk (0.2.0-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* New upstream release: embedder primitives for hosting an externally-laid-out widget tree (path/rect clipping, RGBA readback, standalone text measurement, CPU draw source, RichText).
|
||||||
|
|
||||||
|
-- Pedro M. de Echanove Pasquin <pedro.echanove@liberux.net> Thu, 25 Jun 2026 09:49:35 +0200
|
||||||
|
|
||||||
ltk (0.1.0-1) unstable; urgency=low
|
ltk (0.1.0-1) unstable; urgency=low
|
||||||
|
|
||||||
* Initial Debian packaging.
|
* Initial Debian packaging.
|
||||||
|
|||||||
2
debian/control
vendored
2
debian/control
vendored
@@ -2,7 +2,7 @@ Source: ltk
|
|||||||
Section: libdevel
|
Section: libdevel
|
||||||
Priority: optional
|
Priority: optional
|
||||||
Maintainer: Pedro M. de Echanove Pasquin <pedro.echanove@liberux.net>
|
Maintainer: Pedro M. de Echanove Pasquin <pedro.echanove@liberux.net>
|
||||||
Build-Depends: debhelper-compat (= 13), libxkbcommon-dev
|
Build-Depends: debhelper-compat (= 13), libxkbcommon-dev, pkg-config
|
||||||
Rules-Requires-Root: no
|
Rules-Requires-Root: no
|
||||||
Standards-Version: 4.7.0
|
Standards-Version: 4.7.0
|
||||||
Homepage: https://liberux.net
|
Homepage: https://liberux.net
|
||||||
|
|||||||
58
debian/copyright
vendored
58
debian/copyright
vendored
@@ -62,6 +62,27 @@ Comment:
|
|||||||
sibling `Sora-LICENSE.txt`. Upstream:
|
sibling `Sora-LICENSE.txt`. Upstream:
|
||||||
https://github.com/sora-xor/sora-font
|
https://github.com/sora-xor/sora-font
|
||||||
|
|
||||||
|
Files: themes/default/cursors/*
|
||||||
|
Copyright: 2002-2014 The GNOME Project and the Adwaita icon theme authors
|
||||||
|
License: CC-BY-SA-3.0 or LGPL-3, and CC-BY-SA-4.0
|
||||||
|
Comment:
|
||||||
|
Pointer cursors under themes/default/cursors/ are GNOME's *Adwaita*
|
||||||
|
cursor theme — the cursors GNOME Shell ships — bundled verbatim from
|
||||||
|
the adwaita-icon-theme distribution so the default ltk theme is
|
||||||
|
self-contained (it does not depend on adwaita-icon-theme being
|
||||||
|
installed). The files are in XCursor binary format, one per CSS cursor
|
||||||
|
name (default, text, pointer, *-resize, …) plus the customary X11 alias
|
||||||
|
symlinks (arrow → default, hand2 → pointer, …); the sibling
|
||||||
|
cursor.theme makes the tree a valid XCursor theme. They are unmodified
|
||||||
|
from upstream. A Wayland compositor draws them in response to
|
||||||
|
wp_cursor_shape_v1, so ltk applications get them without bundling their
|
||||||
|
own. Upstream offers the artwork under the disjunction in the License
|
||||||
|
field; redistributing under any one of the options satisfies the
|
||||||
|
licence. Attribution shorthand: "Cursors by the GNOME Project". The
|
||||||
|
full functional and licence notes live in
|
||||||
|
themes/default/cursors/README.md and themes/default/cursors/LICENSE.md.
|
||||||
|
Upstream: https://gitlab.gnome.org/GNOME/adwaita-icon-theme
|
||||||
|
|
||||||
License: LGPL-2.1-only
|
License: LGPL-2.1-only
|
||||||
This library is free software; you can redistribute it and/or modify it
|
This library is free software; you can redistribute it and/or modify it
|
||||||
under the terms of the GNU Lesser General Public License as published by
|
under the terms of the GNU Lesser General Public License as published by
|
||||||
@@ -196,3 +217,40 @@ License: CC-BY-4.0
|
|||||||
The licence text above is a summary; the canonical legal text at the URL
|
The licence text above is a summary; the canonical legal text at the URL
|
||||||
is what governs use, distribution and modification of the Licensed
|
is what governs use, distribution and modification of the Licensed
|
||||||
Material referenced under `Files: themes/default/icons/catalogue/*`.
|
Material referenced under `Files: themes/default/icons/catalogue/*`.
|
||||||
|
|
||||||
|
License: CC-BY-SA-3.0
|
||||||
|
Creative Commons Attribution-ShareAlike 3.0 (CC BY-SA 3.0).
|
||||||
|
.
|
||||||
|
You are free to share and adapt the material for any purpose, even
|
||||||
|
commercially, provided you give appropriate credit, provide a link to
|
||||||
|
the licence, indicate if changes were made, and distribute your
|
||||||
|
contributions under the same licence as the original (ShareAlike).
|
||||||
|
.
|
||||||
|
The canonical legal text is at:
|
||||||
|
https://creativecommons.org/licenses/by-sa/3.0/legalcode
|
||||||
|
.
|
||||||
|
A human-readable summary (which is not a substitute for the licence) is
|
||||||
|
available at:
|
||||||
|
https://creativecommons.org/licenses/by-sa/3.0/
|
||||||
|
.
|
||||||
|
The summary above governs the Adwaita cursor files referenced under
|
||||||
|
`Files: themes/default/cursors/*` when distributed under the 3.0 option.
|
||||||
|
|
||||||
|
License: CC-BY-SA-4.0
|
||||||
|
Creative Commons Attribution-ShareAlike 4.0 International (CC BY-SA 4.0).
|
||||||
|
.
|
||||||
|
You are free to share and adapt the material for any purpose, even
|
||||||
|
commercially, under the same Attribution and ShareAlike terms as the
|
||||||
|
CC BY-SA 3.0 paragraph above.
|
||||||
|
.
|
||||||
|
The canonical legal text is at:
|
||||||
|
https://creativecommons.org/licenses/by-sa/4.0/legalcode
|
||||||
|
.
|
||||||
|
A human-readable summary (which is not a substitute for the licence) is
|
||||||
|
available at:
|
||||||
|
https://creativecommons.org/licenses/by-sa/4.0/
|
||||||
|
|
||||||
|
License: LGPL-3
|
||||||
|
The Adwaita cursors are alternatively available under the GNU Lesser
|
||||||
|
General Public License version 3. On Debian systems the complete text
|
||||||
|
of the LGPL version 3 is in `/usr/share/common-licenses/LGPL-3`.
|
||||||
|
|||||||
@@ -151,6 +151,19 @@ For many third-party apps, theming is optional at first. It is reasonable to
|
|||||||
start with the default theme and come back to the runtime theme APIs later as
|
start with the default theme and come back to the runtime theme APIs later as
|
||||||
part of the `ltk::runtime` layer.
|
part of the `ltk::runtime` layer.
|
||||||
|
|
||||||
|
## Responsive sizing
|
||||||
|
|
||||||
|
Every size in a widget tree is a `Length`, resolved to concrete pixels at layout time against the surface. Two coordinate spaces matter. **Geometry** (widths, heights, paddings, gaps, box sizes) is computed in *physical* pixels — the layout root rect is `pw × ph` — so geometry `Length` values resolve against `Canvas::viewport_layout()` (physical). **Font sizes** are the exception: they resolve against `Canvas::viewport_logical()` (physical ÷ `dpi_scale`) and are multiplied by `dpi_scale` again at raster time, so a `vmin` font ends up as a fraction of the *physical* short side regardless of `dpi_scale`. Keep this split in mind when adding a widget: resolve a geometry constant with `Canvas::geom_px(n)` and a font constant with `Canvas::font_px(n)` — the two helpers hide the difference.
|
||||||
|
|
||||||
|
`ltk` offers two adaptation strategies, and both live in the same `Length` type so an app can mix them per value:
|
||||||
|
|
||||||
|
- **Fluid** (`Length::fluid(n)`, and the raw `vmin` / `vmax` / `vw` / `vh` / `orient` units): surface-proportional. `fluid(n)` reads a single design pixel `n` as `vmin(n / fluid_reference() * 100).clamp(n * FLUID_MIN, n * FLUID_MAX)` — at a surface whose short side equals the reference (412 px by default) it is exactly `n`, and it scales with the short side elsewhere, auto-clamped to `[0.7n, 1.5n]`. This tracks the width in portrait and the height in landscape, because the short side *is* the width in portrait and the height in landscape. `orient(portrait, landscape)` is the escape hatch for a different percentage per orientation.
|
||||||
|
- **Physical** (`Length::dp(n)`): constant physical size. `dp(n)` is `n × density()`, where `density()` is a process-wide factor (default `1.0`, typically set from the output DPI via `set_density`). It does not scale with the surface, only with pixel density — the mainstream HiDPI `dp`.
|
||||||
|
|
||||||
|
Stock widgets do not hard-code either strategy. Each carries a design pixel per dimension (e.g. `button` height 48, font 16) and resolves it through the process-wide `WidgetScaling` mode: `Length::widget(n)` returns `fluid(n)` under `WidgetScaling::Fluid` (the default) or `dp(n)` under `WidgetScaling::Physical`. `set_widget_scaling(mode)` flips it once for the whole app. An explicit `Length` on an individual widget (`button.height(...)`, `text_edit.height(...)`, `font_size(...)`) bypasses the mode entirely — the mode only decides the meaning of the *default* design pixels, never an override the app wrote on purpose.
|
||||||
|
|
||||||
|
Both `density()` and `widget_scaling()` are process globals read during layout; set them at startup (or, for density, whenever the surface moves to an output with a different DPI). Because they are global, ltk's own test suite serialises the tests that touch them.
|
||||||
|
|
||||||
## Animations
|
## Animations
|
||||||
|
|
||||||
The render loop is event-driven by default: it sleeps until input arrives, a `poll_interval` ticks, or `set_channel_sender` is woken from a thread. To run a tween, override `is_animating()`:
|
The render loop is event-driven by default: it sleeps until input arrives, a `poll_interval` ticks, or `set_channel_sender` is woken from a thread. To run a tween, override `is_animating()`:
|
||||||
@@ -228,10 +241,12 @@ The cheap things and the expensive things, in rough order:
|
|||||||
- *Avoid `is_animating() = true` when nothing is moving.* It pegs the loop at 60 Hz and burns battery on the mobile target.
|
- *Avoid `is_animating() = true` when nothing is moving.* It pegs the loop at 60 Hz and burns battery on the mobile target.
|
||||||
- *Lower `poll_interval()` is not free.* Crustace polls every 30 s because the clock only shows HH:MM. If your UI shows seconds, `Some(Duration::from_secs(1))` is fine; if it shows nothing time-sensitive, leave it `None`.
|
- *Lower `poll_interval()` is not free.* Crustace polls every 30 s because the clock only shows HH:MM. If your UI shows seconds, `Some(Duration::from_secs(1))` is fine; if it shows nothing time-sensitive, leave it `None`.
|
||||||
- *Scroll viewports own a sub-canvas.* They're slightly more expensive to draw than a plain column. Use them when you need clipping or actual scrolling, not as a wrapper.
|
- *Scroll viewports own a sub-canvas.* They're slightly more expensive to draw than a plain column. Use them when you need clipping or actual scrolling, not as a wrapper.
|
||||||
- *GPU vs software*: the GLES path is selected automatically when EGL is available; both render the same pixels (see the recent commits for the alpha/SDF parity work). There is no API-level difference for the application.
|
- *GPU vs software*: the GLES path is selected automatically when EGL is available, with no API-level difference for the application. The two backends are not yet pixel-identical — gradients and the shadow / backdrop pipeline degrade under software; the authoritative list of gaps is the README's *Backend Differences* section.
|
||||||
|
|
||||||
When a redraw feels sluggish: add a one-line print at the top of `view()` and confirm it's not being called more often than expected. The single most common mistake is leaving `is_animating()` returning `true` after the animation finished.
|
When a redraw feels sluggish: add a one-line print at the top of `view()` and confirm it's not being called more often than expected. The single most common mistake is leaving `is_animating()` returning `true` after the animation finished.
|
||||||
|
|
||||||
|
**Runtime guardrails.** The rules above are the app's responsibility, but the runtime also helps catch and blunt the common footguns. Set `LTK_PERF_WARN=1` to get one-shot stderr diagnostics during development when `is_animating()` stays true for 10 s (a settled animation that forgot to return `false`), when `poll_interval()` is under 100 ms (defeats the idle model), or when the software backend animates continuously for seconds (mobile CPU sink). Independently, animation on the **software** renderer is capped to ~30 Hz by default — GLES is never capped — since 60 fps software rasterization is a battery drain with no GPU offload; override [`App::cap_software_animation`] to keep the full rate. These live in `event_loop/perf.rs`.
|
||||||
|
|
||||||
## Where to look in the consumer repos
|
## Where to look in the consumer repos
|
||||||
|
|
||||||
| Pattern | File |
|
| Pattern | File |
|
||||||
|
|||||||
148
docs/cookbook.md
148
docs/cookbook.md
@@ -13,6 +13,7 @@ reference, see [`docs/widgets.md`](./widgets.md). For theme JSON, see
|
|||||||
|
|
||||||
## Table of contents
|
## Table of contents
|
||||||
|
|
||||||
|
- [Responsive sizing across mobile / tablet / desktop](#responsive-sizing-across-mobile--tablet--desktop)
|
||||||
- [Slide-in panel](#slide-in-panel)
|
- [Slide-in panel](#slide-in-panel)
|
||||||
- [Password field with PAM submit](#password-field-with-pam-submit)
|
- [Password field with PAM submit](#password-field-with-pam-submit)
|
||||||
- [Swipe-to-dismiss overlay](#swipe-to-dismiss-overlay)
|
- [Swipe-to-dismiss overlay](#swipe-to-dismiss-overlay)
|
||||||
@@ -23,6 +24,47 @@ reference, see [`docs/widgets.md`](./widgets.md). For theme JSON, see
|
|||||||
- [Tab navigation between widgets](#tab-navigation-between-widgets)
|
- [Tab navigation between widgets](#tab-navigation-between-widgets)
|
||||||
- [Multi-screen app via sub-state pattern](#multi-screen-app-via-sub-state-pattern)
|
- [Multi-screen app via sub-state pattern](#multi-screen-app-via-sub-state-pattern)
|
||||||
- [Embedding ltk without `ltk::run`](#embedding-ltk-without-ltkrun)
|
- [Embedding ltk without `ltk::run`](#embedding-ltk-without-ltkrun)
|
||||||
|
- [Custom CPU drawing and path clipping](#custom-cpu-drawing-and-path-clipping)
|
||||||
|
- [Projecting an externally-laid-out view tree](#projecting-an-externally-laid-out-view-tree)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Responsive sizing across mobile / tablet / desktop
|
||||||
|
|
||||||
|
Size everything as a fraction of the surface so one view reads
|
||||||
|
coherently from a portrait phone to a landscape desktop window,
|
||||||
|
without per-target forks. The rule is *fluid but clamped*: a `vmin`
|
||||||
|
percentage tracks the surface's smaller side, and a px `clamp` keeps
|
||||||
|
it from collapsing on a tiny screen or ballooning on a 4K monitor.
|
||||||
|
Use `Length::orient( portrait, landscape )` when the *proportion*
|
||||||
|
itself should differ between orientations, and `Image::short_side` to
|
||||||
|
size a logo along the screen's short side while preserving its aspect
|
||||||
|
ratio.
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
# use std::sync::Arc;
|
||||||
|
# use ltk::{ column, img_widget, text, Length, Element };
|
||||||
|
# #[ derive( Clone ) ] enum Msg {}
|
||||||
|
# fn _ex( logo: Arc<Vec<u8>>, lw: u32, lh: u32 ) -> Element<Msg> {
|
||||||
|
column::<Msg>()
|
||||||
|
.padding( Length::vmin( 4.0 ).clamp( 16.0, 48.0 ) )
|
||||||
|
.spacing( Length::vmin( 2.0 ).clamp( 8.0, 24.0 ) )
|
||||||
|
// Logo: 40 % of the width in portrait, 5 % of the height in landscape.
|
||||||
|
.push( img_widget( logo, lw, lh ).short_side( Length::orient( 40.0, 5.0 ) ) )
|
||||||
|
// Heading: fluid, but never below 20 px nor above 44 px.
|
||||||
|
.push( text( "Welcome" ).size( Length::vmin( 6.0 ).clamp( 20.0, 44.0 ) ) )
|
||||||
|
.into()
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
Fluid units scale with the screen's pixels, not real-world
|
||||||
|
millimetres. For body text that must stay physically legible and
|
||||||
|
honour the user's font-size preference across an open-ended device
|
||||||
|
set, prefer `Length::dp` or `Length::em` over raw `vmin`; the
|
||||||
|
[`typography`](../src/theme/typography.rs) scale (`h0`…`body_xs`)
|
||||||
|
gives clamped-`vmin` sizes tuned for running text. Reserve
|
||||||
|
`view()`-level branching on `surface_width` / `surface_height` for
|
||||||
|
genuine layout restructuring (sidebar → bottom tabs), not for sizing.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -34,7 +76,7 @@ edge does not knife-cut against the layer below.
|
|||||||
|
|
||||||
```rust,no_run
|
```rust,no_run
|
||||||
# use std::time::Instant;
|
# use std::time::Instant;
|
||||||
# use ltk::{ container, text, viewport, Anchor, Element, Layer, OverlayId, OverlaySpec };
|
# use ltk::{ container, text, viewport, Anchor, Element, Layer, Length, OverlayId, OverlaySpec };
|
||||||
# const OVERLAY_QS: OverlayId = OverlayId( 1 );
|
# const OVERLAY_QS: OverlayId = OverlayId( 1 );
|
||||||
# const SLIDE_DURATION: f32 = 0.25;
|
# const SLIDE_DURATION: f32 = 0.25;
|
||||||
# #[ derive( Clone ) ] enum Msg { CloseQs }
|
# #[ derive( Clone ) ] enum Msg { CloseQs }
|
||||||
@@ -69,7 +111,7 @@ fn build_quick_settings_overlay( &self ) -> OverlaySpec<Msg>
|
|||||||
id: OVERLAY_QS,
|
id: OVERLAY_QS,
|
||||||
layer: Layer::Overlay,
|
layer: Layer::Overlay,
|
||||||
anchor: Anchor::TOP,
|
anchor: Anchor::TOP,
|
||||||
size: ( self.surface_width, visible_h as u32 ),
|
size: ( Length::px( self.surface_width as f32 ), Length::px( visible_h ) ),
|
||||||
exclusive_zone: 0,
|
exclusive_zone: 0,
|
||||||
keyboard_exclusive: false,
|
keyboard_exclusive: false,
|
||||||
input_region: None,
|
input_region: None,
|
||||||
@@ -209,7 +251,7 @@ A modal panel that closes when the user swipes down past a threshold or
|
|||||||
taps outside the panel.
|
taps outside the panel.
|
||||||
|
|
||||||
```rust,no_run
|
```rust,no_run
|
||||||
# use ltk::{ column, container, spacer, text, Anchor, Element, Layer, OverlayId, OverlaySpec };
|
# use ltk::{ column, container, spacer, text, Anchor, Element, Layer, Length, OverlayId, OverlaySpec };
|
||||||
# const OVERLAY_MODAL: OverlayId = OverlayId( 2 );
|
# const OVERLAY_MODAL: OverlayId = OverlayId( 2 );
|
||||||
# #[ derive( Clone ) ] enum Msg { CloseModal }
|
# #[ derive( Clone ) ] enum Msg { CloseModal }
|
||||||
# struct App { modal_open: bool, modal_drag_progress: f32 }
|
# struct App { modal_open: bool, modal_drag_progress: f32 }
|
||||||
@@ -237,7 +279,7 @@ fn overlays( &self ) -> Vec<OverlaySpec<Msg>>
|
|||||||
id: OVERLAY_MODAL,
|
id: OVERLAY_MODAL,
|
||||||
layer: Layer::Overlay,
|
layer: Layer::Overlay,
|
||||||
anchor: Anchor::ALL,
|
anchor: Anchor::ALL,
|
||||||
size: ( 0, 0 ),
|
size: ( Length::px( 0.0 ), Length::px( 0.0 ) ),
|
||||||
exclusive_zone: 0,
|
exclusive_zone: 0,
|
||||||
keyboard_exclusive: false,
|
keyboard_exclusive: false,
|
||||||
input_region: None, // accept input
|
input_region: None, // accept input
|
||||||
@@ -486,7 +528,7 @@ blocking other UI.
|
|||||||
|
|
||||||
```rust,no_run
|
```rust,no_run
|
||||||
# use std::time::Instant;
|
# use std::time::Instant;
|
||||||
# use ltk::{ container, text, App, Anchor, Color, Element, Layer, OverlayId, OverlaySpec };
|
# use ltk::{ container, text, App, Anchor, Color, Element, Layer, Length, OverlayId, OverlaySpec };
|
||||||
# const OVERLAY_TOAST: OverlayId = OverlayId( 3 );
|
# const OVERLAY_TOAST: OverlayId = OverlayId( 3 );
|
||||||
# #[ derive( Clone ) ] enum Msg {}
|
# #[ derive( Clone ) ] enum Msg {}
|
||||||
struct AppState
|
struct AppState
|
||||||
@@ -537,7 +579,7 @@ impl App for AppState
|
|||||||
id: OVERLAY_TOAST,
|
id: OVERLAY_TOAST,
|
||||||
layer: Layer::Overlay,
|
layer: Layer::Overlay,
|
||||||
anchor: Anchor::BOTTOM,
|
anchor: Anchor::BOTTOM,
|
||||||
size: ( 0, 0 ),
|
size: ( Length::px( 0.0 ), Length::px( 0.0 ) ),
|
||||||
exclusive_zone: 0,
|
exclusive_zone: 0,
|
||||||
keyboard_exclusive: false,
|
keyboard_exclusive: false,
|
||||||
input_region: Some( vec![] ), // pass-through
|
input_region: Some( vec![] ), // pass-through
|
||||||
@@ -823,3 +865,97 @@ interaction state changed.
|
|||||||
**See also**: [`tests/core_surface.rs`](../tests/core_surface.rs) for
|
**See also**: [`tests/core_surface.rs`](../tests/core_surface.rs) for
|
||||||
the full set of supported operations,
|
the full set of supported operations,
|
||||||
[`docs/onboarding.md`](./onboarding.md#when-to-use-coreuisurface).
|
[`docs/onboarding.md`](./onboarding.md#when-to-use-coreuisurface).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Custom CPU drawing and path clipping
|
||||||
|
|
||||||
|
Painting something the widget set does not cover — a gauge, a
|
||||||
|
`VectorDrawable`, a Lottie frame, a shaped avatar — straight onto the
|
||||||
|
canvas, identically on the GLES and software backends. `External::cpu`
|
||||||
|
gives you a closure invoked once per frame with the `Canvas` and the
|
||||||
|
widget's laid-out rect; `set_clip_path` then clips arbitrary draws to a
|
||||||
|
vector path with an anti-aliased edge.
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
# use ltk::{ External, Element, Color, PathCmd };
|
||||||
|
# #[ derive( Clone ) ] enum Msg {}
|
||||||
|
# fn _ex() -> Element<Msg> {
|
||||||
|
External::cpu( 96.0, 96.0, |canvas, rect|
|
||||||
|
{
|
||||||
|
// Background, unclipped.
|
||||||
|
canvas.fill_rect( rect, Color::rgb( 0.10, 0.10, 0.12 ), 0.0 );
|
||||||
|
|
||||||
|
// Snapshot the outer clip so the path clip does not leak into the
|
||||||
|
// rest of the frame, then clip the foreground to a circle.
|
||||||
|
let saved = canvas.clip_bounds();
|
||||||
|
let r = rect.width.min( rect.height ) / 2.0;
|
||||||
|
let ( cx, cy ) = ( rect.x + rect.width / 2.0, rect.y + rect.height / 2.0 );
|
||||||
|
let k = r * 0.5523;
|
||||||
|
canvas.set_clip_path( &[
|
||||||
|
PathCmd::MoveTo( cx, cy - r ),
|
||||||
|
PathCmd::CubicTo( cx + k, cy - r, cx + r, cy - k, cx + r, cy ),
|
||||||
|
PathCmd::CubicTo( cx + r, cy + k, cx + k, cy + r, cx, cy + r ),
|
||||||
|
PathCmd::CubicTo( cx - k, cy + r, cx - r, cy + k, cx - r, cy ),
|
||||||
|
PathCmd::CubicTo( cx - r, cy - k, cx - k, cy - r, cx, cy - r ),
|
||||||
|
PathCmd::Close,
|
||||||
|
] );
|
||||||
|
canvas.fill_rect( rect, Color::rgb( 0.20, 0.50, 0.95 ), 0.0 );
|
||||||
|
canvas.set_clip_rects( &saved );
|
||||||
|
} ).into()
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
The path clip is bracketed: `set_clip_path` installs it and
|
||||||
|
`set_clip_rects( &saved )` flushes it (on GLES this is when the offscreen
|
||||||
|
layer is composited). `clip_bounds` snapshots the prior clip first so an
|
||||||
|
outer clip is restored rather than dropped. `fill_path` / `stroke_path`
|
||||||
|
take the same `PathCmd` list to paint a path instead of clipping to it.
|
||||||
|
|
||||||
|
**See also**: [`examples/clip_path.rs`](../examples/clip_path.rs) (rounded
|
||||||
|
rect, circle, triangle — same smooth result on both backends).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Projecting an externally-laid-out view tree
|
||||||
|
|
||||||
|
Hosting a widget tree whose geometry is computed elsewhere — an Android
|
||||||
|
measure/layout pass, say, which yields one absolute rect per view —
|
||||||
|
projected onto an ltk `stack` in paint order. `measure_text` gives the
|
||||||
|
external layout the same metrics the renderer will use, without a live
|
||||||
|
`Canvas`; `push_placed` drops each child at its exact rect; and
|
||||||
|
`push_placed_clipped` clips a child's overflow like Android's
|
||||||
|
`clipChildren`.
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
# use ltk::{ measure_text, stack, text, Element, Rect };
|
||||||
|
# #[ derive( Clone ) ] enum Msg {}
|
||||||
|
# struct View { label: String, rect: Rect, clip: Option<Rect> }
|
||||||
|
# fn external_layout_pass() -> Vec<View> { Vec::new() }
|
||||||
|
# fn _ex() -> Element<Msg> {
|
||||||
|
// The external layout pass measures text with the renderer's own metrics.
|
||||||
|
let ( w, line_h ) = measure_text( "Inbox", 16.0 );
|
||||||
|
let _ = ( w, line_h );
|
||||||
|
|
||||||
|
let views = external_layout_pass();
|
||||||
|
let mut s = stack();
|
||||||
|
for v in views
|
||||||
|
{
|
||||||
|
let child = text( v.label );
|
||||||
|
s = match v.clip
|
||||||
|
{
|
||||||
|
Some( clip ) => s.push_placed_clipped( child, v.rect, clip ),
|
||||||
|
None => s.push_placed( child, v.rect ),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
s.into()
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
To rasterise the result into a caller-owned buffer instead of presenting
|
||||||
|
it, render through a [`core::UiSurface`](#embedding-ltk-without-ltkrun)
|
||||||
|
and call `Canvas::read_rgba_pixels( &mut buf )` — it returns tightly
|
||||||
|
packed straight-alpha RGBA8 (top-left row first) from either backend
|
||||||
|
(the software path un-premultiplies for you). Branch on
|
||||||
|
`Canvas::is_software()` when a draw must honour a real path clip on
|
||||||
|
software but only a bounding rect on GLES.
|
||||||
|
|||||||
@@ -322,6 +322,28 @@ enum AppMsg
|
|||||||
|
|
||||||
This is the pattern used by `examples/mini_shell.rs`.
|
This is the pattern used by `examples/mini_shell.rs`.
|
||||||
|
|
||||||
|
## Responsive sizing: fluid vs physical
|
||||||
|
|
||||||
|
`ltk` gives you two ways to make an interface adapt to the display, and you can
|
||||||
|
mix them per value. **Fluid** sizes are a fraction of the surface (best for
|
||||||
|
full-screen system surfaces); **physical** sizes stay a constant real-world
|
||||||
|
size (best for conventional windowed apps). In practice you write a size as a
|
||||||
|
`Length` and bound it with `.clamp`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use ltk::{ text, Length };
|
||||||
|
|
||||||
|
// Fluid: 6 % of the surface's short side, never below 20 px nor above 44 px.
|
||||||
|
text("Welcome").size(Length::vmin(6.0).clamp(20.0, 44.0));
|
||||||
|
```
|
||||||
|
|
||||||
|
Stock widgets follow a process-wide mode (`set_widget_scaling`, fluid by
|
||||||
|
default); an explicit `Length` on a widget overrides it. For the units
|
||||||
|
(`vmin` / `orient` / `dp` / …), the clamp discipline and how it all resolves,
|
||||||
|
see the *Responsive sizing* section of
|
||||||
|
[`architecture.md`](architecture.md#responsive-sizing) and the `Length`
|
||||||
|
rustdoc.
|
||||||
|
|
||||||
## Recommended learning order
|
## Recommended learning order
|
||||||
|
|
||||||
If you are new to the library, this order minimizes confusion:
|
If you are new to the library, this order minimizes confusion:
|
||||||
|
|||||||
114
docs/widgets.md
114
docs/widgets.md
@@ -18,11 +18,11 @@ patterns built from these widgets, see [`docs/cookbook.md`](./cookbook.md).
|
|||||||
- [Continuous controls](#continuous-controls)
|
- [Continuous controls](#continuous-controls)
|
||||||
- [`slider`](#slider) · [`vslider`](#vslider) · [`progress_bar`](#progress_bar)
|
- [`slider`](#slider) · [`vslider`](#vslider) · [`progress_bar`](#progress_bar)
|
||||||
- [Text input and display](#text-input-and-display)
|
- [Text input and display](#text-input-and-display)
|
||||||
- [`text`](#text) · [`text_edit`](#text_edit)
|
- [`text`](#text) · [`text_edit`](#text_edit) · [`rich_text`](#rich_text)
|
||||||
- [Decoration and chrome](#decoration-and-chrome)
|
- [Decoration and chrome](#decoration-and-chrome)
|
||||||
- [`container`](#container) · [`separator`](#separator) · [`img_widget`](#img_widget)
|
- [`container`](#container) · [`separator`](#separator) · [`img_widget`](#img_widget) · [`external`](#external)
|
||||||
- [Clipping wrappers](#clipping-wrappers)
|
- [Clipping wrappers](#clipping-wrappers)
|
||||||
- [`scroll`](#scroll) · [`viewport`](#viewport) · [`flex`](#flex)
|
- [`scroll`](#scroll) · [`viewport`](#viewport) · [`flex`](#flex) · [`carousel`](#carousel)
|
||||||
- [Overlays and feedback](#overlays-and-feedback)
|
- [Overlays and feedback](#overlays-and-feedback)
|
||||||
- [`spinner`](#spinner) · [`toast`](#toast) · [`tooltip`](#tooltip) · [`combo`](#combo) · [`tabs`](#tabs) · [`notebook`](#notebook) · [`dialog`](#dialog)
|
- [`spinner`](#spinner) · [`toast`](#toast) · [`tooltip`](#tooltip) · [`combo`](#combo) · [`tabs`](#tabs) · [`notebook`](#notebook) · [`dialog`](#dialog)
|
||||||
- [Pickers](#pickers)
|
- [Pickers](#pickers)
|
||||||
@@ -355,6 +355,35 @@ runtime — only what the user sees on screen changes.
|
|||||||
**See also**: the password recipe in
|
**See also**: the password recipe in
|
||||||
[`docs/cookbook.md`](./cookbook.md#password-field-with-pam-submit).
|
[`docs/cookbook.md`](./cookbook.md#password-field-with-pam-submit).
|
||||||
|
|
||||||
|
### `rich_text`
|
||||||
|
|
||||||
|
A wrapped paragraph that carries a `Msg` per clickable link range — the
|
||||||
|
ltk counterpart of an Android `Spanned` with `URLSpan` / `ClickableSpan`.
|
||||||
|
Unlike `text`, the layout pass emits one hit rect per link *line*, so a
|
||||||
|
link that wraps across lines is hit-tested on each of its lines and taps
|
||||||
|
land on the link rather than on the whole paragraph.
|
||||||
|
|
||||||
|
**When**: body copy with inline links — a terms-and-conditions blurb, a
|
||||||
|
chat message with a URL, an "about" screen crediting a project.
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
# use ltk::{ rich_text, Element };
|
||||||
|
# #[ derive( Clone ) ] enum Msg { OpenTerms, OpenPrivacy }
|
||||||
|
# fn _ex() -> Element<Msg> {
|
||||||
|
let body = "By continuing you accept the Terms and the Privacy Policy.";
|
||||||
|
rich_text( body )
|
||||||
|
.size( 14.0 )
|
||||||
|
.link( 30, 35, Msg::OpenTerms ) // byte range of "Terms"
|
||||||
|
.link( 44, 58, Msg::OpenPrivacy ) // byte range of "Privacy Policy"
|
||||||
|
.into()
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
Link ranges are **byte** offsets into the content `[start, end)`, drawn
|
||||||
|
underlined in `link_color`. `color` sets the non-link text colour, `size`
|
||||||
|
the font size (any `Length`), and `font( family, weight, style )` the
|
||||||
|
typeface resolved through the active theme on draw.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Decoration and chrome
|
## Decoration and chrome
|
||||||
@@ -436,6 +465,39 @@ explicit display dimensions.
|
|||||||
**See also**: [`Image::from_path`](../src/widget/image.rs) helper for
|
**See also**: [`Image::from_path`](../src/widget/image.rs) helper for
|
||||||
disk-loaded files (PNG, JPEG via the `image` crate).
|
disk-loaded files (PNG, JPEG via the `image` crate).
|
||||||
|
|
||||||
|
### `external`
|
||||||
|
|
||||||
|
An escape hatch that reserves layout space and defers its pixels to a
|
||||||
|
caller-provided producer, composited in-line with the rest of the tree.
|
||||||
|
Two sources:
|
||||||
|
|
||||||
|
- `External::cpu( w, h, |canvas, rect| … )` — an immediate-mode CPU
|
||||||
|
drawing closure invoked once per frame with the `Canvas` and the
|
||||||
|
widget's laid-out `rect` (physical pixels). Works on **both** backends
|
||||||
|
and is the way to host a custom `onDraw`-style routine — paths, clips,
|
||||||
|
text — straight onto the canvas with no GL round-trip.
|
||||||
|
- `External::new( w, h, ExternalSource::Texture( … ) )` — samples a
|
||||||
|
caller-owned GL texture each frame (a web engine, a video decoder).
|
||||||
|
**GLES only**; the producer keeps the texture and ltk only composites.
|
||||||
|
|
||||||
|
**When**: a `VectorDrawable` / Lottie frame, a custom-painted gauge, or
|
||||||
|
embedding another renderer's output.
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
# use ltk::{ External, Element };
|
||||||
|
# #[ derive( Clone ) ] enum Msg {}
|
||||||
|
# fn _ex() -> Element<Msg> {
|
||||||
|
External::cpu( 120.0, 120.0, |canvas, rect|
|
||||||
|
{
|
||||||
|
canvas.fill_rect( rect, ltk::Color::rgb( 0.1, 0.1, 0.12 ), 8.0 );
|
||||||
|
// any Canvas primitive: fill_path, set_clip_path, draw_text, …
|
||||||
|
} ).into()
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
**See also**: the CPU-drawing and path-clip recipe in
|
||||||
|
[`docs/cookbook.md`](./cookbook.md#custom-cpu-drawing-and-path-clipping).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Clipping wrappers
|
## Clipping wrappers
|
||||||
@@ -522,6 +584,47 @@ spacer siblings.
|
|||||||
**See also**: [`spacer`](#spacer) for invisible fillers,
|
**See also**: [`spacer`](#spacer) for invisible fillers,
|
||||||
[`column`](#column) and [`row`](#row).
|
[`column`](#column) and [`row`](#row).
|
||||||
|
|
||||||
|
### `carousel`
|
||||||
|
|
||||||
|
Horizontal carousel: the focused child sits centred in the viewport at
|
||||||
|
`focused_width_frac` of the viewport width and its neighbours peek out
|
||||||
|
on the left / right at `gap` separation. The widget is a pure layout
|
||||||
|
primitive — the `offset` (positive shifts content right) is owned by
|
||||||
|
the caller, so drag / inertia / snap-ease live in the host (compositor,
|
||||||
|
gesture recogniser, etc.).
|
||||||
|
|
||||||
|
**When**: mobile-style app switchers, image / story strips, any
|
||||||
|
single-focus horizontal navigation where neighbours hint at the next /
|
||||||
|
previous tile.
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
# use ltk::{ button, carousel, container, Element, Color, Corners };
|
||||||
|
# #[ derive( Clone ) ] enum Msg { Open( usize ) }
|
||||||
|
# fn _ex( offset: f32 ) -> Element<Msg> {
|
||||||
|
carousel()
|
||||||
|
.focused_width_frac( 0.8 )
|
||||||
|
.gap( 16.0 )
|
||||||
|
.offset( offset )
|
||||||
|
.push( container( button::<Msg>( "Tile 1" ).on_press( Msg::Open( 0 ) ) )
|
||||||
|
.background( Color::rgb( 0.95, 0.4, 0.4 ) )
|
||||||
|
.radius( Corners::all( 12.0 ) ) )
|
||||||
|
.push( container( button::<Msg>( "Tile 2" ).on_press( Msg::Open( 1 ) ) )
|
||||||
|
.background( Color::rgb( 0.95, 0.85, 0.3 ) )
|
||||||
|
.radius( Corners::all( 12.0 ) ) )
|
||||||
|
.into()
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
Helpers on the widget translate between offsets and indices:
|
||||||
|
`snap_offset( viewport_w, idx )` returns the offset that centres tile
|
||||||
|
`idx`; `focused_index( viewport_w )` rounds the current offset to the
|
||||||
|
nearest tile. The runnable demo at
|
||||||
|
[`examples/carousel.rs`](../examples/carousel.rs) shows Prev / Next
|
||||||
|
buttons and arrow-key navigation against an external `offset` state.
|
||||||
|
|
||||||
|
**See also**: [`scroll`](#scroll) for free vertical / horizontal panning
|
||||||
|
of arbitrary content; [`tabs`](#tabs) for a non-touch alternative.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Overlays and feedback
|
## Overlays and feedback
|
||||||
@@ -834,6 +937,11 @@ grid( 4 )
|
|||||||
|
|
||||||
Wrap inside [`scroll`](#scroll) when the grid may overflow.
|
Wrap inside [`scroll`](#scroll) when the grid may overflow.
|
||||||
|
|
||||||
|
`centre_last_row( true )` shifts a partial last row so its tiles sit
|
||||||
|
centred under the full rows above instead of left-aligned — useful for
|
||||||
|
app switchers and gallery layouts where a 7-of-9 leftover band reads
|
||||||
|
better balanced.
|
||||||
|
|
||||||
### `spacer`
|
### `spacer`
|
||||||
|
|
||||||
An invisible flexible filler. Inside a column / row, absorbs leftover
|
An invisible flexible filler. Inside a column / row, absorbs leftover
|
||||||
|
|||||||
182
examples/carousel.rs
Normal file
182
examples/carousel.rs
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
|
//! `cargo run --example carousel`
|
||||||
|
//!
|
||||||
|
//! Demonstrates the `carousel()` widget: the focused tile sits centred
|
||||||
|
//! in the viewport at `focused_width_frac` of its width, and its
|
||||||
|
//! neighbours peek out on the left / right at `gap` separation.
|
||||||
|
//!
|
||||||
|
//! The carousel widget itself is a stateless layout primitive — the
|
||||||
|
//! `offset` (positive shifts content right) is owned by the host. The
|
||||||
|
//! example mutates that offset directly when Prev / Next / arrow keys
|
||||||
|
//! change the focused index; in a real touch-driven app the host would
|
||||||
|
//! drive it from drag / inertia / snap-ease.
|
||||||
|
//!
|
||||||
|
//! Esc quits. Arrow keys = Prev / Next.
|
||||||
|
//!
|
||||||
|
//! NOTE: ltk is a Wayland layer-shell toolkit. This example needs a
|
||||||
|
//! running Wayland compositor.
|
||||||
|
|
||||||
|
use ltk::{
|
||||||
|
App, ButtonVariant, Color, Corners, Element, Keysym,
|
||||||
|
button, carousel, column, container, row, spacer, text,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[ derive( Clone ) ]
|
||||||
|
enum Message
|
||||||
|
{
|
||||||
|
Prev,
|
||||||
|
Next,
|
||||||
|
Tile( usize ),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CarouselApp
|
||||||
|
{
|
||||||
|
focused: usize,
|
||||||
|
offset: f32,
|
||||||
|
last_msg: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
const TILE_COUNT: usize = 7;
|
||||||
|
|
||||||
|
/// Approximate viewport width used to translate "focused index → offset".
|
||||||
|
/// Real apps would derive this from the laid-out viewport rect; for an
|
||||||
|
/// example the constant keeps the focus / step math obvious.
|
||||||
|
const VIEWPORT_W: f32 = 800.0;
|
||||||
|
const FOCUSED_FRAC: f32 = 0.7;
|
||||||
|
const GAP: f32 = 16.0;
|
||||||
|
|
||||||
|
const COLORS: [( f32, f32, f32 ); TILE_COUNT] = [
|
||||||
|
( 0.95, 0.40, 0.40 ),
|
||||||
|
( 0.95, 0.65, 0.30 ),
|
||||||
|
( 0.95, 0.85, 0.30 ),
|
||||||
|
( 0.50, 0.85, 0.35 ),
|
||||||
|
( 0.35, 0.80, 0.85 ),
|
||||||
|
( 0.45, 0.55, 0.95 ),
|
||||||
|
( 0.75, 0.45, 0.90 ),
|
||||||
|
];
|
||||||
|
|
||||||
|
impl CarouselApp
|
||||||
|
{
|
||||||
|
fn new() -> Self
|
||||||
|
{
|
||||||
|
Self { focused: 0, offset: 0.0, last_msg: String::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snap_offset_for( &self, focused: usize ) -> f32
|
||||||
|
{
|
||||||
|
let stride = VIEWPORT_W * FOCUSED_FRAC + GAP;
|
||||||
|
-( focused as f32 ) * stride
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App for CarouselApp
|
||||||
|
{
|
||||||
|
type Message = Message;
|
||||||
|
|
||||||
|
fn view( &self ) -> Element<Message>
|
||||||
|
{
|
||||||
|
let palette = ltk::theme_palette();
|
||||||
|
let primary = palette.text_primary;
|
||||||
|
let secondary = palette.text_secondary;
|
||||||
|
|
||||||
|
let mut car = carousel::<Message>()
|
||||||
|
.focused_width_frac( FOCUSED_FRAC )
|
||||||
|
.gap( GAP )
|
||||||
|
.offset( self.offset );
|
||||||
|
|
||||||
|
for i in 0..TILE_COUNT
|
||||||
|
{
|
||||||
|
let ( r, g, b ) = COLORS[i];
|
||||||
|
let tile = container::<Message>(
|
||||||
|
column::<Message>()
|
||||||
|
.padding( 24.0 )
|
||||||
|
.spacing( 12.0 )
|
||||||
|
.push( text( format!( "Tile {}", i + 1 ) ).size( 28.0 ).color( Color::WHITE ).align_center() )
|
||||||
|
.push( spacer() )
|
||||||
|
.push(
|
||||||
|
button::<Message>( "Activate" )
|
||||||
|
.variant( ButtonVariant::Primary )
|
||||||
|
.on_press( Message::Tile( i ) ),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.background( Color::rgb( r, g, b ) )
|
||||||
|
.radius( Corners::all( 12.0 ) );
|
||||||
|
car = car.push( tile );
|
||||||
|
}
|
||||||
|
|
||||||
|
let status_line = if self.last_msg.is_empty()
|
||||||
|
{
|
||||||
|
text( "← / → cycle · click Activate to fire Message::Tile · Esc quits" )
|
||||||
|
.size( 12.0 )
|
||||||
|
.color( secondary )
|
||||||
|
.align_center()
|
||||||
|
} else {
|
||||||
|
text( &self.last_msg )
|
||||||
|
.size( 12.0 )
|
||||||
|
.color( secondary )
|
||||||
|
.align_center()
|
||||||
|
};
|
||||||
|
|
||||||
|
column::<Message>()
|
||||||
|
.padding( 16.0 )
|
||||||
|
.spacing( 12.0 )
|
||||||
|
.push( text( "ltk — carousel showcase" ).size( 22.0 ).color( primary ).align_center() )
|
||||||
|
.push(
|
||||||
|
row::<Message>()
|
||||||
|
.spacing( 8.0 )
|
||||||
|
.push( button::<Message>( "◀ Prev" ).on_press( Message::Prev ) )
|
||||||
|
.push( spacer() )
|
||||||
|
.push( text( format!( "Focused: {} / {}", self.focused + 1, TILE_COUNT ) ).size( 14.0 ).color( secondary ) )
|
||||||
|
.push( spacer() )
|
||||||
|
.push( button::<Message>( "Next ▶" ).on_press( Message::Next ) ),
|
||||||
|
)
|
||||||
|
.push( car )
|
||||||
|
.push( status_line )
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update( &mut self, msg: Message )
|
||||||
|
{
|
||||||
|
match msg
|
||||||
|
{
|
||||||
|
Message::Prev =>
|
||||||
|
{
|
||||||
|
if self.focused > 0
|
||||||
|
{
|
||||||
|
self.focused -= 1;
|
||||||
|
self.offset = self.snap_offset_for( self.focused );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::Next =>
|
||||||
|
{
|
||||||
|
if self.focused + 1 < TILE_COUNT
|
||||||
|
{
|
||||||
|
self.focused += 1;
|
||||||
|
self.offset = self.snap_offset_for( self.focused );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::Tile( i ) =>
|
||||||
|
{
|
||||||
|
self.last_msg = format!( "Pressed tile {}", i + 1 );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_key( &mut self, keysym: Keysym ) -> Option<Message>
|
||||||
|
{
|
||||||
|
match keysym
|
||||||
|
{
|
||||||
|
Keysym::Escape => { std::process::exit( 0 ); }
|
||||||
|
Keysym::Left => Some( Message::Prev ),
|
||||||
|
Keysym::Right => Some( Message::Next ),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main()
|
||||||
|
{
|
||||||
|
ltk::run( CarouselApp::new() );
|
||||||
|
}
|
||||||
131
examples/clip_path.rs
Normal file
131
examples/clip_path.rs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
//! `cargo run --example clip_path`
|
||||||
|
//!
|
||||||
|
//! Demonstrates [`Canvas::set_clip_path`] — real per-path clipping. Each cell
|
||||||
|
//! fills its whole rect with a background colour, then installs a path clip and
|
||||||
|
//! fills again with a foreground colour: only the pixels inside the path are
|
||||||
|
//! repainted, so the shape's silhouette appears (not its bounding box).
|
||||||
|
//!
|
||||||
|
//! Both backends clip with smooth, anti-aliased edges: the software backend
|
||||||
|
//! with a tiny-skia coverage mask, the GLES backend by compositing an offscreen
|
||||||
|
//! layer through an equivalent coverage mask. The circle and rounded corners
|
||||||
|
//! look the same on both. Esc exits.
|
||||||
|
|
||||||
|
use ltk::{ column, row, text, App, Color, Element, External, Keysym, PathCmd, Rect };
|
||||||
|
|
||||||
|
#[ derive( Clone, Debug ) ]
|
||||||
|
enum Msg {}
|
||||||
|
|
||||||
|
struct Demo;
|
||||||
|
|
||||||
|
const CELL: f32 = 220.0;
|
||||||
|
const MARGIN: f32 = 24.0;
|
||||||
|
|
||||||
|
fn bg() -> Color { Color::rgba( 0.16, 0.18, 0.22, 1.0 ) }
|
||||||
|
fn fg() -> Color { Color::rgba( 0.30, 0.68, 0.90, 1.0 ) }
|
||||||
|
|
||||||
|
/// Rounded rectangle inset by `MARGIN`, corners of `radius`, built from lines
|
||||||
|
/// and quadratic corners.
|
||||||
|
fn rounded_rect_path( r: Rect, radius: f32 ) -> Vec<PathCmd>
|
||||||
|
{
|
||||||
|
let l = r.x + MARGIN;
|
||||||
|
let t = r.y + MARGIN;
|
||||||
|
let right = r.x + r.width - MARGIN;
|
||||||
|
let b = r.y + r.height - MARGIN;
|
||||||
|
vec![
|
||||||
|
PathCmd::MoveTo( l + radius, t ),
|
||||||
|
PathCmd::LineTo( right - radius, t ),
|
||||||
|
PathCmd::QuadTo( right, t, right, t + radius ),
|
||||||
|
PathCmd::LineTo( right, b - radius ),
|
||||||
|
PathCmd::QuadTo( right, b, right - radius, b ),
|
||||||
|
PathCmd::LineTo( l + radius, b ),
|
||||||
|
PathCmd::QuadTo( l, b, l, b - radius ),
|
||||||
|
PathCmd::LineTo( l, t + radius ),
|
||||||
|
PathCmd::QuadTo( l, t, l + radius, t ),
|
||||||
|
PathCmd::Close,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Circle centred in the cell, drawn as four cubic-Bézier quadrants.
|
||||||
|
fn circle_path( r: Rect ) -> Vec<PathCmd>
|
||||||
|
{
|
||||||
|
let cx = r.x + r.width / 2.0;
|
||||||
|
let cy = r.y + r.height / 2.0;
|
||||||
|
let rad = ( r.width.min( r.height ) ) / 2.0 - MARGIN;
|
||||||
|
let k = rad * 0.552_284_75; // 4/3 * tan(pi/8): cubic circle approximation
|
||||||
|
vec![
|
||||||
|
PathCmd::MoveTo( cx + rad, cy ),
|
||||||
|
PathCmd::CubicTo( cx + rad, cy + k, cx + k, cy + rad, cx, cy + rad ),
|
||||||
|
PathCmd::CubicTo( cx - k, cy + rad, cx - rad, cy + k, cx - rad, cy ),
|
||||||
|
PathCmd::CubicTo( cx - rad, cy - k, cx - k, cy - rad, cx, cy - rad ),
|
||||||
|
PathCmd::CubicTo( cx + k, cy - rad, cx + rad, cy - k, cx + rad, cy ),
|
||||||
|
PathCmd::Close,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downward triangle inset by `MARGIN` — a polygon clip.
|
||||||
|
fn triangle_path( r: Rect ) -> Vec<PathCmd>
|
||||||
|
{
|
||||||
|
vec![
|
||||||
|
PathCmd::MoveTo( r.x + r.width / 2.0, r.y + MARGIN ),
|
||||||
|
PathCmd::LineTo( r.x + r.width - MARGIN, r.y + r.height - MARGIN ),
|
||||||
|
PathCmd::LineTo( r.x + MARGIN, r.y + r.height - MARGIN ),
|
||||||
|
PathCmd::Close,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A cell that paints `bg()` everywhere, then `fg()` clipped to `shape`. The
|
||||||
|
/// previous clip is snapshotted and restored so the clip does not leak to the
|
||||||
|
/// rest of the frame.
|
||||||
|
fn clipped_cell( shape: fn( Rect ) -> Vec<PathCmd> ) -> Element<Msg>
|
||||||
|
{
|
||||||
|
External::cpu( CELL, CELL, move |canvas, rect|
|
||||||
|
{
|
||||||
|
canvas.fill_rect( rect, bg(), 0.0 );
|
||||||
|
let saved = canvas.clip_bounds();
|
||||||
|
canvas.set_clip_path( &shape( rect ) );
|
||||||
|
canvas.fill_rect( rect, fg(), 0.0 );
|
||||||
|
canvas.set_clip_rects( &saved );
|
||||||
|
} ).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App for Demo
|
||||||
|
{
|
||||||
|
type Message = Msg;
|
||||||
|
|
||||||
|
fn view( &self ) -> Element<Msg>
|
||||||
|
{
|
||||||
|
column()
|
||||||
|
.spacing( 16.0 )
|
||||||
|
.padding( 24.0 )
|
||||||
|
.push( text( "Canvas::set_clip_path — rounded rect, circle, triangle" ) )
|
||||||
|
.push(
|
||||||
|
row()
|
||||||
|
.spacing( 16.0 )
|
||||||
|
.push( clipped_cell( |r| rounded_rect_path( r, 32.0 ) ) )
|
||||||
|
.push( clipped_cell( circle_path ) )
|
||||||
|
.push( clipped_cell( triangle_path ) )
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update( &mut self, _msg: Msg ) {}
|
||||||
|
|
||||||
|
fn on_key( &mut self, keysym: Keysym ) -> Option<Msg>
|
||||||
|
{
|
||||||
|
if keysym == Keysym::Escape
|
||||||
|
{
|
||||||
|
std::process::exit( 0 );
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn background_color( &self ) -> Color
|
||||||
|
{
|
||||||
|
ltk::theme_palette().bg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main()
|
||||||
|
{
|
||||||
|
ltk::run( Demo );
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ use std::time::{ Duration, Instant };
|
|||||||
|
|
||||||
use ltk::
|
use ltk::
|
||||||
{
|
{
|
||||||
App, ButtonVariant, Color, Element, Keysym, OverlayId, OverlaySpec,
|
App, ButtonVariant, Color, Element, Keysym, Length, OverlayId, OverlaySpec,
|
||||||
ThemeMode,
|
ThemeMode,
|
||||||
button, column, container, progress_bar, row, slider, spacer, text, toggle,
|
button, column, container, progress_bar, row, slider, spacer, text, toggle,
|
||||||
};
|
};
|
||||||
@@ -190,7 +190,7 @@ impl App for AppState
|
|||||||
id: OVERLAY_QUICK_SETTINGS,
|
id: OVERLAY_QUICK_SETTINGS,
|
||||||
layer: ltk::Layer::Overlay,
|
layer: ltk::Layer::Overlay,
|
||||||
anchor: ltk::Anchor::ALL,
|
anchor: ltk::Anchor::ALL,
|
||||||
size: ( 0, 0 ),
|
size: ( Length::px( 0.0 ), Length::px( 0.0 ) ),
|
||||||
exclusive_zone: 0,
|
exclusive_zone: 0,
|
||||||
keyboard_exclusive: false,
|
keyboard_exclusive: false,
|
||||||
input_region: None,
|
input_region: None,
|
||||||
@@ -207,7 +207,7 @@ impl App for AppState
|
|||||||
id: OVERLAY_CONFIRM,
|
id: OVERLAY_CONFIRM,
|
||||||
layer: ltk::Layer::Overlay,
|
layer: ltk::Layer::Overlay,
|
||||||
anchor: ltk::Anchor::ALL,
|
anchor: ltk::Anchor::ALL,
|
||||||
size: ( 0, 0 ),
|
size: ( Length::px( 0.0 ), Length::px( 0.0 ) ),
|
||||||
exclusive_zone: 0,
|
exclusive_zone: 0,
|
||||||
keyboard_exclusive: false,
|
keyboard_exclusive: false,
|
||||||
input_region: None,
|
input_region: None,
|
||||||
@@ -224,7 +224,7 @@ impl App for AppState
|
|||||||
id: OVERLAY_TOAST,
|
id: OVERLAY_TOAST,
|
||||||
layer: ltk::Layer::Overlay,
|
layer: ltk::Layer::Overlay,
|
||||||
anchor: ltk::Anchor::ALL,
|
anchor: ltk::Anchor::ALL,
|
||||||
size: ( 0, 0 ),
|
size: ( Length::px( 0.0 ), Length::px( 0.0 ) ),
|
||||||
exclusive_zone: 0,
|
exclusive_zone: 0,
|
||||||
keyboard_exclusive: false,
|
keyboard_exclusive: false,
|
||||||
// Pass-through: the OSD must not steal taps from anything below.
|
// Pass-through: the OSD must not steal taps from anything below.
|
||||||
|
|||||||
156
examples/responsive.rs
Normal file
156
examples/responsive.rs
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
//! `cargo run --example responsive`
|
||||||
|
//!
|
||||||
|
//! Demonstrates ltk's two responsive modes side by side on stock widgets.
|
||||||
|
//! None of the widgets below set an explicit size — they all follow the
|
||||||
|
//! process-wide [`ltk::WidgetScaling`] mode:
|
||||||
|
//!
|
||||||
|
//! - **Fluid** (the default): sizes are a fraction of the surface, so the
|
||||||
|
//! whole set grows and shrinks as you resize the window.
|
||||||
|
//! - **Physical**: sizes are a constant real-world size scaled by
|
||||||
|
//! [`ltk::density`], independent of the surface.
|
||||||
|
//!
|
||||||
|
//! Tap **Switch mode** to flip between them, and **−/+ density** to change
|
||||||
|
//! the physical density. Watch the button, field, checkbox, switch, slider
|
||||||
|
//! and progress bar resize (or not) accordingly. Esc exits.
|
||||||
|
//!
|
||||||
|
//! NOTE: ltk is a Wayland layer-shell toolkit. This example requires a running
|
||||||
|
//! Wayland compositor (e.g. sway, labwc, or a full desktop session).
|
||||||
|
|
||||||
|
use ltk::
|
||||||
|
{
|
||||||
|
App, Element, Keysym, ButtonVariant, WidgetScaling,
|
||||||
|
button, checkbox, column, progress_bar, row, separator, slider, spacer, text, text_edit, toggle,
|
||||||
|
set_widget_scaling, set_density, density,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[ derive( Clone ) ]
|
||||||
|
enum Message
|
||||||
|
{
|
||||||
|
SwitchMode,
|
||||||
|
DensityUp,
|
||||||
|
DensityDown,
|
||||||
|
NameChanged( String ),
|
||||||
|
ToggleCheck,
|
||||||
|
ToggleSwitch,
|
||||||
|
SliderChanged( f32 ),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ResponsiveApp
|
||||||
|
{
|
||||||
|
physical: bool,
|
||||||
|
name: String,
|
||||||
|
checked: bool,
|
||||||
|
switched: bool,
|
||||||
|
volume: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResponsiveApp
|
||||||
|
{
|
||||||
|
fn new() -> Self
|
||||||
|
{
|
||||||
|
// Start in the default fluid mode with a neutral density.
|
||||||
|
set_widget_scaling( WidgetScaling::Fluid );
|
||||||
|
set_density( 1.5 );
|
||||||
|
Self
|
||||||
|
{
|
||||||
|
physical: false,
|
||||||
|
name: String::new(),
|
||||||
|
checked: true,
|
||||||
|
switched: false,
|
||||||
|
volume: 0.4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App for ResponsiveApp
|
||||||
|
{
|
||||||
|
type Message = Message;
|
||||||
|
|
||||||
|
fn view( &self ) -> Element<Message>
|
||||||
|
{
|
||||||
|
let palette = ltk::theme_palette();
|
||||||
|
let primary = palette.text_primary;
|
||||||
|
let secondary = palette.text_secondary;
|
||||||
|
|
||||||
|
let mode_label = if self.physical
|
||||||
|
{
|
||||||
|
format!( "Mode: Physical (density {:.1})", density() )
|
||||||
|
} else {
|
||||||
|
"Mode: Fluid (resize the window to see it scale)".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mode + density controls. Explicit sizes here so the controls stay
|
||||||
|
// stable while the demo widgets below react to the mode.
|
||||||
|
let controls = row::<Message>()
|
||||||
|
.spacing( 12.0 )
|
||||||
|
.push( button::<Message>( "Switch mode".to_string() )
|
||||||
|
.variant( ButtonVariant::Primary )
|
||||||
|
.font_size( 16.0 )
|
||||||
|
.height( 44.0 )
|
||||||
|
.on_press( Message::SwitchMode ) )
|
||||||
|
.push( button::<Message>( "− density".to_string() )
|
||||||
|
.font_size( 16.0 )
|
||||||
|
.height( 44.0 )
|
||||||
|
.on_press( Message::DensityDown ) )
|
||||||
|
.push( button::<Message>( "+ density".to_string() )
|
||||||
|
.font_size( 16.0 )
|
||||||
|
.height( 44.0 )
|
||||||
|
.on_press( Message::DensityUp ) );
|
||||||
|
|
||||||
|
// The demo widgets — NO explicit sizes, so they follow the mode.
|
||||||
|
let demo = column::<Message>()
|
||||||
|
.spacing( 16.0 )
|
||||||
|
.max_width( 520.0 )
|
||||||
|
.push( button::<Message>( "A stock button".to_string() ).variant( ButtonVariant::Secondary ).on_press( Message::SwitchMode ) )
|
||||||
|
.push( text_edit( "A stock text field".to_string(), self.name.clone() ).on_change( Message::NameChanged ) )
|
||||||
|
.push( checkbox( self.checked ).label( "A stock checkbox".to_string() ).on_toggle( Message::ToggleCheck ) )
|
||||||
|
.push( toggle( self.switched ).label( "A stock switch".to_string() ).on_toggle( Message::ToggleSwitch ) )
|
||||||
|
.push( slider( self.volume ).on_change( Message::SliderChanged ) )
|
||||||
|
.push( progress_bar( self.volume ) );
|
||||||
|
|
||||||
|
column::<Message>()
|
||||||
|
.padding( 32.0 )
|
||||||
|
.spacing( 20.0 )
|
||||||
|
.center_y( true )
|
||||||
|
.push( text( "ltk responsive modes".to_string() ).size( 26.0 ).color( primary ).align_center() )
|
||||||
|
.push( text( mode_label ).size( 15.0 ).color( secondary ).align_center() )
|
||||||
|
.push( controls )
|
||||||
|
.push( separator() )
|
||||||
|
.push( demo )
|
||||||
|
.push( spacer().weight( 1 ) )
|
||||||
|
.push( text( "Esc to exit".to_string() ).size( 12.0 ).color( secondary ).align_center() )
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update( &mut self, msg: Message )
|
||||||
|
{
|
||||||
|
match msg
|
||||||
|
{
|
||||||
|
Message::SwitchMode =>
|
||||||
|
{
|
||||||
|
self.physical = !self.physical;
|
||||||
|
set_widget_scaling( if self.physical { WidgetScaling::Physical } else { WidgetScaling::Fluid } );
|
||||||
|
}
|
||||||
|
Message::DensityUp => set_density( ( density() + 0.25 ).min( 4.0 ) ),
|
||||||
|
Message::DensityDown => set_density( ( density() - 0.25 ).max( 0.5 ) ),
|
||||||
|
Message::NameChanged( v ) => self.name = v,
|
||||||
|
Message::ToggleCheck => self.checked = !self.checked,
|
||||||
|
Message::ToggleSwitch => self.switched = !self.switched,
|
||||||
|
Message::SliderChanged( v ) => self.volume = v,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_key( &mut self, keysym: Keysym ) -> Option<Message>
|
||||||
|
{
|
||||||
|
if keysym == Keysym::Escape
|
||||||
|
{
|
||||||
|
std::process::exit( 0 );
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main()
|
||||||
|
{
|
||||||
|
ltk::run( ResponsiveApp::new() );
|
||||||
|
}
|
||||||
@@ -1,23 +1,43 @@
|
|||||||
//! `cargo run --example showcase`
|
//! `cargo run --example showcase`
|
||||||
//!
|
//!
|
||||||
//! Shows button variants, container with background, a slider, plus the
|
//! Shows button variants, container with background, a slider, a
|
||||||
//! newer additions: a tab strip, a spinner driven by the wall clock, a
|
//! viewport-relative image, plus the newer additions: a tab strip, a spinner
|
||||||
//! multiline text edit, and on-demand toast / tooltip overlays.
|
//! driven by the wall clock, a multiline text edit, and on-demand toast /
|
||||||
|
//! tooltip overlays.
|
||||||
//! Tab / Shift+Tab cycles keyboard focus. Enter or Space activates the
|
//! Tab / Shift+Tab cycles keyboard focus. Enter or Space activates the
|
||||||
//! focused button. Esc exits.
|
//! focused button. Esc exits.
|
||||||
//!
|
//!
|
||||||
//! NOTE: ltk is a Wayland layer-shell toolkit. This example requires a running
|
//! NOTE: ltk is a Wayland layer-shell toolkit. This example requires a running
|
||||||
//! Wayland compositor (e.g. sway, labwc, or a full desktop session).
|
//! Wayland compositor (e.g. sway, labwc, or a full desktop session).
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::{ Duration, Instant };
|
use std::time::{ Duration, Instant };
|
||||||
|
|
||||||
use ltk::{
|
use ltk::{
|
||||||
App, Element, Keysym, ButtonVariant, WidgetId,
|
App, Element, Keysym, ButtonVariant, Length, WidgetId,
|
||||||
OverlaySpec,
|
OverlaySpec,
|
||||||
button, column, row, stack, text, text_edit, spacer, container, slider, scroll,
|
button, column, row, stack, text, text_edit, spacer, container, slider, scroll,
|
||||||
spinner, tabs, toast, tooltip,
|
spinner, tabs, toast, tooltip, img_widget,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const IMG_W: u32 = 64;
|
||||||
|
const IMG_H: u32 = 64;
|
||||||
|
|
||||||
|
fn gradient_rgba() -> Arc<Vec<u8>>
|
||||||
|
{
|
||||||
|
let mut data = Vec::with_capacity( ( IMG_W * IMG_H * 4 ) as usize );
|
||||||
|
for y in 0..IMG_H
|
||||||
|
{
|
||||||
|
for x in 0..IMG_W
|
||||||
|
{
|
||||||
|
let r = ( 255 * x / IMG_W ) as u8;
|
||||||
|
let g = ( 255 * y / IMG_H ) as u8;
|
||||||
|
data.extend_from_slice( &[ r, g, 180, 255 ] );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Arc::new( data )
|
||||||
|
}
|
||||||
|
|
||||||
// ── Messages ──────────────────────────────────────────────────────────────────
|
// ── Messages ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[ derive( Clone ) ]
|
#[ derive( Clone ) ]
|
||||||
@@ -43,6 +63,7 @@ struct ShowcaseApp
|
|||||||
started_at: Instant,
|
started_at: Instant,
|
||||||
toast_until: Option<Instant>,
|
toast_until: Option<Instant>,
|
||||||
tooltip_visible: bool,
|
tooltip_visible: bool,
|
||||||
|
image: Arc<Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ShowcaseApp
|
impl ShowcaseApp
|
||||||
@@ -58,6 +79,7 @@ impl ShowcaseApp
|
|||||||
started_at: Instant::now(),
|
started_at: Instant::now(),
|
||||||
toast_until: None,
|
toast_until: None,
|
||||||
tooltip_visible: false,
|
tooltip_visible: false,
|
||||||
|
image: gradient_rgba(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +149,25 @@ impl App for ShowcaseApp
|
|||||||
.radius( 12.0 )
|
.radius( 12.0 )
|
||||||
.padding( 16.0 );
|
.padding( 16.0 );
|
||||||
|
|
||||||
|
// Viewport-relative image: 30 % of the surface width by 10 % of its
|
||||||
|
// height, so it rescales with the window instead of staying fixed.
|
||||||
|
//
|
||||||
|
// The second image uses the orientation-aware helpers: `short_side`
|
||||||
|
// sizes it along the screen's short side (width in portrait, height
|
||||||
|
// in landscape) while `orient` gives that side a different proportion
|
||||||
|
// per orientation — 40 % of the width in portrait, 8 % of the height
|
||||||
|
// in landscape — with the other axis following the source aspect.
|
||||||
|
let banner = column::<Message>()
|
||||||
|
.spacing( 4.0 )
|
||||||
|
.push( text( "img_widget().size( vw 30, vh 10 )" ).size( 12.0 ).color( secondary ) )
|
||||||
|
.push( img_widget( self.image.clone(), IMG_W, IMG_H ).size( Length::vw( 30.0 ), Length::vh( 10.0 ) ) )
|
||||||
|
.push(
|
||||||
|
text( "short_side( orient( 40 %w, 8 %h ) ) — resize / rotate to compare" )
|
||||||
|
.size( Length::orient( 3.0, 2.2 ).clamp( 11.0, 16.0 ) )
|
||||||
|
.color( secondary )
|
||||||
|
)
|
||||||
|
.push( img_widget( self.image.clone(), IMG_W, IMG_H ).short_side( Length::orient( 40.0, 8.0 ) ) );
|
||||||
|
|
||||||
let vol_label = format!( "Volume: {:.0}%", self.slider_value * 100.0 );
|
let vol_label = format!( "Volume: {:.0}%", self.slider_value * 100.0 );
|
||||||
|
|
||||||
// Spinner — phase comes straight from the wall clock so the arc
|
// Spinner — phase comes straight from the wall clock so the arc
|
||||||
@@ -163,6 +204,7 @@ impl App for ShowcaseApp
|
|||||||
.push( spacer() )
|
.push( spacer() )
|
||||||
.push( buttons )
|
.push( buttons )
|
||||||
.push( card )
|
.push( card )
|
||||||
|
.push( banner )
|
||||||
.push( text( vol_label ).size( 13.0 ).color( secondary ) )
|
.push( text( vol_label ).size( 13.0 ).color( secondary ) )
|
||||||
.push( slider( self.slider_value ).on_change( Message::SliderChanged ) )
|
.push( slider( self.slider_value ).on_change( Message::SliderChanged ) )
|
||||||
.push( spin_row )
|
.push( spin_row )
|
||||||
|
|||||||
178
src/app.rs
178
src/app.rs
@@ -33,6 +33,11 @@ pub enum ShellMode
|
|||||||
/// Layer-shell surface at the specified layer.
|
/// Layer-shell surface at the specified layer.
|
||||||
/// Used for shell components like panels, backgrounds, overlays.
|
/// Used for shell components like panels, backgrounds, overlays.
|
||||||
Layer( Layer ),
|
Layer( Layer ),
|
||||||
|
|
||||||
|
/// `ext-session-lock-v1` lock surface. The compositor blanks the outputs
|
||||||
|
/// and shows only this surface until the app requests exit (see
|
||||||
|
/// [`App::requested_exit`]). Used by the screen locker.
|
||||||
|
SessionLock,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Layer-shell layer position.
|
/// Layer-shell layer position.
|
||||||
@@ -113,6 +118,24 @@ impl Anchor
|
|||||||
#[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord )]
|
#[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord )]
|
||||||
pub struct OverlayId( pub u32 );
|
pub struct OverlayId( pub u32 );
|
||||||
|
|
||||||
|
/// Stable identifier for a subsurface, used to diff the list returned by
|
||||||
|
/// [`App::subsurfaces`] between frames the same way [`OverlayId`] diffs
|
||||||
|
/// overlays.
|
||||||
|
#[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord )]
|
||||||
|
pub struct SubsurfaceId( pub u32 );
|
||||||
|
|
||||||
|
/// Which surface a [`SubsurfaceSpec`] is composited as a child of. `Main`
|
||||||
|
/// (the default position) parents to the app's main surface; `Overlay(id)`
|
||||||
|
/// parents to one of the [`App::overlays`] surfaces, so a slide can ride
|
||||||
|
/// above app windows the way an overlay panel does. An `Overlay` parent that
|
||||||
|
/// is absent or not yet configured is skipped for that frame.
|
||||||
|
#[derive( Debug, Clone, Copy, PartialEq, Eq )]
|
||||||
|
pub enum SubsurfaceParent
|
||||||
|
{
|
||||||
|
Main,
|
||||||
|
Overlay( OverlayId ),
|
||||||
|
}
|
||||||
|
|
||||||
/// One of the surfaces an [`App`] can target with an invalidation. Used inside
|
/// One of the surfaces an [`App`] can target with an invalidation. Used inside
|
||||||
/// [`InvalidationScope::Only`] to name the affected surfaces.
|
/// [`InvalidationScope::Only`] to name the affected surfaces.
|
||||||
#[derive( Debug, Clone, Copy, PartialEq, Eq, Hash )]
|
#[derive( Debug, Clone, Copy, PartialEq, Eq, Hash )]
|
||||||
@@ -190,9 +213,14 @@ pub struct OverlaySpec<Message: Clone>
|
|||||||
/// Screen edges to anchor to.
|
/// Screen edges to anchor to.
|
||||||
pub anchor: Anchor,
|
pub anchor: Anchor,
|
||||||
|
|
||||||
/// Desired size `( width, height )` in logical pixels. `0` in either
|
/// Desired size `( width, height )`, resolved to logical pixels against
|
||||||
/// component means "fill available space in that dimension".
|
/// the main surface (output) when the overlay is materialized. A
|
||||||
pub size: ( u32, u32 ),
|
/// [`Length::px`](crate::Length::px) keeps a fixed size; a
|
||||||
|
/// [`Length::widget`](crate::Length::widget) / `vmin` / `dp` makes the
|
||||||
|
/// overlay surface scale with the display like any other sized element.
|
||||||
|
/// A component that resolves to `0` (e.g. `Length::px( 0.0 )`) means
|
||||||
|
/// "fill available space in that dimension".
|
||||||
|
pub size: ( crate::types::Length, crate::types::Length ),
|
||||||
|
|
||||||
/// Exclusive zone in pixels reserved from the anchored edge.
|
/// Exclusive zone in pixels reserved from the anchored edge.
|
||||||
/// `-1` requests focus without reserving space, `0` is the default for
|
/// `-1` requests focus without reserving space, `0` is the default for
|
||||||
@@ -250,6 +278,53 @@ pub struct OverlaySpec<Message: Clone>
|
|||||||
pub anchor_widget_id: Option<crate::types::WidgetId>,
|
pub anchor_widget_id: Option<crate::types::WidgetId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An input-transparent child surface composited over the main surface and
|
||||||
|
/// moved by the compositor.
|
||||||
|
///
|
||||||
|
/// Returned from [`App::subsurfaces`]. The runtime creates one `wl_subsurface`
|
||||||
|
/// per active spec, full-size over the main surface, with an **empty input
|
||||||
|
/// region** — all pointer/touch falls through to the main surface, so the host
|
||||||
|
/// keeps a single input/gesture model. The win is in the move: the content
|
||||||
|
/// buffer is rasterised only when [`content_version`](Self::content_version)
|
||||||
|
/// (or the surface size) changes, while [`x`](Self::x) / [`y`](Self::y)
|
||||||
|
/// changes only emit `wl_subsurface.set_position` + a parent commit — no
|
||||||
|
/// re-raster, no buffer re-upload. This makes an animated reveal/slide track
|
||||||
|
/// the finger at the compositor's compositing cost rather than the client's
|
||||||
|
/// raster cost.
|
||||||
|
pub struct SubsurfaceSpec<Message: Clone>
|
||||||
|
{
|
||||||
|
/// Stable identifier, used to diff subsurfaces between frames.
|
||||||
|
pub id: SubsurfaceId,
|
||||||
|
|
||||||
|
/// Surface this subsurface is composited as a child of. Sized to that
|
||||||
|
/// parent; [`x`](Self::x) / [`y`](Self::y) are relative to its top-left.
|
||||||
|
pub parent: SubsurfaceParent,
|
||||||
|
|
||||||
|
/// Widget tree for this subsurface. Should paint its own opaque
|
||||||
|
/// background if it must cover the main surface beneath it.
|
||||||
|
pub view: Element<Message>,
|
||||||
|
|
||||||
|
/// Position of the subsurface relative to the parent's top-left, in
|
||||||
|
/// layout (physical) pixels — the same space as [`App::on_resize`] and
|
||||||
|
/// widget rects. The runtime divides by the surface scale to obtain the
|
||||||
|
/// logical position it hands to the compositor. Animate this for a cheap
|
||||||
|
/// compositor-side move.
|
||||||
|
pub x: i32,
|
||||||
|
pub y: i32,
|
||||||
|
|
||||||
|
/// Content revision. Bump it whenever [`view`](Self::view) would paint
|
||||||
|
/// differently; leave it unchanged for position-only updates so the
|
||||||
|
/// runtime skips the re-raster and only repositions.
|
||||||
|
pub content_version: u64,
|
||||||
|
|
||||||
|
/// Render through GLES (so `backdrop-filter` glass works) rather than the
|
||||||
|
/// software rasteriser. GLES is only worth it for content that actually
|
||||||
|
/// uses the blur: it pays a per-surface EGL-surface creation (and a
|
||||||
|
/// one-time shader compile) the cheap software path avoids, so a panel
|
||||||
|
/// without glass should leave this `false`.
|
||||||
|
pub gpu: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// Trait that application types must implement to integrate with ltk.
|
/// Trait that application types must implement to integrate with ltk.
|
||||||
pub trait App: 'static
|
pub trait App: 'static
|
||||||
{
|
{
|
||||||
@@ -290,9 +365,35 @@ pub trait App: 'static
|
|||||||
/// IDs that disappear cause the surface to be destroyed.
|
/// IDs that disappear cause the surface to be destroyed.
|
||||||
fn overlays( &self ) -> Vec<OverlaySpec<Self::Message>> { Vec::new() }
|
fn overlays( &self ) -> Vec<OverlaySpec<Self::Message>> { Vec::new() }
|
||||||
|
|
||||||
|
/// Describe the input-transparent child surfaces composited over the main
|
||||||
|
/// surface this frame. Each becomes a `wl_subsurface` the compositor moves
|
||||||
|
/// by position; see [`SubsurfaceSpec`]. Diffed across frames by
|
||||||
|
/// [`SubsurfaceSpec::id`]. Default empty.
|
||||||
|
fn subsurfaces( &self ) -> Vec<SubsurfaceSpec<Self::Message>> { Vec::new() }
|
||||||
|
|
||||||
/// Return any pending messages from external sources (timers, async, etc.).
|
/// Return any pending messages from external sources (timers, async, etc.).
|
||||||
fn poll_external( &mut self ) -> Vec<Self::Message> { vec![] }
|
fn poll_external( &mut self ) -> Vec<Self::Message> { vec![] }
|
||||||
|
|
||||||
|
/// Tags for which the app wants a fresh `xdg-activation-v1` token this
|
||||||
|
/// iteration. Drained once per loop; each tag is echoed back verbatim
|
||||||
|
/// through [`App::on_activation_token`] once the compositor issues the
|
||||||
|
/// token, so the app can hand it to a child it is about to spawn via the
|
||||||
|
/// `$XDG_ACTIVATION_TOKEN` launch convention. Default none.
|
||||||
|
fn take_activation_requests( &mut self ) -> Vec<String> { Vec::new() }
|
||||||
|
|
||||||
|
/// Deliver a token issued for an earlier [`App::take_activation_requests`]
|
||||||
|
/// tag. `tag` is the string the app handed out; `token` is the opaque
|
||||||
|
/// activation token to place in the launched child's environment.
|
||||||
|
fn on_activation_token( &mut self, _tag: String, _token: String ) -> Option<Self::Message> { None }
|
||||||
|
|
||||||
|
/// Called once, immediately after the first buffer for the main surface
|
||||||
|
/// has been rendered and committed to the compositor. Note: this is the
|
||||||
|
/// client-side commit, not the compositor's present — under VT switching
|
||||||
|
/// the actual present can be deferred (no DRM master yet), so a handoff
|
||||||
|
/// signal that fires here is enough to tell a parent "ready to be
|
||||||
|
/// presented as soon as my VT becomes active".
|
||||||
|
fn on_first_frame_committed( &mut self ) {}
|
||||||
|
|
||||||
/// Called when the surface is asked to close (compositor request, titlebar button,
|
/// Called when the surface is asked to close (compositor request, titlebar button,
|
||||||
/// layer-shell closed event). Return `true` to allow the application to exit,
|
/// layer-shell closed event). Return `true` to allow the application to exit,
|
||||||
/// or `false` to cancel.
|
/// or `false` to cancel.
|
||||||
@@ -320,11 +421,18 @@ pub trait App: 'static
|
|||||||
/// `swipe_threshold()` × screen height and then releases.
|
/// `swipe_threshold()` × screen height and then releases.
|
||||||
fn on_swipe_up( &mut self ) -> Option<Self::Message> { None }
|
fn on_swipe_up( &mut self ) -> Option<Self::Message> { None }
|
||||||
|
|
||||||
/// Called during an upward swipe gesture with progress 0.0..=1.0.
|
/// Called during an upward swipe gesture with progress ≥ 0.0.
|
||||||
///
|
///
|
||||||
/// Use this to create follow-the-finger animations. `progress` starts at 0.0
|
/// Use this to create follow-the-finger animations. `progress` starts at 0.0
|
||||||
/// when the drag begins and increases as the finger moves upward, reaching 1.0
|
/// when the drag begins and increases as the finger moves upward, reaching 1.0
|
||||||
/// when the drag distance equals `swipe_threshold()` × screen height.
|
/// when the drag distance equals `swipe_threshold()` × screen height. It is
|
||||||
|
/// **not** clamped at 1.0 — the value keeps growing if the finger drags further
|
||||||
|
/// so that follow-the-finger panels can continue tracking the finger all the way
|
||||||
|
/// up the screen. Clamp inside the handler if you need a bounded `[0, 1]` range
|
||||||
|
/// for a visual indicator.
|
||||||
|
///
|
||||||
|
/// When the user releases without completing the gesture, this method is called
|
||||||
|
/// once more with `progress = 0.0` to signal cancellation.
|
||||||
fn on_swipe_progress( &mut self, _progress: f32 ) {}
|
fn on_swipe_progress( &mut self, _progress: f32 ) {}
|
||||||
|
|
||||||
/// Called when a sufficient downward swipe gesture is detected.
|
/// Called when a sufficient downward swipe gesture is detected.
|
||||||
@@ -370,6 +478,22 @@ pub trait App: 'static
|
|||||||
/// edge, matching the usual system-panel pull-down UX.
|
/// edge, matching the usual system-panel pull-down UX.
|
||||||
fn swipe_down_edge( &self ) -> f32 { 1.0 }
|
fn swipe_down_edge( &self ) -> f32 { 1.0 }
|
||||||
|
|
||||||
|
/// Called while a vertical `scroll` is pinned at
|
||||||
|
/// its top and the finger keeps pulling down. `progress` is the
|
||||||
|
/// accumulated overscroll as a fraction of the surface height (≥ 0.0,
|
||||||
|
/// unbounded). Use it to drive a pull-from-top-to-dismiss panel that
|
||||||
|
/// tracks the finger.
|
||||||
|
fn on_overscroll_down_progress( &mut self, _progress: f32 ) {}
|
||||||
|
|
||||||
|
/// Called when the finger releases after a top overscroll pull-down.
|
||||||
|
/// `committed` is `true` when the pull passed
|
||||||
|
/// [`overscroll_dismiss_threshold`](Self::overscroll_dismiss_threshold).
|
||||||
|
fn on_overscroll_down( &mut self, _committed: bool ) -> Option<Self::Message> { None }
|
||||||
|
|
||||||
|
/// Fraction of surface height a top overscroll pull-down must exceed to
|
||||||
|
/// commit a dismiss on release. Default: `0.25`.
|
||||||
|
fn overscroll_dismiss_threshold( &self ) -> f32 { 0.25 }
|
||||||
|
|
||||||
/// Called when a sufficient leftward swipe gesture is detected.
|
/// Called when a sufficient leftward swipe gesture is detected.
|
||||||
///
|
///
|
||||||
/// Return a message to handle the swipe (e.g., paging to the next
|
/// Return a message to handle the swipe (e.g., paging to the next
|
||||||
@@ -533,6 +657,13 @@ pub trait App: 'static
|
|||||||
/// physical dimensions.
|
/// physical dimensions.
|
||||||
fn on_resize( &mut self, _width: u32, _height: u32 ) {}
|
fn on_resize( &mut self, _width: u32, _height: u32 ) {}
|
||||||
|
|
||||||
|
/// Called when the compositor reports a new integer buffer scale for the
|
||||||
|
/// main surface. `scale` is the integer buffer scale (1 = standard DPI,
|
||||||
|
/// 2 = HiDPI ×2); physical pixels equal logical pixels × scale. The default
|
||||||
|
/// is inert so apps that only care about physical pixels can keep using
|
||||||
|
/// [`on_resize`](Self::on_resize).
|
||||||
|
fn on_scale_changed( &mut self, _scale: u32 ) {}
|
||||||
|
|
||||||
/// Called once at startup with a channel sender that can be used from any
|
/// Called once at startup with a channel sender that can be used from any
|
||||||
/// thread to deliver messages into the event loop. Sending a message
|
/// thread to deliver messages into the event loop. Sending a message
|
||||||
/// immediately wakes the event loop — no polling delay.
|
/// immediately wakes the event loop — no polling delay.
|
||||||
@@ -596,16 +727,24 @@ pub trait App: 'static
|
|||||||
/// The event loop will keep requesting redraws at ~60 fps until this returns `false`.
|
/// The event loop will keep requesting redraws at ~60 fps until this returns `false`.
|
||||||
fn is_animating( &self ) -> bool { false }
|
fn is_animating( &self ) -> bool { false }
|
||||||
|
|
||||||
/// Return `true` while the next frame should swap the expensive
|
/// Cap [`Self::is_animating`] redraws to ~30 Hz when the **software**
|
||||||
/// Glass passes for cheap fallbacks — currently the
|
/// renderer is active. Default `true`: 60 fps software rasterization is a
|
||||||
/// `backdrop-filter` blur drops from a 41-tap kernel to a 9-tap
|
/// common battery sink on mobile (no GPU offload), and halving it is
|
||||||
/// kernel, with the snapshot region shrunk to match. The event
|
/// usually imperceptible for the animations software fallback can afford.
|
||||||
/// loop sets this on the renderer right before drawing through an
|
/// The GLES backend is never capped. Return `false` to keep the full
|
||||||
/// internal low-quality-paint flag.
|
/// display rate on software.
|
||||||
///
|
fn cap_software_animation( &self ) -> bool { true }
|
||||||
/// Default: matches [`Self::is_animating`], so a settle animation
|
|
||||||
/// automatically downgrades. Override to include other "in motion"
|
/// Return `true` when a finger-tracked swipe or an [`Self::is_animating`]
|
||||||
/// states the runtime cannot observe — e.g. a finger-tracked
|
/// slide only repositions the app's input-transparent subsurfaces
|
||||||
|
/// ([`Self::subsurfaces`]) while the main surface buffer stays unchanged.
|
||||||
|
/// The runtime then skips the per-frame main re-raster that
|
||||||
|
/// [`Self::on_swipe_progress`] and `is_animating` would otherwise force,
|
||||||
|
/// keeping the animation pumped (motion events during the drag, a bare
|
||||||
|
/// frame callback while `is_animating`) and letting the per-frame
|
||||||
|
/// subsurface reconcile carry the motion. Use it for a slide-to-reveal
|
||||||
|
/// panel over a static main surface.
|
||||||
|
fn subsurface_motion_only( &self ) -> bool { false }
|
||||||
|
|
||||||
/// Background color for the canvas. Override to make the surface
|
/// Background color for the canvas. Override to make the surface
|
||||||
/// transparent or to deviate from the theme. Default: the active
|
/// transparent or to deviate from the theme. Default: the active
|
||||||
@@ -633,11 +772,20 @@ pub trait App: 'static
|
|||||||
///
|
///
|
||||||
/// - [`ShellMode::Window`]: Normal application window (xdg-shell). **Default.**
|
/// - [`ShellMode::Window`]: Normal application window (xdg-shell). **Default.**
|
||||||
/// - [`ShellMode::Layer`]: System component at a specific layer (layer-shell).
|
/// - [`ShellMode::Layer`]: System component at a specific layer (layer-shell).
|
||||||
|
/// - [`ShellMode::SessionLock`]: `ext-session-lock-v1` lock surface (screen locker).
|
||||||
///
|
///
|
||||||
/// For regular applications, use the default `Window` mode.
|
/// For regular applications, use the default `Window` mode.
|
||||||
/// For shell components (panels, backgrounds, overlays), use `Layer`.
|
/// For shell components (panels, backgrounds, overlays), use `Layer`.
|
||||||
|
/// For a screen locker, use `SessionLock` and request the unlock by
|
||||||
|
/// returning `true` from [`requested_exit`](Self::requested_exit).
|
||||||
fn shell_mode( &self ) -> ShellMode { ShellMode::Window }
|
fn shell_mode( &self ) -> ShellMode { ShellMode::Window }
|
||||||
|
|
||||||
|
/// Return `true` to tear the surface down and exit the event loop. For a
|
||||||
|
/// [`ShellMode::SessionLock`] surface the runtime calls `unlock` first, so
|
||||||
|
/// the compositor lifts the lock instead of leaving the outputs blanked.
|
||||||
|
/// Polled after every batch of `update`s.
|
||||||
|
fn requested_exit( &self ) -> bool { false }
|
||||||
|
|
||||||
/// Suggest an initial size for an xdg-shell window in logical pixels.
|
/// Suggest an initial size for an xdg-shell window in logical pixels.
|
||||||
///
|
///
|
||||||
/// Returning `Some(( w, h ))` makes ltk call both
|
/// Returning `Some(( w, h ))` makes ltk call both
|
||||||
|
|||||||
66
src/chassis.rs
Normal file
66
src/chassis.rs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
//! Scaffolding shared by full-screen ambient surfaces (greeter, lock screen,
|
||||||
|
//! kiosk): theme bring-up, branding/wallpaper loading and the wallpaper-backed
|
||||||
|
//! view stack. Thin convenience over [`theme`](crate::theme) and
|
||||||
|
//! [`WallpaperBundle`] — every app that paints a
|
||||||
|
//! wallpaper behind centred content repeats this otherwise.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::{ Color, Element, ImageData, ThemeMode, WallpaperBundle };
|
||||||
|
|
||||||
|
/// Find, install and activate the `default` theme document in `mode`. Returns
|
||||||
|
/// the failure message instead of exiting; the caller decides how to abort.
|
||||||
|
pub fn set_default_theme( mode: ThemeMode ) -> Result<(), String>
|
||||||
|
{
|
||||||
|
let doc = crate::ThemeDocument::find( "default" ).map_err( |e| format!( "{e}" ) )?;
|
||||||
|
crate::set_active_document( doc );
|
||||||
|
crate::set_active_mode( mode );
|
||||||
|
Ok( () )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode the active theme's horizontal logo to RGBA, `size` px on the longer
|
||||||
|
/// edge. `None` if the theme ships no logo or the SVG fails to rasterise.
|
||||||
|
pub fn theme_logo_rgba( size: u32 ) -> Option<ImageData>
|
||||||
|
{
|
||||||
|
let path = crate::theme_logo_horizontal()?;
|
||||||
|
let bytes = std::fs::read( &path ).ok()?;
|
||||||
|
crate::decode_svg_bytes( &bytes, size )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a symbolic theme icon to RGBA, tinted with `tint`. `None` if missing.
|
||||||
|
pub fn theme_icon_tinted( name: &str, size: u32, tint: Color ) -> Option<ImageData>
|
||||||
|
{
|
||||||
|
let ( rgba, w, h ) = crate::theme_icon_rgba( name, size )?;
|
||||||
|
Some( ( Arc::new( crate::tint_symbolic( &rgba, tint ) ), w, h ) )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The active theme's branding image `name` (e.g. `"wallpaper"`,
|
||||||
|
/// `"lockscreen"`) as a [`WallpaperBundle`], falling back to a solid fill of
|
||||||
|
/// the palette background when the theme ships none.
|
||||||
|
pub fn branding_bundle_or_solid( name: &str ) -> WallpaperBundle
|
||||||
|
{
|
||||||
|
let path = crate::theme_branding_image( name, 0, 0 );
|
||||||
|
let bg = crate::theme_palette().bg;
|
||||||
|
WallpaperBundle::from_path_or_solid(
|
||||||
|
path.as_deref(),
|
||||||
|
( bg.r * 255.0 ) as u8,
|
||||||
|
( bg.g * 255.0 ) as u8,
|
||||||
|
( bg.b * 255.0 ) as u8,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience for [`branding_bundle_or_solid`] with `"wallpaper"`.
|
||||||
|
pub fn wallpaper_bundle_or_solid() -> WallpaperBundle
|
||||||
|
{
|
||||||
|
branding_bundle_or_solid( "wallpaper" )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stack `content` over `wallpaper` resolved for the given surface size.
|
||||||
|
pub fn backdrop<M: Clone>( content: Element<M>, wallpaper: &WallpaperBundle, w: u32, h: u32 ) -> Element<M>
|
||||||
|
{
|
||||||
|
let ( rgba, iw, ih ) = wallpaper.for_size( w, h );
|
||||||
|
crate::stack::<M>()
|
||||||
|
.push( crate::img_widget( rgba, iw, ih ) )
|
||||||
|
.push( content )
|
||||||
|
.into()
|
||||||
|
}
|
||||||
@@ -319,8 +319,11 @@ impl<Msg: Clone> UiSurface<Msg>
|
|||||||
self.pressed_idx = idx;
|
self.pressed_idx = idx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Flat index of the keyboard-focused widget, or `None` when nothing holds focus.
|
||||||
pub fn focused( &self ) -> Option<usize> { self.focused_idx }
|
pub fn focused( &self ) -> Option<usize> { self.focused_idx }
|
||||||
|
/// Flat index of the hovered widget, or `None` when the pointer is over no interactive widget.
|
||||||
pub fn hovered( &self ) -> Option<usize> { self.hovered_idx }
|
pub fn hovered( &self ) -> Option<usize> { self.hovered_idx }
|
||||||
|
/// Flat index of the pressed widget, or `None` when no widget is held down.
|
||||||
pub fn pressed( &self ) -> Option<usize> { self.pressed_idx }
|
pub fn pressed( &self ) -> Option<usize> { self.pressed_idx }
|
||||||
|
|
||||||
/// Render `element` into the backing canvas.
|
/// Render `element` into the backing canvas.
|
||||||
|
|||||||
@@ -84,15 +84,23 @@ pub( crate ) fn apply_input_region(
|
|||||||
wl_surface: &WlSurface,
|
wl_surface: &WlSurface,
|
||||||
compositor: &CompositorState,
|
compositor: &CompositorState,
|
||||||
input_region: Option<&[Rect]>,
|
input_region: Option<&[Rect]>,
|
||||||
|
scale: u32,
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if let Some( regions ) = input_region
|
if let Some( regions ) = input_region
|
||||||
{
|
{
|
||||||
if let Ok( region ) = Region::new( compositor )
|
if let Ok( region ) = Region::new( compositor )
|
||||||
{
|
{
|
||||||
|
// `Rect`s are physical (layout space); `wl_region` is logical.
|
||||||
|
let s = scale.max( 1 ) as f32;
|
||||||
for r in regions
|
for r in regions
|
||||||
{
|
{
|
||||||
region.add( r.x as i32, r.y as i32, r.width as i32, r.height as i32 );
|
region.add(
|
||||||
|
( r.x / s ).round() as i32,
|
||||||
|
( r.y / s ).round() as i32,
|
||||||
|
( r.width / s ).round() as i32,
|
||||||
|
( r.height / s ).round() as i32,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
wl_surface.set_input_region( Some( region.wl_region() ) );
|
wl_surface.set_input_region( Some( region.wl_region() ) );
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ use crate::render::Canvas;
|
|||||||
use crate::types::{ Color, Rect };
|
use crate::types::{ Color, Rect };
|
||||||
use crate::widget::Element;
|
use crate::widget::Element;
|
||||||
|
|
||||||
use super::{ DrawCtx, layout_and_draw };
|
use super::{ build_draw_ctx, commit_draw_ctx, layout_and_draw };
|
||||||
use super::chrome::{ apply_input_region, draw_fallback_banner, draw_titlebar };
|
use super::chrome::{ apply_input_region, draw_fallback_banner, draw_titlebar };
|
||||||
|
|
||||||
/// GPU full-redraw path. Mirrors [`super::software::draw_surface_full`]
|
/// GPU full-redraw path. Mirrors [`super::software::draw_surface_full`]
|
||||||
@@ -88,23 +88,7 @@ pub( crate ) fn draw_surface_full_gpu<Msg: Clone>(
|
|||||||
|
|
||||||
ss.titlebar_close_rect = draw_titlebar( canvas, &ss.titlebar_title, pw, tb_h, sf );
|
ss.titlebar_close_rect = draw_titlebar( canvas, &ss.titlebar_title, pw, tb_h, sf );
|
||||||
|
|
||||||
let mut ctx: DrawCtx<Msg> = DrawCtx
|
let mut ctx = build_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, debug_layout );
|
||||||
{
|
|
||||||
focused_idx: ss.focused_idx,
|
|
||||||
hovered_idx: ss.hovered_idx,
|
|
||||||
pressed_idx: ss.gesture.pressed_idx,
|
|
||||||
cursor_state: std::mem::take( &mut ss.cursor_state ),
|
|
||||||
selection_anchor: std::mem::take( &mut ss.selection_anchor ),
|
|
||||||
widget_rects: Vec::new(),
|
|
||||||
debug_layout,
|
|
||||||
scroll_offsets: std::mem::take( &mut ss.scroll_offsets ),
|
|
||||||
scroll_rects: Vec::new(),
|
|
||||||
scroll_canvases: std::mem::take( &mut ss.scroll_canvases ),
|
|
||||||
scroll_navigable_items: std::mem::take( &mut ss.scroll_navigable_items ),
|
|
||||||
previous_widget_rects: ss.widget_rects.clone(),
|
|
||||||
accessible_extras: Vec::new(),
|
|
||||||
live_depth: 0,
|
|
||||||
};
|
|
||||||
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
|
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
|
||||||
|
|
||||||
if ctx.debug_layout
|
if ctx.debug_layout
|
||||||
@@ -126,22 +110,11 @@ pub( crate ) fn draw_surface_full_gpu<Msg: Clone>(
|
|||||||
|
|
||||||
canvas.present();
|
canvas.present();
|
||||||
|
|
||||||
ss.prev_focused = ss.focused_idx;
|
commit_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, ctx );
|
||||||
ss.prev_hovered = ss.hovered_idx;
|
|
||||||
ss.prev_pressed = ss.gesture.pressed_idx;
|
|
||||||
|
|
||||||
ss.widget_rects = ctx.widget_rects;
|
|
||||||
ss.scroll_rects = ctx.scroll_rects;
|
|
||||||
ss.scroll_canvases = std::mem::take( &mut ctx.scroll_canvases );
|
|
||||||
ss.cursor_state = ctx.cursor_state;
|
|
||||||
ss.selection_anchor = ctx.selection_anchor;
|
|
||||||
ss.scroll_offsets = ctx.scroll_offsets;
|
|
||||||
ss.accessible_extras = ctx.accessible_extras;
|
|
||||||
ss.scroll_navigable_items = ctx.scroll_navigable_items;
|
|
||||||
ss.content_dirty = false;
|
ss.content_dirty = false;
|
||||||
|
|
||||||
let wl_surface = ss.surface.wl_surface();
|
let wl_surface = ss.surface.wl_surface();
|
||||||
apply_input_region( wl_surface, compositor, input_region );
|
apply_input_region( wl_surface, compositor, input_region, scale );
|
||||||
// Frame callback request must precede the implicit commit done by
|
// Frame callback request must precede the implicit commit done by
|
||||||
// `swap_buffers_with_damage`, so the compositor can attach it to this
|
// `swap_buffers_with_damage`, so the compositor can attach it to this
|
||||||
// frame and not the previous one.
|
// frame and not the previous one.
|
||||||
@@ -195,23 +168,7 @@ pub( crate ) fn draw_surface_partial_gpu<Msg: Clone>(
|
|||||||
|
|
||||||
ss.titlebar_close_rect = draw_titlebar( canvas, &ss.titlebar_title, pw, tb_h, sf );
|
ss.titlebar_close_rect = draw_titlebar( canvas, &ss.titlebar_title, pw, tb_h, sf );
|
||||||
|
|
||||||
let mut ctx: DrawCtx<Msg> = DrawCtx
|
let mut ctx = build_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, false );
|
||||||
{
|
|
||||||
focused_idx: ss.focused_idx,
|
|
||||||
hovered_idx: ss.hovered_idx,
|
|
||||||
pressed_idx: ss.gesture.pressed_idx,
|
|
||||||
cursor_state: std::mem::take( &mut ss.cursor_state ),
|
|
||||||
selection_anchor: std::mem::take( &mut ss.selection_anchor ),
|
|
||||||
widget_rects: Vec::new(),
|
|
||||||
debug_layout: false,
|
|
||||||
scroll_offsets: std::mem::take( &mut ss.scroll_offsets ),
|
|
||||||
scroll_rects: Vec::new(),
|
|
||||||
scroll_canvases: std::mem::take( &mut ss.scroll_canvases ),
|
|
||||||
scroll_navigable_items: std::mem::take( &mut ss.scroll_navigable_items ),
|
|
||||||
previous_widget_rects: ss.widget_rects.clone(),
|
|
||||||
accessible_extras: Vec::new(),
|
|
||||||
live_depth: 0,
|
|
||||||
};
|
|
||||||
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
|
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
|
||||||
|
|
||||||
if let Some( ref menu ) = ss.context_menu
|
if let Some( ref menu ) = ss.context_menu
|
||||||
@@ -226,22 +183,11 @@ pub( crate ) fn draw_surface_partial_gpu<Msg: Clone>(
|
|||||||
canvas.clear_clip();
|
canvas.clear_clip();
|
||||||
canvas.present();
|
canvas.present();
|
||||||
|
|
||||||
ss.prev_focused = ss.focused_idx;
|
commit_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, ctx );
|
||||||
ss.prev_hovered = ss.hovered_idx;
|
|
||||||
ss.prev_pressed = ss.gesture.pressed_idx;
|
|
||||||
|
|
||||||
ss.widget_rects = ctx.widget_rects;
|
|
||||||
ss.scroll_rects = ctx.scroll_rects;
|
|
||||||
ss.scroll_canvases = std::mem::take( &mut ctx.scroll_canvases );
|
|
||||||
ss.cursor_state = ctx.cursor_state;
|
|
||||||
ss.selection_anchor = ctx.selection_anchor;
|
|
||||||
ss.scroll_offsets = ctx.scroll_offsets;
|
|
||||||
ss.accessible_extras = ctx.accessible_extras;
|
|
||||||
ss.scroll_navigable_items = ctx.scroll_navigable_items;
|
|
||||||
ss.content_dirty = false;
|
ss.content_dirty = false;
|
||||||
|
|
||||||
let wl_surface = ss.surface.wl_surface();
|
let wl_surface = ss.surface.wl_surface();
|
||||||
apply_input_region( wl_surface, compositor, input_region );
|
apply_input_region( wl_surface, compositor, input_region, scale );
|
||||||
request_frame( wl_surface );
|
request_frame( wl_surface );
|
||||||
// Convert top-left dirty rects to EGL bottom-left coords for the
|
// Convert top-left dirty rects to EGL bottom-left coords for the
|
||||||
// swap-with-damage call.
|
// swap-with-damage call.
|
||||||
|
|||||||
@@ -53,7 +53,24 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
|
|||||||
let mut idx = flat_idx;
|
let mut idx = flat_idx;
|
||||||
for ( child_rect, child_i ) in child_rects
|
for ( child_rect, child_i ) in child_rects
|
||||||
{
|
{
|
||||||
idx = layout_and_draw::<Msg>( &s.children[ child_i ].0, canvas, child_rect, ctx, idx );
|
let child = &s.children[ child_i ];
|
||||||
|
// A placed child may carry a clip rect (Android clipChildren): clip the
|
||||||
|
// canvas to it for the child's subtree, then restore the prior clip.
|
||||||
|
// The clip is in the Stack's coordinate space (like the placed rect),
|
||||||
|
// so it is shifted by the Stack's origin to match — otherwise a Stack
|
||||||
|
// laid out at a non-zero origin clips in the wrong place.
|
||||||
|
if let Some( clip ) = child.7
|
||||||
|
{
|
||||||
|
let saved = canvas.clip_bounds();
|
||||||
|
let clip = Rect { x: rect.x + clip.x, y: rect.y + clip.y, width: clip.width, height: clip.height };
|
||||||
|
canvas.set_clip_rects( &[ clip ] );
|
||||||
|
idx = layout_and_draw::<Msg>( &child.0, canvas, child_rect, ctx, idx );
|
||||||
|
canvas.set_clip_rects( &saved );
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
idx = layout_and_draw::<Msg>( &child.0, canvas, child_rect, ctx, idx );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
idx
|
idx
|
||||||
}
|
}
|
||||||
@@ -67,6 +84,16 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
|
|||||||
}
|
}
|
||||||
idx
|
idx
|
||||||
}
|
}
|
||||||
|
Element::Carousel( car ) =>
|
||||||
|
{
|
||||||
|
let child_rects = car.layout( rect, canvas );
|
||||||
|
let mut idx = flat_idx;
|
||||||
|
for ( child_rect, child_i ) in child_rects
|
||||||
|
{
|
||||||
|
idx = layout_and_draw::<Msg>( &car.children[child_i], canvas, child_rect, ctx, idx );
|
||||||
|
}
|
||||||
|
idx
|
||||||
|
}
|
||||||
Element::Flex( f ) =>
|
Element::Flex( f ) =>
|
||||||
{
|
{
|
||||||
// `Flex` is invisible chrome: it claimed leftover width up at
|
// `Flex` is invisible chrome: it claimed leftover width up at
|
||||||
@@ -185,12 +212,18 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
|
|||||||
{
|
{
|
||||||
canvas.stroke_rect( rect, color, width, c.corners );
|
canvas.stroke_rect( rect, color, width, c.corners );
|
||||||
}
|
}
|
||||||
|
let vp = canvas.viewport_layout();
|
||||||
|
let em = crate::types::Length::EM_BASE_DEFAULT;
|
||||||
|
let pad_l = c.pad_left.resolve( vp, em );
|
||||||
|
let pad_r = c.pad_right.resolve( vp, em );
|
||||||
|
let pad_t = c.pad_top.resolve( vp, em );
|
||||||
|
let pad_b = c.pad_bottom.resolve( vp, em );
|
||||||
let inner = crate::types::Rect
|
let inner = crate::types::Rect
|
||||||
{
|
{
|
||||||
x: rect.x + c.pad_left,
|
x: rect.x + pad_l,
|
||||||
y: rect.y + c.pad_top,
|
y: rect.y + pad_t,
|
||||||
width: ( rect.width - c.pad_left - c.pad_right ).max( 0.0 ),
|
width: ( rect.width - pad_l - pad_r ).max( 0.0 ),
|
||||||
height: ( rect.height - c.pad_top - c.pad_bottom ).max( 0.0 ),
|
height: ( rect.height - pad_t - pad_b ).max( 0.0 ),
|
||||||
};
|
};
|
||||||
let result = layout_and_draw::<Msg>( c.child.as_ref(), canvas, inner, ctx, flat_idx );
|
let result = layout_and_draw::<Msg>( c.child.as_ref(), canvas, inner, ctx, flat_idx );
|
||||||
|
|
||||||
@@ -364,6 +397,38 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
|
|||||||
canvas.blit_fade_bottom( &sub, rect.x as i32, rect.y as i32, v.fade_bottom );
|
canvas.blit_fade_bottom( &sub, rect.x as i32, rect.y as i32, v.fade_bottom );
|
||||||
next_idx
|
next_idx
|
||||||
}
|
}
|
||||||
|
Element::RichText( r ) =>
|
||||||
|
{
|
||||||
|
r.draw( canvas, rect );
|
||||||
|
// One hit rect per link line, each its own flat_idx so hover/press
|
||||||
|
// state does not bleed between links sharing a paragraph.
|
||||||
|
let link_rects = r.link_rects( rect, canvas );
|
||||||
|
let consumed = link_rects.len().max( 1 );
|
||||||
|
for ( k, ( lr, msg ) ) in link_rects.into_iter().enumerate()
|
||||||
|
{
|
||||||
|
ctx.widget_rects.push( LaidOutWidget
|
||||||
|
{
|
||||||
|
rect: lr,
|
||||||
|
flat_idx: flat_idx + k,
|
||||||
|
id: None,
|
||||||
|
paint_rect: lr,
|
||||||
|
handlers: WidgetHandlers::Button
|
||||||
|
{
|
||||||
|
on_press: Some( msg ),
|
||||||
|
on_long_press: None,
|
||||||
|
on_drag_start: None,
|
||||||
|
on_escape: None,
|
||||||
|
repeating: false,
|
||||||
|
},
|
||||||
|
keyboard_focusable: false,
|
||||||
|
cursor: crate::types::CursorShape::Pointer,
|
||||||
|
tooltip: None,
|
||||||
|
accessible_label: None,
|
||||||
|
is_live_region: ctx.live_depth > 0,
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
flat_idx + consumed
|
||||||
|
}
|
||||||
other =>
|
other =>
|
||||||
{
|
{
|
||||||
// Gate the focus ring by `is_focusable`: a widget that opts out
|
// Gate the focus ring by `is_focusable`: a widget that opts out
|
||||||
@@ -388,13 +453,22 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
|
|||||||
other.draw( canvas, rect, is_focused, is_hovered, is_pressed, cursor_pos, sel_anchor );
|
other.draw( canvas, rect, is_focused, is_hovered, is_pressed, cursor_pos, sel_anchor );
|
||||||
if other.is_interactive()
|
if other.is_interactive()
|
||||||
{
|
{
|
||||||
|
let mut handlers = other.handlers();
|
||||||
|
// Horizontal sliders resolve their thumb size through the
|
||||||
|
// widget-scaling mode here — layout has the canvas — so the
|
||||||
|
// input path maps clicks against the same thumb the renderer
|
||||||
|
// drew (see `slider::value_from_x_in_rect`).
|
||||||
|
if matches!( other, Element::Slider( _ ) )
|
||||||
|
{
|
||||||
|
handlers.set_slider_thumb_px( crate::widget::slider::resolved_thumb_px( canvas ) );
|
||||||
|
}
|
||||||
ctx.widget_rects.push( LaidOutWidget
|
ctx.widget_rects.push( LaidOutWidget
|
||||||
{
|
{
|
||||||
rect,
|
rect,
|
||||||
flat_idx,
|
flat_idx,
|
||||||
id: widget_id,
|
id: widget_id,
|
||||||
paint_rect: other.paint_bounds( rect ),
|
paint_rect: other.paint_bounds( rect ),
|
||||||
handlers: other.handlers(),
|
handlers,
|
||||||
keyboard_focusable: other.is_focusable(),
|
keyboard_focusable: other.is_focusable(),
|
||||||
cursor: other.cursor_shape(),
|
cursor: other.cursor_shape(),
|
||||||
tooltip: other.tooltip().map( str::to_string ),
|
tooltip: other.tooltip().map( str::to_string ),
|
||||||
|
|||||||
@@ -90,6 +90,65 @@ pub( crate ) struct DrawCtx<Msg: Clone>
|
|||||||
pub live_depth: u32,
|
pub live_depth: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the per-frame [`DrawCtx`] from the surface's [`FrameState`], moving the
|
||||||
|
/// carried maps (cursor / selection / scroll) out of `frame` into the context.
|
||||||
|
/// The interaction indices are passed by value (they live on `SurfaceState`, not
|
||||||
|
/// `frame`). `debug_layout` is `false` on the partial paths. Shared by all four
|
||||||
|
/// frame paths; [`commit_draw_ctx`] writes the produced state back.
|
||||||
|
pub( crate ) fn build_draw_ctx<Msg: Clone>(
|
||||||
|
frame: &mut crate::event_loop::FrameState<Msg>,
|
||||||
|
focused_idx: Option<usize>,
|
||||||
|
hovered_idx: Option<usize>,
|
||||||
|
pressed_idx: Option<usize>,
|
||||||
|
debug_layout: bool,
|
||||||
|
) -> DrawCtx<Msg>
|
||||||
|
{
|
||||||
|
DrawCtx
|
||||||
|
{
|
||||||
|
focused_idx,
|
||||||
|
hovered_idx,
|
||||||
|
pressed_idx,
|
||||||
|
cursor_state: std::mem::take( &mut frame.cursor_state ),
|
||||||
|
selection_anchor: std::mem::take( &mut frame.selection_anchor ),
|
||||||
|
widget_rects: Vec::new(),
|
||||||
|
debug_layout,
|
||||||
|
scroll_offsets: std::mem::take( &mut frame.scroll_offsets ),
|
||||||
|
scroll_rects: Vec::new(),
|
||||||
|
scroll_canvases: std::mem::take( &mut frame.scroll_canvases ),
|
||||||
|
scroll_navigable_items: std::mem::take( &mut frame.scroll_navigable_items ),
|
||||||
|
previous_widget_rects: frame.widget_rects.clone(),
|
||||||
|
accessible_extras: Vec::new(),
|
||||||
|
live_depth: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the finished frame's [`DrawCtx`] back into the [`FrameState`]: snapshot
|
||||||
|
/// the interaction indices into `prev_*` and move the produced rect lists and
|
||||||
|
/// carried maps back. Shared by all four frame paths; the caller must have
|
||||||
|
/// already consumed anything it needs from `ctx` (e.g. `compute_damage`) before
|
||||||
|
/// calling this, and resets `content_dirty` itself (it lives on `SurfaceState`).
|
||||||
|
pub( crate ) fn commit_draw_ctx<Msg: Clone>(
|
||||||
|
frame: &mut crate::event_loop::FrameState<Msg>,
|
||||||
|
focused_idx: Option<usize>,
|
||||||
|
hovered_idx: Option<usize>,
|
||||||
|
pressed_idx: Option<usize>,
|
||||||
|
mut ctx: DrawCtx<Msg>,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
frame.prev_focused = focused_idx;
|
||||||
|
frame.prev_hovered = hovered_idx;
|
||||||
|
frame.prev_pressed = pressed_idx;
|
||||||
|
|
||||||
|
frame.widget_rects = ctx.widget_rects;
|
||||||
|
frame.scroll_rects = ctx.scroll_rects;
|
||||||
|
frame.scroll_canvases = std::mem::take( &mut ctx.scroll_canvases );
|
||||||
|
frame.cursor_state = ctx.cursor_state;
|
||||||
|
frame.selection_anchor = ctx.selection_anchor;
|
||||||
|
frame.scroll_offsets = ctx.scroll_offsets;
|
||||||
|
frame.accessible_extras = ctx.accessible_extras;
|
||||||
|
frame.scroll_navigable_items = ctx.scroll_navigable_items;
|
||||||
|
}
|
||||||
|
|
||||||
/// Paint the built-in Copy / Cut / Paste context menu on top of the
|
/// Paint the built-in Copy / Cut / Paste context menu on top of the
|
||||||
/// finished surface content. Called from the software and GLES draw
|
/// finished surface content. Called from the software and GLES draw
|
||||||
/// paths right before `present()` so the menu sits above everything
|
/// paths right before `present()` so the menu sits above everything
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ use crate::render::Canvas;
|
|||||||
use crate::types::{ Color, Rect };
|
use crate::types::{ Color, Rect };
|
||||||
use crate::widget::Element;
|
use crate::widget::Element;
|
||||||
|
|
||||||
use super::{ DrawCtx, compute_damage, layout_and_draw };
|
use super::{ build_draw_ctx, commit_draw_ctx, compute_damage, layout_and_draw };
|
||||||
use super::chrome::{ apply_input_region, draw_fallback_banner, draw_titlebar };
|
use super::chrome::{ apply_input_region, draw_fallback_banner, draw_titlebar };
|
||||||
|
|
||||||
/// Full redraw path: clear the canvas, run layout+draw for every widget, then
|
/// Full redraw path: clear the canvas, run layout+draw for every widget, then
|
||||||
@@ -85,23 +85,7 @@ pub( crate ) fn draw_surface_full<Msg: Clone>(
|
|||||||
|
|
||||||
ss.titlebar_close_rect = draw_titlebar( canvas, &ss.titlebar_title, pw, tb_h, sf );
|
ss.titlebar_close_rect = draw_titlebar( canvas, &ss.titlebar_title, pw, tb_h, sf );
|
||||||
|
|
||||||
let mut ctx: DrawCtx<Msg> = DrawCtx
|
let mut ctx = build_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, debug_layout );
|
||||||
{
|
|
||||||
focused_idx: ss.focused_idx,
|
|
||||||
hovered_idx: ss.hovered_idx,
|
|
||||||
pressed_idx: ss.gesture.pressed_idx,
|
|
||||||
cursor_state: std::mem::take( &mut ss.cursor_state ),
|
|
||||||
selection_anchor: std::mem::take( &mut ss.selection_anchor ),
|
|
||||||
widget_rects: Vec::new(),
|
|
||||||
debug_layout,
|
|
||||||
scroll_offsets: std::mem::take( &mut ss.scroll_offsets ),
|
|
||||||
scroll_rects: Vec::new(),
|
|
||||||
scroll_canvases: std::mem::take( &mut ss.scroll_canvases ),
|
|
||||||
scroll_navigable_items: std::mem::take( &mut ss.scroll_navigable_items ),
|
|
||||||
previous_widget_rects: ss.widget_rects.clone(),
|
|
||||||
accessible_extras: Vec::new(),
|
|
||||||
live_depth: 0,
|
|
||||||
};
|
|
||||||
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
|
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
|
||||||
|
|
||||||
if ctx.debug_layout
|
if ctx.debug_layout
|
||||||
@@ -126,11 +110,11 @@ pub( crate ) fn draw_surface_full<Msg: Clone>(
|
|||||||
Vec::new()
|
Vec::new()
|
||||||
} else {
|
} else {
|
||||||
compute_damage(
|
compute_damage(
|
||||||
&ss.widget_rects,
|
&ss.frame.widget_rects,
|
||||||
&ctx.widget_rects,
|
&ctx.widget_rects,
|
||||||
ss.prev_focused,
|
ss.frame.prev_focused,
|
||||||
ss.prev_hovered,
|
ss.frame.prev_hovered,
|
||||||
ss.prev_pressed,
|
ss.frame.prev_pressed,
|
||||||
ss.focused_idx,
|
ss.focused_idx,
|
||||||
ss.hovered_idx,
|
ss.hovered_idx,
|
||||||
ss.gesture.pressed_idx,
|
ss.gesture.pressed_idx,
|
||||||
@@ -138,18 +122,7 @@ pub( crate ) fn draw_surface_full<Msg: Clone>(
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
ss.prev_focused = ss.focused_idx;
|
commit_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, ctx );
|
||||||
ss.prev_hovered = ss.hovered_idx;
|
|
||||||
ss.prev_pressed = ss.gesture.pressed_idx;
|
|
||||||
|
|
||||||
ss.widget_rects = ctx.widget_rects;
|
|
||||||
ss.scroll_rects = ctx.scroll_rects;
|
|
||||||
ss.scroll_canvases = std::mem::take( &mut ctx.scroll_canvases );
|
|
||||||
ss.cursor_state = ctx.cursor_state;
|
|
||||||
ss.selection_anchor = ctx.selection_anchor;
|
|
||||||
ss.scroll_offsets = ctx.scroll_offsets;
|
|
||||||
ss.accessible_extras = ctx.accessible_extras;
|
|
||||||
ss.scroll_navigable_items = ctx.scroll_navigable_items;
|
|
||||||
ss.content_dirty = false;
|
ss.content_dirty = false;
|
||||||
|
|
||||||
canvas.write_to_wayland_buf( canvas_buf, swap_rb );
|
canvas.write_to_wayland_buf( canvas_buf, swap_rb );
|
||||||
@@ -167,7 +140,7 @@ pub( crate ) fn draw_surface_full<Msg: Clone>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
apply_input_region( wl_surface, compositor, input_region );
|
apply_input_region( wl_surface, compositor, input_region, scale );
|
||||||
// Request a frame callback BEFORE commit so the compositor schedules it
|
// Request a frame callback BEFORE commit so the compositor schedules it
|
||||||
// against this exact frame; sets `frame_pending` so the run loop won't
|
// against this exact frame; sets `frame_pending` so the run loop won't
|
||||||
// try to draw this surface again until the callback fires.
|
// try to draw this surface again until the callback fires.
|
||||||
@@ -225,23 +198,7 @@ pub( crate ) fn draw_surface_partial<Msg: Clone>(
|
|||||||
|
|
||||||
ss.titlebar_close_rect = draw_titlebar( canvas, &ss.titlebar_title, pw, tb_h, sf );
|
ss.titlebar_close_rect = draw_titlebar( canvas, &ss.titlebar_title, pw, tb_h, sf );
|
||||||
|
|
||||||
let mut ctx: DrawCtx<Msg> = DrawCtx
|
let mut ctx = build_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, false );
|
||||||
{
|
|
||||||
focused_idx: ss.focused_idx,
|
|
||||||
hovered_idx: ss.hovered_idx,
|
|
||||||
pressed_idx: ss.gesture.pressed_idx,
|
|
||||||
cursor_state: std::mem::take( &mut ss.cursor_state ),
|
|
||||||
selection_anchor: std::mem::take( &mut ss.selection_anchor ),
|
|
||||||
widget_rects: Vec::new(),
|
|
||||||
debug_layout: false,
|
|
||||||
scroll_offsets: std::mem::take( &mut ss.scroll_offsets ),
|
|
||||||
scroll_rects: Vec::new(),
|
|
||||||
scroll_canvases: std::mem::take( &mut ss.scroll_canvases ),
|
|
||||||
scroll_navigable_items: std::mem::take( &mut ss.scroll_navigable_items ),
|
|
||||||
previous_widget_rects: ss.widget_rects.clone(),
|
|
||||||
accessible_extras: Vec::new(),
|
|
||||||
live_depth: 0,
|
|
||||||
};
|
|
||||||
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
|
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
|
||||||
|
|
||||||
if let Some( ref menu ) = ss.context_menu
|
if let Some( ref menu ) = ss.context_menu
|
||||||
@@ -258,18 +215,7 @@ pub( crate ) fn draw_surface_partial<Msg: Clone>(
|
|||||||
|
|
||||||
canvas.clear_clip();
|
canvas.clear_clip();
|
||||||
|
|
||||||
ss.prev_focused = ss.focused_idx;
|
commit_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, ctx );
|
||||||
ss.prev_hovered = ss.hovered_idx;
|
|
||||||
ss.prev_pressed = ss.gesture.pressed_idx;
|
|
||||||
|
|
||||||
ss.widget_rects = ctx.widget_rects;
|
|
||||||
ss.scroll_rects = ctx.scroll_rects;
|
|
||||||
ss.scroll_canvases = std::mem::take( &mut ctx.scroll_canvases );
|
|
||||||
ss.cursor_state = ctx.cursor_state;
|
|
||||||
ss.selection_anchor = ctx.selection_anchor;
|
|
||||||
ss.scroll_offsets = ctx.scroll_offsets;
|
|
||||||
ss.accessible_extras = ctx.accessible_extras;
|
|
||||||
ss.scroll_navigable_items = ctx.scroll_navigable_items;
|
|
||||||
ss.content_dirty = false;
|
ss.content_dirty = false;
|
||||||
|
|
||||||
canvas.write_to_wayland_buf( canvas_buf, swap_rb );
|
canvas.write_to_wayland_buf( canvas_buf, swap_rb );
|
||||||
@@ -282,7 +228,7 @@ pub( crate ) fn draw_surface_partial<Msg: Clone>(
|
|||||||
wl_surface.damage_buffer( r.x as i32, r.y as i32, r.width as i32, r.height as i32 );
|
wl_surface.damage_buffer( r.x as i32, r.y as i32, r.width as i32, r.height as i32 );
|
||||||
}
|
}
|
||||||
|
|
||||||
apply_input_region( wl_surface, compositor, input_region );
|
apply_input_region( wl_surface, compositor, input_region, scale );
|
||||||
request_frame( wl_surface );
|
request_frame( wl_surface );
|
||||||
wl_surface.commit();
|
wl_surface.commit();
|
||||||
ss.frame_pending = true;
|
ss.frame_pending = true;
|
||||||
|
|||||||
@@ -62,10 +62,17 @@ type SwapBuffersWithDamageFn = unsafe extern "system" fn(
|
|||||||
/// raw EGL handles are POD.
|
/// raw EGL handles are POD.
|
||||||
pub struct EglContext
|
pub struct EglContext
|
||||||
{
|
{
|
||||||
|
/// Refcounted handle to the loaded `libEGL` instance. Shared with every
|
||||||
|
/// [`EglSurface`] this context creates.
|
||||||
pub egl: Arc<EglInstance>,
|
pub egl: Arc<EglInstance>,
|
||||||
|
/// The initialised `EGLDisplay` derived from the Wayland connection.
|
||||||
pub display: egl::Display,
|
pub display: egl::Display,
|
||||||
|
/// The chosen `EGLConfig` (8-8-8-8 RGBA, window-renderable, GLES2-capable);
|
||||||
|
/// every surface and the context are created against it.
|
||||||
pub config: egl::Config,
|
pub config: egl::Config,
|
||||||
|
/// The `EGLContext` made current by [`Self::make_current`].
|
||||||
pub context: egl::Context,
|
pub context: egl::Context,
|
||||||
|
/// The GLES major version actually obtained (ES3 preferred, ES2 fallback).
|
||||||
pub version: GlesVersion,
|
pub version: GlesVersion,
|
||||||
// Initialised lazily on the first `make_current`. Glow's constructor
|
// Initialised lazily on the first `make_current`. Glow's constructor
|
||||||
// eagerly calls `glGetString( GL_VERSION )`, which requires a current
|
// eagerly calls `glGetString( GL_VERSION )`, which requires a current
|
||||||
@@ -84,7 +91,11 @@ pub struct EglContext
|
|||||||
/// requiring a `&EglContext` at the call site.
|
/// requiring a `&EglContext` at the call site.
|
||||||
pub struct EglSurface
|
pub struct EglSurface
|
||||||
{
|
{
|
||||||
|
/// The `wl_egl_window` backing this surface. EGL holds a raw pointer into
|
||||||
|
/// it, so it must not be dropped while `surface` is alive.
|
||||||
pub egl_window: wayland_egl::WlEglSurface,
|
pub egl_window: wayland_egl::WlEglSurface,
|
||||||
|
/// The `EGLSurface` bound to the Wayland surface, current target for GL
|
||||||
|
/// rendering once [`EglContext::make_current`] selects it.
|
||||||
pub surface: egl::Surface,
|
pub surface: egl::Surface,
|
||||||
egl: Arc<EglInstance>,
|
egl: Arc<EglInstance>,
|
||||||
display: egl::Display,
|
display: egl::Display,
|
||||||
@@ -303,6 +314,11 @@ impl EglContext
|
|||||||
Ok( () )
|
Ok( () )
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Post the rendered back buffer of `surface` to the compositor via
|
||||||
|
/// `eglSwapBuffers`. Requires `surface` to be the current draw surface
|
||||||
|
/// (call [`Self::make_current`] first). Damages the whole surface; on Mesa
|
||||||
|
/// this emits the `INT32_MAX` damage sentinel, so prefer
|
||||||
|
/// [`Self::swap_buffers_with_damage`] where the extension is available.
|
||||||
pub fn swap_buffers( &self, surface: &EglSurface ) -> Result<(), String>
|
pub fn swap_buffers( &self, surface: &EglSurface ) -> Result<(), String>
|
||||||
{
|
{
|
||||||
self.egl.swap_buffers( self.display, surface.surface )
|
self.egl.swap_buffers( self.display, surface.surface )
|
||||||
@@ -360,6 +376,11 @@ impl EglContext
|
|||||||
|
|
||||||
impl EglSurface
|
impl EglSurface
|
||||||
{
|
{
|
||||||
|
/// Resize the backing `wl_egl_window` to `width` x `height` physical
|
||||||
|
/// pixels, so the next swap allocates a buffer of the new size. Must be
|
||||||
|
/// called whenever the Wayland surface changes size. Dimensions are
|
||||||
|
/// clamped to a minimum of 1; this only resizes the buffer and does not
|
||||||
|
/// set the GL viewport — the caller updates `glViewport` separately.
|
||||||
pub fn resize( &self, width: i32, height: i32 )
|
pub fn resize( &self, width: i32, height: i32 )
|
||||||
{
|
{
|
||||||
self.egl_window.resize( width.max( 1 ), height.max( 1 ), 0, 0 );
|
self.egl_window.resize( width.max( 1 ), height.max( 1 ), 0, 0 );
|
||||||
@@ -471,8 +492,12 @@ impl EglOffscreenContext
|
|||||||
).map_err( |e| format!( "eglMakeCurrent: {e:?}" ) )
|
).map_err( |e| format!( "eglMakeCurrent: {e:?}" ) )
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The shared `glow::Context` for this offscreen context. Built eagerly in
|
||||||
|
/// [`Self::new`] (which leaves the context current), so it is always ready.
|
||||||
pub fn gl( &self ) -> &Arc<glow::Context> { &self.gl }
|
pub fn gl( &self ) -> &Arc<glow::Context> { &self.gl }
|
||||||
|
/// The GLES major version obtained (ES3 preferred, ES2 fallback).
|
||||||
pub fn version( &self ) -> GlesVersion { self.version }
|
pub fn version( &self ) -> GlesVersion { self.version }
|
||||||
|
/// The `EGLConfig` the context and pbuffer were created against.
|
||||||
pub fn config( &self ) -> egl::Config { self.config }
|
pub fn config( &self ) -> egl::Config { self.config }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ use smithay_client_toolkit::
|
|||||||
seat::SeatState,
|
seat::SeatState,
|
||||||
shell::{ wlr_layer::LayerShell, xdg::XdgShell },
|
shell::{ wlr_layer::LayerShell, xdg::XdgShell },
|
||||||
shm::Shm,
|
shm::Shm,
|
||||||
|
session_lock::SessionLock,
|
||||||
|
subcompositor::SubcompositorState,
|
||||||
};
|
};
|
||||||
use smithay_client_toolkit::reexports::client::
|
use smithay_client_toolkit::reexports::client::
|
||||||
{
|
{
|
||||||
@@ -29,11 +31,12 @@ use wayland_protocols::wp::text_input::zv3::client::
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::app::{ App, OverlayId };
|
use crate::app::{ App, OverlayId, SubsurfaceId };
|
||||||
use crate::egl_context::EglContext;
|
use crate::egl_context::EglContext;
|
||||||
use crate::types::Point;
|
use crate::types::Point;
|
||||||
|
|
||||||
use super::repeat::{ ButtonRepeatState, KeyRepeatState };
|
use super::repeat::{ ButtonRepeatState, KeyRepeatState };
|
||||||
|
use super::subsurface::SubsurfaceSlot;
|
||||||
use super::surface::{ SurfaceFocus, SurfaceState };
|
use super::surface::{ SurfaceFocus, SurfaceState };
|
||||||
use super::tooltip::{ TooltipPending, TooltipVisible };
|
use super::tooltip::{ TooltipPending, TooltipVisible };
|
||||||
|
|
||||||
@@ -44,7 +47,11 @@ pub struct AppData<A: App>
|
|||||||
pub seat_state: SeatState,
|
pub seat_state: SeatState,
|
||||||
pub output_state: OutputState,
|
pub output_state: OutputState,
|
||||||
pub compositor_state: CompositorState,
|
pub compositor_state: CompositorState,
|
||||||
|
/// `wl_subcompositor` binding for [`crate::app::App::subsurfaces`].
|
||||||
|
/// `None` when the compositor does not advertise the protocol.
|
||||||
|
pub subcompositor: Option<SubcompositorState>,
|
||||||
pub shm: Shm,
|
pub shm: Shm,
|
||||||
|
pub session_lock: Option<SessionLock>,
|
||||||
/// Process-wide EGL context (display + GLES context). `None` when EGL
|
/// Process-wide EGL context (display + GLES context). `None` when EGL
|
||||||
/// failed to initialise or `LTK_FORCE_SOFTWARE=1` — every surface then
|
/// failed to initialise or `LTK_FORCE_SOFTWARE=1` — every surface then
|
||||||
/// falls back to the SHM path.
|
/// falls back to the SHM path.
|
||||||
@@ -80,6 +87,9 @@ pub struct AppData<A: App>
|
|||||||
pub current_cursor_shape: Option<crate::types::CursorShape>,
|
pub current_cursor_shape: Option<crate::types::CursorShape>,
|
||||||
pub text_input_manager: Option<ZwpTextInputManagerV3>,
|
pub text_input_manager: Option<ZwpTextInputManagerV3>,
|
||||||
pub text_input: Option<ZwpTextInputV3>,
|
pub text_input: Option<ZwpTextInputV3>,
|
||||||
|
/// Whether the currently focused text field is `secure` (password).
|
||||||
|
/// Drives the text-input-v3 content type.
|
||||||
|
pub text_input_secure: bool,
|
||||||
/// `xdg-activation-v1`. Present when the compositor advertises the
|
/// `xdg-activation-v1`. Present when the compositor advertises the
|
||||||
/// global. Used on first configure to honour an incoming
|
/// global. Used on first configure to honour an incoming
|
||||||
/// `XDG_ACTIVATION_TOKEN` (a launcher that spawned us wants the
|
/// `XDG_ACTIVATION_TOKEN` (a launcher that spawned us wants the
|
||||||
@@ -208,6 +218,14 @@ pub struct AppData<A: App>
|
|||||||
/// Populated and reconciled by the run loop from [`App::overlays`]. Always
|
/// Populated and reconciled by the run loop from [`App::overlays`]. Always
|
||||||
/// empty until overlay diffing is wired up.
|
/// empty until overlay diffing is wired up.
|
||||||
pub overlays: HashMap<OverlayId, SurfaceState<A::Message>>,
|
pub overlays: HashMap<OverlayId, SurfaceState<A::Message>>,
|
||||||
|
/// Input-transparent child surfaces keyed by their stable
|
||||||
|
/// [`SubsurfaceId`]. Reconciled each frame from [`App::subsurfaces`].
|
||||||
|
pub subsurfaces: HashMap<SubsurfaceId, SubsurfaceSlot>,
|
||||||
|
/// Shared GLES canvas reused across every subsurface raster. Held here
|
||||||
|
/// (not per-slot) so a subsurface that is dropped and re-created — e.g. a
|
||||||
|
/// lazily-emitted sliding panel — does not recompile the ~14 shader
|
||||||
|
/// programs (hundreds of ms on a mobile GPU) every time it reappears.
|
||||||
|
pub subsurface_gles_canvas: Option<crate::render::Canvas>,
|
||||||
/// Surface currently receiving pointer events (updated on each pointer
|
/// Surface currently receiving pointer events (updated on each pointer
|
||||||
/// event via its `surface` field).
|
/// event via its `surface` field).
|
||||||
pub pointer_focus: SurfaceFocus,
|
pub pointer_focus: SurfaceFocus,
|
||||||
@@ -232,6 +250,15 @@ pub struct AppData<A: App>
|
|||||||
pub view_dirty: bool,
|
pub view_dirty: bool,
|
||||||
/// Counterpart of `view_dirty` for `cached_overlays`.
|
/// Counterpart of `view_dirty` for `cached_overlays`.
|
||||||
pub overlays_dirty: bool,
|
pub overlays_dirty: bool,
|
||||||
|
/// Set after the first commit of a rendered buffer on the main surface.
|
||||||
|
/// Used to drive `App::on_first_frame_committed` exactly once.
|
||||||
|
pub first_frame_committed: bool,
|
||||||
|
/// Focus request deferred because the target widget was absent from
|
||||||
|
/// `widget_rects` (e.g. read_only, not yet rebuilt). Retried next draw.
|
||||||
|
pub focus_retry: Option<crate::types::WidgetId>,
|
||||||
|
/// Runtime performance guardrails (stuck-animation / poll-interval
|
||||||
|
/// diagnostics and the software animation-rate cap). See [`super::perf`].
|
||||||
|
pub perf: super::perf::PerfState,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<A: App> AppData<A>
|
impl<A: App> AppData<A>
|
||||||
@@ -324,6 +351,11 @@ impl<A: App> AppData<A>
|
|||||||
{
|
{
|
||||||
self.main.gesture.long_press_fired = true;
|
self.main.gesture.long_press_fired = true;
|
||||||
self.main.gesture.long_press_origin = ss.gesture.long_press_origin;
|
self.main.gesture.long_press_origin = ss.gesture.long_press_origin;
|
||||||
|
// Keep the primary touch slot with the migrated drag so motion / release still route through the gesture machine.
|
||||||
|
if self.main.primary_touch_id.is_none()
|
||||||
|
{
|
||||||
|
self.main.primary_touch_id = ss.primary_touch_id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let SurfaceFocus::Overlay( fid ) = self.pointer_focus
|
if let SurfaceFocus::Overlay( fid ) = self.pointer_focus
|
||||||
@@ -387,6 +419,7 @@ impl<A: App> AppData<A>
|
|||||||
// configure.new_size is surface-local (logical), so multiply by the
|
// configure.new_size is surface-local (logical), so multiply by the
|
||||||
// current buffer scale before handing the dimensions to the app.
|
// current buffer scale before handing the dimensions to the app.
|
||||||
let sf = self.main.scale_factor.max( 1 ) as u32;
|
let sf = self.main.scale_factor.max( 1 ) as u32;
|
||||||
|
self.app.on_scale_changed( sf );
|
||||||
self.app.on_resize( w * sf, h * sf );
|
self.app.on_resize( w * sf, h * sf );
|
||||||
// `on_resize` may flip app-state that the view depends on (apps that
|
// `on_resize` may flip app-state that the view depends on (apps that
|
||||||
// branch on the new dimensions, layout caches keyed by size, …), so
|
// branch on the new dimensions, layout caches keyed by size, …), so
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ impl<A: App> AppData<A>
|
|||||||
}
|
}
|
||||||
if let Some( new_value ) = self.delete_selection( focus )
|
if let Some( new_value ) = self.delete_selection( focus )
|
||||||
{
|
{
|
||||||
let msg = find_handlers( &self.surface( focus ).widget_rects, idx )
|
let msg = find_handlers( &self.surface( focus ).frame.widget_rects, idx )
|
||||||
.and_then( |h| h.text_change_msg( &new_value ) );
|
.and_then( |h| h.text_change_msg( &new_value ) );
|
||||||
if let Some( m ) = msg { self.pending_msgs.push( m ); }
|
if let Some( m ) = msg { self.pending_msgs.push( m ); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ impl<A: App> AppData<A>
|
|||||||
let paste_offset = self.text_input_geometry( focus, widget_idx )
|
let paste_offset = self.text_input_geometry( focus, widget_idx )
|
||||||
.and_then( |( rect, value, multiline, secure, align, font_size )|
|
.and_then( |( rect, value, multiline, secure, align, font_size )|
|
||||||
{
|
{
|
||||||
let cursor_pos = self.surface( focus ).cursor_state.get( &widget_idx ).copied().unwrap_or( 0 );
|
let cursor_pos = self.surface( focus ).frame.cursor_state.get( &widget_idx ).copied().unwrap_or( 0 );
|
||||||
let canvas = self.surface( focus ).canvas.as_ref()?;
|
let canvas = self.surface( focus ).canvas.as_ref()?;
|
||||||
Some( crate::widget::text_edit::byte_offset_at(
|
Some( crate::widget::text_edit::byte_offset_at(
|
||||||
canvas, rect, pos, &value, multiline, secure, cursor_pos, align, font_size,
|
canvas, rect, pos, &value, multiline, secure, cursor_pos, align, font_size,
|
||||||
@@ -163,8 +163,8 @@ impl<A: App> AppData<A>
|
|||||||
if let Some( ofs ) = menu.paste_offset
|
if let Some( ofs ) = menu.paste_offset
|
||||||
{
|
{
|
||||||
let ss = self.surface_mut( focus );
|
let ss = self.surface_mut( focus );
|
||||||
ss.cursor_state.insert( menu.widget_idx, ofs );
|
ss.frame.cursor_state.insert( menu.widget_idx, ofs );
|
||||||
ss.selection_anchor.insert( menu.widget_idx, ofs );
|
ss.frame.selection_anchor.insert( menu.widget_idx, ofs );
|
||||||
}
|
}
|
||||||
self.handle_paste( focus );
|
self.handle_paste( focus );
|
||||||
}
|
}
|
||||||
@@ -176,7 +176,7 @@ impl<A: App> AppData<A>
|
|||||||
if !menu.has_selection { return true; }
|
if !menu.has_selection { return true; }
|
||||||
if let Some( new_value ) = self.delete_selection( focus )
|
if let Some( new_value ) = self.delete_selection( focus )
|
||||||
{
|
{
|
||||||
let msg = find_handlers( &self.surface( focus ).widget_rects, menu.widget_idx )
|
let msg = find_handlers( &self.surface( focus ).frame.widget_rects, menu.widget_idx )
|
||||||
.and_then( |h| h.text_change_msg( &new_value ) );
|
.and_then( |h| h.text_change_msg( &new_value ) );
|
||||||
if let Some( m ) = msg { self.pending_msgs.push( m ); }
|
if let Some( m ) = msg { self.pending_msgs.push( m ); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ impl<A: App> AppData<A>
|
|||||||
let app_override = self.app.cursor_override();
|
let app_override = self.app.cursor_override();
|
||||||
let dragging = self.surface( focus ).gesture.dragging_slider.is_some();
|
let dragging = self.surface( focus ).gesture.dragging_slider.is_some();
|
||||||
let hover_cursor = self.surface( focus ).hovered_idx
|
let hover_cursor = self.surface( focus ).hovered_idx
|
||||||
.and_then( |idx| crate::tree::find_widget( &self.surface( focus ).widget_rects, idx ) )
|
.and_then( |idx| crate::tree::find_widget( &self.surface( focus ).frame.widget_rects, idx ) )
|
||||||
.map( |w| w.cursor )
|
.map( |w| w.cursor )
|
||||||
.unwrap_or( CursorShape::Default );
|
.unwrap_or( CursorShape::Default );
|
||||||
let resize_cursor = self.resize_edge_under_pointer( focus ).map( resize_edge_to_cursor );
|
let resize_cursor = self.resize_edge_under_pointer( focus ).map( resize_edge_to_cursor );
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ impl<A: App> AppData<A>
|
|||||||
// dismiss that races other intents (e.g. forge's topbar
|
// dismiss that races other intents (e.g. forge's topbar
|
||||||
// taps that route a separate IPC into the same overlay).
|
// taps that route a separate IPC into the same overlay).
|
||||||
let Some( anchor_id ) = spec.anchor_widget_id else { continue; };
|
let Some( anchor_id ) = spec.anchor_widget_id else { continue; };
|
||||||
let anchor_rect = self.main.widget_rects.iter()
|
let anchor_rect = self.main.frame.widget_rects.iter()
|
||||||
.find( | w | w.id == Some( anchor_id ) )
|
.find( | w | w.id == Some( anchor_id ) )
|
||||||
.map( | w | w.rect );
|
.map( | w | w.rect );
|
||||||
let on_anchor = anchor_rect
|
let on_anchor = anchor_rect
|
||||||
@@ -84,15 +84,20 @@ impl<A: App> AppData<A>
|
|||||||
{
|
{
|
||||||
let was_text_input;
|
let was_text_input;
|
||||||
let is_text_input;
|
let is_text_input;
|
||||||
|
let secure;
|
||||||
{
|
{
|
||||||
let ss = self.surface_mut( focus );
|
let ss = self.surface_mut( focus );
|
||||||
|
|
||||||
is_text_input = idx
|
is_text_input = idx
|
||||||
.and_then( |i| find_handlers( &ss.widget_rects, i ) )
|
.and_then( |i| find_handlers( &ss.frame.widget_rects, i ) )
|
||||||
.map( |h| h.is_text_input() )
|
.map( |h| h.is_text_input() )
|
||||||
.unwrap_or( false );
|
.unwrap_or( false );
|
||||||
|
secure = idx
|
||||||
|
.and_then( |i| find_handlers( &ss.frame.widget_rects, i ) )
|
||||||
|
.map( |h| matches!( h, WidgetHandlers::TextEdit { secure: true, .. } ) )
|
||||||
|
.unwrap_or( false );
|
||||||
was_text_input = ss.focused_idx
|
was_text_input = ss.focused_idx
|
||||||
.and_then( |i| find_handlers( &ss.widget_rects, i ) )
|
.and_then( |i| find_handlers( &ss.frame.widget_rects, i ) )
|
||||||
.map( |h| h.is_text_input() )
|
.map( |h| h.is_text_input() )
|
||||||
.unwrap_or( false );
|
.unwrap_or( false );
|
||||||
|
|
||||||
@@ -108,7 +113,7 @@ impl<A: App> AppData<A>
|
|||||||
ss.focused_idx = idx;
|
ss.focused_idx = idx;
|
||||||
ss.focused_id = idx.and_then( |i|
|
ss.focused_id = idx.and_then( |i|
|
||||||
{
|
{
|
||||||
ss.widget_rects.iter()
|
ss.frame.widget_rects.iter()
|
||||||
.find( |w| w.flat_idx == i )
|
.find( |w| w.flat_idx == i )
|
||||||
.and_then( |w| w.id )
|
.and_then( |w| w.id )
|
||||||
} );
|
} );
|
||||||
@@ -125,7 +130,7 @@ impl<A: App> AppData<A>
|
|||||||
{
|
{
|
||||||
if let Some( i ) = idx
|
if let Some( i ) = idx
|
||||||
{
|
{
|
||||||
let handler = find_handlers( &ss.widget_rects, i );
|
let handler = find_handlers( &ss.frame.widget_rects, i );
|
||||||
let cursor = handler
|
let cursor = handler
|
||||||
.and_then( |h| h.current_value() )
|
.and_then( |h| h.current_value() )
|
||||||
.map( |v| v.len() )
|
.map( |v| v.len() )
|
||||||
@@ -135,8 +140,8 @@ impl<A: App> AppData<A>
|
|||||||
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ) => 0,
|
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ) => 0,
|
||||||
_ => cursor,
|
_ => cursor,
|
||||||
};
|
};
|
||||||
ss.cursor_state.insert( i, cursor );
|
ss.frame.cursor_state.insert( i, cursor );
|
||||||
ss.selection_anchor.insert( i, anchor );
|
ss.frame.selection_anchor.insert( i, anchor );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,11 +152,17 @@ impl<A: App> AppData<A>
|
|||||||
self.app.on_text_input_focused( false );
|
self.app.on_text_input_focused( false );
|
||||||
self.dirty_caches();
|
self.dirty_caches();
|
||||||
}
|
}
|
||||||
if is_text_input && !was_text_input
|
else if is_text_input && !was_text_input
|
||||||
{
|
{
|
||||||
self.activate_text_input( qh );
|
self.activate_text_input( qh, secure );
|
||||||
self.app.on_text_input_focused( true );
|
self.app.on_text_input_focused( true );
|
||||||
self.dirty_caches();
|
self.dirty_caches();
|
||||||
}
|
}
|
||||||
|
else if is_text_input
|
||||||
|
{
|
||||||
|
// Focus moved between text fields: refresh the content type so a
|
||||||
|
// password field is flagged even without re-activating.
|
||||||
|
self.activate_text_input( qh, secure );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ pub( crate ) fn pick_shm_format( shm: &Shm ) -> ( wl_shm::Format, bool )
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub( crate ) fn draw_frame<A: App>( data: &mut AppData<A> )
|
/// Returns `true` when this call performed the very first commit on the main
|
||||||
|
/// surface — the run loop uses that to fire `App::on_first_frame_committed`
|
||||||
|
/// exactly once, after the borrows held by draw_frame have been released.
|
||||||
|
pub( crate ) fn draw_frame<A: App>( data: &mut AppData<A> ) -> bool
|
||||||
{
|
{
|
||||||
let main_view = data.cached_view.as_ref().expect( "view cache populated" );
|
let main_view = data.cached_view.as_ref().expect( "view cache populated" );
|
||||||
let overlays = data.cached_overlays.as_ref().expect( "overlays cache populated" );
|
let overlays = data.cached_overlays.as_ref().expect( "overlays cache populated" );
|
||||||
@@ -43,6 +46,7 @@ pub( crate ) fn draw_frame<A: App>( data: &mut AppData<A> )
|
|||||||
let egl_ctx = data.egl_context.as_ref();
|
let egl_ctx = data.egl_context.as_ref();
|
||||||
let qh = &data.qh;
|
let qh = &data.qh;
|
||||||
|
|
||||||
|
let mut first_commit_now = false;
|
||||||
if data.main.configured && data.main.needs_redraw && !data.main.frame_pending
|
if data.main.configured && data.main.needs_redraw && !data.main.frame_pending
|
||||||
{
|
{
|
||||||
let req_frame = | wl: &WlSurface | { let _ = wl.frame( qh, SurfaceFocus::Main ); };
|
let req_frame = | wl: &WlSurface | { let _ = wl.frame( qh, SurfaceFocus::Main ); };
|
||||||
@@ -60,6 +64,11 @@ pub( crate ) fn draw_frame<A: App>( data: &mut AppData<A> )
|
|||||||
);
|
);
|
||||||
data.main.needs_redraw = false;
|
data.main.needs_redraw = false;
|
||||||
data.main.last_draw = std::time::Instant::now();
|
data.main.last_draw = std::time::Instant::now();
|
||||||
|
if !data.first_frame_committed
|
||||||
|
{
|
||||||
|
data.first_frame_committed = true;
|
||||||
|
first_commit_now = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for spec in overlays
|
for spec in overlays
|
||||||
@@ -86,6 +95,8 @@ pub( crate ) fn draw_frame<A: App>( data: &mut AppData<A> )
|
|||||||
ss.last_draw = std::time::Instant::now();
|
ss.last_draw = std::time::Instant::now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
first_commit_now
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_surface<Msg: Clone>(
|
fn draw_surface<Msg: Clone>(
|
||||||
@@ -113,13 +124,13 @@ fn draw_surface<Msg: Clone>(
|
|||||||
.unwrap_or( false );
|
.unwrap_or( false );
|
||||||
let partial_eligible = !ss.content_dirty
|
let partial_eligible = !ss.content_dirty
|
||||||
&& canvas_ready
|
&& canvas_ready
|
||||||
&& !ss.widget_rects.is_empty();
|
&& !ss.frame.widget_rects.is_empty();
|
||||||
|
|
||||||
if partial_eligible
|
if partial_eligible
|
||||||
{
|
{
|
||||||
let dirty_rects = compute_interaction_dirty_rects(
|
let dirty_rects = compute_interaction_dirty_rects(
|
||||||
&ss.widget_rects,
|
&ss.frame.widget_rects,
|
||||||
ss.prev_focused, ss.prev_hovered, ss.prev_pressed,
|
ss.frame.prev_focused, ss.frame.prev_hovered, ss.frame.prev_pressed,
|
||||||
ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx,
|
ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx,
|
||||||
pw, ph,
|
pw, ph,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
use smithay_client_toolkit::
|
use smithay_client_toolkit::
|
||||||
{
|
{
|
||||||
compositor::CompositorHandler,
|
compositor::CompositorHandler,
|
||||||
delegate_compositor, delegate_foreign_toplevel_list, delegate_layer,
|
delegate_compositor, delegate_subcompositor, delegate_foreign_toplevel_list, delegate_layer,
|
||||||
delegate_output, delegate_registry, delegate_seat, delegate_keyboard,
|
delegate_output, delegate_registry, delegate_seat, delegate_keyboard,
|
||||||
delegate_pointer, delegate_touch, delegate_shm, delegate_xdg_popup,
|
delegate_pointer, delegate_touch, delegate_shm, delegate_xdg_popup,
|
||||||
delegate_xdg_shell, delegate_xdg_window,
|
delegate_xdg_shell, delegate_xdg_window, delegate_session_lock,
|
||||||
foreign_toplevel_list::{ ForeignToplevelList, ForeignToplevelListHandler },
|
foreign_toplevel_list::{ ForeignToplevelList, ForeignToplevelListHandler },
|
||||||
output::{ OutputHandler, OutputState },
|
output::{ OutputHandler, OutputState },
|
||||||
registry::{ ProvidesRegistryState, RegistryState },
|
registry::{ ProvidesRegistryState, RegistryState },
|
||||||
@@ -22,6 +22,7 @@ use smithay_client_toolkit::
|
|||||||
xdg::window::{ Window, WindowConfigure, WindowHandler },
|
xdg::window::{ Window, WindowConfigure, WindowHandler },
|
||||||
},
|
},
|
||||||
shm::{ Shm, ShmHandler },
|
shm::{ Shm, ShmHandler },
|
||||||
|
session_lock::{ SessionLock, SessionLockHandler, SessionLockSurface, SessionLockSurfaceConfigure },
|
||||||
};
|
};
|
||||||
use smithay_client_toolkit::reexports::protocols::ext::foreign_toplevel_list::v1::client::ext_foreign_toplevel_handle_v1::ExtForeignToplevelHandleV1;
|
use smithay_client_toolkit::reexports::protocols::ext::foreign_toplevel_list::v1::client::ext_foreign_toplevel_handle_v1::ExtForeignToplevelHandleV1;
|
||||||
use smithay_client_toolkit::reexports::client::
|
use smithay_client_toolkit::reexports::client::
|
||||||
@@ -98,6 +99,7 @@ impl<A: App> CompositorHandler for AppData<A>
|
|||||||
// `App::on_resize`.
|
// `App::on_resize`.
|
||||||
if matches!( focus, super::SurfaceFocus::Main )
|
if matches!( focus, super::SurfaceFocus::Main )
|
||||||
{
|
{
|
||||||
|
self.app.on_scale_changed( new_factor as u32 );
|
||||||
self.app.on_resize( pw, ph );
|
self.app.on_resize( pw, ph );
|
||||||
self.dirty_caches();
|
self.dirty_caches();
|
||||||
}
|
}
|
||||||
@@ -172,7 +174,6 @@ impl<A: App> LayerShellHandler for AppData<A>
|
|||||||
Some( super::SurfaceFocus::Main ) | None =>
|
Some( super::SurfaceFocus::Main ) | None =>
|
||||||
{
|
{
|
||||||
self.on_configure( w, h );
|
self.on_configure( w, h );
|
||||||
self.app.on_resize( w, h );
|
|
||||||
}
|
}
|
||||||
Some( super::SurfaceFocus::Overlay( id ) ) =>
|
Some( super::SurfaceFocus::Overlay( id ) ) =>
|
||||||
{
|
{
|
||||||
@@ -226,7 +227,6 @@ impl<A: App> WindowHandler for AppData<A>
|
|||||||
let h = configure.new_size.1.map( |v| v.get() ).unwrap_or( hint_h );
|
let h = configure.new_size.1.map( |v| v.get() ).unwrap_or( hint_h );
|
||||||
window.xdg_surface().set_window_geometry( 0, 0, w as i32, h as i32 );
|
window.xdg_surface().set_window_geometry( 0, 0, w as i32, h as i32 );
|
||||||
self.on_configure( w, h );
|
self.on_configure( w, h );
|
||||||
self.app.on_resize( w, h );
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,6 +324,13 @@ impl<A: App> SeatHandler for AppData<A>
|
|||||||
}
|
}
|
||||||
Capability::Touch if self.touch.is_none() =>
|
Capability::Touch if self.touch.is_none() =>
|
||||||
{
|
{
|
||||||
|
// Touch coming back (resume on devices that power the
|
||||||
|
// touchscreen down at suspend): clear anything the removal
|
||||||
|
// path didn't, so the first post-resume gesture starts clean.
|
||||||
|
if self.reset_touch_state()
|
||||||
|
{
|
||||||
|
eprintln!( "[ltk] touch capability re-added — cleared touch state stranded across the gap" );
|
||||||
|
}
|
||||||
self.touch = Some(
|
self.touch = Some(
|
||||||
self.seat_state
|
self.seat_state
|
||||||
.get_touch( qh, &seat )
|
.get_touch( qh, &seat )
|
||||||
@@ -346,7 +353,17 @@ impl<A: App> SeatHandler for AppData<A>
|
|||||||
{
|
{
|
||||||
Capability::Keyboard => { self.keyboard = None; }
|
Capability::Keyboard => { self.keyboard = None; }
|
||||||
Capability::Pointer => { self.pointer = None; }
|
Capability::Pointer => { self.pointer = None; }
|
||||||
Capability::Touch => { self.touch = None; }
|
Capability::Touch =>
|
||||||
|
{
|
||||||
|
self.touch = None;
|
||||||
|
// The touchscreen is gone before any pending up / cancel for
|
||||||
|
// the last touch could arrive; drop the stale gesture state
|
||||||
|
// now rather than stranding it until the next clean release.
|
||||||
|
if self.reset_touch_state()
|
||||||
|
{
|
||||||
|
eprintln!( "[ltk] touch capability removed mid-gesture — reset stale touch state" );
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -398,7 +415,41 @@ impl<A: App> PopupHandler for AppData<A>
|
|||||||
|
|
||||||
// --- Delegate macros ---
|
// --- Delegate macros ---
|
||||||
|
|
||||||
|
impl<A: App> SessionLockHandler for AppData<A>
|
||||||
|
{
|
||||||
|
fn locked( &mut self, _conn: &Connection, qh: &QueueHandle<Self>, session_lock: SessionLock )
|
||||||
|
{
|
||||||
|
if let Some( output ) = self.output_state.outputs().next()
|
||||||
|
{
|
||||||
|
let surface = self.compositor_state.create_surface( qh );
|
||||||
|
let lock_surface = session_lock.create_lock_surface( surface, &output, qh );
|
||||||
|
self.main.surface = super::SurfaceKind::Lock( lock_surface );
|
||||||
|
}
|
||||||
|
self.session_lock = Some( session_lock );
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finished( &mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _session_lock: SessionLock )
|
||||||
|
{
|
||||||
|
// Compositor refused the lock or ended it; nothing left to show.
|
||||||
|
self.exit_requested = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configure(
|
||||||
|
&mut self,
|
||||||
|
_conn: &Connection,
|
||||||
|
_qh: &QueueHandle<Self>,
|
||||||
|
_surface: SessionLockSurface,
|
||||||
|
configure: SessionLockSurfaceConfigure,
|
||||||
|
_serial: u32,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
let ( w, h ) = configure.new_size;
|
||||||
|
self.on_configure( w.max( 1 ), h.max( 1 ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
delegate_compositor!( @<A: App> AppData<A> );
|
delegate_compositor!( @<A: App> AppData<A> );
|
||||||
|
delegate_subcompositor!( @<A: App> AppData<A> );
|
||||||
delegate_output!( @<A: App> AppData<A> );
|
delegate_output!( @<A: App> AppData<A> );
|
||||||
delegate_shm!( @<A: App> AppData<A> );
|
delegate_shm!( @<A: App> AppData<A> );
|
||||||
delegate_seat!( @<A: App> AppData<A> );
|
delegate_seat!( @<A: App> AppData<A> );
|
||||||
@@ -406,6 +457,7 @@ delegate_keyboard!( @<A: App> AppData<A> );
|
|||||||
delegate_pointer!( @<A: App> AppData<A> );
|
delegate_pointer!( @<A: App> AppData<A> );
|
||||||
delegate_touch!( @<A: App> AppData<A> );
|
delegate_touch!( @<A: App> AppData<A> );
|
||||||
delegate_layer!( @<A: App> AppData<A> );
|
delegate_layer!( @<A: App> AppData<A> );
|
||||||
|
delegate_session_lock!( @<A: App> AppData<A> );
|
||||||
delegate_xdg_shell!( @<A: App> AppData<A> );
|
delegate_xdg_shell!( @<A: App> AppData<A> );
|
||||||
delegate_xdg_window!( @<A: App> AppData<A> );
|
delegate_xdg_window!( @<A: App> AppData<A> );
|
||||||
delegate_xdg_popup!( @<A: App> AppData<A> );
|
delegate_xdg_popup!( @<A: App> AppData<A> );
|
||||||
@@ -416,16 +468,25 @@ smithay_client_toolkit::delegate_data_device!( @<A: App> AppData<A> );
|
|||||||
|
|
||||||
// --- `xdg-activation-v1` handler ---
|
// --- `xdg-activation-v1` handler ---
|
||||||
//
|
//
|
||||||
// We only honour inbound activation here: when a token issued for our
|
// A token carrying a `RequestData::app_id` was requested by the app via
|
||||||
// own request gets delivered, we activate the main surface. Outbound
|
// [`App::take_activation_requests`]: echo the tag + token back through
|
||||||
// requests (so the app can pass a token to another app) are out of
|
// [`App::on_activation_token`] so the app can pass it to a child it is
|
||||||
// scope for now — adding them only requires a new public method on
|
// launching. A token with no tag was issued for our own surface, so we
|
||||||
// `AppData` and an extra trait method on `App`.
|
// self-activate.
|
||||||
impl<A: App> smithay_client_toolkit::activation::ActivationHandler for AppData<A>
|
impl<A: App> smithay_client_toolkit::activation::ActivationHandler for AppData<A>
|
||||||
{
|
{
|
||||||
type RequestData = smithay_client_toolkit::activation::RequestData;
|
type RequestData = smithay_client_toolkit::activation::RequestData;
|
||||||
fn new_token( &mut self, token: String, _data: &Self::RequestData )
|
fn new_token( &mut self, token: String, data: &Self::RequestData )
|
||||||
{
|
{
|
||||||
|
use smithay_client_toolkit::activation::RequestDataExt;
|
||||||
|
if let Some( tag ) = data.app_id()
|
||||||
|
{
|
||||||
|
if let Some( msg ) = self.app.on_activation_token( tag.to_string(), token )
|
||||||
|
{
|
||||||
|
self.pending_msgs.push( msg );
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if let ( Some( ref activation ), Some( wl ) ) = ( self.activation_state.as_ref(), self.main.surface.try_wl_surface() )
|
if let ( Some( ref activation ), Some( wl ) ) = ( self.activation_state.as_ref(), self.main.surface.try_wl_surface() )
|
||||||
{
|
{
|
||||||
activation.activate::<Self>( &wl, token );
|
activation.activate::<Self>( &wl, token );
|
||||||
@@ -468,10 +529,16 @@ fn toplevel_display_id(
|
|||||||
handle: &ExtForeignToplevelHandleV1,
|
handle: &ExtForeignToplevelHandleV1,
|
||||||
) -> String
|
) -> String
|
||||||
{
|
{
|
||||||
|
// Only ever surface the client's `app_id`. The protocol-level
|
||||||
|
// `identifier` is a server-internal random opaque token, not an
|
||||||
|
// app handle; smithay creates the toplevel handle with an empty
|
||||||
|
// `app_id` and `init_new_instance` flushes a `done` immediately,
|
||||||
|
// so apps subscribing through this binding would otherwise see
|
||||||
|
// the random identifier as their "app id" until the client's
|
||||||
|
// real `set_app_id` lands. `title` is also unsuitable — it
|
||||||
|
// changes every time the window's title text updates.
|
||||||
let Some( info ) = list.info( handle ) else { return String::new(); };
|
let Some( info ) = list.info( handle ) else { return String::new(); };
|
||||||
if !info.app_id.is_empty() { return info.app_id; }
|
info.app_id
|
||||||
if !info.title.is_empty() { return info.title; }
|
|
||||||
info.identifier
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<A: App> ForeignToplevelListHandler for AppData<A>
|
impl<A: App> ForeignToplevelListHandler for AppData<A>
|
||||||
@@ -579,10 +646,45 @@ impl<A: App> Dispatch<WlCallback, super::SurfaceFocus> for AppData<A>
|
|||||||
{
|
{
|
||||||
state.main.frame_pending = false;
|
state.main.frame_pending = false;
|
||||||
if is_animating
|
if is_animating
|
||||||
|
{
|
||||||
|
if state.app.subsurface_motion_only()
|
||||||
|
{
|
||||||
|
// The main buffer is unchanged; only the subsurface
|
||||||
|
// moves (repositioned by the per-frame reconcile).
|
||||||
|
// Keep the vsync cadence with a bare frame callback +
|
||||||
|
// commit — no buffer attach, no re-raster.
|
||||||
|
if let Some( wl ) = state.main.surface.try_wl_surface().cloned()
|
||||||
|
{
|
||||||
|
let _ = wl.frame( &state.qh, super::SurfaceFocus::Main );
|
||||||
|
wl.commit();
|
||||||
|
state.main.frame_pending = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Perf guardrails: run the opt-in diagnostics and apply
|
||||||
|
// the software animation-rate cap (~30 Hz). A throttled
|
||||||
|
// frame keeps the vsync cadence with a bare callback but
|
||||||
|
// skips the re-raster.
|
||||||
|
let software = crate::render::is_software_render();
|
||||||
|
let cap = state.app.cap_software_animation();
|
||||||
|
if state.perf.animated_frame( software, cap )
|
||||||
{
|
{
|
||||||
state.view_dirty = true;
|
state.view_dirty = true;
|
||||||
state.main.request_redraw();
|
state.main.request_redraw();
|
||||||
}
|
}
|
||||||
|
else if let Some( wl ) = state.main.surface.try_wl_surface().cloned()
|
||||||
|
{
|
||||||
|
let _ = wl.frame( &state.qh, super::SurfaceFocus::Main );
|
||||||
|
wl.commit();
|
||||||
|
state.main.frame_pending = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
state.perf.animation_stopped();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
super::SurfaceFocus::Overlay( id ) =>
|
super::SurfaceFocus::Overlay( id ) =>
|
||||||
{
|
{
|
||||||
@@ -635,6 +737,10 @@ impl<A: App> Dispatch<ZwpTextInputV3, ()> for AppData<A>
|
|||||||
ss.request_redraw();
|
ss.request_redraw();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
zwp_text_input_v3::Event::Enter { .. } =>
|
||||||
|
{
|
||||||
|
state.reenable_text_input();
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// SPDX-License-Identifier: LGPL-2.1-only
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
|
#[ cfg( feature = "test-support" ) ]
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use crate::app::{ App, InvalidationScope, SurfaceTarget };
|
use crate::app::{ App, InvalidationScope, SurfaceTarget };
|
||||||
@@ -62,6 +63,10 @@ pub( super ) fn apply_invalidation<A: App>( data: &mut AppData<A>, scope: Invali
|
|||||||
///
|
///
|
||||||
/// `added` preserves the order of `next` (so creation order is deterministic);
|
/// `added` preserves the order of `next` (so creation order is deterministic);
|
||||||
/// `removed` is unordered (driven by HashMap iteration in the caller).
|
/// `removed` is unordered (driven by HashMap iteration in the caller).
|
||||||
|
///
|
||||||
|
/// Exposed only for `test_support`; gated behind that feature so it does not
|
||||||
|
/// warn as dead code in ordinary builds.
|
||||||
|
#[ cfg( feature = "test-support" ) ]
|
||||||
pub fn diff_overlay_ids(
|
pub fn diff_overlay_ids(
|
||||||
current: impl IntoIterator<Item = crate::app::OverlayId>,
|
current: impl IntoIterator<Item = crate::app::OverlayId>,
|
||||||
next: &[ crate::app::OverlayId ],
|
next: &[ crate::app::OverlayId ],
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ pub( crate ) mod cursor_shape;
|
|||||||
pub( crate ) mod drag;
|
pub( crate ) mod drag;
|
||||||
pub( crate ) mod focus;
|
pub( crate ) mod focus;
|
||||||
mod handlers;
|
mod handlers;
|
||||||
|
pub( crate ) mod perf;
|
||||||
pub( crate ) mod repeat;
|
pub( crate ) mod repeat;
|
||||||
|
pub( crate ) mod subsurface;
|
||||||
pub( crate ) mod surface;
|
pub( crate ) mod surface;
|
||||||
pub( crate ) mod text_editing;
|
pub( crate ) mod text_editing;
|
||||||
pub( crate ) mod tooltip;
|
pub( crate ) mod tooltip;
|
||||||
@@ -21,7 +23,8 @@ pub( crate ) mod invalidation;
|
|||||||
pub( crate ) mod overlays_reconcile;
|
pub( crate ) mod overlays_reconcile;
|
||||||
|
|
||||||
pub( crate ) use app_data::AppData;
|
pub( crate ) use app_data::AppData;
|
||||||
pub( crate ) use surface::{ LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState };
|
pub( crate ) use surface::{ FrameState, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState };
|
||||||
pub use error::RunError;
|
pub use error::RunError;
|
||||||
pub( crate ) use run::{ run, try_run };
|
pub( crate ) use run::{ run, try_run };
|
||||||
|
#[ cfg( feature = "test-support" ) ]
|
||||||
pub use invalidation::diff_overlay_ids;
|
pub use invalidation::diff_overlay_ids;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ use wayland_protocols::xdg::shell::client::xdg_positioner::
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::types::Rect;
|
use crate::types::{ Length, Rect };
|
||||||
use super::{ AppData, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState };
|
use super::{ AppData, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState };
|
||||||
|
|
||||||
/// Sync `data.overlays` with the app's current `App::overlays()` spec list:
|
/// Sync `data.overlays` with the app's current `App::overlays()` spec list:
|
||||||
@@ -56,6 +56,11 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
|
|||||||
{
|
{
|
||||||
data.main.gesture.long_press_fired = true;
|
data.main.gesture.long_press_fired = true;
|
||||||
data.main.gesture.long_press_origin = ss.gesture.long_press_origin;
|
data.main.gesture.long_press_origin = ss.gesture.long_press_origin;
|
||||||
|
// Keep the primary touch slot with the migrated drag so motion / release still route through the gesture machine.
|
||||||
|
if data.main.primary_touch_id.is_none()
|
||||||
|
{
|
||||||
|
data.main.primary_touch_id = ss.primary_touch_id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,14 +104,39 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
|
|||||||
SurfaceKind::Window( ref w ) => Some( w.xdg_surface().clone() ),
|
SurfaceKind::Window( ref w ) => Some( w.xdg_surface().clone() ),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
let parent_scale = data.main.scale_factor.max( 1 ) as f32;
|
let parent_scale_i = data.main.scale_factor.max( 1 );
|
||||||
|
let parent_scale = parent_scale_i as f32;
|
||||||
|
// `OverlaySpec::size` carries `Length`s resolved against the main
|
||||||
|
// surface's physical viewport (the app's layout space), so an overlay
|
||||||
|
// sized with `Length::widget( … )` scales with the display exactly like
|
||||||
|
// any other widget. `Length::px( n )` keeps a fixed size.
|
||||||
|
let main_vp = ( data.main.physical_width() as f32, data.main.physical_height() as f32 );
|
||||||
|
let resolve_size = move | s: &( Length, Length ) | -> ( u32, u32 )
|
||||||
|
{
|
||||||
|
(
|
||||||
|
s.0.resolve( main_vp, Length::EM_BASE_DEFAULT ).max( 0.0 ).round() as u32,
|
||||||
|
s.1.resolve( main_vp, Length::EM_BASE_DEFAULT ).max( 0.0 ).round() as u32,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
// `OverlaySpec::size` is physical pixels (the app's layout space); the
|
||||||
|
// layer-shell `set_size` is logical. They only coincide at scale 1, so
|
||||||
|
// convert here — without it a scale-2 overlay requests a surface twice
|
||||||
|
// its intended size. `0 = fill` survives the divide.
|
||||||
|
let to_logical_size = move | ( w, h ): ( u32, u32 ) | -> ( u32, u32 )
|
||||||
|
{
|
||||||
|
(
|
||||||
|
( w as f32 / parent_scale ).round() as u32,
|
||||||
|
( h as f32 / parent_scale ).round() as u32,
|
||||||
|
)
|
||||||
|
};
|
||||||
// Snapshot the previous-frame anchor lookup table so we can resolve
|
// Snapshot the previous-frame anchor lookup table so we can resolve
|
||||||
// `anchor_widget_id` → `Rect` without holding a borrow on `data.main`
|
// `anchor_widget_id` → `Rect` without holding a borrow on `data.main`
|
||||||
// across the overlay-mut loop below.
|
// across the overlay-mut loop below.
|
||||||
let main_widget_rects = &data.main.widget_rects;
|
let main_widget_rects = &data.main.frame.widget_rects;
|
||||||
let overlays_m = &mut data.overlays;
|
let overlays_m = &mut data.overlays;
|
||||||
for spec in &specs
|
for spec in &specs
|
||||||
{
|
{
|
||||||
|
let resolved_size = resolve_size( &spec.size );
|
||||||
if let Some( ss ) = overlays_m.get_mut( &spec.id )
|
if let Some( ss ) = overlays_m.get_mut( &spec.id )
|
||||||
{
|
{
|
||||||
// Already-live overlay: propagate size changes to the layer
|
// Already-live overlay: propagate size changes to the layer
|
||||||
@@ -118,13 +148,14 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
|
|||||||
// sends a configure; the usual `on_configure` path picks up the
|
// sends a configure; the usual `on_configure` path picks up the
|
||||||
// new dimensions and drives the redraw. Popups don't grow /
|
// new dimensions and drives the redraw. Popups don't grow /
|
||||||
// shrink mid-life — close and reopen instead.
|
// shrink mid-life — close and reopen instead.
|
||||||
if spec.size != ss.last_requested_size
|
if resolved_size != ss.last_requested_size
|
||||||
{
|
{
|
||||||
if let SurfaceKind::Layer( ref layer_surface ) = ss.surface
|
if let SurfaceKind::Layer( ref layer_surface ) = ss.surface
|
||||||
{
|
{
|
||||||
layer_surface.set_size( spec.size.0, spec.size.1 );
|
let ( lw, lh ) = to_logical_size( resolved_size );
|
||||||
|
layer_surface.set_size( lw, lh );
|
||||||
layer_surface.commit();
|
layer_surface.commit();
|
||||||
ss.last_requested_size = spec.size;
|
ss.last_requested_size = resolved_size;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Compare the anchor at integer logical-pixel resolution:
|
// Compare the anchor at integer logical-pixel resolution:
|
||||||
@@ -155,7 +186,7 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
|
|||||||
if let Ok( positioner ) = XdgPositioner::new( xdg_shell )
|
if let Ok( positioner ) = XdgPositioner::new( xdg_shell )
|
||||||
{
|
{
|
||||||
let ( ax, ay, aw, ah ) = new_q;
|
let ( ax, ay, aw, ah ) = new_q;
|
||||||
let ( spec_w, spec_h ) = spec.size;
|
let ( spec_w, spec_h ) = resolved_size;
|
||||||
let popup_w = if spec_w == 0 { aw } else { spec_w.max( 1 ) as i32 };
|
let popup_w = if spec_w == 0 { aw } else { spec_w.max( 1 ) as i32 };
|
||||||
let popup_h = spec_h.max( 1 ) as i32;
|
let popup_h = spec_h.max( 1 ) as i32;
|
||||||
positioner.set_size( popup_w, popup_h );
|
positioner.set_size( popup_w, popup_h );
|
||||||
@@ -218,7 +249,7 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
|
|||||||
let ay = ( anchor_rect.y / parent_scale ).round() as i32;
|
let ay = ( anchor_rect.y / parent_scale ).round() as i32;
|
||||||
let aw = ( anchor_rect.width / parent_scale ).round().max( 1.0 ) as i32;
|
let aw = ( anchor_rect.width / parent_scale ).round().max( 1.0 ) as i32;
|
||||||
let ah = ( anchor_rect.height / parent_scale ).round().max( 1.0 ) as i32;
|
let ah = ( anchor_rect.height / parent_scale ).round().max( 1.0 ) as i32;
|
||||||
let ( spec_w, spec_h ) = spec.size;
|
let ( spec_w, spec_h ) = resolved_size;
|
||||||
let popup_w = if spec_w == 0 { aw } else { spec_w.max( 1 ) as i32 };
|
let popup_w = if spec_w == 0 { aw } else { spec_w.max( 1 ) as i32 };
|
||||||
let popup_h = spec_h.max( 1 ) as i32;
|
let popup_h = spec_h.max( 1 ) as i32;
|
||||||
positioner.set_size( popup_w, popup_h );
|
positioner.set_size( popup_w, popup_h );
|
||||||
@@ -256,7 +287,7 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
|
|||||||
}
|
}
|
||||||
popup.wl_surface().commit();
|
popup.wl_surface().commit();
|
||||||
let mut ss = SurfaceState::<A::Message>::new( SurfaceKind::Popup( popup ), 0.0, String::new() );
|
let mut ss = SurfaceState::<A::Message>::new( SurfaceKind::Popup( popup ), 0.0, String::new() );
|
||||||
ss.last_requested_size = spec.size;
|
ss.last_requested_size = resolved_size;
|
||||||
ss.last_popup_anchor = Some( anchor_rect );
|
ss.last_popup_anchor = Some( anchor_rect );
|
||||||
overlays_m.insert( spec.id, ss );
|
overlays_m.insert( spec.id, ss );
|
||||||
continue;
|
continue;
|
||||||
@@ -272,7 +303,7 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
|
|||||||
layer: spec.layer.to_wlr_layer(),
|
layer: spec.layer.to_wlr_layer(),
|
||||||
exclusive_zone: spec.exclusive_zone,
|
exclusive_zone: spec.exclusive_zone,
|
||||||
anchor: spec.anchor,
|
anchor: spec.anchor,
|
||||||
size: spec.size,
|
size: to_logical_size( resolved_size ),
|
||||||
keyboard_exclusive: spec.keyboard_exclusive,
|
keyboard_exclusive: spec.keyboard_exclusive,
|
||||||
namespace: "ltk-overlay",
|
namespace: "ltk-overlay",
|
||||||
};
|
};
|
||||||
@@ -282,7 +313,12 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
|
|||||||
surface.materialize( cs, layer_shell, qh, output );
|
surface.materialize( cs, layer_shell, qh, output );
|
||||||
}
|
}
|
||||||
let mut ss = SurfaceState::<A::Message>::new( surface, 0.0, String::new() );
|
let mut ss = SurfaceState::<A::Message>::new( surface, 0.0, String::new() );
|
||||||
ss.last_requested_size = spec.size;
|
// Inherit the parent's scale so the first configure allocates a
|
||||||
|
// HiDPI buffer and lays out at the right size from frame one,
|
||||||
|
// instead of rendering at scale 1 until `scale_factor_changed`
|
||||||
|
// lands a frame or two later.
|
||||||
|
ss.scale_factor = parent_scale_i;
|
||||||
|
ss.last_requested_size = resolved_size;
|
||||||
ss.layer_anchor = Some( spec.anchor );
|
ss.layer_anchor = Some( spec.anchor );
|
||||||
overlays_m.insert( spec.id, ss );
|
overlays_m.insert( spec.id, ss );
|
||||||
}
|
}
|
||||||
|
|||||||
173
src/event_loop/perf.rs
Normal file
173
src/event_loop/perf.rs
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
|
//! Runtime performance guardrails.
|
||||||
|
//!
|
||||||
|
//! The idle/redraw model is efficient only if the app cooperates — a stuck
|
||||||
|
//! [`App::is_animating`](crate::App::is_animating), an aggressive
|
||||||
|
//! [`poll_interval`](crate::App::poll_interval), or continuous animation on
|
||||||
|
//! the software backend all quietly burn CPU/battery. The docs warn about
|
||||||
|
//! these, but the runtime can also **detect** them (opt-in dev diagnostics,
|
||||||
|
//! `LTK_PERF_WARN=1`) and **mitigate** one of them (cap animation to ~30 Hz
|
||||||
|
//! on the software renderer, opt-out via
|
||||||
|
//! [`App::cap_software_animation`](crate::App::cap_software_animation)).
|
||||||
|
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use std::time::{ Duration, Instant };
|
||||||
|
|
||||||
|
/// A settled animation should return `false` well before this; staying `true`
|
||||||
|
/// this long is almost always a forgotten `is_animating = false`.
|
||||||
|
const STUCK_THRESHOLD: Duration = Duration::from_secs( 10 );
|
||||||
|
/// Continuous software-rendered animation past this is worth flagging on
|
||||||
|
/// mobile, where it means sustained CPU with no GPU offload.
|
||||||
|
const SW_ANIM_THRESHOLD: Duration = Duration::from_secs( 3 );
|
||||||
|
/// Minimum gap between software-backend animation re-rasters (≈ 30 Hz).
|
||||||
|
const SOFTWARE_ANIM_MIN_INTERVAL: Duration = Duration::from_millis( 33 );
|
||||||
|
/// A `poll_interval` shorter than this defeats the event-driven idle model.
|
||||||
|
const POLL_WARN_THRESHOLD: Duration = Duration::from_millis( 100 );
|
||||||
|
|
||||||
|
/// `true` when `LTK_PERF_WARN` is set to a non-empty, non-`0` value. Cached.
|
||||||
|
fn perf_warn_enabled() -> bool
|
||||||
|
{
|
||||||
|
static ENABLED: OnceLock<bool> = OnceLock::new();
|
||||||
|
*ENABLED.get_or_init( ||
|
||||||
|
std::env::var( "LTK_PERF_WARN" )
|
||||||
|
.map( |v| !v.is_empty() && v != "0" )
|
||||||
|
.unwrap_or( false )
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-runtime performance-guard state. Lives on `AppData`.
|
||||||
|
pub struct PerfState
|
||||||
|
{
|
||||||
|
/// When the current continuous animation began (`None` while idle).
|
||||||
|
animating_since: Option<Instant>,
|
||||||
|
/// Last software-backend animation re-raster, for the 30 Hz cap.
|
||||||
|
last_anim_draw: Instant,
|
||||||
|
warned_stuck: bool,
|
||||||
|
warned_software_anim: bool,
|
||||||
|
warned_poll: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PerfState
|
||||||
|
{
|
||||||
|
pub fn new() -> Self
|
||||||
|
{
|
||||||
|
Self
|
||||||
|
{
|
||||||
|
animating_since: None,
|
||||||
|
// Start in the past so the first animated frame is never throttled.
|
||||||
|
// `checked_sub` guards against underflow shortly after boot.
|
||||||
|
last_anim_draw: Instant::now().checked_sub( SOFTWARE_ANIM_MIN_INTERVAL ).unwrap_or_else( Instant::now ),
|
||||||
|
warned_stuck: false,
|
||||||
|
warned_software_anim: false,
|
||||||
|
warned_poll: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset when the app stops animating, so the next animation starts fresh
|
||||||
|
/// (and can warn again if it, too, gets stuck).
|
||||||
|
pub fn animation_stopped( &mut self )
|
||||||
|
{
|
||||||
|
self.animating_since = None;
|
||||||
|
self.warned_stuck = false;
|
||||||
|
self.warned_software_anim = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called on every animated main-surface frame callback. Runs the opt-in
|
||||||
|
/// diagnostics and applies the software animation-rate cap. Returns `true`
|
||||||
|
/// if this frame should actually re-raster, `false` to throttle-skip it
|
||||||
|
/// (the caller keeps the vsync cadence with a bare frame callback).
|
||||||
|
pub fn animated_frame( &mut self, software: bool, cap_software: bool ) -> bool
|
||||||
|
{
|
||||||
|
let since = *self.animating_since.get_or_insert_with( Instant::now );
|
||||||
|
|
||||||
|
if perf_warn_enabled()
|
||||||
|
{
|
||||||
|
if !self.warned_stuck && since.elapsed() >= STUCK_THRESHOLD
|
||||||
|
{
|
||||||
|
eprintln!(
|
||||||
|
"[ltk][perf] App::is_animating() has stayed true for {}s — a settled \
|
||||||
|
animation must return false, or the loop redraws at the display rate \
|
||||||
|
forever (battery drain).",
|
||||||
|
STUCK_THRESHOLD.as_secs(),
|
||||||
|
);
|
||||||
|
self.warned_stuck = true;
|
||||||
|
}
|
||||||
|
if software && !self.warned_software_anim && since.elapsed() >= SW_ANIM_THRESHOLD
|
||||||
|
{
|
||||||
|
eprintln!(
|
||||||
|
"[ltk][perf] continuous animation on the software renderer — sustained \
|
||||||
|
CPU with no GPU offload (costly on mobile). Prefer event-driven redraws, \
|
||||||
|
or a GLES-capable compositor.",
|
||||||
|
);
|
||||||
|
self.warned_software_anim = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if software && cap_software && self.last_anim_draw.elapsed() < SOFTWARE_ANIM_MIN_INTERVAL
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.last_anim_draw = Instant::now();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Warn once (under `LTK_PERF_WARN`) if the app's `poll_interval` is short
|
||||||
|
/// enough to defeat the idle model.
|
||||||
|
pub fn warn_poll_interval( &mut self, dur: Duration )
|
||||||
|
{
|
||||||
|
if perf_warn_enabled() && !self.warned_poll && dur < POLL_WARN_THRESHOLD
|
||||||
|
{
|
||||||
|
eprintln!(
|
||||||
|
"[ltk][perf] poll_interval() of {}ms defeats the event-driven idle model — \
|
||||||
|
wake the loop from your worker with set_channel_sender instead of polling.",
|
||||||
|
dur.as_millis(),
|
||||||
|
);
|
||||||
|
self.warned_poll = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ cfg( test ) ]
|
||||||
|
mod tests
|
||||||
|
{
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn gles_animation_is_never_capped()
|
||||||
|
{
|
||||||
|
let mut p = PerfState::new();
|
||||||
|
// Two back-to-back frames on the GPU backend both re-raster.
|
||||||
|
assert!( p.animated_frame( false, true ) );
|
||||||
|
assert!( p.animated_frame( false, true ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn software_animation_is_capped_between_rerasters()
|
||||||
|
{
|
||||||
|
let mut p = PerfState::new();
|
||||||
|
// First software frame draws; an immediate second is throttled
|
||||||
|
// (< 33 ms since the last re-raster).
|
||||||
|
assert!( p.animated_frame( true, true ) );
|
||||||
|
assert!( !p.animated_frame( true, true ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn software_cap_opt_out_keeps_full_rate()
|
||||||
|
{
|
||||||
|
let mut p = PerfState::new();
|
||||||
|
assert!( p.animated_frame( true, false ) );
|
||||||
|
assert!( p.animated_frame( true, false ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn stopping_resets_stuck_warning_state()
|
||||||
|
{
|
||||||
|
let mut p = PerfState::new();
|
||||||
|
let _ = p.animated_frame( false, true );
|
||||||
|
assert!( p.animating_since.is_some() );
|
||||||
|
p.animation_stopped();
|
||||||
|
assert!( p.animating_since.is_none() );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ use smithay_client_toolkit::
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
shm::Shm,
|
shm::Shm,
|
||||||
|
subcompositor::SubcompositorState,
|
||||||
};
|
};
|
||||||
use smithay_client_toolkit::reexports::client::Connection;
|
use smithay_client_toolkit::reexports::client::Connection;
|
||||||
use smithay_client_toolkit::reexports::calloop::EventLoop;
|
use smithay_client_toolkit::reexports::calloop::EventLoop;
|
||||||
@@ -70,6 +71,10 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
|||||||
let shm = Shm::bind( &globals, &qh )
|
let shm = Shm::bind( &globals, &qh )
|
||||||
.map_err( |e| RunError::MissingProtocol { name: "wl_shm", detail: format!( "{e:?}" ) } )?;
|
.map_err( |e| RunError::MissingProtocol { name: "wl_shm", detail: format!( "{e:?}" ) } )?;
|
||||||
|
|
||||||
|
// Optional: compositors lacking wl_subcompositor leave this None and
|
||||||
|
// App::subsurfaces() silently degrades to no subsurfaces.
|
||||||
|
let subcompositor = SubcompositorState::bind( compositor.wl_compositor().clone(), &globals, &qh ).ok();
|
||||||
|
|
||||||
// Try to bring EGL up. On failure (no libEGL, no compatible config,
|
// Try to bring EGL up. On failure (no libEGL, no compatible config,
|
||||||
// LTK_FORCE_SOFTWARE=1, ES2/ES3 context creation refused…) we log the
|
// LTK_FORCE_SOFTWARE=1, ES2/ES3 context creation refused…) we log the
|
||||||
// reason and every surface falls back to the SHM path.
|
// reason and every surface falls back to the SHM path.
|
||||||
@@ -143,6 +148,11 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
|||||||
use crate::app::ShellMode;
|
use crate::app::ShellMode;
|
||||||
match app.shell_mode()
|
match app.shell_mode()
|
||||||
{
|
{
|
||||||
|
ShellMode::SessionLock => {
|
||||||
|
// Lock surface is created in SessionLockHandler::locked once the
|
||||||
|
// compositor grants the lock; until then a placeholder.
|
||||||
|
( SurfaceKind::PendingLock, None )
|
||||||
|
}
|
||||||
ShellMode::Window => {
|
ShellMode::Window => {
|
||||||
let xdg = bind_xdg( &globals, &qh )?;
|
let xdg = bind_xdg( &globals, &qh )?;
|
||||||
let surface = compositor.create_surface( &qh );
|
let surface = compositor.create_surface( &qh );
|
||||||
@@ -183,6 +193,20 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Bind the session-lock manager and request the lock. We don't keep the
|
||||||
|
// `SessionLockState` (the manager) around: it has no `Drop`, so the
|
||||||
|
// manager object persists in the connection, and the lock lifecycle runs
|
||||||
|
// entirely off the returned `SessionLock` + its surfaces.
|
||||||
|
let session_lock =
|
||||||
|
if force_window.is_none() && matches!( app.shell_mode(), crate::app::ShellMode::SessionLock )
|
||||||
|
{
|
||||||
|
smithay_client_toolkit::session_lock::SessionLockState::new( &globals, &qh )
|
||||||
|
.lock( &qh )
|
||||||
|
.ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
let text_input_manager: Option<ZwpTextInputManagerV3> = globals.bind( &qh, 1..=1, () ).ok();
|
let text_input_manager: Option<ZwpTextInputManagerV3> = globals.bind( &qh, 1..=1, () ).ok();
|
||||||
// wl_data_device_manager: optional. Required for cross-application
|
// wl_data_device_manager: optional. Required for cross-application
|
||||||
// copy/paste; absence means the clipboard stays process-local.
|
// copy/paste; absence means the clipboard stays process-local.
|
||||||
@@ -242,7 +266,9 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
|||||||
seat_state: SeatState::new( &globals, &qh ),
|
seat_state: SeatState::new( &globals, &qh ),
|
||||||
output_state: OutputState::new( &globals, &qh ),
|
output_state: OutputState::new( &globals, &qh ),
|
||||||
compositor_state: compositor,
|
compositor_state: compositor,
|
||||||
|
subcompositor,
|
||||||
shm,
|
shm,
|
||||||
|
session_lock,
|
||||||
egl_context,
|
egl_context,
|
||||||
xdg_shell,
|
xdg_shell,
|
||||||
layer_shell: layer_shell_opt,
|
layer_shell: layer_shell_opt,
|
||||||
@@ -257,6 +283,7 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
|||||||
current_cursor_shape: None,
|
current_cursor_shape: None,
|
||||||
text_input_manager,
|
text_input_manager,
|
||||||
text_input: None,
|
text_input: None,
|
||||||
|
text_input_secure: false,
|
||||||
activation_state,
|
activation_state,
|
||||||
activation_token_pending,
|
activation_token_pending,
|
||||||
data_device_manager,
|
data_device_manager,
|
||||||
@@ -293,6 +320,8 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
|||||||
pending_size_hint_unpin,
|
pending_size_hint_unpin,
|
||||||
main: SurfaceState::<A::Message>::new( surface_kind, titlebar_height, titlebar_title ),
|
main: SurfaceState::<A::Message>::new( surface_kind, titlebar_height, titlebar_title ),
|
||||||
overlays: std::collections::HashMap::new(),
|
overlays: std::collections::HashMap::new(),
|
||||||
|
subsurfaces: std::collections::HashMap::new(),
|
||||||
|
subsurface_gles_canvas: None,
|
||||||
pointer_focus: SurfaceFocus::Main,
|
pointer_focus: SurfaceFocus::Main,
|
||||||
keyboard_focus: SurfaceFocus::Main,
|
keyboard_focus: SurfaceFocus::Main,
|
||||||
touch_focus: std::collections::HashMap::new(),
|
touch_focus: std::collections::HashMap::new(),
|
||||||
@@ -300,6 +329,9 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
|||||||
cached_overlays: None,
|
cached_overlays: None,
|
||||||
view_dirty: true,
|
view_dirty: true,
|
||||||
overlays_dirty: true,
|
overlays_dirty: true,
|
||||||
|
first_frame_committed: false,
|
||||||
|
focus_retry: None,
|
||||||
|
perf: super::perf::PerfState::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Register a calloop channel so the app can send messages from any thread.
|
// Register a calloop channel so the app can send messages from any thread.
|
||||||
@@ -329,6 +361,7 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
|||||||
// The timer fires independently of Wayland events, waking the event loop on schedule.
|
// The timer fires independently of Wayland events, waking the event loop on schedule.
|
||||||
if let Some( dur ) = data.app.poll_interval()
|
if let Some( dur ) = data.app.poll_interval()
|
||||||
{
|
{
|
||||||
|
data.perf.warn_poll_interval( dur );
|
||||||
event_loop.handle()
|
event_loop.handle()
|
||||||
.insert_source(
|
.insert_source(
|
||||||
Timer::from_duration( dur ),
|
Timer::from_duration( dur ),
|
||||||
@@ -383,6 +416,36 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
|||||||
data.exit_requested = true;
|
data.exit_requested = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
Err( calloop::Error::OtherError( ref e ) ) =>
|
||||||
|
{
|
||||||
|
// wayland-client surfaces the closed-socket condition as an
|
||||||
|
// OtherError wrapping a WaylandError::Io(BrokenPipe) — one
|
||||||
|
// level deeper than a direct io::Error. Walk the source()
|
||||||
|
// chain so we catch it regardless of how many wrapper types
|
||||||
|
// sit between calloop and the raw io::Error.
|
||||||
|
let mut src: Option<&dyn std::error::Error> = Some( e.as_ref() );
|
||||||
|
let mut is_closed = false;
|
||||||
|
while let Some( err ) = src
|
||||||
|
{
|
||||||
|
if let Some( io ) = err.downcast_ref::<std::io::Error>()
|
||||||
|
{
|
||||||
|
if matches!( io.kind(),
|
||||||
|
std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::ConnectionReset )
|
||||||
|
{
|
||||||
|
is_closed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
src = err.source();
|
||||||
|
}
|
||||||
|
if is_closed
|
||||||
|
{
|
||||||
|
eprintln!( "ltk: wayland connection lost; exiting" );
|
||||||
|
data.exit_requested = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
panic!( "dispatch: {e:?}" );
|
||||||
|
}
|
||||||
Err( e ) => panic!( "dispatch: {e:?}" ),
|
Err( e ) => panic!( "dispatch: {e:?}" ),
|
||||||
}
|
}
|
||||||
// Any surface whose press has now crossed `long_press_duration`
|
// Any surface whose press has now crossed `long_press_duration`
|
||||||
@@ -395,6 +458,34 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
|||||||
let ext: Vec<_> = data.app.poll_external();
|
let ext: Vec<_> = data.app.poll_external();
|
||||||
data.pending_msgs.extend( ext );
|
data.pending_msgs.extend( ext );
|
||||||
|
|
||||||
|
// Issue any xdg-activation tokens the app asked for. The tag rides
|
||||||
|
// in `RequestData::app_id` and comes back through
|
||||||
|
// `ActivationHandler::new_token` (handlers.rs) as the token. When the
|
||||||
|
// compositor never advertised the global, answer with an empty token
|
||||||
|
// so the caller still proceeds (falling back to its own matching).
|
||||||
|
for tag in data.app.take_activation_requests()
|
||||||
|
{
|
||||||
|
match data.activation_state
|
||||||
|
{
|
||||||
|
Some( ref activation ) =>
|
||||||
|
{
|
||||||
|
let req = smithay_client_toolkit::activation::RequestData {
|
||||||
|
app_id: Some( tag ),
|
||||||
|
seat_and_serial: None,
|
||||||
|
surface: data.main.surface.try_wl_surface().cloned(),
|
||||||
|
};
|
||||||
|
activation.request_token::<AppData<A>>( &data.qh, req );
|
||||||
|
}
|
||||||
|
None =>
|
||||||
|
{
|
||||||
|
if let Some( msg ) = data.app.on_activation_token( tag, String::new() )
|
||||||
|
{
|
||||||
|
data.pending_msgs.push( msg );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Drain any inbound clipboard payloads delivered by the
|
// Drain any inbound clipboard payloads delivered by the
|
||||||
// data-device worker thread. Latest writer wins; the channel
|
// data-device worker thread. Latest writer wins; the channel
|
||||||
// is unbounded but selection payloads are small (capped at
|
// is unbounded but selection payloads are small (capped at
|
||||||
@@ -428,6 +519,20 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
|||||||
let pf = data.pointer_focus;
|
let pf = data.pointer_focus;
|
||||||
data.dispatch_cursor_shape( pf );
|
data.dispatch_cursor_shape( pf );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// App asked to exit (e.g. the lockscreen authenticated). For a
|
||||||
|
// session-lock surface, unlock first — exiting without unlocking
|
||||||
|
// leaves the compositor's lock in place by design.
|
||||||
|
if data.app.requested_exit()
|
||||||
|
{
|
||||||
|
if let Some( lock ) = data.session_lock.take()
|
||||||
|
{
|
||||||
|
lock.unlock();
|
||||||
|
let _ = conn.roundtrip();
|
||||||
|
}
|
||||||
|
data.exit_requested = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Seed `on_drag_move` with the long-press origin for any drag that
|
// Seed `on_drag_move` with the long-press origin for any drag that
|
||||||
// just started. Must run *after* `update()` so the app's drag state
|
// just started. Must run *after* `update()` so the app's drag state
|
||||||
// has already been set up by the paired long-press message — the
|
// has already been set up by the paired long-press message — the
|
||||||
@@ -495,7 +600,11 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
|||||||
data.cached_overlays = Some( specs );
|
data.cached_overlays = Some( specs );
|
||||||
data.overlays_dirty = false;
|
data.overlays_dirty = false;
|
||||||
}
|
}
|
||||||
draw_frame( &mut data );
|
let first_commit_now = draw_frame( &mut data );
|
||||||
|
if first_commit_now
|
||||||
|
{
|
||||||
|
data.app.on_first_frame_committed();
|
||||||
|
}
|
||||||
|
|
||||||
// Push the freshly-laid-out widget tree to AccessKit. The
|
// Push the freshly-laid-out widget tree to AccessKit. The
|
||||||
// closure only runs when an AT client is actually
|
// closure only runs when an AT client is actually
|
||||||
@@ -531,12 +640,12 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
|||||||
focus_id: 0,
|
focus_id: 0,
|
||||||
is_main: true,
|
is_main: true,
|
||||||
label: None,
|
label: None,
|
||||||
widget_rects: &data.main.widget_rects,
|
widget_rects: &data.main.frame.widget_rects,
|
||||||
extras: &data.main.accessible_extras,
|
extras: &data.main.frame.accessible_extras,
|
||||||
focused_idx: data.main.focused_idx,
|
focused_idx: data.main.focused_idx,
|
||||||
pressed_idx: data.main.gesture.pressed_idx,
|
pressed_idx: data.main.gesture.pressed_idx,
|
||||||
pending_text_values: &data.main.pending_text_values,
|
pending_text_values: &data.main.pending_text_values,
|
||||||
cursor_state: &data.main.cursor_state,
|
cursor_state: &data.main.frame.cursor_state,
|
||||||
width: main_w,
|
width: main_w,
|
||||||
height: main_h,
|
height: main_h,
|
||||||
offset_x: 0.0,
|
offset_x: 0.0,
|
||||||
@@ -550,12 +659,12 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
|||||||
focus_id: *fid,
|
focus_id: *fid,
|
||||||
is_main: false,
|
is_main: false,
|
||||||
label: None,
|
label: None,
|
||||||
widget_rects: &ss.widget_rects,
|
widget_rects: &ss.frame.widget_rects,
|
||||||
extras: &ss.accessible_extras,
|
extras: &ss.frame.accessible_extras,
|
||||||
focused_idx: ss.focused_idx,
|
focused_idx: ss.focused_idx,
|
||||||
pressed_idx: ss.gesture.pressed_idx,
|
pressed_idx: ss.gesture.pressed_idx,
|
||||||
pending_text_values: &ss.pending_text_values,
|
pending_text_values: &ss.pending_text_values,
|
||||||
cursor_state: &ss.cursor_state,
|
cursor_state: &ss.frame.cursor_state,
|
||||||
width: ss.width as f32,
|
width: ss.width as f32,
|
||||||
height: ss.height as f32,
|
height: ss.height as f32,
|
||||||
offset_x: *ox,
|
offset_x: *ox,
|
||||||
@@ -568,6 +677,14 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
|||||||
data.a11y = a11y_taken;
|
data.a11y = a11y_taken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reconcile + reposition input-transparent subsurfaces every
|
||||||
|
// iteration, decoupled from the main redraw cadence: a finger drag
|
||||||
|
// emits motion events faster than frame callbacks clear
|
||||||
|
// `frame_pending`, so gating this on a main draw would pin the
|
||||||
|
// subsurface between frames. A position-only change is just
|
||||||
|
// set_position + a bare parent commit; no-ops when nothing moved.
|
||||||
|
super::subsurface::reconcile_subsurfaces( &mut data );
|
||||||
|
|
||||||
// Drain inbound AT-SPI2 actions (Orca pressing a button,
|
// Drain inbound AT-SPI2 actions (Orca pressing a button,
|
||||||
// switch-control focusing a node, etc.) and translate each
|
// switch-control focusing a node, etc.) and translate each
|
||||||
// into a synthetic press / focus on the matching widget.
|
// into a synthetic press / focus on the matching widget.
|
||||||
@@ -596,8 +713,8 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
|||||||
};
|
};
|
||||||
let widget = match focus_target
|
let widget = match focus_target
|
||||||
{
|
{
|
||||||
SurfaceFocus::Main => data.main.widget_rects.iter().find( |w| w.flat_idx == idx ).cloned(),
|
SurfaceFocus::Main => data.main.frame.widget_rects.iter().find( |w| w.flat_idx == idx ).cloned(),
|
||||||
SurfaceFocus::Overlay( id ) => data.overlays.get( &id ).and_then( |ss| ss.widget_rects.iter().find( |w| w.flat_idx == idx ).cloned() ),
|
SurfaceFocus::Overlay( id ) => data.overlays.get( &id ).and_then( |ss| ss.frame.widget_rects.iter().find( |w| w.flat_idx == idx ).cloned() ),
|
||||||
};
|
};
|
||||||
match req.action
|
match req.action
|
||||||
{
|
{
|
||||||
@@ -671,10 +788,10 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
|||||||
};
|
};
|
||||||
if let Some( ss ) = ss
|
if let Some( ss ) = ss
|
||||||
{
|
{
|
||||||
if let Some( ( _, _, _ ) ) = ss.scroll_rects.last().copied()
|
if let Some( ( _, _, _ ) ) = ss.frame.scroll_rects.last().copied()
|
||||||
{
|
{
|
||||||
let scroll_idx = ss.scroll_rects.last().unwrap().1;
|
let scroll_idx = ss.frame.scroll_rects.last().unwrap().1;
|
||||||
let entry = ss.scroll_offsets.entry( scroll_idx ).or_insert( ( 0.0, 0.0 ) );
|
let entry = ss.frame.scroll_offsets.entry( scroll_idx ).or_insert( ( 0.0, 0.0 ) );
|
||||||
entry.0 = ( entry.0 + dx ).max( 0.0 );
|
entry.0 = ( entry.0 + dx ).max( 0.0 );
|
||||||
entry.1 = ( entry.1 + dy ).max( 0.0 );
|
entry.1 = ( entry.1 + dy ).max( 0.0 );
|
||||||
ss.request_redraw();
|
ss.request_redraw();
|
||||||
@@ -693,12 +810,12 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
|||||||
{
|
{
|
||||||
let target = w.rect;
|
let target = w.rect;
|
||||||
let probe = crate::types::Point { x: target.x + 1.0, y: target.y + 1.0 };
|
let probe = crate::types::Point { x: target.x + 1.0, y: target.y + 1.0 };
|
||||||
let container = ss.scroll_rects.iter().rev()
|
let container = ss.frame.scroll_rects.iter().rev()
|
||||||
.find( |( r, _, _ )| r.contains( probe ) )
|
.find( |( r, _, _ )| r.contains( probe ) )
|
||||||
.copied();
|
.copied();
|
||||||
if let Some( ( r, idx, _ ) ) = container
|
if let Some( ( r, idx, _ ) ) = container
|
||||||
{
|
{
|
||||||
let entry = ss.scroll_offsets.entry( idx ).or_insert( ( 0.0, 0.0 ) );
|
let entry = ss.frame.scroll_offsets.entry( idx ).or_insert( ( 0.0, 0.0 ) );
|
||||||
if target.y < r.y { entry.1 -= r.y - target.y; }
|
if target.y < r.y { entry.1 -= r.y - target.y; }
|
||||||
if target.y + target.height > r.y + r.height
|
if target.y + target.height > r.y + r.height
|
||||||
{
|
{
|
||||||
@@ -724,16 +841,17 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
|||||||
// Walks main + every overlay because the targeted widget can live
|
// Walks main + every overlay because the targeted widget can live
|
||||||
// on any of them (a search field on a launcher overlay, a text
|
// on any of them (a search field on a launcher overlay, a text
|
||||||
// edit on a dialog modal, …).
|
// edit on a dialog modal, …).
|
||||||
if let Some( id ) = data.app.take_focus_request()
|
let focus_id = data.app.take_focus_request().or_else( || data.focus_retry.take() );
|
||||||
|
if let Some( id ) = focus_id
|
||||||
{
|
{
|
||||||
let mut hit: Option<( SurfaceFocus, usize )> = data.main.widget_rects.iter()
|
let mut hit: Option<( SurfaceFocus, usize )> = data.main.frame.widget_rects.iter()
|
||||||
.find( |w| w.id == Some( id ) )
|
.find( |w| w.id == Some( id ) )
|
||||||
.map( |w| ( SurfaceFocus::Main, w.flat_idx ) );
|
.map( |w| ( SurfaceFocus::Main, w.flat_idx ) );
|
||||||
if hit.is_none()
|
if hit.is_none()
|
||||||
{
|
{
|
||||||
for ( ov_id, surf ) in &data.overlays
|
for ( ov_id, surf ) in &data.overlays
|
||||||
{
|
{
|
||||||
if let Some( w ) = surf.widget_rects.iter().find( |w| w.id == Some( id ) )
|
if let Some( w ) = surf.frame.widget_rects.iter().find( |w| w.id == Some( id ) )
|
||||||
{
|
{
|
||||||
hit = Some( ( SurfaceFocus::Overlay( *ov_id ), w.flat_idx ) );
|
hit = Some( ( SurfaceFocus::Overlay( *ov_id ), w.flat_idx ) );
|
||||||
break;
|
break;
|
||||||
@@ -755,6 +873,10 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
data.focus_retry = Some( id );
|
||||||
|
data.view_dirty = true;
|
||||||
|
data.main.request_redraw();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
396
src/event_loop/subsurface.rs
Normal file
396
src/event_loop/subsurface.rs
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
|
//! Input-transparent child surfaces ([`crate::app::SubsurfaceSpec`]).
|
||||||
|
//!
|
||||||
|
//! Each spec maps to one `wl_subsurface` sized to the main surface, with an
|
||||||
|
//! empty input region so all input falls through to the parent — the host
|
||||||
|
//! keeps a single gesture/input model. The content buffer is rasterised only
|
||||||
|
//! when the size or the spec's `content_version` changes; a position change
|
||||||
|
//! emits `wl_subsurface.set_position` plus a bare parent commit, so an animated
|
||||||
|
//! slide costs a compositor recomposite, not a client re-raster.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use smithay_client_toolkit::compositor::CompositorState;
|
||||||
|
use smithay_client_toolkit::shm::Shm;
|
||||||
|
use smithay_client_toolkit::shm::slot::SlotPool;
|
||||||
|
use smithay_client_toolkit::reexports::client::protocol::wl_shm;
|
||||||
|
use smithay_client_toolkit::reexports::client::protocol::wl_subsurface::WlSubsurface;
|
||||||
|
use smithay_client_toolkit::reexports::client::protocol::wl_surface::WlSurface;
|
||||||
|
|
||||||
|
use crate::app::{ App, SubsurfaceParent };
|
||||||
|
use crate::egl_context::{ EglContext, EglSurface };
|
||||||
|
use crate::draw::DrawCtx;
|
||||||
|
use crate::draw::chrome::apply_input_region;
|
||||||
|
use crate::draw::layout_and_draw;
|
||||||
|
use crate::render::Canvas;
|
||||||
|
use crate::types::Rect;
|
||||||
|
use crate::widget::Element;
|
||||||
|
|
||||||
|
use super::frame::pick_shm_format;
|
||||||
|
use super::AppData;
|
||||||
|
|
||||||
|
/// Convert a layout (physical) position to the logical position the
|
||||||
|
/// compositor expects for `wl_subsurface.set_position`.
|
||||||
|
fn to_logical( x: i32, y: i32, scale: u32 ) -> ( i32, i32 )
|
||||||
|
{
|
||||||
|
let s = scale.max( 1 ) as i32;
|
||||||
|
( x / s, y / s )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live state for one subsurface: its Wayland objects, its own SHM pool and
|
||||||
|
/// canvas, and the last (size, version, position) we committed so the per-frame
|
||||||
|
/// pass can tell a re-raster from a cheap reposition.
|
||||||
|
pub( crate ) struct SubsurfaceSlot
|
||||||
|
{
|
||||||
|
subsurface: WlSubsurface,
|
||||||
|
surface: WlSurface,
|
||||||
|
/// SHM pool for the software raster path. `None` on the GLES path.
|
||||||
|
pool: Option<SlotPool>,
|
||||||
|
/// EGL window surface for the GLES raster path (so the `surface-panel`
|
||||||
|
/// backdrop blur, a GLES-only pass, renders). `None` on the software path.
|
||||||
|
egl_surface: Option<EglSurface>,
|
||||||
|
canvas: Option<Canvas>,
|
||||||
|
last_pos: ( i32, i32 ),
|
||||||
|
last_size: ( u32, u32 ),
|
||||||
|
last_version: u64,
|
||||||
|
rastered: bool,
|
||||||
|
parent: SubsurfaceParent,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SubsurfaceSlot
|
||||||
|
{
|
||||||
|
fn destroy( mut self )
|
||||||
|
{
|
||||||
|
// Drop the EGL surface before the wl_surface its wl_egl_window wraps.
|
||||||
|
self.egl_surface = None;
|
||||||
|
self.subsurface.destroy();
|
||||||
|
self.surface.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a subsurface's parent surface to `( wl_surface, phys_w, phys_h,
|
||||||
|
/// scale )`. Returns `None` when an [`SubsurfaceParent::Overlay`] target is
|
||||||
|
/// absent, not yet configured, or zero-sized — the caller skips the spec for
|
||||||
|
/// that frame. The `WlSurface` is cloned (an `Arc`) so the render / reposition
|
||||||
|
/// loop can run without holding a borrow on `data.overlays` / `data.main`.
|
||||||
|
fn resolve_parent<A: App>( data: &AppData<A>, parent: SubsurfaceParent ) -> Option<( WlSurface, u32, u32, u32 )>
|
||||||
|
{
|
||||||
|
let ss = match parent
|
||||||
|
{
|
||||||
|
SubsurfaceParent::Main => &data.main,
|
||||||
|
SubsurfaceParent::Overlay( id ) => data.overlays.get( &id )?,
|
||||||
|
};
|
||||||
|
if !ss.configured { return None; }
|
||||||
|
if ss.width == 0 || ss.height == 0 { return None; }
|
||||||
|
let surface = ss.surface.try_wl_surface()?.clone();
|
||||||
|
let scale = ss.scale_factor.max( 1 ) as u32;
|
||||||
|
Some( ( surface, ss.width * scale, ss.height * scale, scale ) )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a parent surface that needs a commit this frame, deduped by parent
|
||||||
|
/// identity so each is committed once.
|
||||||
|
fn mark_parent_dirty( list: &mut Vec<( SubsurfaceParent, WlSurface )>, parent: SubsurfaceParent, wl: &WlSurface )
|
||||||
|
{
|
||||||
|
if !list.iter().any( |( p, _ )| *p == parent )
|
||||||
|
{
|
||||||
|
list.push( ( parent, wl.clone() ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reconcile the live subsurfaces against [`App::subsurfaces`], render content
|
||||||
|
/// where it changed and reposition where it moved. Cheap when nothing but a
|
||||||
|
/// position changed. Each spec is composited under its own parent surface
|
||||||
|
/// (main or an overlay), so a slide can ride above app windows. Must run after
|
||||||
|
/// the parent surface is configured.
|
||||||
|
pub( crate ) fn reconcile_subsurfaces<A: App>( data: &mut AppData<A> )
|
||||||
|
{
|
||||||
|
if data.subcompositor.is_none() { return; }
|
||||||
|
if !data.main.configured { return; }
|
||||||
|
|
||||||
|
let ( format, swap_rb ) = pick_shm_format( &data.shm );
|
||||||
|
let specs: Vec<crate::app::SubsurfaceSpec<A::Message>> = data.app.subsurfaces();
|
||||||
|
|
||||||
|
// Parents touched this frame; each committed once at the end so the
|
||||||
|
// placement / mapping actually lands.
|
||||||
|
let mut dirty_parents: Vec<( SubsurfaceParent, WlSurface )> = Vec::new();
|
||||||
|
|
||||||
|
// Drop subsurfaces whose id disappeared from the spec list.
|
||||||
|
let present: std::collections::HashSet<crate::app::SubsurfaceId> =
|
||||||
|
specs.iter().map( |s| s.id ).collect();
|
||||||
|
let stale: Vec<crate::app::SubsurfaceId> = data.subsurfaces.keys()
|
||||||
|
.copied()
|
||||||
|
.filter( |id| !present.contains( id ) )
|
||||||
|
.collect();
|
||||||
|
for id in stale
|
||||||
|
{
|
||||||
|
if let Some( slot ) = data.subsurfaces.remove( &id )
|
||||||
|
{
|
||||||
|
let parent = slot.parent;
|
||||||
|
slot.destroy();
|
||||||
|
if let Some( ( wl, _, _, _ ) ) = resolve_parent( data, parent )
|
||||||
|
{
|
||||||
|
mark_parent_dirty( &mut dirty_parents, parent, &wl );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for spec in &specs
|
||||||
|
{
|
||||||
|
let Some( ( parent_wl, pw, ph, scale ) ) = resolve_parent( data, spec.parent ) else { continue };
|
||||||
|
|
||||||
|
// Create on first sight.
|
||||||
|
if !data.subsurfaces.contains_key( &spec.id )
|
||||||
|
{
|
||||||
|
let ( subsurface, surface ) = {
|
||||||
|
let sc = data.subcompositor.as_ref().unwrap();
|
||||||
|
sc.create_subsurface( parent_wl.clone(), &data.qh )
|
||||||
|
};
|
||||||
|
// Independent buffer commits; placement still applies on the
|
||||||
|
// parent commit we issue below.
|
||||||
|
subsurface.set_desync();
|
||||||
|
let ( lx, ly ) = to_logical( spec.x, spec.y, scale );
|
||||||
|
subsurface.set_position( lx, ly );
|
||||||
|
data.subsurfaces.insert( spec.id, SubsurfaceSlot {
|
||||||
|
subsurface,
|
||||||
|
surface,
|
||||||
|
pool: None,
|
||||||
|
egl_surface: None,
|
||||||
|
canvas: None,
|
||||||
|
last_pos: ( spec.x, spec.y ),
|
||||||
|
last_size: ( 0, 0 ),
|
||||||
|
last_version: u64::MAX,
|
||||||
|
rastered: false,
|
||||||
|
parent: spec.parent,
|
||||||
|
} );
|
||||||
|
mark_parent_dirty( &mut dirty_parents, spec.parent, &parent_wl );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tracked on the slot (not the canvas) because the GLES canvas lives
|
||||||
|
// in `data.subsurface_gles_canvas`, shared across all subsurfaces.
|
||||||
|
let size_changed = data.subsurfaces.get( &spec.id )
|
||||||
|
.map( |s| s.last_size != ( pw, ph ) )
|
||||||
|
.unwrap_or( true );
|
||||||
|
let needs_raster = {
|
||||||
|
let slot = data.subsurfaces.get( &spec.id ).unwrap();
|
||||||
|
!slot.rastered || size_changed || slot.last_version != spec.content_version
|
||||||
|
};
|
||||||
|
|
||||||
|
if needs_raster
|
||||||
|
{
|
||||||
|
// GLES only when the spec asks for it (glass content); otherwise
|
||||||
|
// the cheaper-to-create software rasteriser.
|
||||||
|
let egl = if spec.gpu { data.egl_context.as_ref() } else { None };
|
||||||
|
let slot = data.subsurfaces.get_mut( &spec.id ).unwrap();
|
||||||
|
render_slot::<A::Message>(
|
||||||
|
slot, egl, &mut data.subsurface_gles_canvas,
|
||||||
|
&data.shm, &data.compositor_state, &spec.view,
|
||||||
|
pw, ph, scale, format, swap_rb, size_changed,
|
||||||
|
);
|
||||||
|
slot.rastered = true;
|
||||||
|
slot.last_version = spec.content_version;
|
||||||
|
slot.last_size = ( pw, ph );
|
||||||
|
mark_parent_dirty( &mut dirty_parents, spec.parent, &parent_wl );
|
||||||
|
}
|
||||||
|
|
||||||
|
let slot = data.subsurfaces.get_mut( &spec.id ).unwrap();
|
||||||
|
if slot.last_pos != ( spec.x, spec.y )
|
||||||
|
{
|
||||||
|
let ( lx, ly ) = to_logical( spec.x, spec.y, scale );
|
||||||
|
slot.subsurface.set_position( lx, ly );
|
||||||
|
// Desync subsurface state (the position) is applied on the child
|
||||||
|
// surface's own commit, not the parent's; commit it here so the
|
||||||
|
// move actually lands. The parent commit below applies placement.
|
||||||
|
slot.surface.commit();
|
||||||
|
slot.last_pos = ( spec.x, spec.y );
|
||||||
|
mark_parent_dirty( &mut dirty_parents, spec.parent, &parent_wl );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applies pending subsurface placement / mapping without re-attaching the
|
||||||
|
// parent buffer — the cheap per-frame move.
|
||||||
|
for ( _, wl ) in dirty_parents
|
||||||
|
{
|
||||||
|
wl.commit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input-transparent subsurfaces never carry focus / hover / scroll state,
|
||||||
|
/// so they draw with an empty context.
|
||||||
|
fn empty_draw_ctx<Msg: Clone>() -> DrawCtx<Msg>
|
||||||
|
{
|
||||||
|
DrawCtx
|
||||||
|
{
|
||||||
|
focused_idx: None,
|
||||||
|
hovered_idx: None,
|
||||||
|
pressed_idx: None,
|
||||||
|
cursor_state: HashMap::new(),
|
||||||
|
selection_anchor: HashMap::new(),
|
||||||
|
widget_rects: Vec::new(),
|
||||||
|
debug_layout: false,
|
||||||
|
scroll_offsets: HashMap::new(),
|
||||||
|
scroll_rects: Vec::new(),
|
||||||
|
scroll_canvases: HashMap::new(),
|
||||||
|
scroll_navigable_items: HashMap::new(),
|
||||||
|
previous_widget_rects: Vec::new(),
|
||||||
|
accessible_extras: Vec::new(),
|
||||||
|
live_depth: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raster a subsurface's content. Uses the GLES path when an [`EglContext`]
|
||||||
|
/// is available — only there does the `surface-panel` backdrop blur render;
|
||||||
|
/// the software fallback paints the same widgets without the blur.
|
||||||
|
#[ allow( clippy::too_many_arguments ) ]
|
||||||
|
fn render_slot<Msg: Clone>(
|
||||||
|
slot: &mut SubsurfaceSlot,
|
||||||
|
egl_ctx: Option<&Arc<EglContext>>,
|
||||||
|
gles_canvas: &mut Option<Canvas>,
|
||||||
|
shm: &Shm,
|
||||||
|
compositor: &CompositorState,
|
||||||
|
view: &Element<Msg>,
|
||||||
|
pw: u32,
|
||||||
|
ph: u32,
|
||||||
|
scale: u32,
|
||||||
|
shm_format: wl_shm::Format,
|
||||||
|
swap_rb: bool,
|
||||||
|
size_changed: bool,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if let Some( ctx ) = egl_ctx
|
||||||
|
{
|
||||||
|
render_slot_gpu::<Msg>( slot, ctx, gles_canvas, compositor, view, pw, ph, scale, size_changed );
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
render_slot_software::<Msg>( slot, shm, compositor, view, pw, ph, scale, shm_format, swap_rb, size_changed );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ allow( clippy::too_many_arguments ) ]
|
||||||
|
fn render_slot_gpu<Msg: Clone>(
|
||||||
|
slot: &mut SubsurfaceSlot,
|
||||||
|
egl_ctx: &Arc<EglContext>,
|
||||||
|
gles_canvas: &mut Option<Canvas>,
|
||||||
|
compositor: &CompositorState,
|
||||||
|
view: &Element<Msg>,
|
||||||
|
pw: u32,
|
||||||
|
ph: u32,
|
||||||
|
scale: u32,
|
||||||
|
size_changed: bool,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
// (Re)create the EGL window surface on first sight or a size change.
|
||||||
|
if slot.egl_surface.is_none() || size_changed
|
||||||
|
{
|
||||||
|
match egl_ctx.create_surface( &slot.surface, pw as i32, ph as i32 )
|
||||||
|
{
|
||||||
|
Ok( es ) => slot.egl_surface = Some( es ),
|
||||||
|
Err( e ) =>
|
||||||
|
{
|
||||||
|
eprintln!( "ltk: subsurface EGL surface creation failed: {e}" );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let es = slot.egl_surface.as_ref().unwrap();
|
||||||
|
if egl_ctx.make_current( es ).is_err() { return; }
|
||||||
|
|
||||||
|
// The canvas (and its compiled shader programs) is shared across all
|
||||||
|
// subsurface rasters, so a lazily re-created panel doesn't recompile.
|
||||||
|
let canvas = gles_canvas.get_or_insert_with( ||
|
||||||
|
{
|
||||||
|
let mut c = Canvas::new_gles( Arc::clone( egl_ctx.gl() ), egl_ctx.version, pw, ph );
|
||||||
|
c.set_dpi_scale( scale as f32 );
|
||||||
|
if let Some( reg ) = crate::theme::build_font_registry()
|
||||||
|
{
|
||||||
|
c.set_font_registry( Arc::new( reg ) );
|
||||||
|
}
|
||||||
|
c
|
||||||
|
} );
|
||||||
|
if canvas.size() != ( pw, ph )
|
||||||
|
{
|
||||||
|
canvas.resize( pw, ph );
|
||||||
|
canvas.set_dpi_scale( scale as f32 );
|
||||||
|
}
|
||||||
|
canvas.clear_clip();
|
||||||
|
canvas.clear();
|
||||||
|
|
||||||
|
let screen_rect = Rect { x: 0.0, y: 0.0, width: pw as f32, height: ph as f32 };
|
||||||
|
let mut ctx = empty_draw_ctx::<Msg>();
|
||||||
|
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
|
||||||
|
canvas.present();
|
||||||
|
|
||||||
|
let wl = &slot.surface;
|
||||||
|
wl.set_buffer_scale( scale as i32 );
|
||||||
|
// Empty input region: pointer/touch fall through to the parent.
|
||||||
|
apply_input_region( wl, compositor, Some( &[] ), scale );
|
||||||
|
// Implicitly commits the wl_surface with the freshly rendered buffer.
|
||||||
|
let _ = egl_ctx.swap_buffers_with_damage( es, &[ ( 0, 0, pw as i32, ph as i32 ) ] );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ allow( clippy::too_many_arguments ) ]
|
||||||
|
fn render_slot_software<Msg: Clone>(
|
||||||
|
slot: &mut SubsurfaceSlot,
|
||||||
|
shm: &Shm,
|
||||||
|
compositor: &CompositorState,
|
||||||
|
view: &Element<Msg>,
|
||||||
|
pw: u32,
|
||||||
|
ph: u32,
|
||||||
|
scale: u32,
|
||||||
|
shm_format: wl_shm::Format,
|
||||||
|
swap_rb: bool,
|
||||||
|
size_changed: bool,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if slot.pool.is_none() || size_changed
|
||||||
|
{
|
||||||
|
match SlotPool::new( ( pw * ph * 4 ) as usize, shm )
|
||||||
|
{
|
||||||
|
Ok( p ) => slot.pool = Some( p ),
|
||||||
|
Err( _ ) => return,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let pool = slot.pool.as_mut().unwrap();
|
||||||
|
let stride = pw * 4;
|
||||||
|
let ( buffer, canvas_buf ) = match pool.create_buffer(
|
||||||
|
pw as i32, ph as i32, stride as i32, shm_format,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Ok( r ) => r,
|
||||||
|
Err( _ ) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let canvas = slot.canvas.get_or_insert_with( || {
|
||||||
|
let mut c = Canvas::new( pw, ph );
|
||||||
|
c.set_dpi_scale( scale as f32 );
|
||||||
|
if let Some( reg ) = crate::theme::build_font_registry()
|
||||||
|
{
|
||||||
|
c.set_font_registry( Arc::new( reg ) );
|
||||||
|
}
|
||||||
|
c
|
||||||
|
} );
|
||||||
|
if canvas.size() != ( pw, ph )
|
||||||
|
{
|
||||||
|
canvas.resize( pw, ph );
|
||||||
|
canvas.set_dpi_scale( scale as f32 );
|
||||||
|
}
|
||||||
|
canvas.clear_clip();
|
||||||
|
canvas.clear();
|
||||||
|
|
||||||
|
let screen_rect = Rect { x: 0.0, y: 0.0, width: pw as f32, height: ph as f32 };
|
||||||
|
let mut ctx = empty_draw_ctx::<Msg>();
|
||||||
|
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
|
||||||
|
|
||||||
|
canvas.write_to_wayland_buf( canvas_buf, swap_rb );
|
||||||
|
|
||||||
|
let wl = &slot.surface;
|
||||||
|
if buffer.attach_to( wl ).is_err() { return; }
|
||||||
|
wl.set_buffer_scale( scale as i32 );
|
||||||
|
wl.damage_buffer( 0, 0, pw as i32, ph as i32 );
|
||||||
|
// Empty input region: pointer/touch fall through to the parent.
|
||||||
|
apply_input_region( wl, compositor, Some( &[] ), scale );
|
||||||
|
wl.commit();
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ use smithay_client_toolkit::reexports::client::
|
|||||||
protocol::wl_surface::WlSurface,
|
protocol::wl_surface::WlSurface,
|
||||||
QueueHandle,
|
QueueHandle,
|
||||||
};
|
};
|
||||||
|
use smithay_client_toolkit::session_lock::SessionLockSurface;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -70,6 +71,11 @@ pub( crate ) enum SurfaceKind
|
|||||||
/// to an anchor rect specified at creation time and may flip it
|
/// to an anchor rect specified at creation time and may flip it
|
||||||
/// (drop-up vs drop-down) when constrained.
|
/// (drop-up vs drop-down) when constrained.
|
||||||
Popup( Popup ),
|
Popup( Popup ),
|
||||||
|
/// `ext-session-lock-v1` lock surface, created in `SessionLockHandler::locked`.
|
||||||
|
Lock( SessionLockSurface ),
|
||||||
|
/// Session lock requested; waiting for the compositor's `locked` event
|
||||||
|
/// before the lock surface exists.
|
||||||
|
PendingLock,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SurfaceKind
|
impl SurfaceKind
|
||||||
@@ -81,9 +87,10 @@ impl SurfaceKind
|
|||||||
SurfaceKind::Layer( l ) => l.wl_surface(),
|
SurfaceKind::Layer( l ) => l.wl_surface(),
|
||||||
SurfaceKind::Window( w ) => w.wl_surface(),
|
SurfaceKind::Window( w ) => w.wl_surface(),
|
||||||
SurfaceKind::Popup( p ) => p.wl_surface(),
|
SurfaceKind::Popup( p ) => p.wl_surface(),
|
||||||
|
SurfaceKind::Lock( s ) => s.wl_surface(),
|
||||||
// Unreachable: draw_frame is only called when configured == true,
|
// Unreachable: draw_frame is only called when configured == true,
|
||||||
// which only becomes true after on_configure, which requires a real surface.
|
// which only becomes true after on_configure, which requires a real surface.
|
||||||
SurfaceKind::Pending( .. ) => unreachable!( "surface not yet created" ),
|
SurfaceKind::Pending( .. ) | SurfaceKind::PendingLock => unreachable!( "surface not yet created" ),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +105,8 @@ impl SurfaceKind
|
|||||||
SurfaceKind::Layer( l ) => Some( l.wl_surface() ),
|
SurfaceKind::Layer( l ) => Some( l.wl_surface() ),
|
||||||
SurfaceKind::Window( w ) => Some( w.wl_surface() ),
|
SurfaceKind::Window( w ) => Some( w.wl_surface() ),
|
||||||
SurfaceKind::Popup( p ) => Some( p.wl_surface() ),
|
SurfaceKind::Popup( p ) => Some( p.wl_surface() ),
|
||||||
SurfaceKind::Pending( .. ) => None,
|
SurfaceKind::Lock( s ) => Some( s.wl_surface() ),
|
||||||
|
SurfaceKind::Pending( .. ) | SurfaceKind::PendingLock => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +149,61 @@ impl SurfaceKind
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The subset of per-surface state the draw pass owns and threads through
|
||||||
|
/// [`crate::draw::DrawCtx`]: the laid-out widget rects it produces, the scroll /
|
||||||
|
/// text-edit / a11y state carried across frames, and the previous frame's
|
||||||
|
/// interaction indices for damage tracking. Grouped into its own struct so the
|
||||||
|
/// draw helpers can borrow it as one unit, disjoint from `canvas` / `pool`.
|
||||||
|
pub( crate ) struct FrameState<Msg: Clone>
|
||||||
|
{
|
||||||
|
pub widget_rects: Vec<LaidOutWidget<Msg>>,
|
||||||
|
pub cursor_state: HashMap<usize, usize>,
|
||||||
|
/// Selection anchor (byte offset). When equal to the cursor, there is no
|
||||||
|
/// active selection. Mutated jointly with `cursor_state`: plain arrow keys
|
||||||
|
/// collapse the selection by setting anchor = cursor; Shift+arrow extends by
|
||||||
|
/// leaving anchor put while moving cursor; pointer drags set both ends.
|
||||||
|
pub selection_anchor: HashMap<usize, usize>,
|
||||||
|
pub accessible_extras: Vec<crate::a11y::tree::AccessibleExtra>,
|
||||||
|
/// Per-scroll-viewport offset, indexed by `flat_idx` of the `Scroll` widget.
|
||||||
|
/// Tuple is `(x, y)` in physical pixels: x is applied only when the widget's
|
||||||
|
/// axis allows horizontal scroll (and stays at `0` otherwise), y mirrors the
|
||||||
|
/// historic single-f32 behaviour for vertical scrolls.
|
||||||
|
pub scroll_offsets: HashMap<usize, ( f32, f32 )>,
|
||||||
|
pub scroll_rects: Vec<( Rect, usize, crate::widget::scroll::ScrollAxis )>,
|
||||||
|
pub scroll_canvases: HashMap<usize, Canvas>,
|
||||||
|
/// Per-scroll list of `(flat_idx, content_y, height)` entries for every
|
||||||
|
/// interactive item the scroll's child laid out, in document order. Includes
|
||||||
|
/// items currently scrolled off-screen so keyboard arrow handlers can step
|
||||||
|
/// `hovered_idx` item-by-item without regenerating the layout pass. Y is in
|
||||||
|
/// pre-translation, pre-offset content coordinates.
|
||||||
|
pub scroll_navigable_items: HashMap<usize, Vec<( usize, f32, f32 )>>,
|
||||||
|
/// Previous frame interaction state for damage tracking.
|
||||||
|
pub prev_focused: Option<usize>,
|
||||||
|
pub prev_hovered: Option<usize>,
|
||||||
|
pub prev_pressed: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Msg: Clone> FrameState<Msg>
|
||||||
|
{
|
||||||
|
pub( crate ) fn new() -> Self
|
||||||
|
{
|
||||||
|
Self
|
||||||
|
{
|
||||||
|
widget_rects: Vec::new(),
|
||||||
|
cursor_state: HashMap::new(),
|
||||||
|
selection_anchor: HashMap::new(),
|
||||||
|
accessible_extras: Vec::new(),
|
||||||
|
scroll_offsets: HashMap::new(),
|
||||||
|
scroll_rects: Vec::new(),
|
||||||
|
scroll_canvases: HashMap::new(),
|
||||||
|
scroll_navigable_items: HashMap::new(),
|
||||||
|
prev_focused: None,
|
||||||
|
prev_hovered: None,
|
||||||
|
prev_pressed: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Per-surface render + interaction state.
|
/// Per-surface render + interaction state.
|
||||||
///
|
///
|
||||||
/// Each Wayland surface managed by the event loop (main surface and any
|
/// Each Wayland surface managed by the event loop (main surface and any
|
||||||
@@ -169,31 +232,11 @@ pub( crate ) struct SurfaceState<Msg: Clone>
|
|||||||
pub focused_idx: Option<usize>,
|
pub focused_idx: Option<usize>,
|
||||||
pub focused_id: Option<WidgetId>,
|
pub focused_id: Option<WidgetId>,
|
||||||
pub hovered_idx: Option<usize>,
|
pub hovered_idx: Option<usize>,
|
||||||
pub widget_rects: Vec<LaidOutWidget<Msg>>,
|
|
||||||
pub cursor_state: HashMap<usize, usize>,
|
|
||||||
/// Selection anchor (byte offset). When equal to the cursor, there
|
|
||||||
/// is no active selection. Mutated jointly with `cursor_state`:
|
|
||||||
/// plain arrow keys collapse the selection by setting anchor =
|
|
||||||
/// cursor; Shift+arrow extends by leaving anchor put while moving
|
|
||||||
/// cursor; pointer drags set both ends.
|
|
||||||
pub selection_anchor: HashMap<usize, usize>,
|
|
||||||
pub pending_text_values: HashMap<usize, String>,
|
pub pending_text_values: HashMap<usize, String>,
|
||||||
/// Per-scroll-viewport offset, indexed by `flat_idx` of the
|
/// Per-frame draw state: laid-out rects, carried scroll / text-edit / a11y
|
||||||
/// `Scroll` widget. Tuple is `(x, y)` in physical pixels: x is
|
/// state, and the previous frame's interaction indices. Grouped so the draw
|
||||||
/// applied only when the widget's axis allows horizontal scroll
|
/// helpers can borrow it as one unit, disjoint from `canvas` / `pool`.
|
||||||
/// (and stays at `0` otherwise), y mirrors the historic single-f32
|
pub frame: FrameState<Msg>,
|
||||||
/// behaviour for vertical scrolls.
|
|
||||||
pub accessible_extras: Vec<crate::a11y::tree::AccessibleExtra>,
|
|
||||||
pub scroll_offsets: HashMap<usize, ( f32, f32 )>,
|
|
||||||
pub scroll_rects: Vec<( Rect, usize, crate::widget::scroll::ScrollAxis )>,
|
|
||||||
pub scroll_canvases: HashMap<usize, Canvas>,
|
|
||||||
/// Per-scroll list of `(flat_idx, content_y, height)` entries for
|
|
||||||
/// every interactive item the scroll's child laid out, in document
|
|
||||||
/// order. Includes items currently scrolled off-screen so keyboard
|
|
||||||
/// arrow handlers can step `hovered_idx` item-by-item without
|
|
||||||
/// regenerating the layout pass. Y is in pre-translation,
|
|
||||||
/// pre-offset content coordinates.
|
|
||||||
pub scroll_navigable_items: HashMap<usize, Vec<( usize, f32, f32 )>>,
|
|
||||||
/// Built-in Copy / Cut / Paste context menu when shown on this
|
/// Built-in Copy / Cut / Paste context menu when shown on this
|
||||||
/// surface. `None` means no menu is up.
|
/// surface. `None` means no menu is up.
|
||||||
pub context_menu: Option<super::context_menu::ContextMenu>,
|
pub context_menu: Option<super::context_menu::ContextMenu>,
|
||||||
@@ -215,10 +258,6 @@ pub( crate ) struct SurfaceState<Msg: Clone>
|
|||||||
/// `wl_touch.up` does not carry a position) and to keep state
|
/// `wl_touch.up` does not carry a position) and to keep state
|
||||||
/// across `motion` events between dispatchers.
|
/// across `motion` events between dispatchers.
|
||||||
pub touch_slots: HashMap<i32, crate::types::Point>,
|
pub touch_slots: HashMap<i32, crate::types::Point>,
|
||||||
/// Previous frame interaction state for damage tracking
|
|
||||||
pub prev_focused: Option<usize>,
|
|
||||||
pub prev_hovered: Option<usize>,
|
|
||||||
pub prev_pressed: Option<usize>,
|
|
||||||
/// Height of the client-side title bar (0 for layer-shell surfaces).
|
/// Height of the client-side title bar (0 for layer-shell surfaces).
|
||||||
pub titlebar_height: f32,
|
pub titlebar_height: f32,
|
||||||
/// Title text shown in the client-side title bar.
|
/// Title text shown in the client-side title bar.
|
||||||
@@ -267,22 +306,12 @@ impl<Msg: Clone> SurfaceState<Msg>
|
|||||||
focused_idx: None,
|
focused_idx: None,
|
||||||
focused_id: None,
|
focused_id: None,
|
||||||
hovered_idx: None,
|
hovered_idx: None,
|
||||||
widget_rects: Vec::new(),
|
|
||||||
cursor_state: HashMap::new(),
|
|
||||||
selection_anchor: HashMap::new(),
|
|
||||||
pending_text_values: HashMap::new(),
|
pending_text_values: HashMap::new(),
|
||||||
accessible_extras: Vec::new(),
|
frame: FrameState::new(),
|
||||||
scroll_offsets: HashMap::new(),
|
|
||||||
scroll_rects: Vec::new(),
|
|
||||||
scroll_navigable_items: HashMap::new(),
|
|
||||||
primary_touch_id: None,
|
primary_touch_id: None,
|
||||||
touch_slots: HashMap::new(),
|
touch_slots: HashMap::new(),
|
||||||
context_menu: None,
|
context_menu: None,
|
||||||
scroll_canvases: HashMap::new(),
|
|
||||||
gesture: GestureState::new(),
|
gesture: GestureState::new(),
|
||||||
prev_focused: None,
|
|
||||||
prev_hovered: None,
|
|
||||||
prev_pressed: None,
|
|
||||||
titlebar_height,
|
titlebar_height,
|
||||||
titlebar_title,
|
titlebar_title,
|
||||||
titlebar_close_rect: Rect::default(),
|
titlebar_close_rect: Rect::default(),
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ impl<A: App> AppData<A>
|
|||||||
{
|
{
|
||||||
pending.clone()
|
pending.clone()
|
||||||
} else {
|
} else {
|
||||||
crate::tree::find_handlers( &self.surface( focus ).widget_rects, idx )?
|
crate::tree::find_handlers( &self.surface( focus ).frame.widget_rects, idx )?
|
||||||
.current_value()
|
.current_value()
|
||||||
.map( |s| s.to_string() )
|
.map( |s| s.to_string() )
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
@@ -32,17 +32,17 @@ impl<A: App> AppData<A>
|
|||||||
pub( crate ) fn handle_cursor_left( &mut self, focus: SurfaceFocus, extend: bool )
|
pub( crate ) fn handle_cursor_left( &mut self, focus: SurfaceFocus, extend: bool )
|
||||||
{
|
{
|
||||||
let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return };
|
let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return };
|
||||||
let cursor = self.surface( focus ).cursor_state.get( &idx ).copied()
|
let cursor = self.surface( focus ).frame.cursor_state.get( &idx ).copied()
|
||||||
.unwrap_or( value.len() ).min( value.len() );
|
.unwrap_or( value.len() ).min( value.len() );
|
||||||
let anchor = self.surface( focus ).selection_anchor.get( &idx ).copied()
|
let anchor = self.surface( focus ).frame.selection_anchor.get( &idx ).copied()
|
||||||
.unwrap_or( cursor ).min( value.len() );
|
.unwrap_or( cursor ).min( value.len() );
|
||||||
// Without shift: if a selection exists, collapse to its start.
|
// Without shift: if a selection exists, collapse to its start.
|
||||||
if !extend && anchor != cursor
|
if !extend && anchor != cursor
|
||||||
{
|
{
|
||||||
let s = cursor.min( anchor );
|
let s = cursor.min( anchor );
|
||||||
let ss = self.surface_mut( focus );
|
let ss = self.surface_mut( focus );
|
||||||
*ss.cursor_state.entry( idx ).or_insert( 0 ) = s;
|
*ss.frame.cursor_state.entry( idx ).or_insert( 0 ) = s;
|
||||||
*ss.selection_anchor.entry( idx ).or_insert( 0 ) = s;
|
*ss.frame.selection_anchor.entry( idx ).or_insert( 0 ) = s;
|
||||||
ss.request_redraw();
|
ss.request_redraw();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -51,8 +51,8 @@ impl<A: App> AppData<A>
|
|||||||
let step = prev_char.map( |c| c.len_utf8() ).unwrap_or( 1 );
|
let step = prev_char.map( |c| c.len_utf8() ).unwrap_or( 1 );
|
||||||
let new_cursor = cursor.saturating_sub( step );
|
let new_cursor = cursor.saturating_sub( step );
|
||||||
let ss = self.surface_mut( focus );
|
let ss = self.surface_mut( focus );
|
||||||
*ss.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor;
|
*ss.frame.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor;
|
||||||
if !extend { *ss.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; }
|
if !extend { *ss.frame.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; }
|
||||||
ss.request_redraw();
|
ss.request_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,16 +60,16 @@ impl<A: App> AppData<A>
|
|||||||
pub( crate ) fn handle_cursor_right( &mut self, focus: SurfaceFocus, extend: bool )
|
pub( crate ) fn handle_cursor_right( &mut self, focus: SurfaceFocus, extend: bool )
|
||||||
{
|
{
|
||||||
let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return };
|
let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return };
|
||||||
let cursor = self.surface( focus ).cursor_state.get( &idx ).copied()
|
let cursor = self.surface( focus ).frame.cursor_state.get( &idx ).copied()
|
||||||
.unwrap_or( value.len() ).min( value.len() );
|
.unwrap_or( value.len() ).min( value.len() );
|
||||||
let anchor = self.surface( focus ).selection_anchor.get( &idx ).copied()
|
let anchor = self.surface( focus ).frame.selection_anchor.get( &idx ).copied()
|
||||||
.unwrap_or( cursor ).min( value.len() );
|
.unwrap_or( cursor ).min( value.len() );
|
||||||
if !extend && anchor != cursor
|
if !extend && anchor != cursor
|
||||||
{
|
{
|
||||||
let e = cursor.max( anchor );
|
let e = cursor.max( anchor );
|
||||||
let ss = self.surface_mut( focus );
|
let ss = self.surface_mut( focus );
|
||||||
*ss.cursor_state.entry( idx ).or_insert( 0 ) = e;
|
*ss.frame.cursor_state.entry( idx ).or_insert( 0 ) = e;
|
||||||
*ss.selection_anchor.entry( idx ).or_insert( 0 ) = e;
|
*ss.frame.selection_anchor.entry( idx ).or_insert( 0 ) = e;
|
||||||
ss.request_redraw();
|
ss.request_redraw();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -78,8 +78,8 @@ impl<A: App> AppData<A>
|
|||||||
let step = next_char.map( |c| c.len_utf8() ).unwrap_or( 1 );
|
let step = next_char.map( |c| c.len_utf8() ).unwrap_or( 1 );
|
||||||
let new_cursor = ( cursor + step ).min( value.len() );
|
let new_cursor = ( cursor + step ).min( value.len() );
|
||||||
let ss = self.surface_mut( focus );
|
let ss = self.surface_mut( focus );
|
||||||
*ss.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor;
|
*ss.frame.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor;
|
||||||
if !extend { *ss.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; }
|
if !extend { *ss.frame.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; }
|
||||||
ss.request_redraw();
|
ss.request_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ impl<A: App> AppData<A>
|
|||||||
pub( crate ) fn handle_cursor_up( &mut self, focus: SurfaceFocus, extend: bool ) -> bool
|
pub( crate ) fn handle_cursor_up( &mut self, focus: SurfaceFocus, extend: bool ) -> bool
|
||||||
{
|
{
|
||||||
let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return false };
|
let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return false };
|
||||||
let cursor = self.surface( focus ).cursor_state.get( &idx ).copied()
|
let cursor = self.surface( focus ).frame.cursor_state.get( &idx ).copied()
|
||||||
.unwrap_or( value.len() ).min( value.len() );
|
.unwrap_or( value.len() ).min( value.len() );
|
||||||
let ( rect, _, multiline, secure, _align, _font_size ) = match self.text_input_geometry( focus, idx )
|
let ( rect, _, multiline, secure, _align, _font_size ) = match self.text_input_geometry( focus, idx )
|
||||||
{
|
{
|
||||||
@@ -111,8 +111,8 @@ impl<A: App> AppData<A>
|
|||||||
None => return false,
|
None => return false,
|
||||||
};
|
};
|
||||||
let ss = self.surface_mut( focus );
|
let ss = self.surface_mut( focus );
|
||||||
*ss.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor;
|
*ss.frame.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor;
|
||||||
if !extend { *ss.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; }
|
if !extend { *ss.frame.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; }
|
||||||
ss.request_redraw();
|
ss.request_redraw();
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -122,7 +122,7 @@ impl<A: App> AppData<A>
|
|||||||
pub( crate ) fn handle_cursor_down( &mut self, focus: SurfaceFocus, extend: bool ) -> bool
|
pub( crate ) fn handle_cursor_down( &mut self, focus: SurfaceFocus, extend: bool ) -> bool
|
||||||
{
|
{
|
||||||
let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return false };
|
let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return false };
|
||||||
let cursor = self.surface( focus ).cursor_state.get( &idx ).copied()
|
let cursor = self.surface( focus ).frame.cursor_state.get( &idx ).copied()
|
||||||
.unwrap_or( value.len() ).min( value.len() );
|
.unwrap_or( value.len() ).min( value.len() );
|
||||||
let ( rect, _, multiline, secure, _align, _font_size ) = match self.text_input_geometry( focus, idx )
|
let ( rect, _, multiline, secure, _align, _font_size ) = match self.text_input_geometry( focus, idx )
|
||||||
{
|
{
|
||||||
@@ -137,8 +137,8 @@ impl<A: App> AppData<A>
|
|||||||
None => return false,
|
None => return false,
|
||||||
};
|
};
|
||||||
let ss = self.surface_mut( focus );
|
let ss = self.surface_mut( focus );
|
||||||
*ss.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor;
|
*ss.frame.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor;
|
||||||
if !extend { *ss.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; }
|
if !extend { *ss.frame.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; }
|
||||||
ss.request_redraw();
|
ss.request_redraw();
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -151,7 +151,7 @@ impl<A: App> AppData<A>
|
|||||||
pub( crate ) fn handle_cursor_home( &mut self, focus: SurfaceFocus, extend: bool )
|
pub( crate ) fn handle_cursor_home( &mut self, focus: SurfaceFocus, extend: bool )
|
||||||
{
|
{
|
||||||
let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return };
|
let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return };
|
||||||
let cursor = self.surface( focus ).cursor_state.get( &idx ).copied()
|
let cursor = self.surface( focus ).frame.cursor_state.get( &idx ).copied()
|
||||||
.unwrap_or( value.len() ).min( value.len() );
|
.unwrap_or( value.len() ).min( value.len() );
|
||||||
let new_cursor = match self.text_input_geometry( focus, idx )
|
let new_cursor = match self.text_input_geometry( focus, idx )
|
||||||
{
|
{
|
||||||
@@ -163,8 +163,8 @@ impl<A: App> AppData<A>
|
|||||||
_ => value[..cursor].rfind( '\n' ).map( |p| p + 1 ).unwrap_or( 0 ),
|
_ => value[..cursor].rfind( '\n' ).map( |p| p + 1 ).unwrap_or( 0 ),
|
||||||
};
|
};
|
||||||
let ss = self.surface_mut( focus );
|
let ss = self.surface_mut( focus );
|
||||||
*ss.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor;
|
*ss.frame.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor;
|
||||||
if !extend { *ss.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; }
|
if !extend { *ss.frame.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; }
|
||||||
ss.request_redraw();
|
ss.request_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ impl<A: App> AppData<A>
|
|||||||
pub( crate ) fn handle_cursor_end( &mut self, focus: SurfaceFocus, extend: bool )
|
pub( crate ) fn handle_cursor_end( &mut self, focus: SurfaceFocus, extend: bool )
|
||||||
{
|
{
|
||||||
let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return };
|
let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return };
|
||||||
let cursor = self.surface( focus ).cursor_state.get( &idx ).copied()
|
let cursor = self.surface( focus ).frame.cursor_state.get( &idx ).copied()
|
||||||
.unwrap_or( value.len() ).min( value.len() );
|
.unwrap_or( value.len() ).min( value.len() );
|
||||||
let new_cursor = match self.text_input_geometry( focus, idx )
|
let new_cursor = match self.text_input_geometry( focus, idx )
|
||||||
{
|
{
|
||||||
@@ -186,8 +186,8 @@ impl<A: App> AppData<A>
|
|||||||
_ => value[cursor..].find( '\n' ).map( |p| cursor + p ).unwrap_or( value.len() ),
|
_ => value[cursor..].find( '\n' ).map( |p| cursor + p ).unwrap_or( value.len() ),
|
||||||
};
|
};
|
||||||
let ss = self.surface_mut( focus );
|
let ss = self.surface_mut( focus );
|
||||||
*ss.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor;
|
*ss.frame.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor;
|
||||||
if !extend { *ss.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; }
|
if !extend { *ss.frame.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; }
|
||||||
ss.request_redraw();
|
ss.request_redraw();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,21 +13,34 @@ use crate::widget::WidgetHandlers;
|
|||||||
|
|
||||||
impl<A: App> AppData<A>
|
impl<A: App> AppData<A>
|
||||||
{
|
{
|
||||||
pub( crate ) fn activate_text_input( &mut self, qh: &QueueHandle<Self> )
|
pub( crate ) fn activate_text_input( &mut self, qh: &QueueHandle<Self>, secure: bool )
|
||||||
{
|
{
|
||||||
if let ( Some( manager ), None ) = ( &self.text_input_manager, &self.text_input )
|
self.text_input_secure = secure;
|
||||||
|
let ( hint, purpose ) = content_type( secure );
|
||||||
|
match ( &self.text_input_manager, &self.text_input )
|
||||||
|
{
|
||||||
|
( Some( manager ), None ) =>
|
||||||
{
|
{
|
||||||
let seats: Vec<_> = self.seat_state.seats().collect();
|
let seats: Vec<_> = self.seat_state.seats().collect();
|
||||||
if let Some( seat ) = seats.into_iter().next()
|
if let Some( seat ) = seats.into_iter().next()
|
||||||
{
|
{
|
||||||
let ti = manager.get_text_input( &seat, qh, () );
|
let ti = manager.get_text_input( &seat, qh, () );
|
||||||
ti.enable();
|
ti.enable();
|
||||||
ti.set_content_type(
|
ti.set_content_type( hint, purpose );
|
||||||
zwp_text_input_v3::ContentHint::None,
|
|
||||||
zwp_text_input_v3::ContentPurpose::Normal,
|
|
||||||
);
|
|
||||||
ti.commit();
|
ti.commit();
|
||||||
self.text_input = Some( ti );
|
self.text_input = Some( ti );
|
||||||
|
} else {
|
||||||
|
eprintln!( "ltk: activate_text_input: no seat available" );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
( None, _ ) =>
|
||||||
|
eprintln!( "ltk: activate_text_input: no text_input_manager (compositor did not advertise zwp_text_input_manager_v3)" ),
|
||||||
|
// Focus moved between text fields: refresh the content type (e.g.
|
||||||
|
// flag a password field) without re-creating the object.
|
||||||
|
( Some( _ ), Some( ti ) ) =>
|
||||||
|
{
|
||||||
|
ti.set_content_type( hint, purpose );
|
||||||
|
ti.commit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,6 +55,17 @@ impl<A: App> AppData<A>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub( crate ) fn reenable_text_input( &self )
|
||||||
|
{
|
||||||
|
if let Some( ti ) = &self.text_input
|
||||||
|
{
|
||||||
|
let ( hint, purpose ) = content_type( self.text_input_secure );
|
||||||
|
ti.enable();
|
||||||
|
ti.set_content_type( hint, purpose );
|
||||||
|
ti.commit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Snapshot a focused-widget's geometry needed by the pointer
|
/// Snapshot a focused-widget's geometry needed by the pointer
|
||||||
/// hit-testers for text editing. Returns `None` when the widget
|
/// hit-testers for text editing. Returns `None` when the widget
|
||||||
/// isn't a TextEdit or its rect is missing — the helper above
|
/// isn't a TextEdit or its rect is missing — the helper above
|
||||||
@@ -53,7 +77,7 @@ impl<A: App> AppData<A>
|
|||||||
) -> Option<( Rect, String, bool, bool, crate::widget::text::TextAlign, f32 )>
|
) -> Option<( Rect, String, bool, bool, crate::widget::text::TextAlign, f32 )>
|
||||||
{
|
{
|
||||||
let ss = self.surface( focus );
|
let ss = self.surface( focus );
|
||||||
let widget = find_widget( &ss.widget_rects, idx )?;
|
let widget = find_widget( &ss.frame.widget_rects, idx )?;
|
||||||
let ( value_handler, multiline, secure, align, font_size ) = match &widget.handlers
|
let ( value_handler, multiline, secure, align, font_size ) = match &widget.handlers
|
||||||
{
|
{
|
||||||
WidgetHandlers::TextEdit { value, multiline, secure, align, font_size, .. } =>
|
WidgetHandlers::TextEdit { value, multiline, secure, align, font_size, .. } =>
|
||||||
@@ -69,3 +93,19 @@ impl<A: App> AppData<A>
|
|||||||
Some( ( widget.rect, value, multiline, secure, align, font_size ) )
|
Some( ( widget.rect, value, multiline, secure, align, font_size ) )
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Map a field's `secure` flag to the text-input-v3 content type. Secure
|
||||||
|
/// fields are flagged `Password` with `SensitiveData | HiddenText` so the
|
||||||
|
/// IME/OSK skips prediction, autocorrect and storing the value.
|
||||||
|
fn content_type( secure: bool ) -> ( zwp_text_input_v3::ContentHint, zwp_text_input_v3::ContentPurpose )
|
||||||
|
{
|
||||||
|
if secure
|
||||||
|
{
|
||||||
|
(
|
||||||
|
zwp_text_input_v3::ContentHint::SensitiveData | zwp_text_input_v3::ContentHint::HiddenText,
|
||||||
|
zwp_text_input_v3::ContentPurpose::Password,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
( zwp_text_input_v3::ContentHint::None, zwp_text_input_v3::ContentPurpose::Normal )
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ impl<A: App> AppData<A>
|
|||||||
{
|
{
|
||||||
pending.clone()
|
pending.clone()
|
||||||
} else {
|
} else {
|
||||||
find_handlers( &self.surface( focus ).widget_rects, idx )
|
find_handlers( &self.surface( focus ).frame.widget_rects, idx )
|
||||||
.and_then( |h| h.current_value() )
|
.and_then( |h| h.current_value() )
|
||||||
.map( |s| s.to_string() )
|
.map( |s| s.to_string() )
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
@@ -41,26 +41,26 @@ impl<A: App> AppData<A>
|
|||||||
// a second digit would insert into the middle of the
|
// a second digit would insert into the middle of the
|
||||||
// normalised string ("0|2" + "3" → "032" → 32 instead of 23).
|
// normalised string ("0|2" + "3" → "032" → 32 instead of 23).
|
||||||
let select_on_focus = matches!(
|
let select_on_focus = matches!(
|
||||||
find_handlers( &self.surface( focus ).widget_rects, idx ),
|
find_handlers( &self.surface( focus ).frame.widget_rects, idx ),
|
||||||
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ),
|
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ),
|
||||||
);
|
);
|
||||||
|
|
||||||
let new_value;
|
let new_value;
|
||||||
{
|
{
|
||||||
let ss = self.surface_mut( focus );
|
let ss = self.surface_mut( focus );
|
||||||
let cursor = ss.cursor_state.entry( idx ).or_insert( current_value.len() );
|
let cursor = ss.frame.cursor_state.entry( idx ).or_insert( current_value.len() );
|
||||||
let safe_cursor = (*cursor).min( current_value.len() );
|
let safe_cursor = (*cursor).min( current_value.len() );
|
||||||
let mut v = current_value.clone();
|
let mut v = current_value.clone();
|
||||||
v.insert_str( safe_cursor, text );
|
v.insert_str( safe_cursor, text );
|
||||||
let next_cursor = if select_on_focus { usize::MAX } else { safe_cursor + text.len() };
|
let next_cursor = if select_on_focus { usize::MAX } else { safe_cursor + text.len() };
|
||||||
*ss.cursor_state.entry( idx ).or_insert( 0 ) = next_cursor;
|
*ss.frame.cursor_state.entry( idx ).or_insert( 0 ) = next_cursor;
|
||||||
*ss.selection_anchor.entry( idx ).or_insert( 0 ) = next_cursor;
|
*ss.frame.selection_anchor.entry( idx ).or_insert( 0 ) = next_cursor;
|
||||||
ss.pending_text_values.insert( idx, v.clone() );
|
ss.pending_text_values.insert( idx, v.clone() );
|
||||||
ss.request_redraw();
|
ss.request_redraw();
|
||||||
new_value = v;
|
new_value = v;
|
||||||
}
|
}
|
||||||
|
|
||||||
let msg = find_handlers( &self.surface( focus ).widget_rects, idx )
|
let msg = find_handlers( &self.surface( focus ).frame.widget_rects, idx )
|
||||||
.and_then( |h| h.text_change_msg( &new_value ) );
|
.and_then( |h| h.text_change_msg( &new_value ) );
|
||||||
if let Some( m ) = msg
|
if let Some( m ) = msg
|
||||||
{
|
{
|
||||||
@@ -78,7 +78,7 @@ impl<A: App> AppData<A>
|
|||||||
|
|
||||||
if let Some( new_value ) = self.delete_selection( focus )
|
if let Some( new_value ) = self.delete_selection( focus )
|
||||||
{
|
{
|
||||||
let msg = find_handlers( &self.surface( focus ).widget_rects, idx )
|
let msg = find_handlers( &self.surface( focus ).frame.widget_rects, idx )
|
||||||
.and_then( |h| h.text_change_msg( &new_value ) );
|
.and_then( |h| h.text_change_msg( &new_value ) );
|
||||||
if let Some( m ) = msg { self.pending_msgs.push( m ); }
|
if let Some( m ) = msg { self.pending_msgs.push( m ); }
|
||||||
return;
|
return;
|
||||||
@@ -88,13 +88,13 @@ impl<A: App> AppData<A>
|
|||||||
{
|
{
|
||||||
pending.clone()
|
pending.clone()
|
||||||
} else {
|
} else {
|
||||||
find_handlers( &self.surface( focus ).widget_rects, idx )
|
find_handlers( &self.surface( focus ).frame.widget_rects, idx )
|
||||||
.and_then( |h| h.current_value() )
|
.and_then( |h| h.current_value() )
|
||||||
.map( |s| s.to_string() )
|
.map( |s| s.to_string() )
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let cursor_val = self.surface( focus ).cursor_state.get( &idx ).copied()
|
let cursor_val = self.surface( focus ).frame.cursor_state.get( &idx ).copied()
|
||||||
.unwrap_or( current_value.len() );
|
.unwrap_or( current_value.len() );
|
||||||
let safe_cursor_pre = cursor_val.min( current_value.len() );
|
let safe_cursor_pre = cursor_val.min( current_value.len() );
|
||||||
if safe_cursor_pre >= current_value.len() { return; }
|
if safe_cursor_pre >= current_value.len() { return; }
|
||||||
@@ -108,7 +108,7 @@ impl<A: App> AppData<A>
|
|||||||
// post-update displayed value via the `usize::MAX` sentinel —
|
// post-update displayed value via the `usize::MAX` sentinel —
|
||||||
// see `handle_text_insert` for the reasoning.
|
// see `handle_text_insert` for the reasoning.
|
||||||
let select_on_focus = matches!(
|
let select_on_focus = matches!(
|
||||||
find_handlers( &self.surface( focus ).widget_rects, idx ),
|
find_handlers( &self.surface( focus ).frame.widget_rects, idx ),
|
||||||
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ),
|
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ),
|
||||||
);
|
);
|
||||||
let next_cursor = if select_on_focus { usize::MAX } else { safe_cursor_pre };
|
let next_cursor = if select_on_focus { usize::MAX } else { safe_cursor_pre };
|
||||||
@@ -116,13 +116,13 @@ impl<A: App> AppData<A>
|
|||||||
let ss = self.surface_mut( focus );
|
let ss = self.surface_mut( focus );
|
||||||
// Cursor stays put — the char to its right is gone, so the
|
// Cursor stays put — the char to its right is gone, so the
|
||||||
// remaining tail shifts left under it.
|
// remaining tail shifts left under it.
|
||||||
*ss.cursor_state.entry( idx ).or_insert( 0 ) = next_cursor;
|
*ss.frame.cursor_state.entry( idx ).or_insert( 0 ) = next_cursor;
|
||||||
*ss.selection_anchor.entry( idx ).or_insert( 0 ) = next_cursor;
|
*ss.frame.selection_anchor.entry( idx ).or_insert( 0 ) = next_cursor;
|
||||||
ss.pending_text_values.insert( idx, new_value.clone() );
|
ss.pending_text_values.insert( idx, new_value.clone() );
|
||||||
ss.request_redraw();
|
ss.request_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
let msg = find_handlers( &self.surface( focus ).widget_rects, idx )
|
let msg = find_handlers( &self.surface( focus ).frame.widget_rects, idx )
|
||||||
.and_then( |h| h.text_change_msg( &new_value ) );
|
.and_then( |h| h.text_change_msg( &new_value ) );
|
||||||
if let Some( m ) = msg
|
if let Some( m ) = msg
|
||||||
{
|
{
|
||||||
@@ -148,13 +148,13 @@ impl<A: App> AppData<A>
|
|||||||
{
|
{
|
||||||
pending.clone()
|
pending.clone()
|
||||||
} else {
|
} else {
|
||||||
find_handlers( &self.surface( focus ).widget_rects, idx )
|
find_handlers( &self.surface( focus ).frame.widget_rects, idx )
|
||||||
.and_then( |h| h.current_value() )
|
.and_then( |h| h.current_value() )
|
||||||
.map( |s| s.to_string() )
|
.map( |s| s.to_string() )
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let cursor_val = self.surface( focus ).cursor_state.get( &idx ).copied()
|
let cursor_val = self.surface( focus ).frame.cursor_state.get( &idx ).copied()
|
||||||
.unwrap_or( current_value.len() );
|
.unwrap_or( current_value.len() );
|
||||||
let safe_cursor = cursor_val.min( current_value.len() );
|
let safe_cursor = cursor_val.min( current_value.len() );
|
||||||
|
|
||||||
@@ -190,20 +190,20 @@ impl<A: App> AppData<A>
|
|||||||
new_value.replace_range( start_byte..end_byte, "" );
|
new_value.replace_range( start_byte..end_byte, "" );
|
||||||
|
|
||||||
let select_on_focus = matches!(
|
let select_on_focus = matches!(
|
||||||
find_handlers( &self.surface( focus ).widget_rects, idx ),
|
find_handlers( &self.surface( focus ).frame.widget_rects, idx ),
|
||||||
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ),
|
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ),
|
||||||
);
|
);
|
||||||
let next_cursor = if select_on_focus { usize::MAX } else { start_byte };
|
let next_cursor = if select_on_focus { usize::MAX } else { start_byte };
|
||||||
|
|
||||||
{
|
{
|
||||||
let ss = self.surface_mut( focus );
|
let ss = self.surface_mut( focus );
|
||||||
*ss.cursor_state.entry( idx ).or_insert( 0 ) = next_cursor;
|
*ss.frame.cursor_state.entry( idx ).or_insert( 0 ) = next_cursor;
|
||||||
*ss.selection_anchor.entry( idx ).or_insert( 0 ) = next_cursor;
|
*ss.frame.selection_anchor.entry( idx ).or_insert( 0 ) = next_cursor;
|
||||||
ss.pending_text_values.insert( idx, new_value.clone() );
|
ss.pending_text_values.insert( idx, new_value.clone() );
|
||||||
ss.request_redraw();
|
ss.request_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
let msg = find_handlers( &self.surface( focus ).widget_rects, idx )
|
let msg = find_handlers( &self.surface( focus ).frame.widget_rects, idx )
|
||||||
.and_then( |h| h.text_change_msg( &new_value ) );
|
.and_then( |h| h.text_change_msg( &new_value ) );
|
||||||
if let Some( m ) = msg
|
if let Some( m ) = msg
|
||||||
{
|
{
|
||||||
@@ -220,7 +220,7 @@ impl<A: App> AppData<A>
|
|||||||
// here so the app sees a single text update.
|
// here so the app sees a single text update.
|
||||||
if let Some( new_value ) = self.delete_selection( focus )
|
if let Some( new_value ) = self.delete_selection( focus )
|
||||||
{
|
{
|
||||||
let msg = find_handlers( &self.surface( focus ).widget_rects, idx )
|
let msg = find_handlers( &self.surface( focus ).frame.widget_rects, idx )
|
||||||
.and_then( |h| h.text_change_msg( &new_value ) );
|
.and_then( |h| h.text_change_msg( &new_value ) );
|
||||||
if let Some( m ) = msg { self.pending_msgs.push( m ); }
|
if let Some( m ) = msg { self.pending_msgs.push( m ); }
|
||||||
return;
|
return;
|
||||||
@@ -230,7 +230,7 @@ impl<A: App> AppData<A>
|
|||||||
{
|
{
|
||||||
pending.clone()
|
pending.clone()
|
||||||
} else {
|
} else {
|
||||||
find_handlers( &self.surface( focus ).widget_rects, idx )
|
find_handlers( &self.surface( focus ).frame.widget_rects, idx )
|
||||||
.and_then( |h| h.current_value() )
|
.and_then( |h| h.current_value() )
|
||||||
.map( |s| s.to_string() )
|
.map( |s| s.to_string() )
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
@@ -238,7 +238,7 @@ impl<A: App> AppData<A>
|
|||||||
|
|
||||||
// Scoped block to release the immutable borrow of `self` (via
|
// Scoped block to release the immutable borrow of `self` (via
|
||||||
// `self.surface( focus )`) before taking a mutable one below.
|
// `self.surface( focus )`) before taking a mutable one below.
|
||||||
let cursor_val = self.surface( focus ).cursor_state.get( &idx ).copied()
|
let cursor_val = self.surface( focus ).frame.cursor_state.get( &idx ).copied()
|
||||||
.unwrap_or( current_value.len() );
|
.unwrap_or( current_value.len() );
|
||||||
if cursor_val == 0 { return; }
|
if cursor_val == 0 { return; }
|
||||||
let safe_cursor = cursor_val.min( current_value.len() );
|
let safe_cursor = cursor_val.min( current_value.len() );
|
||||||
@@ -253,19 +253,19 @@ impl<A: App> AppData<A>
|
|||||||
// post-update displayed value via the `usize::MAX` sentinel —
|
// post-update displayed value via the `usize::MAX` sentinel —
|
||||||
// see `handle_text_insert` for the reasoning.
|
// see `handle_text_insert` for the reasoning.
|
||||||
let select_on_focus = matches!(
|
let select_on_focus = matches!(
|
||||||
find_handlers( &self.surface( focus ).widget_rects, idx ),
|
find_handlers( &self.surface( focus ).frame.widget_rects, idx ),
|
||||||
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ),
|
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ),
|
||||||
);
|
);
|
||||||
let next_cursor = if select_on_focus { usize::MAX } else { new_cursor };
|
let next_cursor = if select_on_focus { usize::MAX } else { new_cursor };
|
||||||
{
|
{
|
||||||
let ss = self.surface_mut( focus );
|
let ss = self.surface_mut( focus );
|
||||||
*ss.cursor_state.entry( idx ).or_insert( 0 ) = next_cursor;
|
*ss.frame.cursor_state.entry( idx ).or_insert( 0 ) = next_cursor;
|
||||||
*ss.selection_anchor.entry( idx ).or_insert( 0 ) = next_cursor;
|
*ss.frame.selection_anchor.entry( idx ).or_insert( 0 ) = next_cursor;
|
||||||
ss.pending_text_values.insert( idx, new_value.clone() );
|
ss.pending_text_values.insert( idx, new_value.clone() );
|
||||||
ss.request_redraw();
|
ss.request_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
let msg = find_handlers( &self.surface( focus ).widget_rects, idx )
|
let msg = find_handlers( &self.surface( focus ).frame.widget_rects, idx )
|
||||||
.and_then( |h| h.text_change_msg( &new_value ) );
|
.and_then( |h| h.text_change_msg( &new_value ) );
|
||||||
if let Some( m ) = msg
|
if let Some( m ) = msg
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ impl<A: App> AppData<A>
|
|||||||
// already the natural selection unit, so a double-click does
|
// already the natural selection unit, so a double-click does
|
||||||
// not need to add a word-bound selection on top.
|
// not need to add a word-bound selection on top.
|
||||||
let select_on_focus = matches!(
|
let select_on_focus = matches!(
|
||||||
find_handlers( &self.surface( focus ).widget_rects, idx ),
|
find_handlers( &self.surface( focus ).frame.widget_rects, idx ),
|
||||||
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ),
|
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ),
|
||||||
);
|
);
|
||||||
if select_on_focus { return; }
|
if select_on_focus { return; }
|
||||||
@@ -97,7 +97,7 @@ impl<A: App> AppData<A>
|
|||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
if value.is_empty() { return; }
|
if value.is_empty() { return; }
|
||||||
let cursor_pos = self.surface( focus ).cursor_state.get( &idx ).copied().unwrap_or( 0 );
|
let cursor_pos = self.surface( focus ).frame.cursor_state.get( &idx ).copied().unwrap_or( 0 );
|
||||||
let click_byte = {
|
let click_byte = {
|
||||||
let ss = self.surface( focus );
|
let ss = self.surface( focus );
|
||||||
let canvas = match ss.canvas.as_ref() { Some( c ) => c, None => return };
|
let canvas = match ss.canvas.as_ref() { Some( c ) => c, None => return };
|
||||||
@@ -107,8 +107,8 @@ impl<A: App> AppData<A>
|
|||||||
};
|
};
|
||||||
let ( start, end ) = word_bounds_at( &value, click_byte );
|
let ( start, end ) = word_bounds_at( &value, click_byte );
|
||||||
let ss = self.surface_mut( focus );
|
let ss = self.surface_mut( focus );
|
||||||
ss.selection_anchor.insert( idx, start );
|
ss.frame.selection_anchor.insert( idx, start );
|
||||||
ss.cursor_state.insert( idx, end );
|
ss.frame.cursor_state.insert( idx, end );
|
||||||
ss.request_redraw();
|
ss.request_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ impl<A: App> AppData<A>
|
|||||||
// this guard the click-to-position below would collapse the
|
// this guard the click-to-position below would collapse the
|
||||||
// selection right after `set_focus` produced it.
|
// selection right after `set_focus` produced it.
|
||||||
let select_on_focus = matches!(
|
let select_on_focus = matches!(
|
||||||
find_handlers( &self.surface( focus ).widget_rects, idx ),
|
find_handlers( &self.surface( focus ).frame.widget_rects, idx ),
|
||||||
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ),
|
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ),
|
||||||
);
|
);
|
||||||
if select_on_focus { return; }
|
if select_on_focus { return; }
|
||||||
@@ -133,7 +133,7 @@ impl<A: App> AppData<A>
|
|||||||
Some( v ) => v,
|
Some( v ) => v,
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
let cursor_pos = self.surface( focus ).cursor_state.get( &idx ).copied().unwrap_or( 0 );
|
let cursor_pos = self.surface( focus ).frame.cursor_state.get( &idx ).copied().unwrap_or( 0 );
|
||||||
let byte_offset =
|
let byte_offset =
|
||||||
{
|
{
|
||||||
let ss = self.surface( focus );
|
let ss = self.surface( focus );
|
||||||
@@ -143,8 +143,8 @@ impl<A: App> AppData<A>
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
let ss = self.surface_mut( focus );
|
let ss = self.surface_mut( focus );
|
||||||
ss.cursor_state.insert( idx, byte_offset );
|
ss.frame.cursor_state.insert( idx, byte_offset );
|
||||||
ss.selection_anchor.insert( idx, byte_offset );
|
ss.frame.selection_anchor.insert( idx, byte_offset );
|
||||||
ss.request_redraw();
|
ss.request_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@ impl<A: App> AppData<A>
|
|||||||
// short-form field stays whole-selected even if the user
|
// short-form field stays whole-selected even if the user
|
||||||
// drags the pointer over the digits.
|
// drags the pointer over the digits.
|
||||||
let select_on_focus = matches!(
|
let select_on_focus = matches!(
|
||||||
find_handlers( &self.surface( focus ).widget_rects, idx ),
|
find_handlers( &self.surface( focus ).frame.widget_rects, idx ),
|
||||||
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ),
|
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ),
|
||||||
);
|
);
|
||||||
if select_on_focus { return; }
|
if select_on_focus { return; }
|
||||||
@@ -167,7 +167,7 @@ impl<A: App> AppData<A>
|
|||||||
Some( v ) => v,
|
Some( v ) => v,
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
let cursor_pos = self.surface( focus ).cursor_state.get( &idx ).copied().unwrap_or( 0 );
|
let cursor_pos = self.surface( focus ).frame.cursor_state.get( &idx ).copied().unwrap_or( 0 );
|
||||||
let byte_offset =
|
let byte_offset =
|
||||||
{
|
{
|
||||||
let ss = self.surface( focus );
|
let ss = self.surface( focus );
|
||||||
@@ -177,7 +177,7 @@ impl<A: App> AppData<A>
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
let ss = self.surface_mut( focus );
|
let ss = self.surface_mut( focus );
|
||||||
ss.cursor_state.insert( idx, byte_offset );
|
ss.frame.cursor_state.insert( idx, byte_offset );
|
||||||
ss.request_redraw();
|
ss.request_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,8 +187,8 @@ impl<A: App> AppData<A>
|
|||||||
pub( crate ) fn collapse_selection_if_any( &mut self, focus: SurfaceFocus ) -> bool
|
pub( crate ) fn collapse_selection_if_any( &mut self, focus: SurfaceFocus ) -> bool
|
||||||
{
|
{
|
||||||
let idx = match self.surface( focus ).focused_idx { Some( i ) => i, None => return false };
|
let idx = match self.surface( focus ).focused_idx { Some( i ) => i, None => return false };
|
||||||
let cursor = self.surface( focus ).cursor_state.get( &idx ).copied();
|
let cursor = self.surface( focus ).frame.cursor_state.get( &idx ).copied();
|
||||||
let anchor = self.surface( focus ).selection_anchor.get( &idx ).copied();
|
let anchor = self.surface( focus ).frame.selection_anchor.get( &idx ).copied();
|
||||||
let ( c, a ) = match ( cursor, anchor )
|
let ( c, a ) = match ( cursor, anchor )
|
||||||
{
|
{
|
||||||
( Some( c ), Some( a ) ) if c != a => ( c, a ),
|
( Some( c ), Some( a ) ) if c != a => ( c, a ),
|
||||||
@@ -196,7 +196,7 @@ impl<A: App> AppData<A>
|
|||||||
};
|
};
|
||||||
let _ = a;
|
let _ = a;
|
||||||
let ss = self.surface_mut( focus );
|
let ss = self.surface_mut( focus );
|
||||||
ss.selection_anchor.insert( idx, c );
|
ss.frame.selection_anchor.insert( idx, c );
|
||||||
ss.request_redraw();
|
ss.request_redraw();
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -207,8 +207,8 @@ impl<A: App> AppData<A>
|
|||||||
{
|
{
|
||||||
let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return };
|
let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return };
|
||||||
let ss = self.surface_mut( focus );
|
let ss = self.surface_mut( focus );
|
||||||
*ss.cursor_state.entry( idx ).or_insert( 0 ) = value.len();
|
*ss.frame.cursor_state.entry( idx ).or_insert( 0 ) = value.len();
|
||||||
*ss.selection_anchor.entry( idx ).or_insert( 0 ) = 0;
|
*ss.frame.selection_anchor.entry( idx ).or_insert( 0 ) = 0;
|
||||||
ss.request_redraw();
|
ss.request_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,9 +218,9 @@ impl<A: App> AppData<A>
|
|||||||
pub( crate ) fn focused_selection_text( &self, focus: SurfaceFocus ) -> Option<String>
|
pub( crate ) fn focused_selection_text( &self, focus: SurfaceFocus ) -> Option<String>
|
||||||
{
|
{
|
||||||
let ( idx, value ) = self.focused_text_value( focus )?;
|
let ( idx, value ) = self.focused_text_value( focus )?;
|
||||||
let cursor = self.surface( focus ).cursor_state.get( &idx ).copied()
|
let cursor = self.surface( focus ).frame.cursor_state.get( &idx ).copied()
|
||||||
.unwrap_or( value.len() ).min( value.len() );
|
.unwrap_or( value.len() ).min( value.len() );
|
||||||
let anchor = self.surface( focus ).selection_anchor.get( &idx ).copied()
|
let anchor = self.surface( focus ).frame.selection_anchor.get( &idx ).copied()
|
||||||
.unwrap_or( cursor ).min( value.len() );
|
.unwrap_or( cursor ).min( value.len() );
|
||||||
if anchor == cursor { return None; }
|
if anchor == cursor { return None; }
|
||||||
let s = cursor.min( anchor );
|
let s = cursor.min( anchor );
|
||||||
@@ -236,9 +236,9 @@ impl<A: App> AppData<A>
|
|||||||
pub( crate ) fn delete_selection( &mut self, focus: SurfaceFocus ) -> Option<String>
|
pub( crate ) fn delete_selection( &mut self, focus: SurfaceFocus ) -> Option<String>
|
||||||
{
|
{
|
||||||
let ( idx, value ) = self.focused_text_value( focus )?;
|
let ( idx, value ) = self.focused_text_value( focus )?;
|
||||||
let cursor = self.surface( focus ).cursor_state.get( &idx ).copied()
|
let cursor = self.surface( focus ).frame.cursor_state.get( &idx ).copied()
|
||||||
.unwrap_or( value.len() ).min( value.len() );
|
.unwrap_or( value.len() ).min( value.len() );
|
||||||
let anchor = self.surface( focus ).selection_anchor.get( &idx ).copied()
|
let anchor = self.surface( focus ).frame.selection_anchor.get( &idx ).copied()
|
||||||
.unwrap_or( cursor ).min( value.len() );
|
.unwrap_or( cursor ).min( value.len() );
|
||||||
if anchor == cursor { return None; }
|
if anchor == cursor { return None; }
|
||||||
let s = cursor.min( anchor );
|
let s = cursor.min( anchor );
|
||||||
@@ -249,13 +249,13 @@ impl<A: App> AppData<A>
|
|||||||
// post-update displayed value via the `usize::MAX` sentinel —
|
// post-update displayed value via the `usize::MAX` sentinel —
|
||||||
// see `handle_text_insert` for the reasoning.
|
// see `handle_text_insert` for the reasoning.
|
||||||
let select_on_focus = matches!(
|
let select_on_focus = matches!(
|
||||||
find_handlers( &self.surface( focus ).widget_rects, idx ),
|
find_handlers( &self.surface( focus ).frame.widget_rects, idx ),
|
||||||
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ),
|
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ),
|
||||||
);
|
);
|
||||||
let next_cursor = if select_on_focus { usize::MAX } else { s };
|
let next_cursor = if select_on_focus { usize::MAX } else { s };
|
||||||
let ss = self.surface_mut( focus );
|
let ss = self.surface_mut( focus );
|
||||||
*ss.cursor_state.entry( idx ).or_insert( 0 ) = next_cursor;
|
*ss.frame.cursor_state.entry( idx ).or_insert( 0 ) = next_cursor;
|
||||||
*ss.selection_anchor.entry( idx ).or_insert( 0 ) = next_cursor;
|
*ss.frame.selection_anchor.entry( idx ).or_insert( 0 ) = next_cursor;
|
||||||
ss.pending_text_values.insert( idx, new_value.clone() );
|
ss.pending_text_values.insert( idx, new_value.clone() );
|
||||||
ss.request_redraw();
|
ss.request_redraw();
|
||||||
Some( new_value )
|
Some( new_value )
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
use super::app_data::AppData;
|
use super::app_data::AppData;
|
||||||
use super::surface::SurfaceFocus;
|
use super::surface::SurfaceFocus;
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::types::Rect;
|
use crate::types::{ Length, Rect };
|
||||||
|
|
||||||
pub const TOOLTIP_DELAY: std::time::Duration = std::time::Duration::from_millis( 600 );
|
pub const TOOLTIP_DELAY: std::time::Duration = std::time::Duration::from_millis( 600 );
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ impl<A: App> AppData<A>
|
|||||||
pub( crate ) fn arm_tooltip( &mut self, focus: SurfaceFocus, flat_idx: usize )
|
pub( crate ) fn arm_tooltip( &mut self, focus: SurfaceFocus, flat_idx: usize )
|
||||||
{
|
{
|
||||||
let Some( ss ) = self.try_surface( focus ) else { return };
|
let Some( ss ) = self.try_surface( focus ) else { return };
|
||||||
let Some( w ) = crate::tree::find_widget( &ss.widget_rects, flat_idx ) else { return };
|
let Some( w ) = crate::tree::find_widget( &ss.frame.widget_rects, flat_idx ) else { return };
|
||||||
let Some( text ) = w.tooltip.clone() else
|
let Some( text ) = w.tooltip.clone() else
|
||||||
{
|
{
|
||||||
self.tooltip_pending = None;
|
self.tooltip_pending = None;
|
||||||
@@ -132,7 +132,7 @@ impl<A: App> AppData<A>
|
|||||||
id,
|
id,
|
||||||
layer: crate::app::Layer::Overlay,
|
layer: crate::app::Layer::Overlay,
|
||||||
anchor: crate::app::Anchor::ALL,
|
anchor: crate::app::Anchor::ALL,
|
||||||
size: ( 0, 0 ),
|
size: ( Length::px( 0.0 ), Length::px( 0.0 ) ),
|
||||||
exclusive_zone: -1,
|
exclusive_zone: -1,
|
||||||
keyboard_exclusive: false,
|
keyboard_exclusive: false,
|
||||||
input_region: Some( Vec::new() ),
|
input_region: Some( Vec::new() ),
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
// SPDX-License-Identifier: LGPL-2.1-only
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
//! `glScissor`-based clipping + whole-canvas fill / clear for
|
//! Clipping + whole-canvas fill / clear for [`GlesCanvas`]. Rect clips
|
||||||
//! [`GlesCanvas`]. When [`GlesCanvas::set_clip_rects`] receives
|
//! ([`GlesCanvas::set_clip_rects`]) use `glScissor`: when given multiple rects
|
||||||
//! multiple rects the bounding-box union is used as the scissor —
|
//! the bounding-box union is the scissor — coarse, but the partial-redraw path
|
||||||
//! coarse, but the partial-redraw path normally clusters 1–3 rects
|
//! normally clusters 1–3 rects so the union is barely larger than the sum
|
||||||
//! so the union is barely larger than the sum. Disjoint regions
|
//! (disjoint regions are not clipped tightly). Arbitrary path clips
|
||||||
//! would want a stencil-buffer path; not implemented today.
|
//! ([`GlesCanvas::set_clip_path`]) are anti-aliased: the clipped draws are
|
||||||
|
//! captured into an offscreen layer FBO, then composited back onto the canvas
|
||||||
|
//! multiplied by a CPU-rasterised (tiny-skia) anti-aliased coverage mask, so the
|
||||||
|
//! clipped edge is smooth rather than the hard edge a 1-bit stencil would give.
|
||||||
|
|
||||||
use glow::HasContext;
|
use glow::HasContext;
|
||||||
|
|
||||||
@@ -16,8 +19,16 @@ use super::GlesCanvas;
|
|||||||
|
|
||||||
impl GlesCanvas
|
impl GlesCanvas
|
||||||
{
|
{
|
||||||
|
/// Clip subsequent draws to `rects` via `glScissor`. The scissor is the
|
||||||
|
/// bounding-box union of all rects clamped to the canvas — a coarse clip,
|
||||||
|
/// unlike the software backend's exact per-rect mask, so pixels between
|
||||||
|
/// disjoint rects are not culled. An empty slice clears the clip; an empty
|
||||||
|
/// union installs a zero-area scissor so subsequent draws become no-ops.
|
||||||
|
/// Replaces any active path clip, flushing its layer first.
|
||||||
pub fn set_clip_rects( &mut self, rects: &[Rect] )
|
pub fn set_clip_rects( &mut self, rects: &[Rect] )
|
||||||
{
|
{
|
||||||
|
// A rect clip replaces any active path clip: flush its layer first.
|
||||||
|
self.flush_clip_layer();
|
||||||
// Scissor is global GL state; rebind our FBO first so the scissor
|
// Scissor is global GL state; rebind our FBO first so the scissor
|
||||||
// applies to this canvas and not whatever target was active before.
|
// applies to this canvas and not whatever target was active before.
|
||||||
self.activate_target();
|
self.activate_target();
|
||||||
@@ -51,6 +62,8 @@ impl GlesCanvas
|
|||||||
self.set_scissor( Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } );
|
self.set_scissor( Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop the active clip — disable the scissor test and flush any open path
|
||||||
|
/// clip layer (compositing it back). Subsequent draws cover the whole canvas.
|
||||||
pub fn clear_clip( &mut self )
|
pub fn clear_clip( &mut self )
|
||||||
{
|
{
|
||||||
// SAFETY: see `primitives.rs` module doc. `disable( SCISSOR_TEST )` is
|
// SAFETY: see `primitives.rs` module doc. `disable( SCISSOR_TEST )` is
|
||||||
@@ -58,6 +71,114 @@ impl GlesCanvas
|
|||||||
// to match below.
|
// to match below.
|
||||||
unsafe { self.gl.disable( glow::SCISSOR_TEST ); }
|
unsafe { self.gl.disable( glow::SCISSOR_TEST ); }
|
||||||
self.clip_scissor = None;
|
self.clip_scissor = None;
|
||||||
|
self.flush_clip_layer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allocate the offscreen clip layer (a full-canvas colour FBO) on first
|
||||||
|
/// use. Freed and re-allocated at the new size by `resize`.
|
||||||
|
fn ensure_clip_layer( &mut self )
|
||||||
|
{
|
||||||
|
if self.clip_layer.is_some() { return; }
|
||||||
|
// SAFETY: GL context current (canvas invariant). Allocates an FBO + colour
|
||||||
|
// texture sized to the canvas and attaches it; the handles are stored so
|
||||||
|
// `Drop`/`resize` manage their lifetime. Restores the canvas FBO binding.
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
let fbo = self.gl.create_framebuffer().expect( "clip-layer FBO" );
|
||||||
|
let tex = super::helpers::alloc_fbo_tex( &self.gl, self.version, self.width, self.height );
|
||||||
|
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( fbo ) );
|
||||||
|
self.gl.framebuffer_texture_2d( glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT0, glow::TEXTURE_2D, Some( tex ), 0 );
|
||||||
|
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( self.fbo ) );
|
||||||
|
self.clip_layer = Some( ( fbo, tex ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clip subsequent draws to an arbitrary vector path, with an anti-aliased
|
||||||
|
/// edge. Begins capturing draws into the offscreen clip layer; the path's
|
||||||
|
/// anti-aliased coverage is rasterised (tiny-skia) into `clip_mask_tex` and
|
||||||
|
/// applied when the layer is composited back by `flush_clip_layer` (on the
|
||||||
|
/// next `clear_clip` / `set_clip_rects`). GLES counterpart of the software
|
||||||
|
/// backend's `set_clip_path`.
|
||||||
|
pub fn set_clip_path( &mut self, cmds: &[ crate::types::PathCmd ] )
|
||||||
|
{
|
||||||
|
// A new path clip supersedes any layer still open.
|
||||||
|
self.flush_clip_layer();
|
||||||
|
let Some( path ) = crate::render::helpers::build_ts_path( cmds ) else { self.clear_clip(); return; };
|
||||||
|
let b = path.bounds();
|
||||||
|
let x0 = b.left().floor().max( 0.0 );
|
||||||
|
let y0 = b.top().floor().max( 0.0 );
|
||||||
|
let x1 = b.right().ceil().min( self.width as f32 );
|
||||||
|
let y1 = b.bottom().ceil().min( self.height as f32 );
|
||||||
|
let pw = ( x1 - x0 ).max( 0.0 ) as u32;
|
||||||
|
let ph = ( y1 - y0 ).max( 0.0 ) as u32;
|
||||||
|
if pw == 0 || ph == 0 || pw > 8192 || ph > 8192 { self.clear_clip(); return; }
|
||||||
|
|
||||||
|
// Rasterise the path coverage (anti-aliased, opaque inside) into a
|
||||||
|
// bbox-sized pixmap; the composite shader multiplies the layer by it.
|
||||||
|
let Some( mut pixmap ) = tiny_skia::Pixmap::new( pw, ph ) else { return; };
|
||||||
|
let mut paint = tiny_skia::Paint::default();
|
||||||
|
paint.set_color( tiny_skia::Color::WHITE );
|
||||||
|
paint.anti_alias = true;
|
||||||
|
let tf = tiny_skia::Transform::from_translate( -x0, -y0 );
|
||||||
|
pixmap.fill_path( &path, &paint, tiny_skia::FillRule::Winding, tf, None );
|
||||||
|
let rgba = pixmap.data().to_vec();
|
||||||
|
|
||||||
|
let mask = super::helpers::upload_rgba_texture( &self.gl, self.version, &rgba, pw as i32, ph as i32 );
|
||||||
|
self.clip_mask_tex = Some( mask );
|
||||||
|
self.clip_bbox = Rect { x: x0, y: y0, width: pw as f32, height: ph as f32 };
|
||||||
|
|
||||||
|
self.ensure_clip_layer();
|
||||||
|
// Redirect to the layer and clear it transparent so untouched pixels stay
|
||||||
|
// empty (they contribute nothing to the composite).
|
||||||
|
self.clip_layer_active = true;
|
||||||
|
self.clip_scissor = None;
|
||||||
|
self.activate_target();
|
||||||
|
// SAFETY: GL context current; `activate_target` bound the layer FBO.
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
self.gl.clear_color( 0.0, 0.0, 0.0, 0.0 );
|
||||||
|
self.gl.clear( glow::COLOR_BUFFER_BIT );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Composite the clip layer back onto the canvas FBO, multiplied by the
|
||||||
|
/// anti-aliased coverage mask, then end the path clip. No-op when no path
|
||||||
|
/// clip is open.
|
||||||
|
fn flush_clip_layer( &mut self )
|
||||||
|
{
|
||||||
|
if !self.clip_layer_active { return; }
|
||||||
|
self.clip_layer_active = false;
|
||||||
|
let Some( ( _, layer_tex ) ) = self.clip_layer else { return; };
|
||||||
|
let Some( mask_tex ) = self.clip_mask_tex.take() else { return; };
|
||||||
|
let bbox = self.clip_bbox;
|
||||||
|
let mvp = super::helpers::ortho_rect( self.width, self.height, bbox );
|
||||||
|
// SAFETY: GL context current. Bind the canvas FBO, disable scissor, and
|
||||||
|
// draw a quad over the bbox sampling the layer (unit 0) and coverage mask
|
||||||
|
// (unit 1); the premultiplied result is blended SRC_OVER. All handles are
|
||||||
|
// canvas-owned; the transient mask texture is deleted after the draw.
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( self.fbo ) );
|
||||||
|
self.gl.viewport( 0, 0, self.width as i32, self.height as i32 );
|
||||||
|
self.gl.disable( glow::SCISSOR_TEST );
|
||||||
|
self.gl.use_program( Some( self.clip_composite_program ) );
|
||||||
|
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_clip_mvp ), false, &mvp );
|
||||||
|
self.gl.uniform_2_f32( Some( &self.u_clip_canvas ), self.width as f32, self.height as f32 );
|
||||||
|
self.gl.uniform_4_f32( Some( &self.u_clip_bbox ), bbox.x, bbox.y, bbox.width, bbox.height );
|
||||||
|
self.gl.active_texture( glow::TEXTURE0 );
|
||||||
|
self.gl.bind_texture( glow::TEXTURE_2D, Some( layer_tex ) );
|
||||||
|
self.gl.uniform_1_i32( Some( &self.u_clip_layer ), 0 );
|
||||||
|
self.gl.active_texture( glow::TEXTURE1 );
|
||||||
|
self.gl.bind_texture( glow::TEXTURE_2D, Some( mask_tex ) );
|
||||||
|
self.gl.uniform_1_i32( Some( &self.u_clip_mask ), 1 );
|
||||||
|
self.gl.bind_vertex_array( Some( self.quad_vao ) );
|
||||||
|
self.gl.draw_arrays( glow::TRIANGLES, 0, 6 );
|
||||||
|
self.gl.bind_vertex_array( None );
|
||||||
|
self.gl.bind_texture( glow::TEXTURE_2D, None );
|
||||||
|
self.gl.active_texture( glow::TEXTURE0 );
|
||||||
|
self.gl.bind_texture( glow::TEXTURE_2D, None );
|
||||||
|
self.gl.delete_texture( mask_tex );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshot of the active scissor as a `Vec<Rect>` (empty when no
|
/// Snapshot of the active scissor as a `Vec<Rect>` (empty when no
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ use super::{ BorrowedGlesTexture, GlesCanvas };
|
|||||||
|
|
||||||
impl GlesCanvas
|
impl GlesCanvas
|
||||||
{
|
{
|
||||||
|
/// Composite `src`'s FBO into this canvas at top-left `( dest_x, dest_y )`,
|
||||||
|
/// premultiplied over. `src` must share this canvas's GL context (guaranteed
|
||||||
|
/// for sub-canvases). Equivalent to [`Self::blit_fade_bottom`] with no fade.
|
||||||
pub fn blit( &mut self, src: &GlesCanvas, dest_x: i32, dest_y: i32 )
|
pub fn blit( &mut self, src: &GlesCanvas, dest_x: i32, dest_y: i32 )
|
||||||
{
|
{
|
||||||
self.blit_fade_bottom( src, dest_x, dest_y, 0.0 );
|
self.blit_fade_bottom( src, dest_x, dest_y, 0.0 );
|
||||||
@@ -83,22 +86,32 @@ impl GlesCanvas
|
|||||||
/// disable scissor when the canvas has no clip).
|
/// disable scissor when the canvas has no clip).
|
||||||
pub( super ) fn activate_target( &self )
|
pub( super ) fn activate_target( &self )
|
||||||
{
|
{
|
||||||
// SAFETY: rebinds canvas-owned FBO + viewport + scissor. All values
|
// While a path clip is active, draws are redirected to the offscreen clip
|
||||||
// (`self.fbo`, `self.width`, `self.height`, `self.clip_scissor`)
|
// layer (unclipped); the anti-aliased mask is applied when the layer is
|
||||||
|
// composited back on clip end. Otherwise draws go to `fbo` with the
|
||||||
|
// active scissor.
|
||||||
|
// SAFETY: rebinds a canvas-owned FBO + viewport + scissor. All values
|
||||||
|
// (`self.fbo`, `self.clip_layer`, `self.width/height`, `self.clip_scissor`)
|
||||||
// live as long as `&self`, and the bind is idempotent.
|
// live as long as `&self`, and the bind is idempotent.
|
||||||
|
let target = match ( self.clip_layer_active, self.clip_layer )
|
||||||
|
{
|
||||||
|
( true, Some( ( fbo, _ ) ) ) => fbo,
|
||||||
|
_ => self.fbo,
|
||||||
|
};
|
||||||
|
let to_layer = self.clip_layer_active && self.clip_layer.is_some();
|
||||||
unsafe
|
unsafe
|
||||||
{
|
{
|
||||||
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( self.fbo ) );
|
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( target ) );
|
||||||
self.gl.viewport( 0, 0, self.width as i32, self.height as i32 );
|
self.gl.viewport( 0, 0, self.width as i32, self.height as i32 );
|
||||||
match self.clip_scissor
|
match self.clip_scissor
|
||||||
{
|
{
|
||||||
Some( r ) =>
|
Some( r ) if !to_layer =>
|
||||||
{
|
{
|
||||||
let ( x, y, w, h ) = self.scissor_pixels( r );
|
let ( x, y, w, h ) = self.scissor_pixels( r );
|
||||||
self.gl.enable( glow::SCISSOR_TEST );
|
self.gl.enable( glow::SCISSOR_TEST );
|
||||||
self.gl.scissor( x, y, w, h );
|
self.gl.scissor( x, y, w, h );
|
||||||
}
|
}
|
||||||
None =>
|
_ =>
|
||||||
{
|
{
|
||||||
self.gl.disable( glow::SCISSOR_TEST );
|
self.gl.disable( glow::SCISSOR_TEST );
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
//! Backend-neutral free helpers for the GLES renderer: MVP matrix
|
//! Backend-neutral free helpers for the GLES renderer: MVP matrix
|
||||||
//! construction, shader compilation, FBO / texture allocation,
|
//! construction, shader compilation, FBO / texture allocation, small
|
||||||
//! system-font lookup, small typed-handle extractors. Visible only
|
//! typed-handle extractors. Visible only within `crate::gles_render` —
|
||||||
//! within `crate::gles_render` — callers always go through
|
//! callers always go through `GlesCanvas`'s public methods. System-font
|
||||||
//! `GlesCanvas`'s public methods.
|
//! resolution lives in `crate::system_fonts`.
|
||||||
|
|
||||||
use glow::HasContext;
|
use glow::HasContext;
|
||||||
|
|
||||||
@@ -218,40 +218,3 @@ pub( super ) fn native_framebuffer_id( framebuffer: glow::Framebuffer ) -> u32
|
|||||||
framebuffer.0.get()
|
framebuffer.0.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
const SYSTEM_FONT_CANDIDATES: &[&str] =
|
|
||||||
&[
|
|
||||||
// Debian `fonts-sora` — the canonical path the `ltk-theme-default`
|
|
||||||
// package depends on. Listed first so Sora wins as the default
|
|
||||||
// font whenever that package is installed.
|
|
||||||
"/usr/share/fonts/opentype/sora/Sora-Regular.otf",
|
|
||||||
"/usr/share/fonts/truetype/sora/Sora-Regular.ttf",
|
|
||||||
"/usr/share/fonts/sora/Sora-Regular.ttf",
|
|
||||||
"/usr/share/fonts/TTF/Sora-Regular.ttf",
|
|
||||||
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
|
||||||
"/usr/share/fonts/liberation/LiberationSans-Regular.ttf",
|
|
||||||
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
|
|
||||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
|
||||||
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
|
|
||||||
"/usr/share/fonts/TTF/DejaVuSans.ttf",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Load the bytes of a default system font. Tries
|
|
||||||
/// [`SYSTEM_FONT_CANDIDATES`] in order; falls back to the embedded
|
|
||||||
/// [`crate::theme::fallback::FALLBACK_FONT`] (Sora Regular, ~50 KB,
|
|
||||||
/// OFL 1.1) when nothing matches or the file cannot be read. Always
|
|
||||||
/// returns usable bytes so canvas construction never panics on a
|
|
||||||
/// system without the expected fonts.
|
|
||||||
pub( super ) fn load_default_font_bytes() -> Vec<u8>
|
|
||||||
{
|
|
||||||
for path in SYSTEM_FONT_CANDIDATES.iter()
|
|
||||||
{
|
|
||||||
if std::path::Path::new( path ).exists()
|
|
||||||
{
|
|
||||||
if let Ok( bytes ) = std::fs::read( path )
|
|
||||||
{
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
crate::theme::fallback::FALLBACK_FONT.to_vec()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -68,13 +68,8 @@ impl GlesCanvas
|
|||||||
/// level so the cache key is never seeded with a bogus mapping.
|
/// level so the cache key is never seeded with a bogus mapping.
|
||||||
pub fn draw_image_data( &mut self, rgba_data: &[u8], img_w: u32, img_h: u32, dest: Rect, opacity: f32 )
|
pub fn draw_image_data( &mut self, rgba_data: &[u8], img_w: u32, img_h: u32, dest: Rect, opacity: f32 )
|
||||||
{
|
{
|
||||||
let expected = ( img_w as usize ).saturating_mul( img_h as usize ).saturating_mul( 4 );
|
if !crate::render::helpers::validate_rgba_dims( "GlesCanvas", rgba_data, img_w, img_h )
|
||||||
if img_w == 0 || img_h == 0 || rgba_data.len() != expected
|
|
||||||
{
|
{
|
||||||
eprintln!(
|
|
||||||
"[ltk] GlesCanvas::draw_image_data: refusing draw — {}×{} declared, {} bytes provided, expected {}",
|
|
||||||
img_w, img_h, rgba_data.len(), expected,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,14 @@
|
|||||||
//! [`crate::egl_context`] — this module is just the renderer that runs
|
//! [`crate::egl_context`] — this module is just the renderer that runs
|
||||||
//! once a context is current.
|
//! once a context is current.
|
||||||
//!
|
//!
|
||||||
//! Clipping is implemented with `glScissor`. When
|
//! Rect clipping is implemented with `glScissor`. When
|
||||||
//! [`GlesCanvas::set_clip_rects`] receives multiple rects the
|
//! [`GlesCanvas::set_clip_rects`] receives multiple rects the
|
||||||
//! bounding-box union is used as the scissor — coarse, but the
|
//! bounding-box union is used as the scissor — coarse, but the
|
||||||
//! partial-redraw path normally clusters the dirty rects of 1–3
|
//! partial-redraw path normally clusters the dirty rects of 1–3
|
||||||
//! widgets so the union is barely larger than the sum. Disjoint
|
//! widgets so the union is barely larger than the sum. Arbitrary
|
||||||
//! regions would want a stencil-buffer path; not implemented today.
|
//! path clipping ([`GlesCanvas::set_clip_path`]) captures the clipped
|
||||||
|
//! draws into an offscreen layer and composites it back through an
|
||||||
|
//! anti-aliased coverage mask, for a smooth clipped edge.
|
||||||
//!
|
//!
|
||||||
//! # Submodule layout
|
//! # Submodule layout
|
||||||
//!
|
//!
|
||||||
@@ -33,8 +35,8 @@
|
|||||||
//! * `image` — `GlesCanvas::draw_image_data`.
|
//! * `image` — `GlesCanvas::draw_image_data`.
|
||||||
//! * `shaders` — GLSL ES 1.00 shader sources (const strings).
|
//! * `shaders` — GLSL ES 1.00 shader sources (const strings).
|
||||||
//! * `helpers` — free functions: `ortho_rect`, `compile_program`,
|
//! * `helpers` — free functions: `ortho_rect`, `compile_program`,
|
||||||
//! `alloc_fbo_tex`, `upload_*_texture`, handle extractors,
|
//! `alloc_fbo_tex`, `upload_*_texture`, handle extractors.
|
||||||
//! `find_font` + `SYSTEM_FONT_CANDIDATES`.
|
//! System-font resolution lives in `crate::system_fonts`.
|
||||||
//! * `raii` — `FboBinding` / `ProgramBinding` scoped guards for the
|
//! * `raii` — `FboBinding` / `ProgramBinding` scoped guards for the
|
||||||
//! handful of operations that change global GL state for a scope
|
//! handful of operations that change global GL state for a scope
|
||||||
//! and must guarantee restoration even on early return / panic
|
//! and must guarantee restoration even on early return / panic
|
||||||
@@ -133,12 +135,12 @@ pub struct GlesCanvas
|
|||||||
{
|
{
|
||||||
pub gl: Arc<glow::Context>,
|
pub gl: Arc<glow::Context>,
|
||||||
pub version: GlesVersion,
|
pub version: GlesVersion,
|
||||||
/// Default font loaded from the system via `helpers::find_font`.
|
/// Default font loaded from the system via `system_fonts::default_handle`.
|
||||||
/// Kept as a fallback for callers that do not route through the
|
/// Kept as a fallback for callers that do not route through the
|
||||||
/// theme registry.
|
/// theme registry.
|
||||||
pub font: Arc<Font>,
|
pub font: Arc<Font>,
|
||||||
/// Raw bytes of the default font. Required by rustybuzz for
|
/// Raw bytes of the default font. Required by rustybuzz for
|
||||||
/// HarfBuzz shaping (see [`crate::text_shaping`]). Kept on the
|
/// HarfBuzz shaping (see the `text_shaping` private module). Kept on the
|
||||||
/// canvas so the shape pipeline has direct access without a
|
/// canvas so the shape pipeline has direct access without a
|
||||||
/// global lookup.
|
/// global lookup.
|
||||||
pub font_bytes: Arc<Vec<u8>>,
|
pub font_bytes: Arc<Vec<u8>>,
|
||||||
@@ -354,6 +356,23 @@ pub struct GlesCanvas
|
|||||||
/// (GL_SCISSOR_TEST is enabled), `None` when cleared.
|
/// (GL_SCISSOR_TEST is enabled), `None` when cleared.
|
||||||
clip_scissor: Option<Rect>,
|
clip_scissor: Option<Rect>,
|
||||||
|
|
||||||
|
/// Anti-aliased path clip (`set_clip_path`). While `clip_layer_active`,
|
||||||
|
/// `activate_target` redirects draws to `clip_layer` (a full-canvas
|
||||||
|
/// offscreen FBO); ending the clip composites that layer back onto `fbo`
|
||||||
|
/// multiplied by `clip_mask_tex` (an anti-aliased coverage texture covering
|
||||||
|
/// `clip_bbox`). The layer FBO is allocated lazily on the first path clip.
|
||||||
|
clip_layer: Option<( glow::Framebuffer, glow::Texture )>,
|
||||||
|
clip_layer_active: bool,
|
||||||
|
clip_mask_tex: Option<glow::Texture>,
|
||||||
|
clip_bbox: Rect,
|
||||||
|
/// Composite program (layer × coverage mask). Shared with sub-canvases.
|
||||||
|
clip_composite_program: glow::Program,
|
||||||
|
u_clip_mvp: glow::UniformLocation,
|
||||||
|
u_clip_layer: glow::UniformLocation,
|
||||||
|
u_clip_mask: glow::UniformLocation,
|
||||||
|
u_clip_canvas: glow::UniformLocation,
|
||||||
|
u_clip_bbox: glow::UniformLocation,
|
||||||
|
|
||||||
/// Persistent shadow framebuffer. All draw methods bind this;
|
/// Persistent shadow framebuffer. All draw methods bind this;
|
||||||
/// [`Self::present`](framebuffer) is the only call that switches
|
/// [`Self::present`](framebuffer) is the only call that switches
|
||||||
/// to the default framebuffer.
|
/// to the default framebuffer.
|
||||||
@@ -398,6 +417,15 @@ impl Drop for GlesCanvas
|
|||||||
{
|
{
|
||||||
self.gl.delete_framebuffer( self.fbo );
|
self.gl.delete_framebuffer( self.fbo );
|
||||||
self.gl.delete_texture( self.fbo_tex );
|
self.gl.delete_texture( self.fbo_tex );
|
||||||
|
if let Some( ( fbo, tex ) ) = self.clip_layer.take()
|
||||||
|
{
|
||||||
|
self.gl.delete_framebuffer( fbo );
|
||||||
|
self.gl.delete_texture( tex );
|
||||||
|
}
|
||||||
|
if let Some( tex ) = self.clip_mask_tex.take()
|
||||||
|
{
|
||||||
|
self.gl.delete_texture( tex );
|
||||||
|
}
|
||||||
if let Some( ( fbo, tex ) ) = self.aux_a.take()
|
if let Some( ( fbo, tex ) ) = self.aux_a.take()
|
||||||
{
|
{
|
||||||
self.gl.delete_framebuffer( fbo );
|
self.gl.delete_framebuffer( fbo );
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
use glow::HasContext;
|
use glow::HasContext;
|
||||||
|
|
||||||
use crate::theme::{ gradient_lut, BlendMode, InsetShadow, LinearGradient, RadialGradient, Shadow };
|
use crate::theme::{ gradient_lut, BlendMode, InsetShadow, LinearGradient, RadialGradient, Shadow };
|
||||||
use crate::types::{ Color, Corners, Rect };
|
use crate::types::{ Color, Corners, PathCmd, Rect };
|
||||||
|
|
||||||
use super::helpers::ortho_rect;
|
use super::helpers::ortho_rect;
|
||||||
use super::GlesCanvas;
|
use super::GlesCanvas;
|
||||||
@@ -51,6 +51,79 @@ impl GlesCanvas
|
|||||||
r_x1 <= clip.x || c_x1 <= r_x0 || r_y1 <= clip.y || c_y1 <= r_y0
|
r_x1 <= clip.x || c_x1 <= r_x0 || r_y1 <= clip.y || c_y1 <= r_y0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fill an arbitrary vector path (commands in surface coordinates) with a
|
||||||
|
/// solid colour. CPU fallback: rasterised with tiny-skia into a bbox-sized
|
||||||
|
/// pixmap and blitted as a transient texture — there is no GPU path shader.
|
||||||
|
pub fn fill_path( &mut self, cmds: &[ PathCmd ], color: Color )
|
||||||
|
{
|
||||||
|
self.rasterise_path( cmds, color, None );
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stroke an arbitrary vector path (commands in surface coordinates) with a
|
||||||
|
/// centered stroke of `width` px. Same tiny-skia-into-texture CPU fallback as
|
||||||
|
/// [`Self::fill_path`].
|
||||||
|
pub fn stroke_path( &mut self, cmds: &[ PathCmd ], color: Color, width: f32 )
|
||||||
|
{
|
||||||
|
self.rasterise_path( cmds, color, Some( width ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CPU fallback for vector paths on the GPU backend: rasterise into a
|
||||||
|
/// tiny-skia pixmap sized to the path's bounding box, then blit it as a
|
||||||
|
/// texture via `draw_image_data`. No GPU path shader.
|
||||||
|
fn rasterise_path( &mut self, cmds: &[ PathCmd ], color: Color, stroke: Option<f32> )
|
||||||
|
{
|
||||||
|
let Some( path ) = crate::render::helpers::build_ts_path( cmds ) else { return };
|
||||||
|
let bounds = path.bounds();
|
||||||
|
let pad = stroke.map_or( 1.0, |w| w * 0.5 + 1.0 );
|
||||||
|
let x0 = ( bounds.left() - pad ).floor();
|
||||||
|
let y0 = ( bounds.top() - pad ).floor();
|
||||||
|
let x1 = ( bounds.right() + pad ).ceil();
|
||||||
|
let y1 = ( bounds.bottom() + pad ).ceil();
|
||||||
|
let pw = ( x1 - x0 ).max( 1.0 ) as u32;
|
||||||
|
let ph = ( y1 - y0 ).max( 1.0 ) as u32;
|
||||||
|
if pw > 8192 || ph > 8192 { return; }
|
||||||
|
let Some( mut pixmap ) = tiny_skia::Pixmap::new( pw, ph ) else { return };
|
||||||
|
|
||||||
|
let mut paint = tiny_skia::Paint::default();
|
||||||
|
paint.set_color( Color::rgba( color.r, color.g, color.b, color.a * self.global_alpha ).to_tiny_skia() );
|
||||||
|
paint.anti_alias = true;
|
||||||
|
let tf = tiny_skia::Transform::from_translate( -x0, -y0 );
|
||||||
|
match stroke
|
||||||
|
{
|
||||||
|
Some( w ) =>
|
||||||
|
{
|
||||||
|
let mut s = tiny_skia::Stroke::default();
|
||||||
|
s.width = w;
|
||||||
|
pixmap.stroke_path( &path, &paint, &s, tf, None );
|
||||||
|
}
|
||||||
|
None => { pixmap.fill_path( &path, &paint, tiny_skia::FillRule::Winding, tf, None ); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// tiny-skia stores premultiplied RGBA; the texture shader expects straight.
|
||||||
|
let mut rgba = pixmap.data().to_vec();
|
||||||
|
for px in rgba.chunks_exact_mut( 4 )
|
||||||
|
{
|
||||||
|
let a = px[ 3 ] as u32;
|
||||||
|
if a > 0 && a < 255
|
||||||
|
{
|
||||||
|
px[ 0 ] = ( px[ 0 ] as u32 * 255 / a ).min( 255 ) as u8;
|
||||||
|
px[ 1 ] = ( px[ 1 ] as u32 * 255 / a ).min( 255 ) as u8;
|
||||||
|
px[ 2 ] = ( px[ 2 ] as u32 * 255 / a ).min( 255 ) as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Transient texture (upload → draw → delete): a path's pixels change
|
||||||
|
// every frame for animated vectors, which would make the content-keyed
|
||||||
|
// image cache in `draw_image_data` grow without bound and exhaust GL.
|
||||||
|
let dest = Rect { x: x0, y: y0, width: pw as f32, height: ph as f32 };
|
||||||
|
let tex = super::helpers::upload_rgba_texture( &self.gl, self.version, &rgba, pw as i32, ph as i32 );
|
||||||
|
self.draw_external_texture( tex, dest, 1.0 );
|
||||||
|
unsafe { self.gl.delete_texture( tex ); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fill `rect` with a solid colour, with per-corner rounding from `corners`.
|
||||||
|
/// Coverage (including the rounded corners) comes from an SDF in the rect
|
||||||
|
/// shader; `color.a` is multiplied by `global_alpha`. Culled early when the
|
||||||
|
/// rect falls entirely outside the active scissor.
|
||||||
pub fn fill_rect( &mut self, rect: Rect, color: Color, corners: Corners )
|
pub fn fill_rect( &mut self, rect: Rect, color: Color, corners: Corners )
|
||||||
{
|
{
|
||||||
if self.rect_culled( rect, 1.0 ) { return; }
|
if self.rect_culled( rect, 1.0 ) { return; }
|
||||||
@@ -61,13 +134,7 @@ impl GlesCanvas
|
|||||||
// stay anchored to the original rect — `u_pad` lets the shader
|
// stay anchored to the original rect — `u_pad` lets the shader
|
||||||
// remap `v_uv` from the larger quad back into rect-local space.
|
// remap `v_uv` from the larger quad back into rect-local space.
|
||||||
let pad = 1.0_f32;
|
let pad = 1.0_f32;
|
||||||
let expanded = Rect
|
let expanded = rect.expand( pad );
|
||||||
{
|
|
||||||
x: rect.x - pad,
|
|
||||||
y: rect.y - pad,
|
|
||||||
width: rect.width + 2.0 * pad,
|
|
||||||
height: rect.height + 2.0 * pad,
|
|
||||||
};
|
|
||||||
let mvp = ortho_rect( self.width, self.height, expanded );
|
let mvp = ortho_rect( self.width, self.height, expanded );
|
||||||
let alpha = color.a * self.global_alpha;
|
let alpha = color.a * self.global_alpha;
|
||||||
// SAFETY: see module doc. `u_rect_stroke = 0.0` triggers the fill
|
// SAFETY: see module doc. `u_rect_stroke = 0.0` triggers the fill
|
||||||
@@ -158,13 +225,7 @@ impl GlesCanvas
|
|||||||
self.activate_target();
|
self.activate_target();
|
||||||
// See fill_rect for the rationale on the 1 px quad pad.
|
// See fill_rect for the rationale on the 1 px quad pad.
|
||||||
let pad = 1.0_f32;
|
let pad = 1.0_f32;
|
||||||
let expanded = Rect
|
let expanded = rect.expand( pad );
|
||||||
{
|
|
||||||
x: rect.x - pad,
|
|
||||||
y: rect.y - pad,
|
|
||||||
width: rect.width + 2.0 * pad,
|
|
||||||
height: rect.height + 2.0 * pad,
|
|
||||||
};
|
|
||||||
let mvp = ortho_rect( self.width, self.height, expanded );
|
let mvp = ortho_rect( self.width, self.height, expanded );
|
||||||
// SAFETY: see module doc. `tex` is the cached LUT for `g.stops`
|
// SAFETY: see module doc. `tex` is the cached LUT for `g.stops`
|
||||||
// produced by `ensure_lut_texture` above (RGBA8, sampler unit 0);
|
// produced by `ensure_lut_texture` above (RGBA8, sampler unit 0);
|
||||||
@@ -206,13 +267,7 @@ impl GlesCanvas
|
|||||||
self.activate_target();
|
self.activate_target();
|
||||||
// See fill_rect for the rationale on the 1 px quad pad.
|
// See fill_rect for the rationale on the 1 px quad pad.
|
||||||
let pad = 1.0_f32;
|
let pad = 1.0_f32;
|
||||||
let expanded = Rect
|
let expanded = rect.expand( pad );
|
||||||
{
|
|
||||||
x: rect.x - pad,
|
|
||||||
y: rect.y - pad,
|
|
||||||
width: rect.width + 2.0 * pad,
|
|
||||||
height: rect.height + 2.0 * pad,
|
|
||||||
};
|
|
||||||
let mvp = ortho_rect( self.width, self.height, expanded );
|
let mvp = ortho_rect( self.width, self.height, expanded );
|
||||||
// SAFETY: see module doc. Same LUT contract as the linear path
|
// SAFETY: see module doc. Same LUT contract as the linear path
|
||||||
// above. `g.center` and `g.radius` are finite fractional values
|
// above. `g.center` and `g.radius` are finite fractional values
|
||||||
@@ -359,22 +414,10 @@ impl GlesCanvas
|
|||||||
// active scissor is culled before the shader runs, so the
|
// active scissor is culled before the shader runs, so the
|
||||||
// snapshot only needs to cover the intersection.
|
// snapshot only needs to cover the intersection.
|
||||||
let pad = 1.0_f32;
|
let pad = 1.0_f32;
|
||||||
let snap_rect = Rect
|
let snap_rect = target.expand( pad );
|
||||||
{
|
|
||||||
x: target.x - pad,
|
|
||||||
y: target.y - pad,
|
|
||||||
width: target.width + 2.0 * pad,
|
|
||||||
height: target.height + 2.0 * pad,
|
|
||||||
};
|
|
||||||
self.snapshot_fbo_region_tight( snap_rect );
|
self.snapshot_fbo_region_tight( snap_rect );
|
||||||
self.activate_target();
|
self.activate_target();
|
||||||
let expanded = Rect
|
let expanded = target.expand( pad );
|
||||||
{
|
|
||||||
x: target.x - pad,
|
|
||||||
y: target.y - pad,
|
|
||||||
width: target.width + 2.0 * pad,
|
|
||||||
height: target.height + 2.0 * pad,
|
|
||||||
};
|
|
||||||
let mvp = ortho_rect( self.width, self.height, expanded );
|
let mvp = ortho_rect( self.width, self.height, expanded );
|
||||||
let aux_tex = self.aux_a.expect( "snapshotted" ).1;
|
let aux_tex = self.aux_a.expect( "snapshotted" ).1;
|
||||||
// SAFETY: see module doc. `aux_tex` was just populated by
|
// SAFETY: see module doc. `aux_tex` was just populated by
|
||||||
@@ -412,13 +455,7 @@ impl GlesCanvas
|
|||||||
// of terminating at the surface rect. Same rationale as
|
// of terminating at the surface rect. Same rationale as
|
||||||
// fill_rect. `u_size` / `u_radii` stay anchored to `target`.
|
// fill_rect. `u_size` / `u_radii` stay anchored to `target`.
|
||||||
let pad = 1.0_f32;
|
let pad = 1.0_f32;
|
||||||
let expanded = Rect
|
let expanded = target.expand( pad );
|
||||||
{
|
|
||||||
x: target.x - pad,
|
|
||||||
y: target.y - pad,
|
|
||||||
width: target.width + 2.0 * pad,
|
|
||||||
height: target.height + 2.0 * pad,
|
|
||||||
};
|
|
||||||
let mvp = ortho_rect( self.width, self.height, expanded );
|
let mvp = ortho_rect( self.width, self.height, expanded );
|
||||||
// SAFETY: see module doc. We swap the global blend state for the
|
// SAFETY: see module doc. We swap the global blend state for the
|
||||||
// duration of one draw and restore the canvas-wide default
|
// duration of one draw and restore the canvas-wide default
|
||||||
@@ -487,13 +524,7 @@ impl GlesCanvas
|
|||||||
// would shift the zero-line outward and, in the circle case
|
// would shift the zero-line outward and, in the circle case
|
||||||
// (radius = size/2), turn the result into a rounded square.
|
// (radius = size/2), turn the result into a rounded square.
|
||||||
let pad = half + 1.0;
|
let pad = half + 1.0;
|
||||||
let expanded = Rect
|
let expanded = rect.expand( pad );
|
||||||
{
|
|
||||||
x: rect.x - pad,
|
|
||||||
y: rect.y - pad,
|
|
||||||
width: rect.width + 2.0 * pad,
|
|
||||||
height: rect.height + 2.0 * pad,
|
|
||||||
};
|
|
||||||
let mvp = ortho_rect( self.width, self.height, expanded );
|
let mvp = ortho_rect( self.width, self.height, expanded );
|
||||||
let alpha = color.a * self.global_alpha;
|
let alpha = color.a * self.global_alpha;
|
||||||
// SAFETY: see module doc. `u_rect_stroke = width > 0.0` triggers
|
// SAFETY: see module doc. `u_rect_stroke = width > 0.0` triggers
|
||||||
|
|||||||
@@ -12,14 +12,14 @@
|
|||||||
//! elided (programs, VAO, default font all come from the parent).
|
//! elided (programs, VAO, default font all come from the parent).
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{ Arc, OnceLock };
|
use std::sync::Arc;
|
||||||
|
|
||||||
use fontdue::{ Font, FontSettings, LineMetrics, Metrics };
|
use fontdue::{ Font, LineMetrics, Metrics };
|
||||||
use glow::HasContext;
|
use glow::HasContext;
|
||||||
|
|
||||||
use crate::theme::{ FontRegistry, FontStyle };
|
use crate::theme::{ FontRegistry, FontStyle };
|
||||||
|
|
||||||
use super::helpers::{ alloc_fbo_tex, bytemuck_cast_slice, compile_program, compile_program_with_attribs, load_default_font_bytes };
|
use super::helpers::{ alloc_fbo_tex, bytemuck_cast_slice, compile_program, compile_program_with_attribs };
|
||||||
use super::shaders::
|
use super::shaders::
|
||||||
{
|
{
|
||||||
BACKDROP_BLUR_H_FRAG_SRC, BACKDROP_COMPOSITE_FRAG_SRC,
|
BACKDROP_BLUR_H_FRAG_SRC, BACKDROP_COMPOSITE_FRAG_SRC,
|
||||||
@@ -28,40 +28,26 @@ use super::shaders::
|
|||||||
GLYPH_FRAG_SRC, GLYPH_BATCH_VERT_SRC, GLYPH_BATCH_FRAG_SRC,
|
GLYPH_FRAG_SRC, GLYPH_BATCH_VERT_SRC, GLYPH_BATCH_FRAG_SRC,
|
||||||
LINEAR_GRADIENT_FRAG_SRC, RADIAL_GRADIENT_FRAG_SRC,
|
LINEAR_GRADIENT_FRAG_SRC, RADIAL_GRADIENT_FRAG_SRC,
|
||||||
RECT_FRAG_SRC,
|
RECT_FRAG_SRC,
|
||||||
|
CLIP_COMPOSITE_FRAG_SRC,
|
||||||
SHADOW_INSET_FRAG_SRC, SHADOW_INSET_OVERLAY_FRAG_SRC, SHADOW_OUTER_FRAG_SRC,
|
SHADOW_INSET_FRAG_SRC, SHADOW_INSET_OVERLAY_FRAG_SRC, SHADOW_OUTER_FRAG_SRC,
|
||||||
SUB_BLIT_FRAG_SRC,
|
SUB_BLIT_FRAG_SRC,
|
||||||
TEX_FRAG_SRC, VERT_SRC,
|
TEX_FRAG_SRC, VERT_SRC,
|
||||||
};
|
};
|
||||||
use super::{ GlesCanvas, GlesVersion };
|
use super::{ GlesCanvas, GlesVersion };
|
||||||
|
|
||||||
/// Process-wide cache of the GLES path's default font. Avoids
|
|
||||||
/// re-reading + re-parsing the small Sora face on every surface
|
|
||||||
/// bring-up. The fallback chain (Noto Sans / CJK / Devanagari / …)
|
|
||||||
/// is owned by the crate-private system-fonts module and loaded
|
|
||||||
/// lazily per codepoint, not per canvas.
|
|
||||||
static DEFAULT_FONT_GLES: OnceLock<crate::system_fonts::FontHandle> = OnceLock::new();
|
|
||||||
|
|
||||||
fn default_handle_gles() -> crate::system_fonts::FontHandle
|
|
||||||
{
|
|
||||||
DEFAULT_FONT_GLES.get_or_init( ||
|
|
||||||
{
|
|
||||||
let bytes = load_default_font_bytes();
|
|
||||||
let font = Font::from_bytes( bytes.as_slice(), FontSettings::default() )
|
|
||||||
.expect( "bad font" );
|
|
||||||
crate::system_fonts::FontHandle
|
|
||||||
{
|
|
||||||
font: Arc::new( font ),
|
|
||||||
bytes: Arc::new( bytes ),
|
|
||||||
face: 0,
|
|
||||||
}
|
|
||||||
} ).clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GlesCanvas
|
impl GlesCanvas
|
||||||
{
|
{
|
||||||
|
/// Build a GPU canvas of `width × height` physical px on an already-current
|
||||||
|
/// EGL/GLES context. Compiles every shader program, looks up its uniforms,
|
||||||
|
/// uploads the shared quad geometry and the glyph atlas (format chosen per ES
|
||||||
|
/// profile), then allocates the persistent shadow FBO and makes it the active
|
||||||
|
/// draw target — every draw writes into the FBO, and [`Self::present`] is the
|
||||||
|
/// only call that blits it to the default framebuffer. `dpi_scale` and
|
||||||
|
/// `global_alpha` start at 1.0; the default font comes from the process-wide
|
||||||
|
/// cached handle. Panics if the FBO is incomplete or a program fails to link.
|
||||||
pub fn new( gl: Arc<glow::Context>, version: GlesVersion, width: u32, height: u32 ) -> Self
|
pub fn new( gl: Arc<glow::Context>, version: GlesVersion, width: u32, height: u32 ) -> Self
|
||||||
{
|
{
|
||||||
let font_handle = default_handle_gles();
|
let font_handle = crate::system_fonts::default_handle();
|
||||||
let font = font_handle.font.clone();
|
let font = font_handle.font.clone();
|
||||||
let font_bytes = font_handle.bytes.clone();
|
let font_bytes = font_handle.bytes.clone();
|
||||||
let font_face = font_handle.face;
|
let font_face = font_handle.face;
|
||||||
@@ -211,6 +197,16 @@ impl GlesCanvas
|
|||||||
gl.get_uniform_location( glyph_batch_program, "u_sampler" ).unwrap(),
|
gl.get_uniform_location( glyph_batch_program, "u_sampler" ).unwrap(),
|
||||||
)};
|
)};
|
||||||
|
|
||||||
|
let clip_composite_program = compile_program( &gl, VERT_SRC, CLIP_COMPOSITE_FRAG_SRC );
|
||||||
|
let ( u_clip_mvp, u_clip_layer, u_clip_mask, u_clip_canvas, u_clip_bbox ) = unsafe
|
||||||
|
{(
|
||||||
|
gl.get_uniform_location( clip_composite_program, "u_mvp" ).unwrap(),
|
||||||
|
gl.get_uniform_location( clip_composite_program, "u_layer" ).unwrap(),
|
||||||
|
gl.get_uniform_location( clip_composite_program, "u_mask" ).unwrap(),
|
||||||
|
gl.get_uniform_location( clip_composite_program, "u_canvas" ).unwrap(),
|
||||||
|
gl.get_uniform_location( clip_composite_program, "u_bbox" ).unwrap(),
|
||||||
|
)};
|
||||||
|
|
||||||
let ( glyph_batch_vao, glyph_batch_vbo ) = unsafe
|
let ( glyph_batch_vao, glyph_batch_vbo ) = unsafe
|
||||||
{
|
{
|
||||||
let vao = gl.create_vertex_array().unwrap();
|
let vao = gl.create_vertex_array().unwrap();
|
||||||
@@ -464,6 +460,16 @@ impl GlesCanvas
|
|||||||
image_cache: HashMap::new(),
|
image_cache: HashMap::new(),
|
||||||
gradient_lut_cache: HashMap::new(),
|
gradient_lut_cache: HashMap::new(),
|
||||||
clip_scissor: None,
|
clip_scissor: None,
|
||||||
|
clip_layer: None,
|
||||||
|
clip_layer_active: false,
|
||||||
|
clip_mask_tex: None,
|
||||||
|
clip_bbox: crate::types::Rect::default(),
|
||||||
|
clip_composite_program,
|
||||||
|
u_clip_mvp,
|
||||||
|
u_clip_layer,
|
||||||
|
u_clip_mask,
|
||||||
|
u_clip_canvas,
|
||||||
|
u_clip_bbox,
|
||||||
fbo,
|
fbo,
|
||||||
fbo_tex,
|
fbo_tex,
|
||||||
aux_a: None,
|
aux_a: None,
|
||||||
@@ -665,6 +671,16 @@ impl GlesCanvas
|
|||||||
image_cache: HashMap::new(),
|
image_cache: HashMap::new(),
|
||||||
gradient_lut_cache: HashMap::new(),
|
gradient_lut_cache: HashMap::new(),
|
||||||
clip_scissor: None,
|
clip_scissor: None,
|
||||||
|
clip_layer: None,
|
||||||
|
clip_layer_active: false,
|
||||||
|
clip_mask_tex: None,
|
||||||
|
clip_bbox: crate::types::Rect::default(),
|
||||||
|
clip_composite_program: self.clip_composite_program,
|
||||||
|
u_clip_mvp: self.u_clip_mvp,
|
||||||
|
u_clip_layer: self.u_clip_layer,
|
||||||
|
u_clip_mask: self.u_clip_mask,
|
||||||
|
u_clip_canvas: self.u_clip_canvas,
|
||||||
|
u_clip_bbox: self.u_clip_bbox,
|
||||||
fbo,
|
fbo,
|
||||||
fbo_tex,
|
fbo_tex,
|
||||||
aux_a: None,
|
aux_a: None,
|
||||||
@@ -672,6 +688,7 @@ impl GlesCanvas
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `( width, height )` of the FBO in physical px.
|
||||||
pub fn size( &self ) -> ( u32, u32 ) { ( self.width, self.height ) }
|
pub fn size( &self ) -> ( u32, u32 ) { ( self.width, self.height ) }
|
||||||
|
|
||||||
/// Discard all cached gradient LUT textures. Call after a theme change
|
/// Discard all cached gradient LUT textures. Call after a theme change
|
||||||
@@ -692,14 +709,19 @@ impl GlesCanvas
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// DPI scale factor applied to font sizes before rasterisation.
|
||||||
pub fn dpi_scale( &self ) -> f32 { self.dpi_scale }
|
pub fn dpi_scale( &self ) -> f32 { self.dpi_scale }
|
||||||
|
|
||||||
|
/// Set the DPI scale factor applied to font sizes.
|
||||||
pub fn set_dpi_scale( &mut self, s: f32 ) { self.dpi_scale = s; }
|
pub fn set_dpi_scale( &mut self, s: f32 ) { self.dpi_scale = s; }
|
||||||
|
|
||||||
|
/// Global alpha multiplier applied to every draw (0.0 transparent, 1.0 opaque).
|
||||||
pub fn global_alpha( &self ) -> f32 { self.global_alpha }
|
pub fn global_alpha( &self ) -> f32 { self.global_alpha }
|
||||||
|
|
||||||
|
/// Set the global alpha multiplier applied to every draw.
|
||||||
pub fn set_global_alpha( &mut self, a: f32 ) { self.global_alpha = a; }
|
pub fn set_global_alpha( &mut self, a: f32 ) { self.global_alpha = a; }
|
||||||
|
|
||||||
|
/// The canvas default font, used when no specific face is resolved.
|
||||||
pub fn font( &self ) -> &Font { &self.font }
|
pub fn font( &self ) -> &Font { &self.font }
|
||||||
|
|
||||||
/// Install a theme font registry so [`Self::font_for`] can resolve
|
/// Install a theme font registry so [`Self::font_for`] can resolve
|
||||||
@@ -734,11 +756,15 @@ impl GlesCanvas
|
|||||||
crate::system_fonts::lookup( ch ).unwrap_or_else( || Arc::clone( &self.font ) )
|
crate::system_fonts::lookup( ch ).unwrap_or_else( || Arc::clone( &self.font ) )
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Glyph metrics for `ch` at `size` logical px, resolved through the fallback
|
||||||
|
/// chain and pre-scaled by `dpi_scale`.
|
||||||
pub fn font_metrics( &self, ch: char, size: f32 ) -> Metrics
|
pub fn font_metrics( &self, ch: char, size: f32 ) -> Metrics
|
||||||
{
|
{
|
||||||
self.font_for_char( ch ).metrics( ch, size * self.dpi_scale )
|
self.font_for_char( ch ).metrics( ch, size * self.dpi_scale )
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Horizontal line metrics of the default font at `size` px (`None` if the
|
||||||
|
/// font lacks them). Not pre-scaled by `dpi_scale`.
|
||||||
pub fn font_line_metrics( &self, size: f32 ) -> Option<LineMetrics>
|
pub fn font_line_metrics( &self, size: f32 ) -> Option<LineMetrics>
|
||||||
{
|
{
|
||||||
self.font.horizontal_line_metrics( size )
|
self.font.horizontal_line_metrics( size )
|
||||||
@@ -768,10 +794,22 @@ impl GlesCanvas
|
|||||||
glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT0,
|
glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT0,
|
||||||
glow::TEXTURE_2D, Some( self.fbo_tex ), 0,
|
glow::TEXTURE_2D, Some( self.fbo_tex ), 0,
|
||||||
);
|
);
|
||||||
|
// The clip layer was sized for the old dimensions — free it so the next
|
||||||
|
// path clip re-allocates at the new size; any in-flight clip is dropped.
|
||||||
|
if let Some( ( fbo, tex ) ) = self.clip_layer.take()
|
||||||
|
{
|
||||||
|
self.gl.delete_framebuffer( fbo );
|
||||||
|
self.gl.delete_texture( tex );
|
||||||
|
}
|
||||||
|
if let Some( tex ) = self.clip_mask_tex.take()
|
||||||
|
{
|
||||||
|
self.gl.delete_texture( tex );
|
||||||
|
}
|
||||||
self.gl.viewport( 0, 0, width as i32, height as i32 );
|
self.gl.viewport( 0, 0, width as i32, height as i32 );
|
||||||
self.gl.clear_color( 0.0, 0.0, 0.0, 0.0 );
|
self.gl.clear_color( 0.0, 0.0, 0.0, 0.0 );
|
||||||
self.gl.clear( glow::COLOR_BUFFER_BIT );
|
self.gl.clear( glow::COLOR_BUFFER_BIT );
|
||||||
}
|
}
|
||||||
|
self.clip_layer_active = false;
|
||||||
// Auxiliary textures were sized for the old dimensions — drop them so
|
// Auxiliary textures were sized for the old dimensions — drop them so
|
||||||
// the next effect that needs them re-allocates at the new size.
|
// the next effect that needs them re-allocates at the new size.
|
||||||
self.invalidate_aux();
|
self.invalidate_aux();
|
||||||
|
|||||||
@@ -147,6 +147,33 @@ void main()
|
|||||||
}
|
}
|
||||||
"##;
|
"##;
|
||||||
|
|
||||||
|
// Clip-layer composite shader for `set_clip_path`. Path-clipped content is
|
||||||
|
// drawn to an offscreen layer; this composites that layer onto the canvas
|
||||||
|
// multiplied by an anti-aliased coverage mask, giving a smooth clipped edge.
|
||||||
|
// `u_layer` is the full-canvas layer texture (premultiplied), `u_mask` the
|
||||||
|
// path coverage rasterised over `u_bbox` (x, y, w, h in canvas pixels). The
|
||||||
|
// quad is drawn over the bbox; `v_uv` runs 0..1 across it. Both FBO-backed
|
||||||
|
// textures are sampled y-flipped (GL's lower-left origin).
|
||||||
|
pub( super ) const CLIP_COMPOSITE_FRAG_SRC: &str = r##"
|
||||||
|
precision mediump float;
|
||||||
|
varying vec2 v_uv;
|
||||||
|
uniform sampler2D u_layer;
|
||||||
|
uniform sampler2D u_mask;
|
||||||
|
uniform vec2 u_canvas;
|
||||||
|
uniform vec4 u_bbox;
|
||||||
|
void main()
|
||||||
|
{
|
||||||
|
// `v_uv.y` runs bottom-to-top in screen space (FBO origin is lower-left, as
|
||||||
|
// `ortho_rect` + the texture shader's flip establish), so the screen Y of
|
||||||
|
// this fragment is `bbox.y + (1 - v_uv.y) * bbox.h`.
|
||||||
|
vec2 sp = vec2( u_bbox.x + v_uv.x * u_bbox.z, u_bbox.y + ( 1.0 - v_uv.y ) * u_bbox.w );
|
||||||
|
vec2 luv = vec2( sp.x / u_canvas.x, 1.0 - sp.y / u_canvas.y );
|
||||||
|
vec4 col = texture2D( u_layer, luv );
|
||||||
|
float cov = texture2D( u_mask, vec2( v_uv.x, 1.0 - v_uv.y ) ).a;
|
||||||
|
gl_FragColor = col * cov;
|
||||||
|
}
|
||||||
|
"##;
|
||||||
|
|
||||||
// Fragment shader for single-channel glyph textures with color tint.
|
// Fragment shader for single-channel glyph textures with color tint.
|
||||||
//
|
//
|
||||||
// Glyphs live in a shared GL_LUMINANCE atlas. `u_uv_scale` and `u_uv_offset`
|
// Glyphs live in a shared GL_LUMINANCE atlas. `u_uv_scale` and `u_uv_offset`
|
||||||
|
|||||||
@@ -34,11 +34,19 @@ fn font_id( font: &Arc<Font> ) -> usize
|
|||||||
|
|
||||||
impl GlesCanvas
|
impl GlesCanvas
|
||||||
{
|
{
|
||||||
|
/// Draw a single shaped line of `text` with the canvas default font and the
|
||||||
|
/// system fallback chain, baseline at `( x, y )` in surface px. `size` is in
|
||||||
|
/// logical px and scaled by `dpi_scale` before rasterisation. Glyphs are
|
||||||
|
/// shelf-packed into the GPU atlas and the whole line is flushed in one batched
|
||||||
|
/// draw call.
|
||||||
pub fn draw_text( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color )
|
pub fn draw_text( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color )
|
||||||
{
|
{
|
||||||
self.draw_text_inner( text, x, y, size, color, None );
|
self.draw_text_inner( text, x, y, size, color, None );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Like [`Self::draw_text`] but leads the resolver with `font` instead of the
|
||||||
|
/// canvas default, falling back to the system chain for codepoints it does not
|
||||||
|
/// cover.
|
||||||
pub fn draw_text_with_font( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color, font: &Arc<Font> )
|
pub fn draw_text_with_font( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color, font: &Arc<Font> )
|
||||||
{
|
{
|
||||||
self.draw_text_inner( text, x, y, size, color, Some( font ) );
|
self.draw_text_inner( text, x, y, size, color, Some( font ) );
|
||||||
@@ -92,7 +100,9 @@ impl GlesCanvas
|
|||||||
if !canvas_handle.bytes.is_empty() { Some( canvas_handle.clone() ) } else { None }
|
if !canvas_handle.bytes.is_empty() { Some( canvas_handle.clone() ) } else { None }
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
let shaped = crate::text_shaping::shape_line( text, scaled, resolve );
|
let shaped = crate::text_shaping::shape_line_cached(
|
||||||
|
text, scaled, Arc::as_ptr( &prefer_handle.font ) as usize, resolve,
|
||||||
|
);
|
||||||
if shaped.is_empty() { return; }
|
if shaped.is_empty() { return; }
|
||||||
|
|
||||||
// Resolve each glyph's `font_id` to an actual `Arc<Font>` for
|
// Resolve each glyph's `font_id` to an actual `Arc<Font>` for
|
||||||
@@ -105,7 +115,7 @@ impl GlesCanvas
|
|||||||
{
|
{
|
||||||
fonts.push( ( font_id( &canvas_handle.font ), Arc::clone( &canvas_handle.font ) ) );
|
fonts.push( ( font_id( &canvas_handle.font ), Arc::clone( &canvas_handle.font ) ) );
|
||||||
}
|
}
|
||||||
for g in &shaped
|
for g in shaped.iter()
|
||||||
{
|
{
|
||||||
if fonts.iter().any( |( id, _ )| *id == g.font_id ) { continue; }
|
if fonts.iter().any( |( id, _ )| *id == g.font_id ) { continue; }
|
||||||
let mut found = None;
|
let mut found = None;
|
||||||
@@ -222,7 +232,7 @@ impl GlesCanvas
|
|||||||
let surface_h = self.height as f32;
|
let surface_h = self.height as f32;
|
||||||
let mut verts: Vec<f32> = Vec::with_capacity( shaped.len() * 24 );
|
let mut verts: Vec<f32> = Vec::with_capacity( shaped.len() * 24 );
|
||||||
let mut cx = x;
|
let mut cx = x;
|
||||||
for g in &shaped
|
for g in shaped.iter()
|
||||||
{
|
{
|
||||||
let glyph_id = g.glyph_id as u16;
|
let glyph_id = g.glyph_id as u16;
|
||||||
let key = ( glyph_id, size_key, g.font_id );
|
let key = ( glyph_id, size_key, g.font_id );
|
||||||
@@ -285,11 +295,16 @@ impl GlesCanvas
|
|||||||
unsafe { self.gl.bind_texture( glow::TEXTURE_2D, None ); }
|
unsafe { self.gl.bind_texture( glow::TEXTURE_2D, None ); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Advance width of one shaped line of `text` in surface px, using the canvas
|
||||||
|
/// default font and the system fallback chain. Shapes through the same path as
|
||||||
|
/// [`Self::draw_text`] (so kerning and fallback advances match) without drawing.
|
||||||
pub fn measure_text( &self, text: &str, size: f32 ) -> f32
|
pub fn measure_text( &self, text: &str, size: f32 ) -> f32
|
||||||
{
|
{
|
||||||
self.measure_inner( text, size, None )
|
self.measure_inner( text, size, None )
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Like [`Self::measure_text`] but measures with `font` leading the resolver,
|
||||||
|
/// so text laid out at one weight and drawn at another stays aligned.
|
||||||
pub fn measure_text_with_font( &self, text: &str, size: f32, font: &Arc<Font> ) -> f32
|
pub fn measure_text_with_font( &self, text: &str, size: f32, font: &Arc<Font> ) -> f32
|
||||||
{
|
{
|
||||||
self.measure_inner( text, size, Some( font ) )
|
self.measure_inner( text, size, Some( font ) )
|
||||||
@@ -325,7 +340,9 @@ impl GlesCanvas
|
|||||||
if !canvas_handle.bytes.is_empty() { Some( canvas_handle.clone() ) } else { None }
|
if !canvas_handle.bytes.is_empty() { Some( canvas_handle.clone() ) } else { None }
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
let shaped = crate::text_shaping::shape_line( text, scaled, resolve );
|
let shaped = crate::text_shaping::shape_line_cached(
|
||||||
|
text, scaled, Arc::as_ptr( &prefer_handle.font ) as usize, resolve,
|
||||||
|
);
|
||||||
if shaped.is_empty()
|
if shaped.is_empty()
|
||||||
{
|
{
|
||||||
return text.chars().map( |ch|
|
return text.chars().map( |ch|
|
||||||
|
|||||||
@@ -44,15 +44,46 @@ impl<A: App> AppData<A>
|
|||||||
{
|
{
|
||||||
self.surface_mut( focus ).request_redraw();
|
self.surface_mut( focus ).request_redraw();
|
||||||
}
|
}
|
||||||
|
MoveOutcome::OverscrollDown { progress } =>
|
||||||
|
{
|
||||||
|
self.app.on_overscroll_down_progress( progress );
|
||||||
|
// Like a vertical swipe, the dismiss rides an overlay panel
|
||||||
|
// (subsurface) over a static main, so refresh the overlays but
|
||||||
|
// leave the main alone — re-rastering it every motion is waste.
|
||||||
|
self.overlays_dirty = true;
|
||||||
|
for ss in self.overlays.values_mut()
|
||||||
|
{
|
||||||
|
ss.request_redraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
MoveOutcome::Swipe { up, down, horizontal } =>
|
MoveOutcome::Swipe { up, down, horizontal } =>
|
||||||
{
|
{
|
||||||
if let Some( v ) = up { self.app.on_swipe_progress( v ); }
|
if let Some( v ) = up { self.app.on_swipe_progress( v ); }
|
||||||
if let Some( v ) = down { self.app.on_swipe_down_progress( v ); }
|
if let Some( v ) = down { self.app.on_swipe_down_progress( v ); }
|
||||||
if let Some( v ) = horizontal { self.app.on_swipe_horizontal_progress( v ); }
|
if let Some( v ) = horizontal { self.app.on_swipe_horizontal_progress( v ); }
|
||||||
// Swipe-progress callbacks mutate app state outside
|
// Swipe-progress callbacks mutate app state outside `update`,
|
||||||
// `update`, so the cached view tree is stale.
|
// so the cached trees are stale and the affected surfaces must
|
||||||
self.dirty_caches();
|
// redraw. Only the HORIZONTAL pager moves the main surface,
|
||||||
self.surface_mut( focus ).request_redraw();
|
// though — vertical swipes drive overlay panels and leave the
|
||||||
|
// main static. Re-rastering the (full-screen) main on every
|
||||||
|
// motion event of a vertical swipe is pure waste and, on a slow
|
||||||
|
// GPU, stalls the event loop so the gesture itself feels laggy
|
||||||
|
// to start. So refresh the overlays always, but the main only
|
||||||
|
// for a horizontal swipe.
|
||||||
|
// ...unless the app is sliding the pager on subsurfaces
|
||||||
|
// (`subsurface_motion_only`): then the main is unchanged and the
|
||||||
|
// slide is a per-iteration subsurface reposition, so re-rastering
|
||||||
|
// the full main every motion event is the same waste the vertical
|
||||||
|
// panels already avoid — and, with child subsurfaces, it flickers.
|
||||||
|
if horizontal.is_some() && !self.app.subsurface_motion_only()
|
||||||
|
{
|
||||||
|
self.view_dirty = true;
|
||||||
|
if let SurfaceFocus::Main = focus
|
||||||
|
{
|
||||||
|
self.main.request_redraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.overlays_dirty = true;
|
||||||
for ss in self.overlays.values_mut()
|
for ss in self.overlays.values_mut()
|
||||||
{
|
{
|
||||||
ss.request_redraw();
|
ss.request_redraw();
|
||||||
@@ -158,6 +189,21 @@ impl<A: App> AppData<A>
|
|||||||
ss.frame_pending = false;
|
ss.frame_pending = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ReleaseEvent::OverscrollDown { committed } =>
|
||||||
|
{
|
||||||
|
if let Some( msg ) = self.app.on_overscroll_down( committed )
|
||||||
|
{
|
||||||
|
self.pending_msgs.push( msg );
|
||||||
|
}
|
||||||
|
self.dirty_caches();
|
||||||
|
self.main.request_redraw();
|
||||||
|
self.main.frame_pending = false;
|
||||||
|
for ss in self.overlays.values_mut()
|
||||||
|
{
|
||||||
|
ss.request_redraw();
|
||||||
|
ss.frame_pending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
ReleaseEvent::HorizontalFellThrough =>
|
ReleaseEvent::HorizontalFellThrough =>
|
||||||
{
|
{
|
||||||
// Below threshold: pulse horizontal 0 so a
|
// Below threshold: pulse horizontal 0 so a
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ impl<A: App> AppData<A>
|
|||||||
pos: Point,
|
pos: Point,
|
||||||
) -> bool
|
) -> bool
|
||||||
{
|
{
|
||||||
let toggle_msg = self.surface( focus ).widget_rects.iter()
|
let toggle_msg = self.surface( focus ).frame.widget_rects.iter()
|
||||||
.find( |w| w.flat_idx == idx )
|
.find( |w| w.flat_idx == idx )
|
||||||
.and_then( |w| match &w.handlers
|
.and_then( |w| match &w.handlers
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -47,6 +47,17 @@ mod tests;
|
|||||||
|
|
||||||
// ─── State ───────────────────────────────────────────────────────────────────
|
// ─── State ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Axis a swipe locked onto during its first 8 px of travel. Once set,
|
||||||
|
/// the perpendicular axis is ignored for the rest of the gesture so a
|
||||||
|
/// vertical swipe that drifts sideways never also drives the pager (and
|
||||||
|
/// vice-versa).
|
||||||
|
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
|
||||||
|
pub enum SwipeAxis
|
||||||
|
{
|
||||||
|
Vertical,
|
||||||
|
Horizontal,
|
||||||
|
}
|
||||||
|
|
||||||
/// Per-surface gesture tracking. Lives on `SurfaceState::gesture`.
|
/// Per-surface gesture tracking. Lives on `SurfaceState::gesture`.
|
||||||
///
|
///
|
||||||
/// Every field is `pub` because `AppData`'s long-press deadline
|
/// Every field is `pub` because `AppData`'s long-press deadline
|
||||||
@@ -68,6 +79,10 @@ pub struct GestureState<Msg: Clone>
|
|||||||
/// Index of the Scroll viewport that owns the current gesture, if
|
/// Index of the Scroll viewport that owns the current gesture, if
|
||||||
/// the press landed inside one.
|
/// the press landed inside one.
|
||||||
pub scrolling_widget: Option<( usize, crate::widget::scroll::ScrollAxis )>,
|
pub scrolling_widget: Option<( usize, crate::widget::scroll::ScrollAxis )>,
|
||||||
|
/// Scroll viewports containing the press, innermost first.
|
||||||
|
pub scroll_candidates: Vec<( usize, crate::widget::scroll::ScrollAxis )>,
|
||||||
|
/// `true` once the first 8 px of motion has pinned `scrolling_widget` to a definite axis.
|
||||||
|
pub scroll_locked: bool,
|
||||||
/// Scroll-viewport drag exceeded the 8 px start tolerance — the
|
/// Scroll-viewport drag exceeded the 8 px start tolerance — the
|
||||||
/// release will be consumed as a scroll instead of a tap.
|
/// release will be consumed as a scroll instead of a tap.
|
||||||
pub scroll_drag_started: bool,
|
pub scroll_drag_started: bool,
|
||||||
@@ -77,6 +92,10 @@ pub struct GestureState<Msg: Clone>
|
|||||||
/// Vertical drag exceeded the 8 px threshold. Release runs the
|
/// Vertical drag exceeded the 8 px threshold. Release runs the
|
||||||
/// swipe-up / swipe-down commit check instead of the button path.
|
/// swipe-up / swipe-down commit check instead of the button path.
|
||||||
pub vertical_drag_started: bool,
|
pub vertical_drag_started: bool,
|
||||||
|
/// Axis this swipe locked onto on its first 8 px of travel. Pins the
|
||||||
|
/// gesture to one axis so vertical and horizontal swipes stay
|
||||||
|
/// mutually exclusive. `None` until the lock fires.
|
||||||
|
pub swipe_axis: Option<SwipeAxis>,
|
||||||
/// Slider widget being dragged for a live-value update.
|
/// Slider widget being dragged for a live-value update.
|
||||||
pub dragging_slider: Option<usize>,
|
pub dragging_slider: Option<usize>,
|
||||||
/// Instant when the long-press window opened. `None` if the hit
|
/// Instant when the long-press window opened. `None` if the hit
|
||||||
@@ -109,6 +128,12 @@ pub struct GestureState<Msg: Clone>
|
|||||||
/// `app.on_drop`. Set by the touch deadline when `drag_start_msg`
|
/// `app.on_drop`. Set by the touch deadline when `drag_start_msg`
|
||||||
/// is present, or by the mouse-motion promotion path.
|
/// is present, or by the mouse-motion promotion path.
|
||||||
pub long_press_fired: bool,
|
pub long_press_fired: bool,
|
||||||
|
/// Accumulated downward overscroll (physical px) while a vertical
|
||||||
|
/// scroll is pinned at its top — built up by [`Self::on_move`] when a
|
||||||
|
/// pull-down can no longer scroll content, unwound by upward drag, and
|
||||||
|
/// drained on release into an `OverscrollDown` event. Drives
|
||||||
|
/// pull-from-top-to-dismiss.
|
||||||
|
pub overscroll_y: f32,
|
||||||
/// `true` when the press came from a mouse (`wl_pointer`) rather
|
/// `true` when the press came from a mouse (`wl_pointer`) rather
|
||||||
/// than touch. The 6 px stray-cancel in [`Self::on_move`] is gated
|
/// than touch. The 6 px stray-cancel in [`Self::on_move`] is gated
|
||||||
/// on this: touch needs the cancel so a hold that turns into a
|
/// on this: touch needs the cancel so a hold that turns into a
|
||||||
@@ -136,9 +161,12 @@ impl<Msg: Clone> GestureState<Msg>
|
|||||||
start: None,
|
start: None,
|
||||||
pressed_idx: None,
|
pressed_idx: None,
|
||||||
scrolling_widget: None,
|
scrolling_widget: None,
|
||||||
|
scroll_candidates: Vec::new(),
|
||||||
|
scroll_locked: false,
|
||||||
scroll_drag_started: false,
|
scroll_drag_started: false,
|
||||||
horizontal_drag_started: false,
|
horizontal_drag_started: false,
|
||||||
vertical_drag_started: false,
|
vertical_drag_started: false,
|
||||||
|
swipe_axis: None,
|
||||||
dragging_slider: None,
|
dragging_slider: None,
|
||||||
long_press_start: None,
|
long_press_start: None,
|
||||||
long_press_origin: None,
|
long_press_origin: None,
|
||||||
@@ -146,6 +174,7 @@ impl<Msg: Clone> GestureState<Msg>
|
|||||||
drag_start_msg: None,
|
drag_start_msg: None,
|
||||||
long_press_text_idx: None,
|
long_press_text_idx: None,
|
||||||
long_press_fired: false,
|
long_press_fired: false,
|
||||||
|
overscroll_y: 0.0,
|
||||||
mouse_press: false,
|
mouse_press: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,12 +204,17 @@ impl<Msg: Clone> GestureState<Msg>
|
|||||||
}).unwrap_or( ( None, None ) );
|
}).unwrap_or( ( None, None ) );
|
||||||
|
|
||||||
self.start = Some( pos );
|
self.start = Some( pos );
|
||||||
self.scrolling_widget = scroll_rects.iter().rev()
|
self.scroll_candidates = scroll_rects.iter()
|
||||||
.find( |( r, _, _ )| r.contains( pos ) )
|
.filter( |( r, _, _ )| r.contains( pos ) )
|
||||||
.map( |( _, idx, ax )| ( *idx, *ax ) );
|
.map( |( _, idx, ax )| ( *idx, *ax ) )
|
||||||
|
.collect();
|
||||||
|
self.scrolling_widget = self.scroll_candidates.first().copied();
|
||||||
|
self.scroll_locked = false;
|
||||||
self.scroll_drag_started = false;
|
self.scroll_drag_started = false;
|
||||||
|
self.overscroll_y = 0.0;
|
||||||
self.horizontal_drag_started = false;
|
self.horizontal_drag_started = false;
|
||||||
self.vertical_drag_started = false;
|
self.vertical_drag_started = false;
|
||||||
|
self.swipe_axis = None;
|
||||||
self.pressed_idx = hit;
|
self.pressed_idx = hit;
|
||||||
// A fresh press resets any stale long-press / source state
|
// A fresh press resets any stale long-press / source state
|
||||||
// from a prior gesture that never released cleanly. The
|
// from a prior gesture that never released cleanly. The
|
||||||
@@ -289,48 +323,100 @@ impl<Msg: Clone> GestureState<Msg>
|
|||||||
return MoveOutcome::Idle;
|
return MoveOutcome::Idle;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll viewport drag: mutate offset in place and advance the
|
if self.scrolling_widget.is_some()
|
||||||
// gesture origin so the next delta is frame-to-frame, not
|
|
||||||
// press-to-now (otherwise the first 8 px trip the threshold
|
|
||||||
// and the entire scroll gets absorbed into one delta). Both
|
|
||||||
// axes are routed independently; an axis the viewport does not
|
|
||||||
// allow is just ignored (delta still consumed by the gesture,
|
|
||||||
// so the swipe handler does not fight the scroll for it).
|
|
||||||
if let Some( ( scroll_idx, axis ) ) = self.scrolling_widget
|
|
||||||
{
|
{
|
||||||
if let Some( start ) = self.start
|
if let Some( start ) = self.start
|
||||||
{
|
{
|
||||||
let dx = pos.x - start.x;
|
let dx = pos.x - start.x;
|
||||||
let dy = pos.y - start.y;
|
let dy = pos.y - start.y;
|
||||||
|
|
||||||
|
if !self.scroll_locked
|
||||||
|
{
|
||||||
|
if dx.abs() <= 8.0 && dy.abs() <= 8.0
|
||||||
|
{
|
||||||
|
return MoveOutcome::Idle;
|
||||||
|
}
|
||||||
|
let prefer_x = dx.abs() > dy.abs();
|
||||||
|
if let Some( c ) = self.scroll_candidates.iter()
|
||||||
|
.find( |( _, ax )| if prefer_x { ax.allows_x() } else { ax.allows_y() } )
|
||||||
|
.copied()
|
||||||
|
{
|
||||||
|
self.scrolling_widget = Some( c );
|
||||||
|
}
|
||||||
|
self.scroll_locked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ( scroll_idx, axis ) = self.scrolling_widget.unwrap();
|
||||||
let entry = scroll_offsets.entry( scroll_idx ).or_insert( ( 0.0, 0.0 ) );
|
let entry = scroll_offsets.entry( scroll_idx ).or_insert( ( 0.0, 0.0 ) );
|
||||||
if axis.allows_x() { entry.0 = ( entry.0 - dx ).max( 0.0 ); }
|
if axis.allows_x() { entry.0 = ( entry.0 - dx ).max( 0.0 ); }
|
||||||
if axis.allows_y() { entry.1 = ( entry.1 - dy ).max( 0.0 ); }
|
if axis.allows_y()
|
||||||
|
{
|
||||||
|
// Split the vertical delta between content scroll and top
|
||||||
|
// overscroll: a downward pull first scrolls toward the top,
|
||||||
|
// then any excess (the offset can't go below 0) feeds
|
||||||
|
// `overscroll_y`; an upward pull unwinds that overscroll
|
||||||
|
// before it resumes scrolling content.
|
||||||
|
if dy >= 0.0
|
||||||
|
{
|
||||||
|
let consume = dy.min( entry.1 );
|
||||||
|
entry.1 -= consume;
|
||||||
|
self.overscroll_y += dy - consume;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
let up = -dy;
|
||||||
|
let consume = up.min( self.overscroll_y );
|
||||||
|
self.overscroll_y -= consume;
|
||||||
|
entry.1 += up - consume;
|
||||||
|
}
|
||||||
|
}
|
||||||
let moved = if axis.allows_x() && axis.allows_y() { dx.hypot( dy ) }
|
let moved = if axis.allows_x() && axis.allows_y() { dx.hypot( dy ) }
|
||||||
else if axis.allows_x() { dx.abs() }
|
else if axis.allows_x() { dx.abs() }
|
||||||
else { dy.abs() };
|
else { dy.abs() };
|
||||||
if moved > 8.0 { self.scroll_drag_started = true; }
|
if moved > 8.0 { self.scroll_drag_started = true; }
|
||||||
self.start = Some( pos );
|
self.start = Some( pos );
|
||||||
|
// Gate the dismiss on the same 8 px arm as a real scroll drag so
|
||||||
|
// tap jitter at the top can't engage a follow-the-finger close.
|
||||||
|
if self.overscroll_y > 0.0 && axis.allows_y() && self.scroll_drag_started
|
||||||
|
{
|
||||||
|
let progress = if swipe.surface_height > 0
|
||||||
|
{
|
||||||
|
self.overscroll_y / swipe.surface_height as f32
|
||||||
|
}
|
||||||
|
else { 0.0 };
|
||||||
|
return MoveOutcome::OverscrollDown { progress };
|
||||||
|
}
|
||||||
return MoveOutcome::Scroll;
|
return MoveOutcome::Scroll;
|
||||||
}
|
}
|
||||||
return MoveOutcome::Idle;
|
return MoveOutcome::Idle;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Swipe progress — independent on both axes so a vertical-
|
// Swipe progress — locked to a single axis (see below) so a
|
||||||
// dominant gesture that picks up horizontal drift still drives
|
// gesture only ever drives one of the vertical panels or the
|
||||||
// the pager.
|
// horizontal pager, never both.
|
||||||
if let Some( start ) = self.start
|
if let Some( start ) = self.start
|
||||||
{
|
{
|
||||||
let dy = start.y - pos.y;
|
let dy = start.y - pos.y;
|
||||||
let dx = pos.x - start.x;
|
let dx = pos.x - start.x;
|
||||||
|
|
||||||
let ( up, down ) = if swipe.surface_height > 0
|
// Lock onto the dominant axis on the first 8 px of travel so
|
||||||
|
// vertical and horizontal swipes stay mutually exclusive.
|
||||||
|
if self.swipe_axis.is_none() && dx.abs().max( dy.abs() ) > 8.0
|
||||||
|
{
|
||||||
|
self.swipe_axis = Some( if dy.abs() >= dx.abs() { SwipeAxis::Vertical } else { SwipeAxis::Horizontal } );
|
||||||
|
}
|
||||||
|
let vertical_allowed = self.swipe_axis != Some( SwipeAxis::Horizontal );
|
||||||
|
|
||||||
|
let ( up, down ) = if vertical_allowed && swipe.surface_height > 0
|
||||||
{
|
{
|
||||||
let h = swipe.surface_height as f32;
|
let h = swipe.surface_height as f32;
|
||||||
if dy > 0.0
|
if dy > 0.0
|
||||||
{
|
{
|
||||||
let threshold = h * swipe.up_thresh;
|
let threshold = h * swipe.up_thresh;
|
||||||
if dy.abs() > 8.0 { self.vertical_drag_started = true; }
|
if dy.abs() > 8.0 { self.vertical_drag_started = true; }
|
||||||
( Some( ( dy / threshold ).clamp( 0.0, 1.0 ) ), None )
|
// Unclamped on purpose — lets follow-the-finger
|
||||||
|
// panels track past the commit threshold.
|
||||||
|
( Some( ( dy / threshold ).max( 0.0 ) ), None )
|
||||||
}
|
}
|
||||||
else if start.y <= h * swipe.down_edge
|
else if start.y <= h * swipe.down_edge
|
||||||
{
|
{
|
||||||
@@ -344,11 +430,18 @@ impl<Msg: Clone> GestureState<Msg>
|
|||||||
}
|
}
|
||||||
else { ( None, None ) };
|
else { ( None, None ) };
|
||||||
|
|
||||||
let horizontal = if swipe.surface_width > 0
|
// Only drive the horizontal pager once the gesture has LOCKED onto the
|
||||||
|
// horizontal axis. Emitting progress while `swipe_axis` is still None
|
||||||
|
// would activate the pager from the sub-8 px lateral jitter at the
|
||||||
|
// start of a vertical swipe; `horizontal_drag_started` would then never
|
||||||
|
// trip, so the release emits no horizontal event and the pager stays
|
||||||
|
// stuck active (frozen homescreen). Locking to Horizontal implies
|
||||||
|
// `dx.abs() > 8.0`, so the drag is unambiguously started here.
|
||||||
|
let horizontal = if self.swipe_axis == Some( SwipeAxis::Horizontal ) && swipe.surface_width > 0
|
||||||
{
|
{
|
||||||
let h_thr = swipe.surface_width as f32 * swipe.horizontal_thresh;
|
let h_thr = swipe.surface_width as f32 * swipe.horizontal_thresh;
|
||||||
let h_progress = if h_thr > 0.0 { dx / h_thr } else { 0.0 };
|
let h_progress = if h_thr > 0.0 { dx / h_thr } else { 0.0 };
|
||||||
if dx.abs() > 8.0 { self.horizontal_drag_started = true; }
|
self.horizontal_drag_started = true;
|
||||||
Some( h_progress )
|
Some( h_progress )
|
||||||
}
|
}
|
||||||
else { None };
|
else { None };
|
||||||
@@ -426,6 +519,15 @@ impl<Msg: Clone> GestureState<Msg>
|
|||||||
if scroll_consumed
|
if scroll_consumed
|
||||||
{
|
{
|
||||||
self.start = None;
|
self.start = None;
|
||||||
|
// A pull past the top releases into an overscroll-dismiss event;
|
||||||
|
// the app decides whether the accumulated pull committed.
|
||||||
|
let over = self.overscroll_y;
|
||||||
|
self.overscroll_y = 0.0;
|
||||||
|
if over > 0.0
|
||||||
|
{
|
||||||
|
let threshold = swipe.surface_height as f32 * swipe.overscroll_thresh;
|
||||||
|
events.push( ReleaseEvent::OverscrollDown { committed: over >= threshold } );
|
||||||
|
}
|
||||||
return events;
|
return events;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ pub enum MoveOutcome<Msg>
|
|||||||
Slider { msg: Option<Msg> },
|
Slider { msg: Option<Msg> },
|
||||||
/// Scroll viewport offset updated. Handler just requests a redraw.
|
/// Scroll viewport offset updated. Handler just requests a redraw.
|
||||||
Scroll,
|
Scroll,
|
||||||
|
/// Vertical scroll pinned at the top while the finger keeps pulling
|
||||||
|
/// down: `progress` is the accumulated overscroll as a fraction of
|
||||||
|
/// surface height. Handler fires `app.on_overscroll_down_progress`.
|
||||||
|
OverscrollDown { progress: f32 },
|
||||||
/// Swipe progress. Each axis is `Some(value)` when it has a valid
|
/// Swipe progress. Each axis is `Some(value)` when it has a valid
|
||||||
/// progress reading for this move (not every motion updates all
|
/// progress reading for this move (not every motion updates all
|
||||||
/// axes). Handler fires the matching app callback.
|
/// axes). Handler fires the matching app callback.
|
||||||
@@ -50,6 +54,10 @@ pub enum ReleaseEvent<Msg>
|
|||||||
SwipeUp,
|
SwipeUp,
|
||||||
/// Vertical swipe committed down.
|
/// Vertical swipe committed down.
|
||||||
SwipeDown,
|
SwipeDown,
|
||||||
|
/// Released after a top overscroll pull-down — fire
|
||||||
|
/// `app.on_overscroll_down(committed)`. `committed` is set when the
|
||||||
|
/// pull passed `overscroll_thresh` of surface height.
|
||||||
|
OverscrollDown { committed: bool },
|
||||||
/// Horizontal swipe did not commit — fire
|
/// Horizontal swipe did not commit — fire
|
||||||
/// `app.on_swipe_horizontal_progress(0.0)` + main redraw.
|
/// `app.on_swipe_horizontal_progress(0.0)` + main redraw.
|
||||||
HorizontalFellThrough,
|
HorizontalFellThrough,
|
||||||
@@ -79,6 +87,9 @@ pub struct SwipeConfig
|
|||||||
pub down_edge: f32,
|
pub down_edge: f32,
|
||||||
/// Horizontal commit fraction of surface width.
|
/// Horizontal commit fraction of surface width.
|
||||||
pub horizontal_thresh: f32,
|
pub horizontal_thresh: f32,
|
||||||
|
/// Top-overscroll dismiss commit fraction of surface height — the
|
||||||
|
/// pull-down past a scroll's top must exceed this to commit.
|
||||||
|
pub overscroll_thresh: f32,
|
||||||
pub surface_width: u32,
|
pub surface_width: u32,
|
||||||
pub surface_height: u32,
|
pub surface_height: u32,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ fn cfg_full( w: u32, h: u32 ) -> SwipeConfig
|
|||||||
down_thresh: 0.30,
|
down_thresh: 0.30,
|
||||||
down_edge: 0.10,
|
down_edge: 0.10,
|
||||||
horizontal_thresh: 0.30,
|
horizontal_thresh: 0.30,
|
||||||
|
overscroll_thresh: 0.25,
|
||||||
surface_width: w,
|
surface_width: w,
|
||||||
surface_height: h,
|
surface_height: h,
|
||||||
}
|
}
|
||||||
@@ -269,6 +270,76 @@ fn move_below_eight_pixels_does_not_arm_scroll_drag()
|
|||||||
assert!( !g.scroll_drag_started );
|
assert!( !g.scroll_drag_started );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── on_move / on_release: top overscroll dismiss ──────────────────────────
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn pull_down_at_top_emits_overscroll_progress()
|
||||||
|
{
|
||||||
|
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
|
||||||
|
let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 3, ScrollAxis::Vertical ) ];
|
||||||
|
let mut g = GestureState::<Msg>::new();
|
||||||
|
let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls );
|
||||||
|
let mut offsets = HashMap::new();
|
||||||
|
// Already at the top; drag down 120 px → all of it is overscroll and the
|
||||||
|
// content offset stays pinned at 0.
|
||||||
|
let out = g.on_move( pt( 100.0, 220.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
|
||||||
|
assert_eq!( offsets.get( &3 ).copied(), Some( ( 0.0, 0.0 ) ) );
|
||||||
|
match out
|
||||||
|
{
|
||||||
|
MoveOutcome::OverscrollDown { progress } =>
|
||||||
|
assert!( ( progress - 120.0 / 1200.0 ).abs() < 1e-4 ),
|
||||||
|
_ => panic!( "expected OverscrollDown" ),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn upward_drag_unwinds_overscroll_before_scrolling_content()
|
||||||
|
{
|
||||||
|
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
|
||||||
|
let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 4, ScrollAxis::Vertical ) ];
|
||||||
|
let mut g = GestureState::<Msg>::new();
|
||||||
|
let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls );
|
||||||
|
let mut offsets = HashMap::new();
|
||||||
|
let _ = g.on_move( pt( 100.0, 200.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
|
||||||
|
assert!( ( g.overscroll_y - 100.0 ).abs() < 1e-4 );
|
||||||
|
// Drag back up 60 px → unwinds 60 px of overscroll, content stays at top.
|
||||||
|
let _ = g.on_move( pt( 100.0, 140.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
|
||||||
|
assert!( ( g.overscroll_y - 40.0 ).abs() < 1e-4 );
|
||||||
|
assert_eq!( offsets.get( &4 ).map( |o| o.1 ), Some( 0.0 ) );
|
||||||
|
// A further 90 px up unwinds the remaining 40 and scrolls the 50 px excess.
|
||||||
|
let _ = g.on_move( pt( 100.0, 50.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
|
||||||
|
assert_eq!( g.overscroll_y, 0.0 );
|
||||||
|
assert_eq!( offsets.get( &4 ).map( |o| o.1 ), Some( 50.0 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn release_after_overscroll_past_threshold_commits_dismiss()
|
||||||
|
{
|
||||||
|
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
|
||||||
|
let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 5, ScrollAxis::Vertical ) ];
|
||||||
|
let mut g = GestureState::<Msg>::new();
|
||||||
|
let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls );
|
||||||
|
let mut offsets = HashMap::new();
|
||||||
|
// cfg_full overscroll_thresh is 0.25 → 300 px of 1200 commits; drag 400 px.
|
||||||
|
let _ = g.on_move( pt( 100.0, 500.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
|
||||||
|
let events = g.on_release( pt( 100.0, 500.0 ), &widgets, &cfg_full( 800, 1200 ), false );
|
||||||
|
assert!( events.iter().any( |e| matches!( e, ReleaseEvent::OverscrollDown { committed: true } ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn release_after_short_overscroll_does_not_commit()
|
||||||
|
{
|
||||||
|
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
|
||||||
|
let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 6, ScrollAxis::Vertical ) ];
|
||||||
|
let mut g = GestureState::<Msg>::new();
|
||||||
|
let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls );
|
||||||
|
let mut offsets = HashMap::new();
|
||||||
|
// 100 px is below the 300 px commit threshold.
|
||||||
|
let _ = g.on_move( pt( 100.0, 200.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
|
||||||
|
let events = g.on_release( pt( 100.0, 200.0 ), &widgets, &cfg_full( 800, 1200 ), false );
|
||||||
|
assert!( events.iter().any( |e| matches!( e, ReleaseEvent::OverscrollDown { committed: false } ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
// ── on_move: swipe progress ───────────────────────────────────────────────
|
// ── on_move: swipe progress ───────────────────────────────────────────────
|
||||||
|
|
||||||
#[ test ]
|
#[ test ]
|
||||||
@@ -309,10 +380,58 @@ fn move_horizontal_eleven_pixels_arms_horizontal_drag()
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[ test ]
|
#[ test ]
|
||||||
fn move_up_progress_clamps_to_unit_interval()
|
fn vertical_lock_suppresses_later_horizontal_drift()
|
||||||
|
{
|
||||||
|
// A swipe that locks vertical must not arm the horizontal pager even
|
||||||
|
// if the finger later drifts sideways past the 8 px threshold.
|
||||||
|
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
|
||||||
|
let mut g = GestureState::<Msg>::new();
|
||||||
|
g.start = Some( pt( 100.0, 600.0 ) );
|
||||||
|
let mut offsets = HashMap::new();
|
||||||
|
let _ = g.on_move( pt( 100.0, 560.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
|
||||||
|
assert_eq!( g.swipe_axis, Some( SwipeAxis::Vertical ) );
|
||||||
|
let out = g.on_move( pt( 140.0, 540.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
|
||||||
|
assert!( !g.horizontal_drag_started );
|
||||||
|
assert!( matches!( out, MoveOutcome::Swipe { horizontal: None, .. } ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn horizontal_lock_suppresses_later_vertical_drift()
|
||||||
|
{
|
||||||
|
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
|
||||||
|
let mut g = GestureState::<Msg>::new();
|
||||||
|
g.start = Some( pt( 100.0, 600.0 ) );
|
||||||
|
let mut offsets = HashMap::new();
|
||||||
|
let _ = g.on_move( pt( 140.0, 600.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
|
||||||
|
assert_eq!( g.swipe_axis, Some( SwipeAxis::Horizontal ) );
|
||||||
|
let out = g.on_move( pt( 160.0, 560.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
|
||||||
|
assert!( !g.vertical_drag_started );
|
||||||
|
assert!( matches!( out, MoveOutcome::Swipe { up: None, down: None, .. } ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn pre_lock_lateral_drift_does_not_drive_horizontal_pager()
|
||||||
|
{
|
||||||
|
// Regression: a vertical swipe that opens with a few px of lateral drift
|
||||||
|
// (axis not yet locked) must not emit horizontal progress. It used to, which
|
||||||
|
// armed the pager without ever tripping `horizontal_drag_started` — so the
|
||||||
|
// release sent no horizontal event and the pager stayed stuck active,
|
||||||
|
// freezing the homescreen.
|
||||||
|
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
|
||||||
|
let mut g = GestureState::<Msg>::new();
|
||||||
|
g.start = Some( pt( 100.0, 600.0 ) );
|
||||||
|
let mut offsets = HashMap::new();
|
||||||
|
let out = g.on_move( pt( 103.0, 595.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
|
||||||
|
assert_eq!( g.swipe_axis, None );
|
||||||
|
assert!( matches!( out, MoveOutcome::Swipe { horizontal: None, .. } ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn move_up_progress_unclamped_past_unit_interval()
|
||||||
{
|
{
|
||||||
// Surface 1000 px tall, threshold 30 % = 300 px. A 600 px upward drag
|
// Surface 1000 px tall, threshold 30 % = 300 px. A 600 px upward drag
|
||||||
// must clamp progress to 1.0 instead of overshooting to 2.0.
|
// overshoots to 2.0 — up progress is unclamped so follow-the-finger
|
||||||
|
// panels can keep tracking past the commit threshold.
|
||||||
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
|
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
|
||||||
let mut g = GestureState::<Msg>::new();
|
let mut g = GestureState::<Msg>::new();
|
||||||
g.start = Some( pt( 100.0, 800.0 ) );
|
g.start = Some( pt( 100.0, 800.0 ) );
|
||||||
@@ -321,7 +440,7 @@ fn move_up_progress_clamps_to_unit_interval()
|
|||||||
match out
|
match out
|
||||||
{
|
{
|
||||||
MoveOutcome::Swipe { up: Some( v ), .. } =>
|
MoveOutcome::Swipe { up: Some( v ), .. } =>
|
||||||
assert!( ( v - 1.0 ).abs() < 1e-6, "expected clamp to 1.0, got {v}" ),
|
assert!( ( v - 2.0 ).abs() < 1e-6, "expected unclamped 2.0, got {v}" ),
|
||||||
_ => panic!( "expected Swipe with up=Some" ),
|
_ => panic!( "expected Swipe with up=Some" ),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ impl<A: App> KeyboardHandler for AppData<A>
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
let focus = self.focus_for_surface( surface ).unwrap_or( SurfaceFocus::Main );
|
let focus = self.focus_for_surface( surface ).unwrap_or( SurfaceFocus::Main );
|
||||||
|
let _ = surface;
|
||||||
self.keyboard_focus = focus;
|
self.keyboard_focus = focus;
|
||||||
self.surface_mut( focus ).request_redraw();
|
self.surface_mut( focus ).request_redraw();
|
||||||
if let Some( ref mut a ) = self.a11y { a.set_window_focus( true ); }
|
if let Some( ref mut a ) = self.a11y { a.set_window_focus( true ); }
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ impl<A: App> AppData<A>
|
|||||||
// Find the topmost scroll that has a navigable item list.
|
// Find the topmost scroll that has a navigable item list.
|
||||||
let scroll_meta = {
|
let scroll_meta = {
|
||||||
let ss = self.surface( focus );
|
let ss = self.surface( focus );
|
||||||
ss.scroll_rects.iter().rev()
|
ss.frame.scroll_rects.iter().rev()
|
||||||
.find_map( |( rect, idx, _ax )|
|
.find_map( |( rect, idx, _ax )|
|
||||||
{
|
{
|
||||||
ss.scroll_navigable_items.get( idx )
|
ss.frame.scroll_navigable_items.get( idx )
|
||||||
.filter( |list| !list.is_empty() )
|
.filter( |list| !list.is_empty() )
|
||||||
.map( |list| ( *rect, *idx, list.clone() ) )
|
.map( |list| ( *rect, *idx, list.clone() ) )
|
||||||
} )
|
} )
|
||||||
@@ -50,7 +50,7 @@ impl<A: App> AppData<A>
|
|||||||
// list-style); the X offset is preserved as-is.
|
// list-style); the X offset is preserved as-is.
|
||||||
let viewport_h = scroll_rect.height;
|
let viewport_h = scroll_rect.height;
|
||||||
let ( current_x, current_y ) = self.surface( focus )
|
let ( current_x, current_y ) = self.surface( focus )
|
||||||
.scroll_offsets.get( &scroll_idx )
|
.frame.scroll_offsets.get( &scroll_idx )
|
||||||
.copied().unwrap_or( ( 0.0, 0.0 ) );
|
.copied().unwrap_or( ( 0.0, 0.0 ) );
|
||||||
let new_y = if content_y < current_y
|
let new_y = if content_y < current_y
|
||||||
{
|
{
|
||||||
@@ -67,7 +67,7 @@ impl<A: App> AppData<A>
|
|||||||
|
|
||||||
let ss = self.surface_mut( focus );
|
let ss = self.surface_mut( focus );
|
||||||
ss.hovered_idx = Some( new_idx );
|
ss.hovered_idx = Some( new_idx );
|
||||||
ss.scroll_offsets.insert( scroll_idx, ( current_x, new_y ) );
|
ss.frame.scroll_offsets.insert( scroll_idx, ( current_x, new_y ) );
|
||||||
ss.request_redraw();
|
ss.request_redraw();
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ impl<A: App> AppData<A>
|
|||||||
{
|
{
|
||||||
let focused = self.surface( focus ).focused_idx;
|
let focused = self.surface( focus ).focused_idx;
|
||||||
let is_text = focused.and_then( |idx|
|
let is_text = focused.and_then( |idx|
|
||||||
find_handlers( &self.surface( focus ).widget_rects, idx )
|
find_handlers( &self.surface( focus ).frame.widget_rects, idx )
|
||||||
.map( |h| h.is_text_input() ) ).unwrap_or( false );
|
.map( |h| h.is_text_input() ) ).unwrap_or( false );
|
||||||
if is_text { self.handle_select_all( focus ); }
|
if is_text { self.handle_select_all( focus ); }
|
||||||
}
|
}
|
||||||
@@ -23,7 +23,7 @@ impl<A: App> AppData<A>
|
|||||||
{
|
{
|
||||||
let focused = self.surface( focus ).focused_idx;
|
let focused = self.surface( focus ).focused_idx;
|
||||||
let is_text = focused.and_then( |idx|
|
let is_text = focused.and_then( |idx|
|
||||||
find_handlers( &self.surface( focus ).widget_rects, idx )
|
find_handlers( &self.surface( focus ).frame.widget_rects, idx )
|
||||||
.map( |h| h.is_text_input() ) ).unwrap_or( false );
|
.map( |h| h.is_text_input() ) ).unwrap_or( false );
|
||||||
if is_text { self.handle_copy( focus ); }
|
if is_text { self.handle_copy( focus ); }
|
||||||
}
|
}
|
||||||
@@ -32,7 +32,7 @@ impl<A: App> AppData<A>
|
|||||||
{
|
{
|
||||||
let focused = self.surface( focus ).focused_idx;
|
let focused = self.surface( focus ).focused_idx;
|
||||||
let is_text = focused.and_then( |idx|
|
let is_text = focused.and_then( |idx|
|
||||||
find_handlers( &self.surface( focus ).widget_rects, idx )
|
find_handlers( &self.surface( focus ).frame.widget_rects, idx )
|
||||||
.map( |h| h.is_text_input() ) ).unwrap_or( false );
|
.map( |h| h.is_text_input() ) ).unwrap_or( false );
|
||||||
if is_text { self.handle_cut( focus ); }
|
if is_text { self.handle_cut( focus ); }
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@ impl<A: App> AppData<A>
|
|||||||
{
|
{
|
||||||
let focused = self.surface( focus ).focused_idx;
|
let focused = self.surface( focus ).focused_idx;
|
||||||
let is_text = focused.and_then( |idx|
|
let is_text = focused.and_then( |idx|
|
||||||
find_handlers( &self.surface( focus ).widget_rects, idx )
|
find_handlers( &self.surface( focus ).frame.widget_rects, idx )
|
||||||
.map( |h| h.is_text_input() ) ).unwrap_or( false );
|
.map( |h| h.is_text_input() ) ).unwrap_or( false );
|
||||||
if is_text { self.handle_paste( focus ); }
|
if is_text { self.handle_paste( focus ); }
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,7 @@ impl<A: App> AppData<A>
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
let ss = self.surface( focus );
|
let ss = self.surface( focus );
|
||||||
let next_idx = next_focusable_index( &ss.widget_rects, ss.focused_idx, reverse );
|
let next_idx = next_focusable_index( &ss.frame.widget_rects, ss.focused_idx, reverse );
|
||||||
if let Some( next_idx ) = next_idx
|
if let Some( next_idx ) = next_idx
|
||||||
{
|
{
|
||||||
self.set_focus( focus, Some( next_idx ), qh );
|
self.set_focus( focus, Some( next_idx ), qh );
|
||||||
@@ -84,7 +84,7 @@ impl<A: App> AppData<A>
|
|||||||
} else if self.collapse_selection_if_any( focus )
|
} else if self.collapse_selection_if_any( focus )
|
||||||
{
|
{
|
||||||
// Selection collapsed — keep focus, no app msg.
|
// Selection collapsed — keep focus, no app msg.
|
||||||
} else if let Some( msg ) = self.surface( focus ).widget_rects.iter()
|
} else if let Some( msg ) = self.surface( focus ).frame.widget_rects.iter()
|
||||||
.rev()
|
.rev()
|
||||||
.find_map( |w| w.handlers.escape_msg() )
|
.find_map( |w| w.handlers.escape_msg() )
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -47,33 +47,39 @@ impl<A: App> AppData<A>
|
|||||||
// literal `\n` into the buffer instead of submitting, so
|
// literal `\n` into the buffer instead of submitting, so
|
||||||
// the user can compose paragraphs.
|
// the user can compose paragraphs.
|
||||||
let is_multi = focused.and_then( |idx|
|
let is_multi = focused.and_then( |idx|
|
||||||
find_handlers( &self.surface( focus ).widget_rects, idx )
|
find_handlers( &self.surface( focus ).frame.widget_rects, idx )
|
||||||
.map( |h| h.is_multiline_text_input() ) ).unwrap_or( false );
|
.map( |h| h.is_multiline_text_input() ) ).unwrap_or( false );
|
||||||
if is_multi
|
if is_multi
|
||||||
{
|
{
|
||||||
self.handle_text_insert( focus, "\n" );
|
self.handle_text_insert( focus, "\n" );
|
||||||
} else {
|
} else {
|
||||||
let target = focused.or( self.surface( focus ).hovered_idx );
|
let target = focused.or( self.surface( focus ).hovered_idx );
|
||||||
|
let mut handled = false;
|
||||||
if let Some( idx ) = target
|
if let Some( idx ) = target
|
||||||
{
|
{
|
||||||
let msg = find_handlers( &self.surface( focus ).widget_rects, idx )
|
let msg = find_handlers( &self.surface( focus ).frame.widget_rects, idx )
|
||||||
.and_then( |h| h.submit_msg().or_else( || h.press_msg() ) );
|
.and_then( |h| h.submit_msg().or_else( || h.press_msg() ) );
|
||||||
if let Some( m ) = msg
|
if let Some( m ) = msg
|
||||||
{
|
{
|
||||||
self.pending_msgs.push( m );
|
self.pending_msgs.push( m );
|
||||||
|
handled = true;
|
||||||
}
|
}
|
||||||
} else if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed )
|
}
|
||||||
|
if !handled
|
||||||
|
{
|
||||||
|
if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed )
|
||||||
{
|
{
|
||||||
self.pending_msgs.push( msg );
|
self.pending_msgs.push( msg );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub( super ) fn handle_key_arrow_vertical( &mut self, focus: SurfaceFocus, event: &KeyEvent )
|
pub( super ) fn handle_key_arrow_vertical( &mut self, focus: SurfaceFocus, event: &KeyEvent )
|
||||||
{
|
{
|
||||||
let focused = self.surface( focus ).focused_idx;
|
let focused = self.surface( focus ).focused_idx;
|
||||||
let is_text = focused.and_then( |idx|
|
let is_text = focused.and_then( |idx|
|
||||||
find_handlers( &self.surface( focus ).widget_rects, idx )
|
find_handlers( &self.surface( focus ).frame.widget_rects, idx )
|
||||||
.map( |h| h.is_text_input() ) ).unwrap_or( false );
|
.map( |h| h.is_text_input() ) ).unwrap_or( false );
|
||||||
let reverse = event.keysym == Keysym::Up;
|
let reverse = event.keysym == Keysym::Up;
|
||||||
let extend = self.shift_pressed;
|
let extend = self.shift_pressed;
|
||||||
@@ -100,7 +106,7 @@ impl<A: App> AppData<A>
|
|||||||
{
|
{
|
||||||
let focused = self.surface( focus ).focused_idx;
|
let focused = self.surface( focus ).focused_idx;
|
||||||
let is_text = focused.and_then( |idx|
|
let is_text = focused.and_then( |idx|
|
||||||
find_handlers( &self.surface( focus ).widget_rects, idx )
|
find_handlers( &self.surface( focus ).frame.widget_rects, idx )
|
||||||
.map( |h| h.is_text_input() ) ).unwrap_or( false );
|
.map( |h| h.is_text_input() ) ).unwrap_or( false );
|
||||||
let extend = self.shift_pressed;
|
let extend = self.shift_pressed;
|
||||||
let intercepted = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed );
|
let intercepted = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed );
|
||||||
@@ -123,7 +129,7 @@ impl<A: App> AppData<A>
|
|||||||
{
|
{
|
||||||
let focused = self.surface( focus ).focused_idx;
|
let focused = self.surface( focus ).focused_idx;
|
||||||
let is_text = focused.and_then( |idx|
|
let is_text = focused.and_then( |idx|
|
||||||
find_handlers( &self.surface( focus ).widget_rects, idx )
|
find_handlers( &self.surface( focus ).frame.widget_rects, idx )
|
||||||
.map( |h| h.is_text_input() ) ).unwrap_or( false );
|
.map( |h| h.is_text_input() ) ).unwrap_or( false );
|
||||||
if is_text { self.handle_cursor_home( focus, self.shift_pressed ); }
|
if is_text { self.handle_cursor_home( focus, self.shift_pressed ); }
|
||||||
}
|
}
|
||||||
@@ -132,7 +138,7 @@ impl<A: App> AppData<A>
|
|||||||
{
|
{
|
||||||
let focused = self.surface( focus ).focused_idx;
|
let focused = self.surface( focus ).focused_idx;
|
||||||
let is_text = focused.and_then( |idx|
|
let is_text = focused.and_then( |idx|
|
||||||
find_handlers( &self.surface( focus ).widget_rects, idx )
|
find_handlers( &self.surface( focus ).frame.widget_rects, idx )
|
||||||
.map( |h| h.is_text_input() ) ).unwrap_or( false );
|
.map( |h| h.is_text_input() ) ).unwrap_or( false );
|
||||||
if is_text { self.handle_cursor_end( focus, self.shift_pressed ); }
|
if is_text { self.handle_cursor_end( focus, self.shift_pressed ); }
|
||||||
}
|
}
|
||||||
@@ -142,7 +148,7 @@ impl<A: App> AppData<A>
|
|||||||
let focused = self.surface( focus ).focused_idx;
|
let focused = self.surface( focus ).focused_idx;
|
||||||
if let Some( idx ) = focused
|
if let Some( idx ) = focused
|
||||||
{
|
{
|
||||||
let press = find_handlers( &self.surface( focus ).widget_rects, idx )
|
let press = find_handlers( &self.surface( focus ).frame.widget_rects, idx )
|
||||||
.and_then( |h| h.press_msg() );
|
.and_then( |h| h.press_msg() );
|
||||||
if let Some( msg ) = press
|
if let Some( msg ) = press
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ impl<A: App> AppData<A>
|
|||||||
down_thresh: self.app.swipe_down_threshold(),
|
down_thresh: self.app.swipe_down_threshold(),
|
||||||
down_edge: self.app.swipe_down_edge(),
|
down_edge: self.app.swipe_down_edge(),
|
||||||
horizontal_thresh: self.app.swipe_horizontal_threshold(),
|
horizontal_thresh: self.app.swipe_horizontal_threshold(),
|
||||||
|
overscroll_thresh: self.app.overscroll_dismiss_threshold(),
|
||||||
surface_width: ss.physical_width(),
|
surface_width: ss.physical_width(),
|
||||||
surface_height: ss.physical_height(),
|
surface_height: ss.physical_height(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,12 +33,12 @@ impl<A: App> AppData<A>
|
|||||||
// Hover tracking — pointer-only (touch has no hover).
|
// Hover tracking — pointer-only (touch has no hover).
|
||||||
// Runs before the gesture motion so the cache-dirty
|
// Runs before the gesture motion so the cache-dirty
|
||||||
// below picks up any hover-dependent redraw request.
|
// below picks up any hover-dependent redraw request.
|
||||||
let new_hover = find_widget_at( &self.surface( focus ).widget_rects, pp );
|
let new_hover = find_widget_at( &self.surface( focus ).frame.widget_rects, pp );
|
||||||
let old_hover = self.surface( focus ).hovered_idx;
|
let old_hover = self.surface( focus ).hovered_idx;
|
||||||
if new_hover != old_hover
|
if new_hover != old_hover
|
||||||
{
|
{
|
||||||
let redraw = hover_affects_paint( &self.surface( focus ).widget_rects, old_hover )
|
let redraw = hover_affects_paint( &self.surface( focus ).frame.widget_rects, old_hover )
|
||||||
|| hover_affects_paint( &self.surface( focus ).widget_rects, new_hover );
|
|| hover_affects_paint( &self.surface( focus ).frame.widget_rects, new_hover );
|
||||||
{
|
{
|
||||||
let ss = self.surface_mut( focus );
|
let ss = self.surface_mut( focus );
|
||||||
ss.hovered_idx = new_hover;
|
ss.hovered_idx = new_hover;
|
||||||
@@ -115,7 +115,7 @@ impl<A: App> AppData<A>
|
|||||||
let outcome =
|
let outcome =
|
||||||
{
|
{
|
||||||
let ss = self.surface_mut( focus );
|
let ss = self.surface_mut( focus );
|
||||||
ss.gesture.on_move( pp, &ss.widget_rects, &mut ss.scroll_offsets, &swipe, global_drag )
|
ss.gesture.on_move( pp, &ss.frame.widget_rects, &mut ss.frame.scroll_offsets, &swipe, global_drag )
|
||||||
};
|
};
|
||||||
self.apply_move_outcome( focus, outcome );
|
self.apply_move_outcome( focus, outcome );
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ impl<A: App> AppData<A>
|
|||||||
let pressed_text = self.surface( focus ).gesture.pressed_idx
|
let pressed_text = self.surface( focus ).gesture.pressed_idx
|
||||||
.and_then( |idx|
|
.and_then( |idx|
|
||||||
{
|
{
|
||||||
let is_text = find_handlers( &self.surface( focus ).widget_rects, idx )
|
let is_text = find_handlers( &self.surface( focus ).frame.widget_rects, idx )
|
||||||
.map( |h| h.is_text_input() ).unwrap_or( false );
|
.map( |h| h.is_text_input() ).unwrap_or( false );
|
||||||
if is_text { Some( idx ) } else { None }
|
if is_text { Some( idx ) } else { None }
|
||||||
} );
|
} );
|
||||||
|
|||||||
@@ -43,9 +43,9 @@ impl<A: App> AppData<A>
|
|||||||
let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 );
|
let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 );
|
||||||
self.pointer_pos = pos;
|
self.pointer_pos = pos;
|
||||||
self.app.on_pointer_move( pos.x, pos.y );
|
self.app.on_pointer_move( pos.x, pos.y );
|
||||||
let hit_idx = find_widget_at( &self.surface( focus ).widget_rects, pos );
|
let hit_idx = find_widget_at( &self.surface( focus ).frame.widget_rects, pos );
|
||||||
let lp_msg = hit_idx.and_then( |idx|
|
let lp_msg = hit_idx.and_then( |idx|
|
||||||
find_handlers( &self.surface( focus ).widget_rects, idx )
|
find_handlers( &self.surface( focus ).frame.widget_rects, idx )
|
||||||
.and_then( |h| h.long_press_msg() ) );
|
.and_then( |h| h.long_press_msg() ) );
|
||||||
if let Some( msg ) = lp_msg
|
if let Some( msg ) = lp_msg
|
||||||
{
|
{
|
||||||
@@ -53,7 +53,7 @@ impl<A: App> AppData<A>
|
|||||||
self.surface_mut( focus ).request_redraw();
|
self.surface_mut( focus ).request_redraw();
|
||||||
} else {
|
} else {
|
||||||
let is_text = hit_idx.and_then( |idx|
|
let is_text = hit_idx.and_then( |idx|
|
||||||
find_handlers( &self.surface( focus ).widget_rects, idx )
|
find_handlers( &self.surface( focus ).frame.widget_rects, idx )
|
||||||
.map( |h| h.is_text_input() ) ).unwrap_or( false );
|
.map( |h| h.is_text_input() ) ).unwrap_or( false );
|
||||||
if is_text
|
if is_text
|
||||||
{
|
{
|
||||||
@@ -170,7 +170,7 @@ impl<A: App> AppData<A>
|
|||||||
let outcome =
|
let outcome =
|
||||||
{
|
{
|
||||||
let ss = self.surface_mut( focus );
|
let ss = self.surface_mut( focus );
|
||||||
let result = ss.gesture.on_press( pos, &ss.widget_rects, &ss.scroll_rects );
|
let result = ss.gesture.on_press( pos, &ss.frame.widget_rects, &ss.frame.scroll_rects );
|
||||||
// Mark this gesture as mouse-driven so the
|
// Mark this gesture as mouse-driven so the
|
||||||
// gesture machine's 6 px stray-cancel skips
|
// gesture machine's 6 px stray-cancel skips
|
||||||
// the drag-start / long-press slots — mouse
|
// the drag-start / long-press slots — mouse
|
||||||
@@ -199,7 +199,7 @@ impl<A: App> AppData<A>
|
|||||||
if let Some( idx ) = outcome.hit_idx
|
if let Some( idx ) = outcome.hit_idx
|
||||||
{
|
{
|
||||||
let immediate = {
|
let immediate = {
|
||||||
let handlers = find_handlers( &self.surface( focus ).widget_rects, idx );
|
let handlers = find_handlers( &self.surface( focus ).frame.widget_rects, idx );
|
||||||
if matches!( handlers, Some( WidgetHandlers::Button { repeating: true, .. } ) )
|
if matches!( handlers, Some( WidgetHandlers::Button { repeating: true, .. } ) )
|
||||||
{
|
{
|
||||||
handlers.and_then( |h| h.press_msg() )
|
handlers.and_then( |h| h.press_msg() )
|
||||||
@@ -222,7 +222,7 @@ impl<A: App> AppData<A>
|
|||||||
// under the cursor instead of just positioning.
|
// under the cursor instead of just positioning.
|
||||||
if let Some( idx ) = outcome.hit_idx
|
if let Some( idx ) = outcome.hit_idx
|
||||||
{
|
{
|
||||||
let is_text = find_handlers( &self.surface( focus ).widget_rects, idx )
|
let is_text = find_handlers( &self.surface( focus ).frame.widget_rects, idx )
|
||||||
.map( |h| h.is_text_input() ).unwrap_or( false );
|
.map( |h| h.is_text_input() ).unwrap_or( false );
|
||||||
if is_text
|
if is_text
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ impl<A: App> AppData<A>
|
|||||||
{
|
{
|
||||||
let ss = self.surface_mut( focus );
|
let ss = self.surface_mut( focus );
|
||||||
ss.needs_redraw = true;
|
ss.needs_redraw = true;
|
||||||
ss.gesture.on_release( pos, &ss.widget_rects, &swipe, global_drag )
|
ss.gesture.on_release( pos, &ss.frame.widget_rects, &swipe, global_drag )
|
||||||
};
|
};
|
||||||
self.apply_release_events( focus, events_out );
|
self.apply_release_events( focus, events_out );
|
||||||
// Cancel any held-button repeat — the press is
|
// Cancel any held-button repeat — the press is
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ impl<A: App> AppData<A>
|
|||||||
let scroll_hit =
|
let scroll_hit =
|
||||||
{
|
{
|
||||||
let ss = self.surface( focus );
|
let ss = self.surface( focus );
|
||||||
ss.scroll_rects.iter().rev()
|
ss.frame.scroll_rects.iter()
|
||||||
.find( |( r, _, _ )| r.contains( pos ) )
|
.find( |( r, _, _ )| r.contains( pos ) )
|
||||||
.map( |( _, idx, ax )| ( *idx, *ax ) )
|
.map( |( _, idx, ax )| ( *idx, *ax ) )
|
||||||
};
|
};
|
||||||
@@ -47,7 +47,7 @@ impl<A: App> AppData<A>
|
|||||||
let step_x = horizontal.absolute as f32 * multiplier;
|
let step_x = horizontal.absolute as f32 * multiplier;
|
||||||
let step_y = vertical.absolute as f32 * multiplier;
|
let step_y = vertical.absolute as f32 * multiplier;
|
||||||
let ss = self.surface_mut( focus );
|
let ss = self.surface_mut( focus );
|
||||||
let entry = ss.scroll_offsets.entry( scroll_idx ).or_insert( ( 0.0, 0.0 ) );
|
let entry = ss.frame.scroll_offsets.entry( scroll_idx ).or_insert( ( 0.0, 0.0 ) );
|
||||||
// Wheels report on a single axis at a time; route to
|
// Wheels report on a single axis at a time; route to
|
||||||
// whichever axis the viewport allows. A pure horizontal
|
// whichever axis the viewport allows. A pure horizontal
|
||||||
// viewport translates a vertical wheel into horizontal
|
// viewport translates a vertical wheel into horizontal
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ impl<A: App> AppData<A>
|
|||||||
let token = self.loop_handle.insert_source( timer, move |_, _, data: &mut AppData<A>|
|
let token = self.loop_handle.insert_source( timer, move |_, _, data: &mut AppData<A>|
|
||||||
{
|
{
|
||||||
let live_msg = crate::tree::find_handlers(
|
let live_msg = crate::tree::find_handlers(
|
||||||
&data.surface( focus ).widget_rects,
|
&data.surface( focus ).frame.widget_rects,
|
||||||
idx,
|
idx,
|
||||||
)
|
)
|
||||||
.and_then( |h| h.press_msg() );
|
.and_then( |h| h.press_msg() );
|
||||||
|
|||||||
@@ -58,6 +58,14 @@ impl<A: App> TouchHandler for AppData<A>
|
|||||||
let pos = self.surface( focus ).to_physical( position.0, position.1 );
|
let pos = self.surface( focus ).to_physical( position.0, position.1 );
|
||||||
self.pointer_pos = pos;
|
self.pointer_pos = pos;
|
||||||
|
|
||||||
|
// Compositor re-grab after the origin surface died mid-drag: this
|
||||||
|
// slot is already our primary and the drag state was migrated here,
|
||||||
|
// so treat the redundant `down` as a continuation, not a restart.
|
||||||
|
if self.surface( focus ).primary_touch_id == Some( id )
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Auxiliary slot path: a second (or third…) finger arriving
|
// Auxiliary slot path: a second (or third…) finger arriving
|
||||||
// while another finger already drives the primary slot stays
|
// while another finger already drives the primary slot stays
|
||||||
// out of the single-slot gesture machine entirely. The app
|
// out of the single-slot gesture machine entirely. The app
|
||||||
@@ -101,7 +109,7 @@ impl<A: App> TouchHandler for AppData<A>
|
|||||||
let outcome =
|
let outcome =
|
||||||
{
|
{
|
||||||
let ss = self.surface_mut( focus );
|
let ss = self.surface_mut( focus );
|
||||||
let result = ss.gesture.on_press( pos, &ss.widget_rects, &ss.scroll_rects );
|
let result = ss.gesture.on_press( pos, &ss.frame.widget_rects, &ss.frame.scroll_rects );
|
||||||
ss.needs_redraw = true;
|
ss.needs_redraw = true;
|
||||||
result
|
result
|
||||||
};
|
};
|
||||||
@@ -114,7 +122,7 @@ impl<A: App> TouchHandler for AppData<A>
|
|||||||
if let Some( idx ) = outcome.hit_idx
|
if let Some( idx ) = outcome.hit_idx
|
||||||
{
|
{
|
||||||
let immediate = {
|
let immediate = {
|
||||||
let handlers = find_handlers( &self.surface( focus ).widget_rects, idx );
|
let handlers = find_handlers( &self.surface( focus ).frame.widget_rects, idx );
|
||||||
if matches!( handlers, Some( crate::widget::WidgetHandlers::Button { repeating: true, .. } ) )
|
if matches!( handlers, Some( crate::widget::WidgetHandlers::Button { repeating: true, .. } ) )
|
||||||
{
|
{
|
||||||
handlers.and_then( |h| h.press_msg() )
|
handlers.and_then( |h| h.press_msg() )
|
||||||
@@ -130,7 +138,7 @@ impl<A: App> TouchHandler for AppData<A>
|
|||||||
// and double-tap selects the word under the press.
|
// and double-tap selects the word under the press.
|
||||||
if let Some( idx ) = outcome.hit_idx
|
if let Some( idx ) = outcome.hit_idx
|
||||||
{
|
{
|
||||||
let is_text = find_handlers( &self.surface( focus ).widget_rects, idx )
|
let is_text = find_handlers( &self.surface( focus ).frame.widget_rects, idx )
|
||||||
.map( |h| h.is_text_input() ).unwrap_or( false );
|
.map( |h| h.is_text_input() ).unwrap_or( false );
|
||||||
if is_text
|
if is_text
|
||||||
{
|
{
|
||||||
@@ -200,7 +208,7 @@ impl<A: App> TouchHandler for AppData<A>
|
|||||||
let ss = self.surface_mut( focus );
|
let ss = self.surface_mut( focus );
|
||||||
ss.needs_redraw = true;
|
ss.needs_redraw = true;
|
||||||
ss.primary_touch_id = None;
|
ss.primary_touch_id = None;
|
||||||
ss.gesture.on_release( pos, &ss.widget_rects, &swipe, global_drag )
|
ss.gesture.on_release( pos, &ss.frame.widget_rects, &swipe, global_drag )
|
||||||
};
|
};
|
||||||
self.apply_release_events( focus, events_out );
|
self.apply_release_events( focus, events_out );
|
||||||
self.stop_button_repeat();
|
self.stop_button_repeat();
|
||||||
@@ -242,7 +250,7 @@ impl<A: App> TouchHandler for AppData<A>
|
|||||||
let outcome =
|
let outcome =
|
||||||
{
|
{
|
||||||
let ss = self.surface_mut( focus );
|
let ss = self.surface_mut( focus );
|
||||||
ss.gesture.on_move( pp, &ss.widget_rects, &mut ss.scroll_offsets, &swipe, global_drag )
|
ss.gesture.on_move( pp, &ss.frame.widget_rects, &mut ss.frame.scroll_offsets, &swipe, global_drag )
|
||||||
};
|
};
|
||||||
self.apply_move_outcome( focus, outcome );
|
self.apply_move_outcome( focus, outcome );
|
||||||
|
|
||||||
@@ -250,7 +258,7 @@ impl<A: App> TouchHandler for AppData<A>
|
|||||||
let pressed_text = self.surface( focus ).gesture.pressed_idx
|
let pressed_text = self.surface( focus ).gesture.pressed_idx
|
||||||
.and_then( |idx|
|
.and_then( |idx|
|
||||||
{
|
{
|
||||||
let is_text = find_handlers( &self.surface( focus ).widget_rects, idx )
|
let is_text = find_handlers( &self.surface( focus ).frame.widget_rects, idx )
|
||||||
.map( |h| h.is_text_input() ).unwrap_or( false );
|
.map( |h| h.is_text_input() ).unwrap_or( false );
|
||||||
if is_text { Some( idx ) } else { None }
|
if is_text { Some( idx ) } else { None }
|
||||||
} );
|
} );
|
||||||
@@ -281,9 +289,30 @@ impl<A: App> TouchHandler for AppData<A>
|
|||||||
|
|
||||||
fn cancel( &mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _touch: &WlTouch )
|
fn cancel( &mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _touch: &WlTouch )
|
||||||
{
|
{
|
||||||
// Snapshot every auxiliary slot's last position so the app
|
self.reset_touch_state();
|
||||||
// can be notified with a meaningful release point, then drop
|
}
|
||||||
// every per-surface slot in one pass.
|
}
|
||||||
|
|
||||||
|
impl<A: App> AppData<A>
|
||||||
|
{
|
||||||
|
/// Drop every in-flight touch gesture across all surfaces, notifying
|
||||||
|
/// the app of any auxiliary slots that were still down. Shared by the
|
||||||
|
/// `wl_touch.cancel` handler and the touch-capability add / remove path
|
||||||
|
/// (suspend / resume on devices that power the touchscreen down): a
|
||||||
|
/// yanked capability never delivers the pending `up` / `cancel`, so
|
||||||
|
/// without this the stale `primary_touch_id` / gesture state strands and
|
||||||
|
/// the first gesture after resume is misrouted and lost. Returns `true`
|
||||||
|
/// when it actually had state to clear.
|
||||||
|
pub( crate ) fn reset_touch_state( &mut self ) -> bool
|
||||||
|
{
|
||||||
|
let had_state = self.main.primary_touch_id.is_some()
|
||||||
|
|| !self.main.touch_slots.is_empty()
|
||||||
|
|| !self.touch_focus.is_empty()
|
||||||
|
|| self.overlays.values().any( |ss| ss.primary_touch_id.is_some() || !ss.touch_slots.is_empty() );
|
||||||
|
|
||||||
|
// Snapshot every auxiliary slot's last position so the app can be
|
||||||
|
// notified with a meaningful release point, then drop every
|
||||||
|
// per-surface slot in one pass.
|
||||||
let mut aux_releases: Vec<( i32, crate::types::Point )> = Vec::new();
|
let mut aux_releases: Vec<( i32, crate::types::Point )> = Vec::new();
|
||||||
let collect = |ss: &mut SurfaceState<A::Message>, out: &mut Vec<( i32, crate::types::Point )>|
|
let collect = |ss: &mut SurfaceState<A::Message>, out: &mut Vec<( i32, crate::types::Point )>|
|
||||||
{
|
{
|
||||||
@@ -302,5 +331,6 @@ impl<A: App> TouchHandler for AppData<A>
|
|||||||
self.app.on_touch_up( id as i64, pos.x, pos.y );
|
self.app.on_touch_up( id as i64, pos.x, pos.y );
|
||||||
}
|
}
|
||||||
self.stop_button_repeat();
|
self.stop_button_repeat();
|
||||||
|
had_state
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// SPDX-License-Identifier: LGPL-2.1-only
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
use crate::types::Rect;
|
use crate::types::{ Length, Rect };
|
||||||
use crate::render::Canvas;
|
use crate::render::Canvas;
|
||||||
use crate::widget::Element;
|
use crate::widget::Element;
|
||||||
|
|
||||||
@@ -23,15 +23,37 @@ use crate::widget::Element;
|
|||||||
/// .into()
|
/// .into()
|
||||||
/// # }
|
/// # }
|
||||||
/// ```
|
/// ```
|
||||||
|
///
|
||||||
|
/// `padding`, `spacing` and `max_width` all accept any
|
||||||
|
/// [`crate::Length`], so a responsive layout reads as:
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// # use ltk::{ button, column, text, Length, Element };
|
||||||
|
/// # #[ derive( Clone ) ] enum Msg { Ok }
|
||||||
|
/// # fn _ex() -> Element<Msg> {
|
||||||
|
/// column()
|
||||||
|
/// // Padding is 3 % of the viewport's smaller side, clamped to 16..48 px.
|
||||||
|
/// .padding( Length::vmin( 3.0 ).clamp( 16.0, 48.0 ) )
|
||||||
|
/// .spacing( Length::vmin( 1.5 ).at_least( 8.0 ) )
|
||||||
|
/// .max_width( Length::vw( 60.0 ).at_most( 720.0 ) )
|
||||||
|
/// .push( text( "Responsive" ) )
|
||||||
|
/// .push( button( "OK" ).on_press( Msg::Ok ) )
|
||||||
|
/// .into()
|
||||||
|
/// # }
|
||||||
|
/// ```
|
||||||
pub struct Column<Msg: Clone>
|
pub struct Column<Msg: Clone>
|
||||||
{
|
{
|
||||||
pub children: Vec<Element<Msg>>,
|
pub( crate ) children: Vec<Element<Msg>>,
|
||||||
pub spacing: f32,
|
/// Vertical gap between children. Stored as [`Length`] so a `Vmin(2.0)`
|
||||||
pub padding: f32,
|
/// or `Em(0.5)` gap scales with the viewport instead of freezing at a
|
||||||
pub align_center_x: bool,
|
/// px constant.
|
||||||
pub center_y: bool,
|
pub( crate ) spacing: Length,
|
||||||
pub max_width: Option<f32>,
|
/// Padding on all sides. Same [`Length`] semantics as `spacing`.
|
||||||
pub fit_content: bool,
|
pub( crate ) padding: Length,
|
||||||
|
pub( crate ) align_center_x: bool,
|
||||||
|
pub( crate ) center_y: bool,
|
||||||
|
pub( crate ) max_width: Option<Length>,
|
||||||
|
pub( crate ) fit_content: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<Msg: Clone> Column<Msg>
|
impl<Msg: Clone> Column<Msg>
|
||||||
@@ -41,8 +63,8 @@ impl<Msg: Clone> Column<Msg>
|
|||||||
Self
|
Self
|
||||||
{
|
{
|
||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
spacing: 8.0,
|
spacing: Length::px( 8.0 ),
|
||||||
padding: 16.0,
|
padding: Length::px( 16.0 ),
|
||||||
align_center_x: true,
|
align_center_x: true,
|
||||||
center_y: false,
|
center_y: false,
|
||||||
max_width: None,
|
max_width: None,
|
||||||
@@ -57,17 +79,20 @@ impl<Msg: Clone> Column<Msg>
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the vertical gap between children in pixels. Default: `8.0`.
|
/// Set the vertical gap between children. Default: `8.0` px. Accepts
|
||||||
pub fn spacing( mut self, s: f32 ) -> Self
|
/// any [`Length`] — pass an `f32` for the px case, or a relative
|
||||||
|
/// value like `Length::vmin( 2.0 )` to scale with the viewport.
|
||||||
|
pub fn spacing( mut self, s: impl Into<Length> ) -> Self
|
||||||
{
|
{
|
||||||
self.spacing = s;
|
self.spacing = s.into();
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the padding (all sides) in pixels. Default: `16.0`.
|
/// Set the padding (all sides). Default: `16.0` px. Accepts any
|
||||||
pub fn padding( mut self, p: f32 ) -> Self
|
/// [`Length`].
|
||||||
|
pub fn padding( mut self, p: impl Into<Length> ) -> Self
|
||||||
{
|
{
|
||||||
self.padding = p;
|
self.padding = p.into();
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,14 +110,33 @@ impl<Msg: Clone> Column<Msg>
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Limit the content width in pixels. The column still reports `max_width` as
|
/// Limit the content width. Accepts any [`Length`]. The column still
|
||||||
/// its preferred width so the parent allocates the full available rect.
|
/// reports the parent's `max_width` as its preferred width so the
|
||||||
pub fn max_width( mut self, w: f32 ) -> Self
|
/// parent allocates the full available rect.
|
||||||
|
pub fn max_width( mut self, w: impl Into<Length> ) -> Self
|
||||||
{
|
{
|
||||||
self.max_width = Some( w );
|
self.max_width = Some( w.into() );
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[ inline ]
|
||||||
|
fn resolved_spacing( &self, canvas: &Canvas ) -> f32
|
||||||
|
{
|
||||||
|
self.spacing.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT )
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ inline ]
|
||||||
|
fn resolved_padding( &self, canvas: &Canvas ) -> f32
|
||||||
|
{
|
||||||
|
self.padding.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT )
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ inline ]
|
||||||
|
fn resolved_max_width( &self, canvas: &Canvas ) -> Option<f32>
|
||||||
|
{
|
||||||
|
self.max_width.map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) )
|
||||||
|
}
|
||||||
|
|
||||||
/// Report the intrinsic content width as preferred width instead of filling
|
/// Report the intrinsic content width as preferred width instead of filling
|
||||||
/// the available `max_width`. Use this when the column represents a card
|
/// the available `max_width`. Use this when the column represents a card
|
||||||
/// or widget meant to sit side-by-side with other children inside a
|
/// or widget meant to sit side-by-side with other children inside a
|
||||||
@@ -108,10 +152,10 @@ impl<Msg: Clone> Column<Msg>
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
fn inner_w( &self, available: f32 ) -> f32
|
fn inner_w( &self, available: f32, canvas: &Canvas ) -> f32
|
||||||
{
|
{
|
||||||
let w = available - self.padding * 2.0;
|
let w = available - self.resolved_padding( canvas ) * 2.0;
|
||||||
self.max_width.map( |m| w.min( m ) ).unwrap_or( w )
|
self.resolved_max_width( canvas ).map( |m| w.min( m ) ).unwrap_or( w )
|
||||||
}
|
}
|
||||||
|
|
||||||
fn content_h( &self, inner_w: f32, canvas: &Canvas ) -> f32
|
fn content_h( &self, inner_w: f32, canvas: &Canvas ) -> f32
|
||||||
@@ -120,18 +164,19 @@ impl<Msg: Clone> Column<Msg>
|
|||||||
self.children.iter()
|
self.children.iter()
|
||||||
.map( |c| match c
|
.map( |c| match c
|
||||||
{
|
{
|
||||||
Element::Spacer( s ) => s.fixed_height.unwrap_or( 0.0 ),
|
Element::Spacer( s ) => s.resolved_height( canvas ).unwrap_or( 0.0 ),
|
||||||
other => other.preferred_size( inner_w, canvas ).1,
|
other => other.preferred_size( inner_w, canvas ).1,
|
||||||
} )
|
} )
|
||||||
.sum::<f32>()
|
.sum::<f32>()
|
||||||
+ self.spacing * (self.children.len().saturating_sub( 1 )) as f32
|
+ self.resolved_spacing( canvas ) * ( self.children.len().saturating_sub( 1 ) ) as f32
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the preferred `(width, height)` given available `max_width`.
|
/// Return the preferred `(width, height)` given available `max_width`.
|
||||||
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
|
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
|
||||||
{
|
{
|
||||||
let inner_w = self.inner_w( max_width );
|
let inner_w = self.inner_w( max_width, canvas );
|
||||||
let total_h = self.content_h( inner_w, canvas ) + self.padding * 2.0;
|
let pad = self.resolved_padding( canvas );
|
||||||
|
let total_h = self.content_h( inner_w, canvas ) + pad * 2.0;
|
||||||
|
|
||||||
let w = if self.fit_content
|
let w = if self.fit_content
|
||||||
{
|
{
|
||||||
@@ -162,7 +207,7 @@ impl<Msg: Clone> Column<Msg>
|
|||||||
other => other.preferred_size( inner_w, canvas ).0,
|
other => other.preferred_size( inner_w, canvas ).0,
|
||||||
} )
|
} )
|
||||||
.fold( 0.0_f32, f32::max );
|
.fold( 0.0_f32, f32::max );
|
||||||
( content_w + self.padding * 2.0 ).min( max_width )
|
( content_w + pad * 2.0 ).min( max_width )
|
||||||
} else {
|
} else {
|
||||||
max_width
|
max_width
|
||||||
};
|
};
|
||||||
@@ -175,15 +220,15 @@ impl<Msg: Clone> Column<Msg>
|
|||||||
/// Layout children within rect and return (rect, child_index) pairs.
|
/// Layout children within rect and return (rect, child_index) pairs.
|
||||||
pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<(Rect, usize)>
|
pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<(Rect, usize)>
|
||||||
{
|
{
|
||||||
let inner_w = self.inner_w( rect.width );
|
let inner_w = self.inner_w( rect.width, canvas );
|
||||||
|
let pad = self.resolved_padding( canvas );
|
||||||
|
let spacing = self.resolved_spacing( canvas );
|
||||||
|
|
||||||
// Flexible spacers and Scroll widgets claim remaining vertical space.
|
|
||||||
// Fixed-height spacers behave like normal fixed-size children.
|
|
||||||
let total_weight: u32 = self.children.iter()
|
let total_weight: u32 = self.children.iter()
|
||||||
.map( |c| match c
|
.map( |c| match c
|
||||||
{
|
{
|
||||||
Element::Spacer( s ) if s.fixed_height.is_none() => s.weight,
|
Element::Spacer( s ) if s.resolved_height( canvas ).is_none() => s.weight,
|
||||||
Element::Scroll( _ ) => 1,
|
Element::Scroll( s ) if s.axis.allows_y() => 1,
|
||||||
_ => 0,
|
_ => 0,
|
||||||
} )
|
} )
|
||||||
.sum();
|
.sum();
|
||||||
@@ -191,27 +236,27 @@ impl<Msg: Clone> Column<Msg>
|
|||||||
let fixed_h: f32 = self.children.iter()
|
let fixed_h: f32 = self.children.iter()
|
||||||
.map( |c|
|
.map( |c|
|
||||||
{
|
{
|
||||||
if matches!( c, Element::Scroll( _ ) )
|
if matches!( c, Element::Scroll( s ) if s.axis.allows_y() )
|
||||||
{
|
{
|
||||||
0.0
|
0.0
|
||||||
} else if let Element::Spacer( s ) = c {
|
} else if let Element::Spacer( s ) = c {
|
||||||
s.fixed_height.unwrap_or( 0.0 )
|
s.resolved_height( canvas ).unwrap_or( 0.0 )
|
||||||
} else {
|
} else {
|
||||||
c.preferred_size( inner_w, canvas ).1
|
c.preferred_size( inner_w, canvas ).1
|
||||||
}
|
}
|
||||||
} )
|
} )
|
||||||
.sum::<f32>()
|
.sum::<f32>()
|
||||||
+ self.spacing * (self.children.len().saturating_sub( 1 )) as f32;
|
+ spacing * ( self.children.len().saturating_sub( 1 ) ) as f32;
|
||||||
|
|
||||||
let avail_h = rect.height - self.padding * 2.0;
|
let avail_h = rect.height - pad * 2.0;
|
||||||
let avail_spare = ( avail_h - fixed_h ).max( 0.0 );
|
let avail_spare = ( avail_h - fixed_h ).max( 0.0 );
|
||||||
|
|
||||||
// `center_y` only applies when there are no spacers.
|
// `center_y` only applies when there are no spacers.
|
||||||
let start_y = if total_weight == 0 && self.center_y
|
let start_y = if total_weight == 0 && self.center_y
|
||||||
{
|
{
|
||||||
rect.y + self.padding + avail_spare / 2.0
|
rect.y + pad + avail_spare / 2.0
|
||||||
} else {
|
} else {
|
||||||
rect.y + self.padding
|
rect.y + pad
|
||||||
};
|
};
|
||||||
|
|
||||||
let start_x = rect.x + (rect.width - inner_w) / 2.0;
|
let start_x = rect.x + (rect.width - inner_w) / 2.0;
|
||||||
@@ -224,7 +269,7 @@ impl<Msg: Clone> Column<Msg>
|
|||||||
{
|
{
|
||||||
Element::Spacer( s ) =>
|
Element::Spacer( s ) =>
|
||||||
{
|
{
|
||||||
let h = if let Some( fixed ) = s.fixed_height
|
let h = if let Some( fixed ) = s.resolved_height( canvas )
|
||||||
{
|
{
|
||||||
fixed
|
fixed
|
||||||
} else if total_weight > 0
|
} else if total_weight > 0
|
||||||
@@ -235,7 +280,7 @@ impl<Msg: Clone> Column<Msg>
|
|||||||
};
|
};
|
||||||
( inner_w, h )
|
( inner_w, h )
|
||||||
},
|
},
|
||||||
Element::Scroll( _ ) =>
|
Element::Scroll( s ) if s.axis.allows_y() =>
|
||||||
{
|
{
|
||||||
let h = if total_weight > 0
|
let h = if total_weight > 0
|
||||||
{
|
{
|
||||||
@@ -254,7 +299,7 @@ impl<Msg: Clone> Column<Msg>
|
|||||||
start_x
|
start_x
|
||||||
};
|
};
|
||||||
result.push( ( Rect { x, y, width: w, height: h }, i ) );
|
result.push( ( Rect { x, y, width: w, height: h }, i ) );
|
||||||
y += h + self.spacing;
|
y += h + spacing;
|
||||||
}
|
}
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
@@ -330,16 +375,18 @@ mod tests
|
|||||||
#[ test ]
|
#[ test ]
|
||||||
fn inner_w_respects_padding_and_max_width()
|
fn inner_w_respects_padding_and_max_width()
|
||||||
{
|
{
|
||||||
|
let canvas = make_canvas();
|
||||||
let col = column::<()>().padding( 20.0 ).max_width( 100.0 );
|
let col = column::<()>().padding( 20.0 ).max_width( 100.0 );
|
||||||
// available = 200, minus padding*2 = 160, capped at max_width = 100
|
// available = 200, minus padding*2 = 160, capped at max_width = 100
|
||||||
assert_eq!( col.inner_w( 200.0 ), 100.0 );
|
assert_eq!( col.inner_w( 200.0, &canvas ), 100.0 );
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ test ]
|
#[ test ]
|
||||||
fn inner_w_without_max_width_subtracts_padding()
|
fn inner_w_without_max_width_subtracts_padding()
|
||||||
{
|
{
|
||||||
|
let canvas = make_canvas();
|
||||||
let col = column::<()>().padding( 10.0 );
|
let col = column::<()>().padding( 10.0 );
|
||||||
assert_eq!( col.inner_w( 200.0 ), 180.0 );
|
assert_eq!( col.inner_w( 200.0, &canvas ), 180.0 );
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ test ]
|
#[ test ]
|
||||||
@@ -356,4 +403,39 @@ mod tests
|
|||||||
let ( _, h ) = col.preferred_size( 100.0, &canvas );
|
let ( _, h ) = col.preferred_size( 100.0, &canvas );
|
||||||
assert_eq!( h, 16.0 );
|
assert_eq!( h, 16.0 );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn vmin_spacing_resolves_against_canvas_viewport()
|
||||||
|
{
|
||||||
|
// Canvas is 800x600 → vmin = 600. 5 % of 600 = 30 px per gap.
|
||||||
|
// Three zero-height spacers → two gaps → 60 px total.
|
||||||
|
let canvas = make_canvas();
|
||||||
|
let col = column::<()>()
|
||||||
|
.padding( 0.0 )
|
||||||
|
.spacing( Length::vmin( 5.0 ) )
|
||||||
|
.push( crate::spacer() )
|
||||||
|
.push( crate::spacer() )
|
||||||
|
.push( crate::spacer() );
|
||||||
|
let ( _, h ) = col.preferred_size( 100.0, &canvas );
|
||||||
|
assert_eq!( h, 60.0 );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn vmin_padding_doubles_around_content()
|
||||||
|
{
|
||||||
|
// 4 % of 600 = 24 px padding on each side → 48 px on an empty column.
|
||||||
|
let canvas = make_canvas();
|
||||||
|
let col = column::<()>().padding( Length::vmin( 4.0 ) );
|
||||||
|
let ( _, h ) = col.preferred_size( 100.0, &canvas );
|
||||||
|
assert_eq!( h, 48.0 );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn vmin_max_width_caps_inner_w()
|
||||||
|
{
|
||||||
|
// 20 % of 600 = 120 px max-width.
|
||||||
|
let canvas = make_canvas();
|
||||||
|
let col = column::<()>().padding( 0.0 ).max_width( Length::vmin( 20.0 ) );
|
||||||
|
assert_eq!( col.inner_w( 200.0, &canvas ), 120.0 );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// SPDX-License-Identifier: LGPL-2.1-only
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
use crate::types::Rect;
|
use crate::types::{ Length, Rect };
|
||||||
use crate::render::Canvas;
|
use crate::render::Canvas;
|
||||||
use crate::widget::Element;
|
use crate::widget::Element;
|
||||||
|
|
||||||
@@ -23,19 +23,43 @@ use crate::widget::Element;
|
|||||||
/// .into()
|
/// .into()
|
||||||
/// # }
|
/// # }
|
||||||
/// ```
|
/// ```
|
||||||
|
///
|
||||||
|
/// `spacing` and `padding` accept any [`crate::Length`]:
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// # use std::sync::Arc;
|
||||||
|
/// # use ltk::{ icon_button, row, Length, Element };
|
||||||
|
/// # #[ derive( Clone ) ] enum Msg { A, B }
|
||||||
|
/// # fn _ex( a_rgba: Arc<Vec<u8>>, b_rgba: Arc<Vec<u8>>, w: u32, h: u32 ) -> Element<Msg> {
|
||||||
|
/// row()
|
||||||
|
/// // 2 % of the viewport's smaller side, never below 8 px.
|
||||||
|
/// .spacing( Length::vmin( 2.0 ).at_least( 8.0 ) )
|
||||||
|
/// .push( icon_button( a_rgba, w, h ).on_press( Msg::A ) )
|
||||||
|
/// .push( icon_button( b_rgba, w, h ).on_press( Msg::B ) )
|
||||||
|
/// .into()
|
||||||
|
/// # }
|
||||||
|
/// ```
|
||||||
pub struct Row<Msg: Clone>
|
pub struct Row<Msg: Clone>
|
||||||
{
|
{
|
||||||
pub children: Vec<Element<Msg>>,
|
pub( crate ) children: Vec<Element<Msg>>,
|
||||||
pub spacing: f32,
|
/// Horizontal gap between children. [`Length`]; default `8.0` px.
|
||||||
pub padding: f32,
|
pub( crate ) spacing: Length,
|
||||||
pub align_right: bool,
|
/// Padding on all sides. [`Length`]; default `0.0` px.
|
||||||
|
pub( crate ) padding: Length,
|
||||||
|
pub( crate ) align_right: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<Msg: Clone> Row<Msg>
|
impl<Msg: Clone> Row<Msg>
|
||||||
{
|
{
|
||||||
pub fn new() -> Self
|
pub fn new() -> Self
|
||||||
{
|
{
|
||||||
Self { children: Vec::new(), spacing: 8.0, padding: 0.0, align_right: false }
|
Self
|
||||||
|
{
|
||||||
|
children: Vec::new(),
|
||||||
|
spacing: Length::px( 8.0 ),
|
||||||
|
padding: Length::px( 0.0 ),
|
||||||
|
align_right: false,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Append a child widget or layout.
|
/// Append a child widget or layout.
|
||||||
@@ -45,20 +69,34 @@ impl<Msg: Clone> Row<Msg>
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the horizontal gap between children in pixels. Default: `8.0`.
|
/// Set the horizontal gap between children. Default: `8.0` px. Accepts
|
||||||
pub fn spacing( mut self, s: f32 ) -> Self
|
/// any [`Length`] so the gap can scale with the viewport.
|
||||||
|
pub fn spacing( mut self, s: impl Into<Length> ) -> Self
|
||||||
{
|
{
|
||||||
self.spacing = s;
|
self.spacing = s.into();
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the padding (all sides) in pixels. Default: `0.0`.
|
/// Set the padding (all sides). Default: `0.0` px. Accepts any
|
||||||
pub fn padding( mut self, p: f32 ) -> Self
|
/// [`Length`].
|
||||||
|
pub fn padding( mut self, p: impl Into<Length> ) -> Self
|
||||||
{
|
{
|
||||||
self.padding = p;
|
self.padding = p.into();
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[ inline ]
|
||||||
|
fn resolved_spacing( &self, canvas: &Canvas ) -> f32
|
||||||
|
{
|
||||||
|
self.spacing.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT )
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ inline ]
|
||||||
|
fn resolved_padding( &self, canvas: &Canvas ) -> f32
|
||||||
|
{
|
||||||
|
self.padding.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT )
|
||||||
|
}
|
||||||
|
|
||||||
/// Push the content block to the right edge of the available width.
|
/// Push the content block to the right edge of the available width.
|
||||||
pub fn align_right( mut self ) -> Self
|
pub fn align_right( mut self ) -> Self
|
||||||
{
|
{
|
||||||
@@ -69,16 +107,18 @@ impl<Msg: Clone> Row<Msg>
|
|||||||
/// Return the preferred `(width, height)` given available `max_width`.
|
/// Return the preferred `(width, height)` given available `max_width`.
|
||||||
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
|
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
|
||||||
{
|
{
|
||||||
|
let pad = self.resolved_padding( canvas );
|
||||||
|
let spacing = self.resolved_spacing( canvas );
|
||||||
// Width contribution of every fixed (non-flex, non-flex-spacer) child.
|
// Width contribution of every fixed (non-flex, non-flex-spacer) child.
|
||||||
// Used to compute the residual width that wrap-style children will
|
// Used to compute the residual width that wrap-style children will
|
||||||
// actually render in, so their reported height matches the layout.
|
// actually render in, so their reported height matches the layout.
|
||||||
let inner_w = ( max_width - self.padding * 2.0 ).max( 0.0 );
|
let inner_w = ( max_width - pad * 2.0 ).max( 0.0 );
|
||||||
let gaps = self.spacing * self.children.len().saturating_sub( 1 ) as f32;
|
let gaps = spacing * self.children.len().saturating_sub( 1 ) as f32;
|
||||||
let fixed_w: f32 = self.children.iter()
|
let fixed_w: f32 = self.children.iter()
|
||||||
.filter( |c| match c
|
.filter( |c| match c
|
||||||
{
|
{
|
||||||
Element::Flex( _ ) => false,
|
Element::Flex( _ ) => false,
|
||||||
Element::Spacer( s ) => s.fixed_width.is_some(),
|
Element::Spacer( s ) => s.resolved_width( canvas ).is_some(),
|
||||||
_ => true,
|
_ => true,
|
||||||
} )
|
} )
|
||||||
.map( |c| c.preferred_size( max_width, canvas ).0 )
|
.map( |c| c.preferred_size( max_width, canvas ).0 )
|
||||||
@@ -103,7 +143,7 @@ impl<Msg: Clone> Row<Msg>
|
|||||||
let has_flex = self.children.iter().any( |c| match c
|
let has_flex = self.children.iter().any( |c| match c
|
||||||
{
|
{
|
||||||
Element::Flex( _ ) => true,
|
Element::Flex( _ ) => true,
|
||||||
Element::Spacer( s ) => s.fixed_width.is_none(),
|
Element::Spacer( s ) => s.resolved_width( canvas ).is_none(),
|
||||||
_ => false,
|
_ => false,
|
||||||
} );
|
} );
|
||||||
|
|
||||||
@@ -115,11 +155,11 @@ impl<Msg: Clone> Row<Msg>
|
|||||||
.map( |c| c.preferred_size( max_width, canvas ).0 )
|
.map( |c| c.preferred_size( max_width, canvas ).0 )
|
||||||
.sum::<f32>()
|
.sum::<f32>()
|
||||||
+ gaps
|
+ gaps
|
||||||
+ self.padding * 2.0;
|
+ pad * 2.0;
|
||||||
total_w.min( max_width )
|
total_w.min( max_width )
|
||||||
};
|
};
|
||||||
|
|
||||||
( w, max_h + self.padding * 2.0 )
|
( w, max_h + pad * 2.0 )
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn draw( &self, _canvas: &mut Canvas, _rect: Rect, _focused: bool ) {}
|
pub fn draw( &self, _canvas: &mut Canvas, _rect: Rect, _focused: bool ) {}
|
||||||
@@ -134,7 +174,9 @@ impl<Msg: Clone> Row<Msg>
|
|||||||
/// [`Row::align_right`]).
|
/// [`Row::align_right`]).
|
||||||
pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<(Rect, usize)>
|
pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<(Rect, usize)>
|
||||||
{
|
{
|
||||||
let inner_h = rect.height - self.padding * 2.0;
|
let pad = self.resolved_padding( canvas );
|
||||||
|
let spacing = self.resolved_spacing( canvas );
|
||||||
|
let inner_h = rect.height - pad * 2.0;
|
||||||
|
|
||||||
// Spacers and `Flex` wrappers report 0 width here; their real width
|
// Spacers and `Flex` wrappers report 0 width here; their real width
|
||||||
// comes from the flex distribution below.
|
// comes from the flex distribution below.
|
||||||
@@ -142,7 +184,7 @@ impl<Msg: Clone> Row<Msg>
|
|||||||
.map( |c| c.preferred_size( rect.width, canvas ) )
|
.map( |c| c.preferred_size( rect.width, canvas ) )
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let gaps = self.spacing * self.children.len().saturating_sub( 1 ) as f32;
|
let gaps = spacing * self.children.len().saturating_sub( 1 ) as f32;
|
||||||
let fixed_w: f32 = self.children.iter().zip( sizes.iter() )
|
let fixed_w: f32 = self.children.iter().zip( sizes.iter() )
|
||||||
.filter( |( c, _ )| match c
|
.filter( |( c, _ )| match c
|
||||||
{
|
{
|
||||||
@@ -151,7 +193,7 @@ impl<Msg: Clone> Row<Msg>
|
|||||||
// `Spacer::width(...)`-pinned spacers, contributes to the
|
// `Spacer::width(...)`-pinned spacers, contributes to the
|
||||||
// fixed-width tally.
|
// fixed-width tally.
|
||||||
Element::Flex( _ ) => false,
|
Element::Flex( _ ) => false,
|
||||||
Element::Spacer( s ) => s.fixed_width.is_some(),
|
Element::Spacer( s ) => s.resolved_width( canvas ).is_some(),
|
||||||
_ => true,
|
_ => true,
|
||||||
} )
|
} )
|
||||||
.map( |( _, ( w, _ ) )| *w )
|
.map( |( _, ( w, _ ) )| *w )
|
||||||
@@ -159,13 +201,13 @@ impl<Msg: Clone> Row<Msg>
|
|||||||
|
|
||||||
let total_weight: u32 = self.children.iter()
|
let total_weight: u32 = self.children.iter()
|
||||||
.filter_map( |c| match c {
|
.filter_map( |c| match c {
|
||||||
Element::Spacer( s ) if s.fixed_width.is_none() => Some( s.weight ),
|
Element::Spacer( s ) if s.resolved_width( canvas ).is_none() => Some( s.weight ),
|
||||||
Element::Flex( f ) => Some( f.weight ),
|
Element::Flex( f ) => Some( f.weight ),
|
||||||
_ => None,
|
_ => None,
|
||||||
} )
|
} )
|
||||||
.sum();
|
.sum();
|
||||||
|
|
||||||
let inner_w = ( rect.width - self.padding * 2.0 ).max( 0.0 );
|
let inner_w = ( rect.width - pad * 2.0 ).max( 0.0 );
|
||||||
let leftover = ( inner_w - fixed_w - gaps ).max( 0.0 );
|
let leftover = ( inner_w - fixed_w - gaps ).max( 0.0 );
|
||||||
let has_spacers = total_weight > 0;
|
let has_spacers = total_weight > 0;
|
||||||
|
|
||||||
@@ -173,11 +215,11 @@ impl<Msg: Clone> Row<Msg>
|
|||||||
{
|
{
|
||||||
// Spacers and `Flex` wrappers claim the leftover; the cluster
|
// Spacers and `Flex` wrappers claim the leftover; the cluster
|
||||||
// sits flush to the left edge of the inner rect.
|
// sits flush to the left edge of the inner rect.
|
||||||
( rect.x + self.padding, leftover / total_weight as f32 )
|
( rect.x + pad, leftover / total_weight as f32 )
|
||||||
}
|
}
|
||||||
else if self.align_right
|
else if self.align_right
|
||||||
{
|
{
|
||||||
( rect.x + rect.width - (fixed_w + gaps) - self.padding, 0.0 )
|
( rect.x + rect.width - ( fixed_w + gaps ) - pad, 0.0 )
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -190,7 +232,7 @@ impl<Msg: Clone> Row<Msg>
|
|||||||
{
|
{
|
||||||
let width = match child
|
let width = match child
|
||||||
{
|
{
|
||||||
Element::Spacer( s ) => match s.fixed_width
|
Element::Spacer( s ) => match s.resolved_width( canvas )
|
||||||
{
|
{
|
||||||
Some( fw ) => fw,
|
Some( fw ) => fw,
|
||||||
None => flex_unit * s.weight as f32,
|
None => flex_unit * s.weight as f32,
|
||||||
@@ -198,9 +240,9 @@ impl<Msg: Clone> Row<Msg>
|
|||||||
Element::Flex( f ) => flex_unit * f.weight as f32,
|
Element::Flex( f ) => flex_unit * f.weight as f32,
|
||||||
_ => w,
|
_ => w,
|
||||||
};
|
};
|
||||||
let y = rect.y + self.padding + (inner_h - h) / 2.0;
|
let y = rect.y + pad + ( inner_h - h ) / 2.0;
|
||||||
result.push( ( Rect { x, y, width, height: h }, i ) );
|
result.push( ( Rect { x, y, width, height: h }, i ) );
|
||||||
x += width + self.spacing;
|
x += width + spacing;
|
||||||
}
|
}
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
@@ -296,4 +338,37 @@ mod tests
|
|||||||
let rect = Rect { x: 0., y: 0., width: 400., height: 48. };
|
let rect = Rect { x: 0., y: 0., width: 400., height: 48. };
|
||||||
assert!( r.layout( rect, &canvas ).is_empty() );
|
assert!( r.layout( rect, &canvas ).is_empty() );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn vmin_padding_doubles_around_content()
|
||||||
|
{
|
||||||
|
// 800x600 canvas → vmin = 600. 4 % = 24 px each side → 48 px height.
|
||||||
|
let canvas = make_canvas();
|
||||||
|
let r = row::<()>().padding( Length::vmin( 4.0 ) );
|
||||||
|
let ( _, h ) = r.preferred_size( 500.0, &canvas );
|
||||||
|
assert_eq!( h, 48.0 );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn vmin_spacing_pins_visible_layout_gap()
|
||||||
|
{
|
||||||
|
// Two fixed-width spacers separated by a vmin spacing of 5 %.
|
||||||
|
// Canvas vmin = 600 → 30 px between the inner edges. With both
|
||||||
|
// spacers 10 px wide, the second one's `x` minus the first one's
|
||||||
|
// `x + width` must equal the gap regardless of where the row chose
|
||||||
|
// to anchor the cluster (centered, since there are no flex spacers).
|
||||||
|
let canvas = make_canvas();
|
||||||
|
let r = row::<()>()
|
||||||
|
.padding( 0.0 )
|
||||||
|
.spacing( Length::vmin( 5.0 ) )
|
||||||
|
.push( crate::spacer().width( 10.0 ) )
|
||||||
|
.push( crate::spacer().width( 10.0 ) );
|
||||||
|
let rect = Rect { x: 0., y: 0., width: 200., height: 48. };
|
||||||
|
let placed = r.layout( rect, &canvas );
|
||||||
|
assert_eq!( placed.len(), 2 );
|
||||||
|
let ( first_rect, _ ) = placed[ 0 ];
|
||||||
|
let ( second_rect, _ ) = placed[ 1 ];
|
||||||
|
let gap = second_rect.x - ( first_rect.x + first_rect.width );
|
||||||
|
assert!( ( gap - 30.0 ).abs() < 1e-3, "expected ~30 px gap, got {gap}" );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// SPDX-License-Identifier: LGPL-2.1-only
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
|
use crate::render::Canvas;
|
||||||
|
use crate::types::Length;
|
||||||
use crate::widget::Element;
|
use crate::widget::Element;
|
||||||
|
|
||||||
/// A flexible, invisible spacer that expands to fill available space.
|
/// A flexible, invisible spacer that expands to fill available space.
|
||||||
@@ -49,14 +51,34 @@ use crate::widget::Element;
|
|||||||
/// .into()
|
/// .into()
|
||||||
/// # }
|
/// # }
|
||||||
/// ```
|
/// ```
|
||||||
|
///
|
||||||
|
/// `.height(...)` and `.width(...)` also accept any [`crate::Length`], so the
|
||||||
|
/// gap can scale with the surface instead of being frozen at a px constant:
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// # use ltk::{ column, spacer, text, Length, Element };
|
||||||
|
/// # #[ derive( Clone ) ] enum Msg {}
|
||||||
|
/// # fn _ex() -> Element<Msg> {
|
||||||
|
/// column()
|
||||||
|
/// .push( text( "Header" ) )
|
||||||
|
/// // 6 % of the surface's smaller side, never below 16 px or above 64 px.
|
||||||
|
/// .push( spacer().height( Length::vmin( 6.0 ).clamp( 16.0, 64.0 ) ) )
|
||||||
|
/// .push( text( "Content" ) )
|
||||||
|
/// .into()
|
||||||
|
/// # }
|
||||||
|
/// ```
|
||||||
pub struct Spacer
|
pub struct Spacer
|
||||||
{
|
{
|
||||||
/// Relative weight of this spacer (default 1).
|
/// Relative weight of this spacer (default 1).
|
||||||
pub weight: u32,
|
pub( crate ) weight: u32,
|
||||||
/// Fixed height in pixels (overrides flexible behavior in a column).
|
/// Fixed height (overrides flexible behavior in a column). Accepts any
|
||||||
pub fixed_height: Option<f32>,
|
/// [`Length`] — pass an `f32`/`i32`/`u32` for the px case (kept for
|
||||||
/// Fixed width in pixels (overrides flexible behavior in a row).
|
/// backward compatibility with existing call sites), or
|
||||||
pub fixed_width: Option<f32>,
|
/// `Length::vmin( … )` etc. for viewport-relative gaps.
|
||||||
|
pub( crate ) fixed_height: Option<Length>,
|
||||||
|
/// Fixed width (overrides flexible behavior in a row). Same length-type
|
||||||
|
/// semantics as [`Self::fixed_height`].
|
||||||
|
pub( crate ) fixed_width: Option<Length>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Spacer
|
impl Spacer
|
||||||
@@ -68,34 +90,50 @@ impl Spacer
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set a fixed height for this spacer in pixels.
|
/// Set a fixed height for this spacer. Accepts any [`Length`]: a bare
|
||||||
/// When set, the spacer will occupy exactly this much vertical space
|
/// `24.0_f32` is treated as `Length::px( 24.0 )` for source-level
|
||||||
/// instead of expanding flexibly.
|
/// backwards compatibility; a `Length::vmin( 6.0 )` makes the gap
|
||||||
pub fn height( mut self, h: f32 ) -> Self
|
/// scale with the surface's smaller dimension.
|
||||||
|
pub fn height( mut self, h: impl Into<Length> ) -> Self
|
||||||
{
|
{
|
||||||
self.fixed_height = Some( h );
|
self.fixed_height = Some( h.into() );
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set a fixed width for this spacer in pixels.
|
/// Set a fixed width for this spacer. Mirrors [`Self::height`] for the
|
||||||
/// When set, the spacer occupies exactly this much horizontal space
|
/// horizontal axis.
|
||||||
/// inside a [`Row`](crate::layout::row::Row) instead of expanding
|
pub fn width( mut self, w: impl Into<Length> ) -> Self
|
||||||
/// flexibly. Mirrors [`Self::height`] for the horizontal axis — useful
|
|
||||||
/// to reserve a precise visual margin while a sibling
|
|
||||||
/// [`Flex`](crate::Flex) claims the remaining width.
|
|
||||||
pub fn width( mut self, w: f32 ) -> Self
|
|
||||||
{
|
{
|
||||||
self.fixed_width = Some( w );
|
self.fixed_width = Some( w.into() );
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `( fixed_width, fixed_height )`, falling back to `0.0` on the
|
/// Returns `( fixed_width, fixed_height )` resolved against the
|
||||||
/// axes that were not pinned. The parent layout distributes leftover
|
/// current canvas viewport, falling back to `0.0` on axes that were
|
||||||
/// along its main axis among the still-flexible spacers and `Flex`
|
/// not pinned. The parent layout distributes leftover along its main
|
||||||
/// wrappers, weighted by `weight`.
|
/// axis among the still-flexible spacers and `Flex` wrappers,
|
||||||
pub fn preferred_size( &self ) -> (f32, f32)
|
/// weighted by `weight`.
|
||||||
|
pub fn preferred_size( &self, canvas: &Canvas ) -> ( f32, f32 )
|
||||||
{
|
{
|
||||||
( self.fixed_width.unwrap_or( 0.0 ), self.fixed_height.unwrap_or( 0.0 ) )
|
let vp = canvas.viewport_layout();
|
||||||
|
let em = Length::EM_BASE_DEFAULT;
|
||||||
|
(
|
||||||
|
self.fixed_width .map( |l| l.resolve( vp, em ) ).unwrap_or( 0.0 ),
|
||||||
|
self.fixed_height.map( |l| l.resolve( vp, em ) ).unwrap_or( 0.0 ),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolved fixed height in logical pixels, or `None` when the
|
||||||
|
/// spacer is flex. Cheaper than [`Self::preferred_size`] when the
|
||||||
|
/// layout only needs the main-axis size for one orientation.
|
||||||
|
pub fn resolved_height( &self, canvas: &Canvas ) -> Option<f32>
|
||||||
|
{
|
||||||
|
self.fixed_height.map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) )
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolved_width( &self, canvas: &Canvas ) -> Option<f32>
|
||||||
|
{
|
||||||
|
self.fixed_width.map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) )
|
||||||
}
|
}
|
||||||
|
|
||||||
/// No-op — spacers are invisible.
|
/// No-op — spacers are invisible.
|
||||||
@@ -141,3 +179,66 @@ pub fn spacer() -> Spacer
|
|||||||
{
|
{
|
||||||
Spacer { weight: 1, fixed_height: None, fixed_width: None }
|
Spacer { weight: 1, fixed_height: None, fixed_width: None }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[ cfg( test ) ]
|
||||||
|
mod tests
|
||||||
|
{
|
||||||
|
use super::*;
|
||||||
|
use crate::render::Canvas;
|
||||||
|
|
||||||
|
fn make_canvas() -> Canvas { Canvas::new( 800, 600 ) }
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn height_accepts_f32_as_pixels()
|
||||||
|
{
|
||||||
|
let s = spacer().height( 24.0 );
|
||||||
|
let canvas = make_canvas();
|
||||||
|
assert_eq!( s.resolved_height( &canvas ), Some( 24.0 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn height_accepts_length_and_resolves_against_viewport()
|
||||||
|
{
|
||||||
|
// 10 % of the smaller side (= 600) = 60 px.
|
||||||
|
let s = spacer().height( Length::vmin( 10.0 ) );
|
||||||
|
let canvas = make_canvas();
|
||||||
|
assert_eq!( s.resolved_height( &canvas ), Some( 60.0 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn width_accepts_length_and_resolves_against_viewport()
|
||||||
|
{
|
||||||
|
let s = spacer().width( Length::vw( 25.0 ) );
|
||||||
|
let canvas = make_canvas();
|
||||||
|
// 25 % of 800 = 200.
|
||||||
|
assert_eq!( s.resolved_width( &canvas ), Some( 200.0 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn flex_spacer_has_no_resolved_dimensions()
|
||||||
|
{
|
||||||
|
let s = spacer().weight( 3 );
|
||||||
|
let canvas = make_canvas();
|
||||||
|
assert_eq!( s.resolved_height( &canvas ), None );
|
||||||
|
assert_eq!( s.resolved_width( &canvas ), None );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn builder_stores_weight_and_fixed_dimensions()
|
||||||
|
{
|
||||||
|
let d = spacer();
|
||||||
|
assert_eq!( d.weight, 1 );
|
||||||
|
assert!( d.fixed_height.is_none() );
|
||||||
|
assert!( d.fixed_width.is_none() );
|
||||||
|
|
||||||
|
let h = spacer().height( 24.0 );
|
||||||
|
assert_eq!( h.fixed_height, Some( Length::px( 24.0 ) ) );
|
||||||
|
assert!( h.fixed_width.is_none() );
|
||||||
|
|
||||||
|
let w = spacer().width( 12.0 );
|
||||||
|
assert_eq!( w.fixed_width, Some( Length::px( 12.0 ) ) );
|
||||||
|
assert!( w.fixed_height.is_none() );
|
||||||
|
|
||||||
|
assert_eq!( spacer().weight( 5 ).weight, 5 );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,8 +52,14 @@ pub enum VAlign
|
|||||||
pub struct Stack<Msg: Clone>
|
pub struct Stack<Msg: Clone>
|
||||||
{
|
{
|
||||||
/// Children with their alignment, margin, and extra `(x, y)` translation
|
/// Children with their alignment, margin, and extra `(x, y)` translation
|
||||||
/// applied after alignment. Drawn in order — last child is on top.
|
/// applied after alignment. Drawn in order — last child is on top. The 7th
|
||||||
pub children: Vec<( Element<Msg>, HAlign, VAlign, f32, f32, f32 )>,
|
/// `Option<Rect>` overrides alignment/sizing with an exact rect (see
|
||||||
|
/// [`push_placed`](Self::push_placed)); the 8th clips the child's draw to a
|
||||||
|
/// rect (Android clipChildren — see [`push_placed_clipped`](Self::push_placed_clipped)).
|
||||||
|
pub( crate ) children: Vec<( Element<Msg>, HAlign, VAlign, f32, f32, f32, Option<Rect>, Option<Rect> )>,
|
||||||
|
/// When `true`, [`preferred_size`](Self::preferred_size) reports the max of
|
||||||
|
/// children's intrinsic widths and heights instead of `(max_width, tallest)`.
|
||||||
|
pub( crate ) fit_content: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<Msg: Clone> Stack<Msg>
|
impl<Msg: Clone> Stack<Msg>
|
||||||
@@ -61,7 +67,14 @@ impl<Msg: Clone> Stack<Msg>
|
|||||||
/// Create an empty stack.
|
/// Create an empty stack.
|
||||||
pub fn new() -> Self
|
pub fn new() -> Self
|
||||||
{
|
{
|
||||||
Self { children: Vec::new() }
|
Self { children: Vec::new(), fit_content: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable [`Self::fit_content`].
|
||||||
|
pub fn fit_content( mut self ) -> Self
|
||||||
|
{
|
||||||
|
self.fit_content = true;
|
||||||
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Append a child that fills the entire Stack rect (Android FrameLayout default).
|
/// Append a child that fills the entire Stack rect (Android FrameLayout default).
|
||||||
@@ -90,7 +103,7 @@ impl<Msg: Clone> Stack<Msg>
|
|||||||
margin: f32,
|
margin: f32,
|
||||||
) -> Self
|
) -> Self
|
||||||
{
|
{
|
||||||
self.children.push( ( e.into(), h_align, v_align, margin, 0.0, 0.0 ) );
|
self.children.push( ( e.into(), h_align, v_align, margin, 0.0, 0.0, None, None ) );
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,15 +120,66 @@ impl<Msg: Clone> Stack<Msg>
|
|||||||
offset_y: f32,
|
offset_y: f32,
|
||||||
) -> Self
|
) -> Self
|
||||||
{
|
{
|
||||||
self.children.push( ( e.into(), h_align, v_align, 0.0, offset_x, offset_y ) );
|
self.children.push( ( e.into(), h_align, v_align, 0.0, offset_x, offset_y, None, None ) );
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append a child placed at an exact `rect` (in the Stack's coordinate space),
|
||||||
|
/// bypassing alignment and intrinsic sizing. This is what hosts a view tree
|
||||||
|
/// whose geometry is computed elsewhere — e.g. Android's measure/layout pass,
|
||||||
|
/// which yields absolute rects per view.
|
||||||
|
pub fn push_placed(
|
||||||
|
mut self,
|
||||||
|
e: impl Into<Element<Msg>>,
|
||||||
|
rect: Rect,
|
||||||
|
) -> Self
|
||||||
|
{
|
||||||
|
self.children.push( ( e.into(), HAlign::Start, VAlign::Top, 0.0, 0.0, 0.0, Some( rect ), None ) );
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`push_placed`](Self::push_placed) but clips the child's drawing to
|
||||||
|
/// `clip` (in the Stack's coordinate space). Mirrors Android's clipChildren:
|
||||||
|
/// content that overflows the clip — e.g. a scrolled list row reaching above
|
||||||
|
/// the list, or an inner card past a rounded bubble — is not painted.
|
||||||
|
pub fn push_placed_clipped(
|
||||||
|
mut self,
|
||||||
|
e: impl Into<Element<Msg>>,
|
||||||
|
rect: Rect,
|
||||||
|
clip: Rect,
|
||||||
|
) -> Self
|
||||||
|
{
|
||||||
|
self.children.push( ( e.into(), HAlign::Start, VAlign::Top, 0.0, 0.0, 0.0, Some( rect ), Some( clip ) ) );
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the preferred `(width, height)` — the maximum height among children.
|
|
||||||
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
|
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
|
||||||
{
|
{
|
||||||
|
if self.fit_content
|
||||||
|
{
|
||||||
|
let content_w = self.children.iter()
|
||||||
|
.map( |( c, .. )| match c
|
||||||
|
{
|
||||||
|
Element::Spacer( s ) => s.resolved_width( canvas ).unwrap_or( 0.0 ),
|
||||||
|
Element::Separator( _ ) => 0.0,
|
||||||
|
Element::Scroll( _ ) => 0.0,
|
||||||
|
Element::ProgressBar( _ ) => 0.0,
|
||||||
|
Element::Slider( _ ) => 0.0,
|
||||||
|
Element::TextEdit( t ) => if t.fixed_width.is_some()
|
||||||
|
{
|
||||||
|
t.preferred_size( max_width, canvas ).0
|
||||||
|
} else { 0.0 },
|
||||||
|
other => other.preferred_size( max_width, canvas ).0,
|
||||||
|
} )
|
||||||
|
.fold( 0.0_f32, f32::max );
|
||||||
let max_h = self.children.iter()
|
let max_h = self.children.iter()
|
||||||
.map( |( c, _, _, _, _, _ )| c.preferred_size( max_width, canvas ).1 )
|
.map( |( c, .. )| c.preferred_size( max_width, canvas ).1 )
|
||||||
|
.fold( 0.0_f32, f32::max );
|
||||||
|
return ( content_w.min( max_width ), max_h );
|
||||||
|
}
|
||||||
|
|
||||||
|
let max_h = self.children.iter()
|
||||||
|
.map( |( c, .. )| c.preferred_size( max_width, canvas ).1 )
|
||||||
.fold( 0.0_f32, f32::max );
|
.fold( 0.0_f32, f32::max );
|
||||||
( max_width, max_h )
|
( max_width, max_h )
|
||||||
}
|
}
|
||||||
@@ -123,8 +187,13 @@ impl<Msg: Clone> Stack<Msg>
|
|||||||
/// Return `(rect, child_index)` pairs, computing each child's rect from its alignment.
|
/// Return `(rect, child_index)` pairs, computing each child's rect from its alignment.
|
||||||
pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<(Rect, usize)>
|
pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<(Rect, usize)>
|
||||||
{
|
{
|
||||||
self.children.iter().enumerate().map( |( i, ( child, h_align, v_align, margin, ox, oy ) )|
|
self.children.iter().enumerate().map( |( i, ( child, h_align, v_align, margin, ox, oy, placed, .. ) )|
|
||||||
{
|
{
|
||||||
|
if let Some( p ) = placed
|
||||||
|
{
|
||||||
|
return ( Rect { x: rect.x + p.x, y: rect.y + p.y, width: p.width, height: p.height }, i );
|
||||||
|
}
|
||||||
|
|
||||||
let inner_w = ( rect.width - margin * 2.0 ).max( 0.0 );
|
let inner_w = ( rect.width - margin * 2.0 ).max( 0.0 );
|
||||||
let inner_h = ( rect.height - margin * 2.0 ).max( 0.0 );
|
let inner_h = ( rect.height - margin * 2.0 ).max( 0.0 );
|
||||||
let ( pref_w, pref_h ) = child.preferred_size( inner_w, canvas );
|
let ( pref_w, pref_h ) = child.preferred_size( inner_w, canvas );
|
||||||
@@ -160,9 +229,10 @@ impl<Msg: Clone> Stack<Msg>
|
|||||||
Stack
|
Stack
|
||||||
{
|
{
|
||||||
children: self.children.into_iter()
|
children: self.children.into_iter()
|
||||||
.map( |( child, ha, va, margin, ox, oy )|
|
.map( |( child, ha, va, margin, ox, oy, placed, clip )|
|
||||||
( child.map_arc( f ), ha, va, margin, ox, oy ) )
|
( child.map_arc( f ), ha, va, margin, ox, oy, placed, clip ) )
|
||||||
.collect(),
|
.collect(),
|
||||||
|
fit_content: self.fit_content,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
use crate::render::Canvas;
|
use crate::render::Canvas;
|
||||||
use crate::types::Rect;
|
use crate::types::{ Length, Rect };
|
||||||
use crate::widget::Element;
|
use crate::widget::Element;
|
||||||
|
|
||||||
/// A grid layout that wraps children into rows of a fixed column count.
|
/// A grid layout that wraps children into rows of a fixed column count.
|
||||||
@@ -32,15 +32,18 @@ use crate::widget::Element;
|
|||||||
pub struct WrapGrid<Msg: Clone>
|
pub struct WrapGrid<Msg: Clone>
|
||||||
{
|
{
|
||||||
/// Child widgets laid out in row-major order.
|
/// Child widgets laid out in row-major order.
|
||||||
pub children: Vec<Element<Msg>>,
|
pub( crate ) children: Vec<Element<Msg>>,
|
||||||
/// Number of columns per row.
|
/// Number of columns per row.
|
||||||
pub columns: usize,
|
pub( crate ) columns: usize,
|
||||||
/// Horizontal gap between cells (pixels).
|
/// Horizontal gap between cells.
|
||||||
pub spacing_x: f32,
|
pub( crate ) spacing_x: Length,
|
||||||
/// Vertical gap between rows (pixels).
|
/// Vertical gap between rows.
|
||||||
pub spacing_y: f32,
|
pub( crate ) spacing_y: Length,
|
||||||
/// Padding on all sides (pixels).
|
/// Padding on all sides.
|
||||||
pub padding: f32,
|
pub( crate ) padding: Length,
|
||||||
|
/// When `true`, a partial last row is centred horizontally within
|
||||||
|
/// the grid's content rect instead of being left-aligned.
|
||||||
|
pub( crate ) centre_last_row: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<Msg: Clone> WrapGrid<Msg>
|
impl<Msg: Clone> WrapGrid<Msg>
|
||||||
@@ -53,34 +56,54 @@ impl<Msg: Clone> WrapGrid<Msg>
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Set both horizontal and vertical gap between cells (default 8.0).
|
/// Set both horizontal and vertical gap between cells (default 8.0).
|
||||||
pub fn spacing( mut self, s: f32 ) -> Self
|
pub fn spacing( mut self, s: impl Into<Length> ) -> Self
|
||||||
{
|
{
|
||||||
|
let s = s.into();
|
||||||
self.spacing_x = s;
|
self.spacing_x = s;
|
||||||
self.spacing_y = s;
|
self.spacing_y = s;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set only the horizontal gap between cells; leaves vertical spacing untouched.
|
/// Set only the horizontal gap between cells; leaves vertical spacing untouched.
|
||||||
pub fn spacing_x( mut self, s: f32 ) -> Self
|
pub fn spacing_x( mut self, s: impl Into<Length> ) -> Self
|
||||||
{
|
{
|
||||||
self.spacing_x = s;
|
self.spacing_x = s.into();
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set only the vertical gap between rows; leaves horizontal spacing untouched.
|
/// Set only the vertical gap between rows; leaves horizontal spacing untouched.
|
||||||
pub fn spacing_y( mut self, s: f32 ) -> Self
|
pub fn spacing_y( mut self, s: impl Into<Length> ) -> Self
|
||||||
{
|
{
|
||||||
self.spacing_y = s;
|
self.spacing_y = s.into();
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the padding on all sides (default 0.0).
|
/// Set the padding on all sides (default 0.0).
|
||||||
pub fn padding( mut self, p: f32 ) -> Self
|
pub fn padding( mut self, p: impl Into<Length> ) -> Self
|
||||||
{
|
{
|
||||||
self.padding = p;
|
self.padding = p.into();
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Centre a partial last row horizontally inside the content rect.
|
||||||
|
/// Default is `false` (left-aligned, like other grids).
|
||||||
|
pub fn centre_last_row( mut self, yes: bool ) -> Self
|
||||||
|
{
|
||||||
|
self.centre_last_row = yes;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolved( &self, canvas: &Canvas ) -> ( f32, f32, f32 )
|
||||||
|
{
|
||||||
|
let vp = canvas.viewport_layout();
|
||||||
|
let em = Length::EM_BASE_DEFAULT;
|
||||||
|
(
|
||||||
|
self.spacing_x.resolve( vp, em ),
|
||||||
|
self.spacing_y.resolve( vp, em ),
|
||||||
|
self.padding.resolve( vp, em ),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Compute the preferred size given an available width.
|
/// Compute the preferred size given an available width.
|
||||||
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
|
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
|
||||||
{
|
{
|
||||||
@@ -88,12 +111,13 @@ impl<Msg: Clone> WrapGrid<Msg>
|
|||||||
{
|
{
|
||||||
return ( max_width, 0.0 );
|
return ( max_width, 0.0 );
|
||||||
}
|
}
|
||||||
|
let ( sx, sy, pad ) = self.resolved( canvas );
|
||||||
let cols = self.columns;
|
let cols = self.columns;
|
||||||
let inner_w = (max_width - self.padding * 2.0).max( 0.0 );
|
let inner_w = (max_width - pad * 2.0).max( 0.0 );
|
||||||
let cell_w = (inner_w - self.spacing_x * (cols as f32 - 1.0)).max( 0.0 ) / cols as f32;
|
let cell_w = (inner_w - sx * (cols as f32 - 1.0)).max( 0.0 ) / cols as f32;
|
||||||
let row_count = (self.children.len() + cols - 1) / cols;
|
let row_count = (self.children.len() + cols - 1) / cols;
|
||||||
|
|
||||||
let mut total_h = self.padding * 2.0;
|
let mut total_h = pad * 2.0;
|
||||||
for row in 0..row_count
|
for row in 0..row_count
|
||||||
{
|
{
|
||||||
let start = row * cols;
|
let start = row * cols;
|
||||||
@@ -103,7 +127,7 @@ impl<Msg: Clone> WrapGrid<Msg>
|
|||||||
.map( |c| c.preferred_size( cell_w, canvas ).1 )
|
.map( |c| c.preferred_size( cell_w, canvas ).1 )
|
||||||
.fold( 0.0_f32, f32::max );
|
.fold( 0.0_f32, f32::max );
|
||||||
total_h += row_h;
|
total_h += row_h;
|
||||||
if row + 1 < row_count { total_h += self.spacing_y; }
|
if row + 1 < row_count { total_h += sy; }
|
||||||
}
|
}
|
||||||
( max_width, total_h )
|
( max_width, total_h )
|
||||||
}
|
}
|
||||||
@@ -115,11 +139,12 @@ impl<Msg: Clone> WrapGrid<Msg>
|
|||||||
{
|
{
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
let ( sx, sy, pad ) = self.resolved( canvas );
|
||||||
let cols = self.columns;
|
let cols = self.columns;
|
||||||
let inner_w = (rect.width - self.padding * 2.0).max( 0.0 );
|
let inner_w = (rect.width - pad * 2.0).max( 0.0 );
|
||||||
let cell_w = (inner_w - self.spacing_x * (cols as f32 - 1.0)).max( 0.0 ) / cols as f32;
|
let cell_w = (inner_w - sx * (cols as f32 - 1.0)).max( 0.0 ) / cols as f32;
|
||||||
let x0 = rect.x + self.padding;
|
let x0 = rect.x + pad;
|
||||||
let mut y = rect.y + self.padding;
|
let mut y = rect.y + pad;
|
||||||
|
|
||||||
let row_count = (self.children.len() + cols - 1) / cols;
|
let row_count = (self.children.len() + cols - 1) / cols;
|
||||||
let mut out = Vec::with_capacity( self.children.len() );
|
let mut out = Vec::with_capacity( self.children.len() );
|
||||||
@@ -133,13 +158,20 @@ impl<Msg: Clone> WrapGrid<Msg>
|
|||||||
.map( |c| c.preferred_size( cell_w, canvas ).1 )
|
.map( |c| c.preferred_size( cell_w, canvas ).1 )
|
||||||
.fold( 0.0_f32, f32::max );
|
.fold( 0.0_f32, f32::max );
|
||||||
|
|
||||||
for col in 0..(end - start)
|
let items_in_row = end - start;
|
||||||
|
let row_offset = if self.centre_last_row && items_in_row < cols
|
||||||
{
|
{
|
||||||
let x = x0 + col as f32 * (cell_w + self.spacing_x);
|
let missing = (cols - items_in_row) as f32;
|
||||||
|
missing * (cell_w + sx) / 2.0
|
||||||
|
} else { 0.0 };
|
||||||
|
|
||||||
|
for col in 0..items_in_row
|
||||||
|
{
|
||||||
|
let x = x0 + row_offset + col as f32 * (cell_w + sx);
|
||||||
let crect = Rect { x, y, width: cell_w, height: row_h };
|
let crect = Rect { x, y, width: cell_w, height: row_h };
|
||||||
out.push( ( crect, start + col ) );
|
out.push( ( crect, start + col ) );
|
||||||
}
|
}
|
||||||
y += row_h + self.spacing_y;
|
y += row_h + sy;
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
@@ -156,6 +188,7 @@ impl<Msg: Clone> WrapGrid<Msg>
|
|||||||
spacing_x: self.spacing_x,
|
spacing_x: self.spacing_x,
|
||||||
spacing_y: self.spacing_y,
|
spacing_y: self.spacing_y,
|
||||||
padding: self.padding,
|
padding: self.padding,
|
||||||
|
centre_last_row: self.centre_last_row,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -320,6 +353,43 @@ mod tests
|
|||||||
assert!( (rects[0].0.x - 50.0).abs() < 0.01 );
|
assert!( (rects[0].0.x - 50.0).abs() < 0.01 );
|
||||||
assert!( (rects[0].0.y - 30.0).abs() < 0.01 );
|
assert!( (rects[0].0.y - 30.0).abs() < 0.01 );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- layout: centre_last_row ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn last_row_centred_when_partial()
|
||||||
|
{
|
||||||
|
// 3 children, 2 cols => row 0: 2 items, row 1: 1 item centred.
|
||||||
|
// cell_w = 200/2 = 100; centred-offset = (2-1)*100/2 = 50.
|
||||||
|
let g = spacer_grid( 2, 3, 0.0, 0.0 ).centre_last_row( true );
|
||||||
|
let c = canvas();
|
||||||
|
let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 400.0 };
|
||||||
|
let rects = g.layout( rect, &c );
|
||||||
|
assert!( (rects[2].0.x - 50.0).abs() < 0.01 );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn centre_last_row_noop_on_full_row()
|
||||||
|
{
|
||||||
|
// 4 children, 2 cols => both rows full; nothing to centre.
|
||||||
|
let g = spacer_grid( 2, 4, 0.0, 0.0 ).centre_last_row( true );
|
||||||
|
let c = canvas();
|
||||||
|
let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 400.0 };
|
||||||
|
let rects = g.layout( rect, &c );
|
||||||
|
assert!( rects[2].0.x.abs() < 0.01 );
|
||||||
|
assert!( (rects[3].0.x - 100.0).abs() < 0.01 );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn centre_last_row_off_by_default()
|
||||||
|
{
|
||||||
|
// Same case as above but without the flag — last item stays at x=0.
|
||||||
|
let g = spacer_grid( 2, 3, 0.0, 0.0 );
|
||||||
|
let c = canvas();
|
||||||
|
let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 400.0 };
|
||||||
|
let rects = g.layout( rect, &c );
|
||||||
|
assert!( rects[2].0.x.abs() < 0.01 );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a grid layout with the given number of columns.
|
/// Create a grid layout with the given number of columns.
|
||||||
@@ -340,8 +410,9 @@ pub fn grid<Msg: Clone>( columns: usize ) -> WrapGrid<Msg>
|
|||||||
{
|
{
|
||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
columns,
|
columns,
|
||||||
spacing_x: 8.0,
|
spacing_x: Length::px( 8.0 ),
|
||||||
spacing_y: 8.0,
|
spacing_y: Length::px( 8.0 ),
|
||||||
padding: 0.0,
|
padding: Length::px( 0.0 ),
|
||||||
|
centre_last_row: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
140
src/lib.rs
140
src/lib.rs
@@ -69,6 +69,10 @@
|
|||||||
//! are concept-oriented landing pages that `cargo doc` exposes for the
|
//! are concept-oriented landing pages that `cargo doc` exposes for the
|
||||||
//! same set, grouped by category.
|
//! same set, grouped by category.
|
||||||
//!
|
//!
|
||||||
|
//! ## Rendering backends
|
||||||
|
//!
|
||||||
|
//! All drawing goes through a single [`Canvas`], which is one of two interchangeable backends exposing the same API. The GLES backend (`GlesCanvas`) is the default when an EGL/OpenGL ES context is available and renders on the GPU; the software backend (`SoftwareCanvas`) rasterises on the CPU with tiny-skia and is the fallback when there is no GL context (and what offscreen/preview rendering uses). The two are kept at visual parity for the common primitives, with a few backend-specific traits documented per method (e.g. multi-rect [`Canvas::set_clip_rects`] is an exact mask on software but a bounding-box scissor on GLES, and [`Canvas::is_software`] lets a caller branch on a real path clip vs a bounding rect). [`run()`] selects the backend automatically; [`core::UiSurface`] lets an embedder force one.
|
||||||
|
//!
|
||||||
//! ## Widgets
|
//! ## Widgets
|
||||||
//!
|
//!
|
||||||
//! The interactive and decorative leaves of the [`Element`] tree:
|
//! The interactive and decorative leaves of the [`Element`] tree:
|
||||||
@@ -107,9 +111,110 @@
|
|||||||
//! Geometry and primitive values that flow through every builder:
|
//! Geometry and primitive values that flow through every builder:
|
||||||
//!
|
//!
|
||||||
//! - [`Color`], [`Rect`], [`Point`], [`Size`], [`Corners`], [`WidgetId`].
|
//! - [`Color`], [`Rect`], [`Point`], [`Size`], [`Corners`], [`WidgetId`].
|
||||||
|
//! - [`Length`] — a size/distance that may be absolute pixels
|
||||||
|
//! ([`LengthBase::Px`]), relative to the surface viewport
|
||||||
|
//! ([`LengthBase::Vw`] / [`LengthBase::Vh`] / [`LengthBase::Vmin`] /
|
||||||
|
//! [`LengthBase::Vmax`] / [`LengthBase::Orient`]) or to the root font
|
||||||
|
//! size ([`LengthBase::Em`]). Every setter that takes a size, padding,
|
||||||
|
//! spacing or font height now accepts `impl Into<Length>`, so legacy
|
||||||
|
//! `.size( 24.0 )` keeps working while new code can write
|
||||||
|
//! `.size( Length::vmin( 4.0 ).clamp( 16.0, 32.0 ) )` for a typeface
|
||||||
|
//! that scales with the screen. See **Responsive design** below.
|
||||||
//!
|
//!
|
||||||
//! See [`types`] for the full module with `//!` description.
|
//! See [`types`] for the full module with `//!` description.
|
||||||
//!
|
//!
|
||||||
|
//! ## Responsive design: one UI from phone to desktop
|
||||||
|
//!
|
||||||
|
//! A core goal of ltk is that a **single view** reads coherently across
|
||||||
|
//! a portrait phone, a tablet and a landscape desktop window — no
|
||||||
|
//! per-target forks, no media-query soup. The mechanism is **fluid
|
||||||
|
//! sizing**: express sizes as a fraction of the surface so the whole
|
||||||
|
//! design breathes with the screen, instead of freezing at one pixel
|
||||||
|
//! size that only looks right on one device.
|
||||||
|
//!
|
||||||
|
//! ### The core rule — fluid, but clamped
|
||||||
|
//!
|
||||||
|
//! Default to `Length::vmin( pct ).clamp( lo, hi )` for every font size,
|
||||||
|
//! padding, spacing and spacer:
|
||||||
|
//!
|
||||||
|
//! - the percentage tracks the surface's **smaller** side, so portrait
|
||||||
|
//! and landscape stay coherent — an element keeps the same fraction of
|
||||||
|
//! the narrow axis whichever way the device is held;
|
||||||
|
//! - the px `clamp` bounds the fluid range so the design never collapses
|
||||||
|
//! on a watch-sized surface nor balloons on a 4K monitor.
|
||||||
|
//!
|
||||||
|
//! The clamp is not optional polish: it is what turns *pure* proportional
|
||||||
|
//! sizing (fragile at the extremes) into *bounded* proportional sizing
|
||||||
|
//! (robust everywhere). Treat "always clamp a fluid value" as the rule,
|
||||||
|
//! not the exception.
|
||||||
|
//!
|
||||||
|
//! ### Orientation-dependent proportion — [`Length::orient`]
|
||||||
|
//!
|
||||||
|
//! Sometimes the right *proportion* differs by orientation, not merely
|
||||||
|
//! the reference axis. A logo may want 40 % of the width in portrait —
|
||||||
|
//! there is vertical room to spare — but only 5 % of the height in
|
||||||
|
//! landscape, where vertical room is scarce.
|
||||||
|
//! [`Length::orient`]`( portrait, landscape )` expresses exactly that:
|
||||||
|
//! `portrait` % of the **width** when the surface is portrait,
|
||||||
|
//! `landscape` % of the **height** when it is landscape (the short side
|
||||||
|
//! of each orientation, but with its own proportion).
|
||||||
|
//!
|
||||||
|
//! For images, pair it with
|
||||||
|
//! [`Image::short_side`](widget::image::Image::short_side), which sizes
|
||||||
|
//! the image along the screen's short side and lets the other axis follow
|
||||||
|
//! the source aspect ratio:
|
||||||
|
//!
|
||||||
|
//! ```rust,no_run
|
||||||
|
//! # use std::sync::Arc;
|
||||||
|
//! # use ltk::{ img_widget, Length, Element };
|
||||||
|
//! # #[ derive( Clone ) ] enum Msg {}
|
||||||
|
//! # fn _ex( rgba: Arc<Vec<u8>>, w: u32, h: u32 ) -> Element<Msg> {
|
||||||
|
//! // 40 % of the width in portrait, 5 % of the height in landscape.
|
||||||
|
//! img_widget( rgba, w, h ).short_side( Length::orient( 40.0, 5.0 ) ).into()
|
||||||
|
//! # }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ### When constant *physical* size matters instead
|
||||||
|
//!
|
||||||
|
//! Fluid units scale with the screen's pixels, not with real-world
|
||||||
|
//! millimetres — and legibility is a function of physical (angular) size,
|
||||||
|
//! not of what fraction of the screen a glyph fills. When a size must stay
|
||||||
|
//! a **constant physical size** across very different displays, use the
|
||||||
|
//! other mode: [`Length::dp`] (a density-independent pixel — `n ×`
|
||||||
|
//! [`density`], the mainstream HiDPI unit), or [`LengthBase::Em`] for text
|
||||||
|
//! relative to the root font size. The pre-calibrated
|
||||||
|
//! [`theme::typography`] scale
|
||||||
|
//! ([`theme::typography::h0`]…[`theme::typography::body_xs`]) is built on
|
||||||
|
//! clamped `vmin`, so it stays fluid while respecting a readable px floor
|
||||||
|
//! and ceiling — a good default for running text.
|
||||||
|
//!
|
||||||
|
//! ### The two modes, and choosing per app
|
||||||
|
//!
|
||||||
|
//! ltk offers both strategies as first-class citizens and lets the app
|
||||||
|
//! pick — per value, or process-wide for every stock widget:
|
||||||
|
//!
|
||||||
|
//! - **Fluid** — [`Length::fluid`] and the raw `vmin` / `vmax` / `vw` /
|
||||||
|
//! `vh` / `orient` units. Surface-proportional; tracks the short side
|
||||||
|
//! (width in portrait, height in landscape). Best for full-screen system
|
||||||
|
//! surfaces on known hardware (lock screen, greeter, splash, kiosk,
|
||||||
|
//! launcher).
|
||||||
|
//! - **Physical** — [`Length::dp`] plus [`set_density`] / [`density`].
|
||||||
|
//! Constant real-world size, HiDPI-aware. Best for conventional windowed
|
||||||
|
//! apps across an open-ended device set.
|
||||||
|
//!
|
||||||
|
//! Stock widgets carry one design pixel per dimension and resolve it
|
||||||
|
//! through the process-wide [`WidgetScaling`] mode
|
||||||
|
//! ([`set_widget_scaling`]): [`WidgetScaling::Fluid`] (the default) reads
|
||||||
|
//! it as [`Length::fluid`], [`WidgetScaling::Physical`] as [`Length::dp`].
|
||||||
|
//! An explicit [`Length`] on a widget always overrides the mode.
|
||||||
|
//!
|
||||||
|
//! ### Structural changes — branch in `view()`
|
||||||
|
//!
|
||||||
|
//! When the layout *structure* must change (sidebar → bottom tabs, two
|
||||||
|
//! columns → one) rather than merely resize, branch in `view()` on
|
||||||
|
//! `surface_width` / `surface_height`. Keep this for genuine
|
||||||
|
//! restructuring; [`Length`] covers all pure sizing.
|
||||||
|
//!
|
||||||
//! ## Runtime-free embedding
|
//! ## Runtime-free embedding
|
||||||
//!
|
//!
|
||||||
//! Use [`core::UiSurface`] when you need ltk's layout, drawing and
|
//! Use [`core::UiSurface`] when you need ltk's layout, drawing and
|
||||||
@@ -132,6 +237,16 @@
|
|||||||
//! - **Sora Regular** (`src/theme/fallback/Sora-Regular.otf`) — the
|
//! - **Sora Regular** (`src/theme/fallback/Sora-Regular.otf`) — the
|
||||||
//! embedded font fallback, [SIL OFL 1.1](https://scripts.sil.org/OFL),
|
//! embedded font fallback, [SIL OFL 1.1](https://scripts.sil.org/OFL),
|
||||||
//! © The Sora Project Authors, Jonathan Barnbrook, Julián Moncada.
|
//! © The Sora Project Authors, Jonathan Barnbrook, Julián Moncada.
|
||||||
|
//! - **Pointer cursors** under `themes/default/cursors/` — GNOME's
|
||||||
|
//! *Adwaita* cursor theme (the cursors GNOME Shell ships), bundled
|
||||||
|
//! verbatim, © the GNOME Project. Offered upstream under
|
||||||
|
//! [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) *or*
|
||||||
|
//! [LGPL 3](https://www.gnu.org/licenses/lgpl-3.0.html), and
|
||||||
|
//! [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) for
|
||||||
|
//! the newer assets; any one option satisfies the licence. See
|
||||||
|
//! `themes/default/cursors/README.md` (what the set is and how it is
|
||||||
|
//! used) and `themes/default/cursors/LICENSE.md` (attribution).
|
||||||
|
//! Upstream: <https://gitlab.gnome.org/GNOME/adwaita-icon-theme>.
|
||||||
//!
|
//!
|
||||||
//! The remaining artwork in the default theme (wallpapers, lockscreens,
|
//! The remaining artwork in the default theme (wallpapers, lockscreens,
|
||||||
//! launcher logo, brand-mark variants, per-application icons) is
|
//! launcher logo, brand-mark variants, per-application icons) is
|
||||||
@@ -146,6 +261,12 @@
|
|||||||
// English entry.
|
// English entry.
|
||||||
rust_i18n::i18n!( "locales", fallback = "en" );
|
rust_i18n::i18n!( "locales", fallback = "en" );
|
||||||
|
|
||||||
|
/// Serialises tests across modules that read or mutate the process-wide
|
||||||
|
/// density / [`WidgetScaling`] globals, so parallel test threads never
|
||||||
|
/// observe each other's transient state.
|
||||||
|
#[ cfg( test ) ]
|
||||||
|
pub( crate ) static TEST_GLOBALS_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new( () );
|
||||||
|
|
||||||
pub mod types;
|
pub mod types;
|
||||||
pub( crate ) mod render;
|
pub( crate ) mod render;
|
||||||
pub( crate ) mod system_fonts;
|
pub( crate ) mod system_fonts;
|
||||||
@@ -156,6 +277,7 @@ pub( crate ) mod layout;
|
|||||||
pub( crate ) mod app;
|
pub( crate ) mod app;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
pub mod wallpaper;
|
pub mod wallpaper;
|
||||||
|
pub mod chassis;
|
||||||
|
|
||||||
pub( crate ) mod tree;
|
pub( crate ) mod tree;
|
||||||
pub( crate ) mod draw;
|
pub( crate ) mod draw;
|
||||||
@@ -169,6 +291,7 @@ pub mod core;
|
|||||||
pub use app::
|
pub use app::
|
||||||
{
|
{
|
||||||
Anchor, App, ChannelSender, ShellMode, Layer, OverlayId, OverlaySpec,
|
Anchor, App, ChannelSender, ShellMode, Layer, OverlayId, OverlaySpec,
|
||||||
|
SubsurfaceId, SubsurfaceSpec, SubsurfaceParent,
|
||||||
InvalidationScope, SurfaceTarget, ToplevelEvent,
|
InvalidationScope, SurfaceTarget, ToplevelEvent,
|
||||||
};
|
};
|
||||||
pub use theme::
|
pub use theme::
|
||||||
@@ -201,9 +324,17 @@ pub use theme::branding_image as theme_branding_image;
|
|||||||
pub use theme::{ decode_svg_bytes, icon_path as theme_icon_path, icon_rgba as theme_icon_rgba };
|
pub use theme::{ decode_svg_bytes, icon_path as theme_icon_path, icon_rgba as theme_icon_rgba };
|
||||||
pub use gles_render::{ BorrowedGlesTexture, GlesVersion };
|
pub use gles_render::{ BorrowedGlesTexture, GlesVersion };
|
||||||
pub use render::is_software_render;
|
pub use render::is_software_render;
|
||||||
|
pub use render::Canvas;
|
||||||
pub use wallpaper::{ WallpaperBundle, ImageData };
|
pub use wallpaper::{ WallpaperBundle, ImageData };
|
||||||
pub use types::{ Color, Corners, CursorShape, Point, Rect, Size, WidgetId };
|
pub use chassis::{ set_default_theme, theme_logo_rgba, theme_icon_tinted, branding_bundle_or_solid, wallpaper_bundle_or_solid, backdrop };
|
||||||
|
pub use types::{ Color, Corners, CursorShape, Length, LengthBase, PathCmd, Point, Rect, Size, WidgetId };
|
||||||
|
pub use types::{ WidgetScaling, FLUID_MIN, FLUID_MAX };
|
||||||
|
pub use types::{ fluid_reference, set_fluid_reference };
|
||||||
|
pub use types::{ density, set_density };
|
||||||
|
pub use types::{ widget_scaling, set_widget_scaling };
|
||||||
pub use widget::{ Element, button, icon_button, text_edit, image as img_widget, text, container };
|
pub use widget::{ Element, button, icon_button, text_edit, image as img_widget, text, container };
|
||||||
|
pub use text_shaping::measure_text;
|
||||||
|
pub use widget::rich_text::{ rich_text, RichText, LinkSpan };
|
||||||
pub use widget::button::ButtonVariant;
|
pub use widget::button::ButtonVariant;
|
||||||
pub use widget::slider::{ Slider, slider, SliderAxis };
|
pub use widget::slider::{ Slider, slider, SliderAxis };
|
||||||
pub use widget::vslider::{ VSlider, vslider };
|
pub use widget::vslider::{ VSlider, vslider };
|
||||||
@@ -244,6 +375,7 @@ pub use layout::stack::{ Stack, stack, HAlign, VAlign };
|
|||||||
pub use layout::wrap_grid::{ WrapGrid, grid };
|
pub use layout::wrap_grid::{ WrapGrid, grid };
|
||||||
pub use widget::scroll::{ scroll, ScrollAxis };
|
pub use widget::scroll::{ scroll, ScrollAxis };
|
||||||
pub use widget::viewport::{ Viewport, viewport };
|
pub use widget::viewport::{ Viewport, viewport };
|
||||||
|
pub use widget::carousel::{ Carousel, carousel };
|
||||||
pub use app::run;
|
pub use app::run;
|
||||||
pub use app::{ try_run, RunError };
|
pub use app::{ try_run, RunError };
|
||||||
pub use smithay_client_toolkit::seat::keyboard::Keysym;
|
pub use smithay_client_toolkit::seat::keyboard::Keysym;
|
||||||
@@ -419,13 +551,15 @@ pub mod runtime
|
|||||||
///
|
///
|
||||||
/// **Not part of the stable public API.** Anything in here may change between
|
/// **Not part of the stable public API.** Anything in here may change between
|
||||||
/// patch releases without notice. Hidden from generated docs via
|
/// patch releases without notice. Hidden from generated docs via
|
||||||
/// `#[doc(hidden)]` for the same reason.
|
/// `#[doc(hidden)]`, and gated behind the `test-support` feature so it is
|
||||||
|
/// absent from third-party builds entirely (ltk's own `make test` enables it).
|
||||||
|
#[ cfg( feature = "test-support" ) ]
|
||||||
#[ doc( hidden ) ]
|
#[ doc( hidden ) ]
|
||||||
pub mod test_support
|
pub mod test_support
|
||||||
{
|
{
|
||||||
pub use crate::tree::{ find_widget_at, find_widget, find_handlers, next_focusable_index };
|
pub use crate::tree::{ find_widget_at, find_widget, find_handlers, next_focusable_index };
|
||||||
pub use crate::widget::{ LaidOutWidget, WidgetHandlers };
|
pub use crate::widget::{ LaidOutWidget, WidgetHandlers };
|
||||||
pub use crate::widget::slider::{ value_from_x_in_rect, value_from_pos_in_rect, SliderAxis };
|
pub use crate::widget::slider::{ value_from_x_in_rect, value_from_pos_in_rect, thumb_design_px, SliderAxis };
|
||||||
pub use crate::widget::vslider::value_from_y_in_rect;
|
pub use crate::widget::vslider::value_from_y_in_rect;
|
||||||
pub use crate::event_loop::diff_overlay_ids;
|
pub use crate::event_loop::diff_overlay_ids;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
|
|
||||||
//! Clip-mask management for [`SoftwareCanvas`]. The partial-redraw
|
//! Clip-mask management for [`SoftwareCanvas`]. The partial-redraw
|
||||||
//! path calls `set_clip_rects` before every repaint so only pixels
|
//! path calls `set_clip_rects` before every repaint so only pixels
|
||||||
//! inside the dirty rects are touched.
|
//! inside the dirty rects are touched. `set_clip_path` installs an
|
||||||
|
//! arbitrary anti-aliased vector path as the clip (an exact tiny-skia
|
||||||
|
//! coverage [`Mask`]) for shaped clipping such as a circular avatar.
|
||||||
|
|
||||||
use tiny_skia::{ FillRule, Mask, PathBuilder, Transform };
|
use tiny_skia::{ FillRule, Mask, PathBuilder, Transform };
|
||||||
|
|
||||||
@@ -46,6 +48,37 @@ impl SoftwareCanvas
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clip subsequent paints to an arbitrary vector path (surface
|
||||||
|
/// coordinates), via an anti-aliased tiny-skia coverage mask. The GLES
|
||||||
|
/// counterpart composites an offscreen layer through an equivalent
|
||||||
|
/// anti-aliased mask; both give a smooth clipped edge.
|
||||||
|
pub fn set_clip_path( &mut self, cmds: &[ crate::types::PathCmd ] )
|
||||||
|
{
|
||||||
|
let w = self.pixmap.width();
|
||||||
|
let h = self.pixmap.height();
|
||||||
|
let Some( path ) = crate::render::helpers::build_ts_path( cmds ) else
|
||||||
|
{
|
||||||
|
self.clear_clip();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some( mut mask ) = Mask::new( w, h ) else
|
||||||
|
{
|
||||||
|
self.clip_mask = None;
|
||||||
|
self.clip_bounds = Vec::new();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
mask.fill_path( &path, FillRule::Winding, true, Transform::identity() );
|
||||||
|
let b = path.bounds();
|
||||||
|
self.clip_mask = Some( mask );
|
||||||
|
self.clip_bounds = vec![ Rect
|
||||||
|
{
|
||||||
|
x: b.left(),
|
||||||
|
y: b.top(),
|
||||||
|
width: b.right() - b.left(),
|
||||||
|
height: b.bottom() - b.top(),
|
||||||
|
} ];
|
||||||
|
}
|
||||||
|
|
||||||
/// Remove the active clip so subsequent paints cover the full canvas.
|
/// Remove the active clip so subsequent paints cover the full canvas.
|
||||||
pub fn clear_clip( &mut self )
|
pub fn clear_clip( &mut self )
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,17 +1,56 @@
|
|||||||
// SPDX-License-Identifier: LGPL-2.1-only
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
//! Backend-neutral helpers for the software renderer: rounded-rect
|
//! Backend-neutral helpers for the software renderer: vector-path and
|
||||||
//! path construction + system-font lookup.
|
//! rounded-rect construction. System-font resolution lives in
|
||||||
|
//! `crate::system_fonts`.
|
||||||
|
|
||||||
use tiny_skia::{ Path, PathBuilder };
|
use tiny_skia::{ Path, PathBuilder };
|
||||||
|
|
||||||
use crate::types::Corners;
|
use crate::types::{ Corners, PathCmd };
|
||||||
|
|
||||||
/// Cubic bezier control-point factor for a quarter-circle approximation
|
/// Cubic bezier control-point factor for a quarter-circle approximation
|
||||||
/// (`(4/3) * (sqrt(2) - 1) ≈ 0.5523`).
|
/// (`(4/3) * (sqrt(2) - 1) ≈ 0.5523`).
|
||||||
const KAPPA: f32 = 0.5523_f32;
|
const KAPPA: f32 = 0.5523_f32;
|
||||||
|
|
||||||
|
/// Build a tiny-skia path from a list of [`PathCmd`]s. Shared by both backends'
|
||||||
|
/// `fill_path` / `stroke_path`. Returns None for an empty or degenerate path.
|
||||||
|
pub ( crate ) fn build_ts_path( cmds: &[ PathCmd ] ) -> Option<Path>
|
||||||
|
{
|
||||||
|
let mut pb = PathBuilder::new();
|
||||||
|
for cmd in cmds
|
||||||
|
{
|
||||||
|
match *cmd
|
||||||
|
{
|
||||||
|
PathCmd::MoveTo( x, y ) => pb.move_to( x, y ),
|
||||||
|
PathCmd::LineTo( x, y ) => pb.line_to( x, y ),
|
||||||
|
PathCmd::QuadTo( x1, y1, x, y ) => pb.quad_to( x1, y1, x, y ),
|
||||||
|
PathCmd::CubicTo( x1, y1, x2, y2, x, y ) => pb.cubic_to( x1, y1, x2, y2, x, y ),
|
||||||
|
PathCmd::Close => pb.close(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pb.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate the dimensions of an RGBA8 image buffer for `draw_image_data`,
|
||||||
|
/// shared by both backends. Returns `true` when the buffer is drawable;
|
||||||
|
/// `false` (after logging a one-line warning tagged with `backend`) when the
|
||||||
|
/// declared `img_w × img_h × 4` does not match `data.len()` or either extent is
|
||||||
|
/// zero, so the caller returns without uploading or seeding a cache key.
|
||||||
|
pub ( crate ) fn validate_rgba_dims( backend: &str, data: &[u8], img_w: u32, img_h: u32 ) -> bool
|
||||||
|
{
|
||||||
|
let expected = ( img_w as usize ).saturating_mul( img_h as usize ).saturating_mul( 4 );
|
||||||
|
if img_w == 0 || img_h == 0 || data.len() != expected
|
||||||
|
{
|
||||||
|
eprintln!(
|
||||||
|
"[ltk] {}::draw_image_data: refusing draw — {}×{} declared, {} bytes provided, expected {}",
|
||||||
|
backend, img_w, img_h, data.len(), expected,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// Build a rounded rectangle path with independent per-corner radii
|
/// Build a rounded rectangle path with independent per-corner radii
|
||||||
/// using cubic bezier curves. Each corner is clamped against the
|
/// using cubic bezier curves. Each corner is clamped against the
|
||||||
/// inscribed-circle limit `min(width, height) / 2` before drawing,
|
/// inscribed-circle limit `min(width, height) / 2` before drawing,
|
||||||
@@ -59,54 +98,3 @@ pub ( super ) fn build_rounded_rect( rect: tiny_skia::Rect, corners: Corners ) -
|
|||||||
pb.close();
|
pb.close();
|
||||||
pb.finish()
|
pb.finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// System-font search chain, ordered by preference. Shared by
|
|
||||||
/// [`find_font`] (which panics when none match) and
|
|
||||||
/// [`find_font_opt`] (which returns `None` — used by tests that
|
|
||||||
/// want to skip gracefully on images without the usual fonts
|
|
||||||
/// installed).
|
|
||||||
const SYSTEM_FONT_CANDIDATES: &[&str] =
|
|
||||||
&[
|
|
||||||
// Debian `fonts-sora` — the canonical path `ltk-theme-default`
|
|
||||||
// depends on. Listed first so Sora wins as the default font
|
|
||||||
// whenever the package is installed.
|
|
||||||
"/usr/share/fonts/opentype/sora/Sora-Regular.otf",
|
|
||||||
"/usr/share/fonts/truetype/sora/Sora-Regular.ttf",
|
|
||||||
"/usr/share/fonts/sora/Sora-Regular.ttf",
|
|
||||||
"/usr/share/fonts/TTF/Sora-Regular.ttf",
|
|
||||||
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
|
||||||
"/usr/share/fonts/liberation/LiberationSans-Regular.ttf",
|
|
||||||
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
|
|
||||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
|
||||||
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
|
|
||||||
"/usr/share/fonts/TTF/DejaVuSans.ttf",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Resolve the first system font available from
|
|
||||||
/// [`SYSTEM_FONT_CANDIDATES`], or `None` if none exist. Used by
|
|
||||||
/// tests; runtime code uses [`find_font`].
|
|
||||||
pub ( crate ) fn find_font_opt() -> Option<String>
|
|
||||||
{
|
|
||||||
SYSTEM_FONT_CANDIDATES.iter()
|
|
||||||
.find( |p| std::path::Path::new( p ).exists() )
|
|
||||||
.copied()
|
|
||||||
.map( str::to_string )
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load the bytes of a default system font. Tries the candidate chain
|
|
||||||
/// via [`find_font_opt`]; falls back to the embedded
|
|
||||||
/// [`crate::theme::fallback::FALLBACK_FONT`] (Sora Regular, ~50 KB,
|
|
||||||
/// OFL 1.1) when nothing matches or the file cannot be read. Always
|
|
||||||
/// returns usable bytes so canvas construction never panics on a
|
|
||||||
/// system without the expected fonts.
|
|
||||||
pub ( super ) fn load_default_font_bytes() -> Vec<u8>
|
|
||||||
{
|
|
||||||
if let Some( path ) = find_font_opt()
|
|
||||||
{
|
|
||||||
if let Ok( bytes ) = std::fs::read( &path )
|
|
||||||
{
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
crate::theme::fallback::FALLBACK_FONT.to_vec()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -23,16 +23,22 @@ impl SoftwareCanvas
|
|||||||
{
|
{
|
||||||
pub fn draw_image_data( &mut self, rgba_data: &[u8], img_w: u32, img_h: u32, dest: Rect, opacity: f32 )
|
pub fn draw_image_data( &mut self, rgba_data: &[u8], img_w: u32, img_h: u32, dest: Rect, opacity: f32 )
|
||||||
{
|
{
|
||||||
let expected = ( img_w as usize ).saturating_mul( img_h as usize ).saturating_mul( 4 );
|
if !super::helpers::validate_rgba_dims( "SoftwareCanvas", rgba_data, img_w, img_h )
|
||||||
if img_w == 0 || img_h == 0 || rgba_data.len() != expected
|
|
||||||
{
|
{
|
||||||
eprintln!(
|
|
||||||
"[ltk] SoftwareCanvas::draw_image_data: refusing draw — {}×{} declared, {} bytes provided, expected {}",
|
|
||||||
img_w, img_h, rgba_data.len(), expected,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Snap the destination to integer pixels (matching the GLES backend) so a
|
||||||
|
// fractional dest does not sub-texel-offset the bilinear sample and read
|
||||||
|
// ~1 px softer than the source; at 1:1 the scale collapses to identity.
|
||||||
|
let dest = Rect
|
||||||
|
{
|
||||||
|
x: dest.x.round(),
|
||||||
|
y: dest.y.round(),
|
||||||
|
width: dest.width.round(),
|
||||||
|
height: dest.height.round(),
|
||||||
|
};
|
||||||
|
|
||||||
let Some( int_size ) = tiny_skia::IntSize::from_wh( img_w, img_h ) else { return };
|
let Some( int_size ) = tiny_skia::IntSize::from_wh( img_w, img_h ) else { return };
|
||||||
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
|
|||||||
@@ -19,15 +19,17 @@
|
|||||||
//!
|
//!
|
||||||
//! * [`setup`] — `SoftwareCanvas::{new, sub_canvas, resize, blit,
|
//! * [`setup`] — `SoftwareCanvas::{new, sub_canvas, resize, blit,
|
||||||
//! set_font_registry, font_for}` (construction + accessors).
|
//! set_font_registry, font_for}` (construction + accessors).
|
||||||
//! * [`clip`] — `SoftwareCanvas::{set_clip_rects, clear_clip,
|
//! * [`clip`] — `SoftwareCanvas::{set_clip_rects, set_clip_path,
|
||||||
//! has_clip, strip_intersects_clip, clear_rects_transparent}`.
|
//! clear_clip, has_clip, strip_intersects_clip,
|
||||||
|
//! clear_rects_transparent}`.
|
||||||
//! * [`primitives`] — `SoftwareCanvas::{clear, fill, fill_rect,
|
//! * [`primitives`] — `SoftwareCanvas::{clear, fill, fill_rect,
|
||||||
//! stroke_rect, draw_line}`.
|
//! stroke_rect, draw_line}`.
|
||||||
//! * [`text`] — `SoftwareCanvas::{draw_text, measure_text}`.
|
//! * [`text`] — `SoftwareCanvas::{draw_text, measure_text}`.
|
||||||
//! * [`image`] — `SoftwareCanvas::{draw_image_data,
|
//! * [`image`] — `SoftwareCanvas::{draw_image_data,
|
||||||
//! write_to_wayland_buf}`.
|
//! write_to_wayland_buf}`.
|
||||||
//! * [`helpers`] — free functions: `build_rounded_rect`,
|
//! * [`helpers`] — free functions: `build_ts_path`,
|
||||||
//! `find_font`, `find_font_opt`, `SYSTEM_FONT_CANDIDATES`.
|
//! `build_rounded_rect`. System-font resolution lives in
|
||||||
|
//! `crate::system_fonts`.
|
||||||
|
|
||||||
use std::cell::Cell;
|
use std::cell::Cell;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -37,7 +39,7 @@ use tiny_skia::{ Mask, Pixmap };
|
|||||||
|
|
||||||
use crate::gles_render::{ BorrowedGlesTexture, GlesCanvas, GlesVersion };
|
use crate::gles_render::{ BorrowedGlesTexture, GlesCanvas, GlesVersion };
|
||||||
use crate::theme::{ FontRegistry, FontStyle, InsetShadow, Paint as ThemePaint, Shadow };
|
use crate::theme::{ FontRegistry, FontStyle, InsetShadow, Paint as ThemePaint, Shadow };
|
||||||
use crate::types::{ Color, Corners, Rect };
|
use crate::types::{ Color, Corners, Length, Rect, WidgetScaling };
|
||||||
|
|
||||||
pub( crate ) mod setup;
|
pub( crate ) mod setup;
|
||||||
pub( crate ) mod clip;
|
pub( crate ) mod clip;
|
||||||
@@ -119,7 +121,7 @@ pub struct SoftwareCanvas
|
|||||||
/// Kept as the default fallback so widgets that do not yet ask for a
|
/// Kept as the default fallback so widgets that do not yet ask for a
|
||||||
/// specific family through [`SoftwareCanvas::font_for`] keep
|
/// specific family through [`SoftwareCanvas::font_for`] keep
|
||||||
/// working. Populated from
|
/// working. Populated from
|
||||||
/// [`crate::render::helpers::find_font`] at construction time.
|
/// `crate::system_fonts::default_handle` at construction time.
|
||||||
pub font: Arc<Font>,
|
pub font: Arc<Font>,
|
||||||
/// Raw bytes of the default font. Kept alongside `font` so the
|
/// Raw bytes of the default font. Kept alongside `font` so the
|
||||||
/// HarfBuzz shaper (rustybuzz) can be invoked without re-reading
|
/// HarfBuzz shaper (rustybuzz) can be invoked without re-reading
|
||||||
@@ -199,6 +201,83 @@ impl Canvas
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `(width, height)` of the surface in **logical** pixels (physical
|
||||||
|
/// size divided by `dpi_scale`). This is the right viewport for
|
||||||
|
/// resolving [`crate::Length`] values, which are themselves in
|
||||||
|
/// logical units. Falls back to physical size if `dpi_scale` is
|
||||||
|
/// 0 or negative so a misconfigured canvas still returns a usable
|
||||||
|
/// non-zero viewport instead of `NaN`/`inf`.
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// # use ltk::core::Canvas;
|
||||||
|
/// let mut c = Canvas::new( 720, 1440 );
|
||||||
|
/// c.set_dpi_scale( 2.0 );
|
||||||
|
/// assert_eq!( c.viewport_logical(), ( 360.0, 720.0 ) );
|
||||||
|
/// ```
|
||||||
|
pub fn viewport_logical( &self ) -> ( f32, f32 )
|
||||||
|
{
|
||||||
|
let ( pw, ph ) = self.size();
|
||||||
|
let scale = self.dpi_scale();
|
||||||
|
if scale > 0.0
|
||||||
|
{
|
||||||
|
( pw as f32 / scale, ph as f32 / scale )
|
||||||
|
} else {
|
||||||
|
( pw as f32, ph as f32 )
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `(width, height)` of the surface in **physical** pixels — the same
|
||||||
|
/// space the layout tree is computed in (the root rect is `pw × ph`).
|
||||||
|
/// This is the viewport that layout-affecting [`crate::Length`] values
|
||||||
|
/// (widths, paddings, gaps, widget sizes) must resolve against so a
|
||||||
|
/// `Vw(100)` fills the surface. Text font sizes are the exception:
|
||||||
|
/// they resolve against [`Self::viewport_logical`] and are then scaled
|
||||||
|
/// by `dpi_scale` at raster time, so they must NOT use this.
|
||||||
|
pub fn viewport_layout( &self ) -> ( f32, f32 )
|
||||||
|
{
|
||||||
|
let ( pw, ph ) = self.size();
|
||||||
|
( pw as f32, ph as f32 )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a stock-widget **geometry** design pixel (height, padding,
|
||||||
|
/// box size, gap…) through the process-wide [`crate::WidgetScaling`]
|
||||||
|
/// mode, into a concrete physical-pixel value for the layout tree.
|
||||||
|
/// Widgets call this instead of using a raw `theme::` constant so their
|
||||||
|
/// intrinsic geometry follows the app's chosen adaptation strategy
|
||||||
|
/// ([`crate::WidgetScaling::Fluid`] → surface-proportional,
|
||||||
|
/// [`crate::WidgetScaling::Physical`] → constant physical size). Geometry
|
||||||
|
/// lives in physical space, so this resolves against
|
||||||
|
/// [`Self::viewport_layout`].
|
||||||
|
pub fn geom_px( &self, design_px: f32 ) -> f32
|
||||||
|
{
|
||||||
|
Length::widget( design_px ).resolve( self.viewport_layout(), Length::EM_BASE_DEFAULT )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a stock-widget **font** design pixel through the process-wide
|
||||||
|
/// [`crate::WidgetScaling`] mode. Font sizes are handed to the raster
|
||||||
|
/// path pre-`dpi_scale`, so this bridges the logical/physical split for
|
||||||
|
/// each mode: in [`crate::WidgetScaling::Fluid`] it resolves the fluid
|
||||||
|
/// size against [`Self::viewport_logical`] (so `× dpi_scale` at raster
|
||||||
|
/// yields a surface-proportional physical size); in
|
||||||
|
/// [`crate::WidgetScaling::Physical`] it divides the density-scaled size
|
||||||
|
/// by `dpi_scale` (so `× dpi_scale` yields a constant physical size).
|
||||||
|
pub fn font_px( &self, design_px: f32 ) -> f32
|
||||||
|
{
|
||||||
|
match crate::types::widget_scaling()
|
||||||
|
{
|
||||||
|
WidgetScaling::Fluid =>
|
||||||
|
{
|
||||||
|
Length::fluid( design_px ).resolve( self.viewport_logical(), Length::EM_BASE_DEFAULT )
|
||||||
|
}
|
||||||
|
WidgetScaling::Physical =>
|
||||||
|
{
|
||||||
|
let scale = self.dpi_scale();
|
||||||
|
let scale = if scale > 0.0 { scale } else { 1.0 };
|
||||||
|
design_px * crate::types::density() / scale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Borrow the GLES texture backing this canvas, when the canvas
|
/// Borrow the GLES texture backing this canvas, when the canvas
|
||||||
/// is GPU-backed.
|
/// is GPU-backed.
|
||||||
pub fn borrowed_gles_texture( &self ) -> Option<BorrowedGlesTexture>
|
pub fn borrowed_gles_texture( &self ) -> Option<BorrowedGlesTexture>
|
||||||
@@ -223,6 +302,45 @@ impl Canvas
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether this is the CPU (software) backend. Callers that can honour a real
|
||||||
|
/// path clip on the software backend but only a bounding rect on GLES branch
|
||||||
|
/// on this.
|
||||||
|
pub fn is_software( &self ) -> bool
|
||||||
|
{
|
||||||
|
matches!( self, Canvas::Software( _ ) )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read this canvas into tightly packed straight-alpha RGBA8, top-left row
|
||||||
|
/// first (`out.len()` must be at least width*height*4). Unlike
|
||||||
|
/// [`Self::read_gles_rgba_pixels`] this also serves the software backend, by
|
||||||
|
/// un-premultiplying its pixmap — used to read back an offscreen software
|
||||||
|
/// canvas (e.g. an Android `Canvas(Bitmap)`) into a straight-alpha buffer.
|
||||||
|
pub fn read_rgba_pixels( &self, out: &mut [u8] ) -> Result<(), String>
|
||||||
|
{
|
||||||
|
match self
|
||||||
|
{
|
||||||
|
Canvas::Software( c ) =>
|
||||||
|
{
|
||||||
|
let pixels = c.pixmap.pixels();
|
||||||
|
if out.len() < pixels.len() * 4
|
||||||
|
{
|
||||||
|
return Err( "read_rgba_pixels: output buffer too small".to_string() );
|
||||||
|
}
|
||||||
|
for ( i, p ) in pixels.iter().enumerate()
|
||||||
|
{
|
||||||
|
let d = p.demultiply();
|
||||||
|
let o = i * 4;
|
||||||
|
out[ o ] = d.red();
|
||||||
|
out[ o + 1 ] = d.green();
|
||||||
|
out[ o + 2 ] = d.blue();
|
||||||
|
out[ o + 3 ] = d.alpha();
|
||||||
|
}
|
||||||
|
Ok( () )
|
||||||
|
}
|
||||||
|
Canvas::Gles( c ) => c.read_rgba_pixels( out ),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Composite an externally-owned GL texture into `dest`. No-op on
|
/// Composite an externally-owned GL texture into `dest`. No-op on
|
||||||
/// the software backend (no GL state to sample from). Used by
|
/// the software backend (no GL state to sample from). Used by
|
||||||
/// widgets that host content rendered by an external producer —
|
/// widgets that host content rendered by an external producer —
|
||||||
@@ -288,6 +406,9 @@ impl Canvas
|
|||||||
/// Install a theme font registry on the active backend.
|
/// Install a theme font registry on the active backend.
|
||||||
pub fn set_font_registry( &mut self, registry: Arc<FontRegistry> )
|
pub fn set_font_registry( &mut self, registry: Arc<FontRegistry> )
|
||||||
{
|
{
|
||||||
|
// Drop the shaped-line cache: a new registry can swap the faces the
|
||||||
|
// resolver leads with, so cached glyph runs may no longer match.
|
||||||
|
crate::text_shaping::clear_shape_cache();
|
||||||
match self
|
match self
|
||||||
{
|
{
|
||||||
Canvas::Software( c ) => c.set_font_registry( registry ),
|
Canvas::Software( c ) => c.set_font_registry( registry ),
|
||||||
@@ -376,6 +497,22 @@ impl Canvas
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clip subsequent draws to an arbitrary vector path (in surface
|
||||||
|
/// coordinates). Anti-aliased per-path clipping: the software backend uses a
|
||||||
|
/// tiny-skia coverage mask; the GLES backend captures the clipped draws into
|
||||||
|
/// an offscreen layer and composites it back through an anti-aliased coverage
|
||||||
|
/// mask. Replaces any active clip; restore the previous clip via
|
||||||
|
/// [`Self::set_clip_rects`] with
|
||||||
|
/// a [`Self::clip_bounds`] snapshot taken beforehand, or [`Self::clear_clip`].
|
||||||
|
pub fn set_clip_path( &mut self, cmds: &[ crate::types::PathCmd ] )
|
||||||
|
{
|
||||||
|
match self
|
||||||
|
{
|
||||||
|
Canvas::Software( c ) => c.set_clip_path( cmds ),
|
||||||
|
Canvas::Gles( c ) => c.set_clip_path( cmds ),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Snapshot the currently installed clip bounds (empty when no clip
|
/// Snapshot the currently installed clip bounds (empty when no clip
|
||||||
/// is active). Used by widgets that need to install a tighter clip
|
/// is active). Used by widgets that need to install a tighter clip
|
||||||
/// for a single primitive and then restore whatever the outer
|
/// for a single primitive and then restore whatever the outer
|
||||||
@@ -480,6 +617,29 @@ impl Canvas
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fill an arbitrary vector path (commands in surface coordinates) with a
|
||||||
|
/// solid colour. The software backend rasterises with tiny-skia directly;
|
||||||
|
/// the GLES backend rasterises into a tiny-skia pixmap and blits it (the
|
||||||
|
/// CPU fallback — no path shader on the GPU path).
|
||||||
|
pub fn fill_path( &mut self, cmds: &[ crate::types::PathCmd ], color: Color )
|
||||||
|
{
|
||||||
|
match self
|
||||||
|
{
|
||||||
|
Canvas::Software( c ) => c.fill_path( cmds, color ),
|
||||||
|
Canvas::Gles( c ) => c.fill_path( cmds, color ),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stroke an arbitrary vector path (commands in surface coordinates).
|
||||||
|
pub fn stroke_path( &mut self, cmds: &[ crate::types::PathCmd ], color: Color, width: f32 )
|
||||||
|
{
|
||||||
|
match self
|
||||||
|
{
|
||||||
|
Canvas::Software( c ) => c.stroke_path( cmds, color, width ),
|
||||||
|
Canvas::Gles( c ) => c.stroke_path( cmds, color, width ),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Paint an outer drop shadow behind the rounded rect `target`.
|
/// Paint an outer drop shadow behind the rounded rect `target`.
|
||||||
///
|
///
|
||||||
/// On the GPU backend this runs an analytic soft-shadow shader
|
/// On the GPU backend this runs an analytic soft-shadow shader
|
||||||
@@ -642,3 +802,211 @@ impl Canvas
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[ cfg( test ) ]
|
||||||
|
mod viewport_tests
|
||||||
|
{
|
||||||
|
use super::Canvas;
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn viewport_logical_at_scale_one_matches_physical()
|
||||||
|
{
|
||||||
|
let c = Canvas::new( 800, 600 );
|
||||||
|
assert_eq!( c.viewport_logical(), ( 800.0, 600.0 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn viewport_logical_divides_by_dpi_scale()
|
||||||
|
{
|
||||||
|
let mut c = Canvas::new( 720, 1440 );
|
||||||
|
c.set_dpi_scale( 2.0 );
|
||||||
|
assert_eq!( c.viewport_logical(), ( 360.0, 720.0 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn viewport_logical_falls_back_to_physical_when_scale_is_zero()
|
||||||
|
{
|
||||||
|
// Guard the misconfigured-scale path: a divide-by-zero would
|
||||||
|
// poison every `Length::Vmin`/`Vw`/`Vh` resolution downstream.
|
||||||
|
let mut c = Canvas::new( 800, 600 );
|
||||||
|
c.set_dpi_scale( 0.0 );
|
||||||
|
assert_eq!( c.viewport_logical(), ( 800.0, 600.0 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn viewport_layout_is_physical_and_ignores_dpi_scale()
|
||||||
|
{
|
||||||
|
// Layout-affecting `Length` values resolve against this, so it must
|
||||||
|
// stay in physical space (where the layout tree is computed) even on
|
||||||
|
// HiDPI — unlike `viewport_logical`, it does not divide by the scale.
|
||||||
|
let mut c = Canvas::new( 720, 1440 );
|
||||||
|
assert_eq!( c.viewport_layout(), ( 720.0, 1440.0 ) );
|
||||||
|
c.set_dpi_scale( 2.0 );
|
||||||
|
assert_eq!( c.viewport_layout(), ( 720.0, 1440.0 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn geom_px_resolves_widget_design_pixel_per_mode()
|
||||||
|
{
|
||||||
|
use crate::types::{ set_widget_scaling, set_density, WidgetScaling, Length };
|
||||||
|
let _g = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() );
|
||||||
|
|
||||||
|
// Fluid (default): geom_px == fluid( n ) against the physical viewport.
|
||||||
|
set_widget_scaling( WidgetScaling::Fluid );
|
||||||
|
let c = Canvas::new( 412, 900 );
|
||||||
|
assert_eq!( c.geom_px( 48.0 ), Length::fluid( 48.0 ).resolve( ( 412.0, 900.0 ), Length::EM_BASE_DEFAULT ) );
|
||||||
|
|
||||||
|
// Physical: geom_px == n * density, independent of surface size.
|
||||||
|
set_widget_scaling( WidgetScaling::Physical );
|
||||||
|
set_density( 2.0 );
|
||||||
|
assert_eq!( c.geom_px( 48.0 ), 96.0 );
|
||||||
|
|
||||||
|
set_density( 1.0 );
|
||||||
|
set_widget_scaling( WidgetScaling::Fluid );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn font_px_is_constant_physical_in_physical_mode()
|
||||||
|
{
|
||||||
|
use crate::types::{ set_widget_scaling, set_density, WidgetScaling };
|
||||||
|
let _g = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() );
|
||||||
|
|
||||||
|
// Physical mode divides by dpi_scale so `× dpi_scale` at raster yields
|
||||||
|
// a constant physical size (n * density).
|
||||||
|
set_widget_scaling( WidgetScaling::Physical );
|
||||||
|
set_density( 2.0 );
|
||||||
|
let mut c = Canvas::new( 720, 1440 );
|
||||||
|
c.set_dpi_scale( 2.0 );
|
||||||
|
// 16 * 2 (density) / 2 (dpi_scale) = 16 logical → 32 physical after raster.
|
||||||
|
assert_eq!( c.font_px( 16.0 ), 16.0 );
|
||||||
|
|
||||||
|
set_density( 1.0 );
|
||||||
|
set_widget_scaling( WidgetScaling::Fluid );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ cfg( test ) ]
|
||||||
|
mod clip_path_tests
|
||||||
|
{
|
||||||
|
// Exercised on the software backend (`Canvas::new`); the GLES layer-composite
|
||||||
|
// path needs a live GL context and is covered by the `clip_path` example.
|
||||||
|
use super::Canvas;
|
||||||
|
use crate::types::{ Color, PathCmd, Rect };
|
||||||
|
|
||||||
|
fn square( x: f32, y: f32, side: f32 ) -> Vec<PathCmd>
|
||||||
|
{
|
||||||
|
vec![
|
||||||
|
PathCmd::MoveTo( x, y ),
|
||||||
|
PathCmd::LineTo( x + side, y ),
|
||||||
|
PathCmd::LineTo( x + side, y + side ),
|
||||||
|
PathCmd::LineTo( x, y + side ),
|
||||||
|
PathCmd::Close,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn set_clip_path_bounds_match_the_path_bounding_box()
|
||||||
|
{
|
||||||
|
let mut c = Canvas::new( 200, 200 );
|
||||||
|
c.set_clip_path( &square( 10.0, 20.0, 100.0 ) );
|
||||||
|
let b = c.clip_bounds();
|
||||||
|
assert_eq!( b.len(), 1 );
|
||||||
|
let r = b[ 0 ];
|
||||||
|
assert!( ( r.x - 10.0 ).abs() < 0.5 && ( r.y - 20.0 ).abs() < 0.5, "origin {r:?}" );
|
||||||
|
assert!( ( r.width - 100.0 ).abs() < 0.5 && ( r.height - 100.0 ).abs() < 0.5, "size {r:?}" );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn set_clip_path_with_no_segments_clears_the_clip()
|
||||||
|
{
|
||||||
|
let mut c = Canvas::new( 100, 100 );
|
||||||
|
c.set_clip_path( &[] );
|
||||||
|
assert!( c.clip_bounds().is_empty() );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn set_clip_path_masks_a_fill_to_the_path_shape_not_its_bbox()
|
||||||
|
{
|
||||||
|
let mut c = Canvas::new( 100, 100 );
|
||||||
|
// Downward triangle: apex top-centre, base along the bottom.
|
||||||
|
let tri = vec![
|
||||||
|
PathCmd::MoveTo( 50.0, 5.0 ),
|
||||||
|
PathCmd::LineTo( 95.0, 95.0 ),
|
||||||
|
PathCmd::LineTo( 5.0, 95.0 ),
|
||||||
|
PathCmd::Close,
|
||||||
|
];
|
||||||
|
c.set_clip_path( &tri );
|
||||||
|
c.fill_rect( Rect { x: 0.0, y: 0.0, width: 100.0, height: 100.0 }, Color::rgba( 1.0, 0.0, 0.0, 1.0 ), 0.0 );
|
||||||
|
|
||||||
|
let Canvas::Software( sc ) = &c else { panic!( "Canvas::new builds a software canvas" ) };
|
||||||
|
// A point well inside the triangle is painted; a top corner — inside the
|
||||||
|
// bounding box but outside the triangle — stays clear, proving the clip
|
||||||
|
// follows the path silhouette rather than its bounding rect.
|
||||||
|
let inside = sc.pixmap.pixel( 50, 70 ).expect( "in-bounds pixel" );
|
||||||
|
let corner = sc.pixmap.pixel( 8, 8 ).expect( "in-bounds pixel" );
|
||||||
|
assert!( inside.red() > 200 && inside.alpha() > 200, "inside triangle must be filled" );
|
||||||
|
assert_eq!( corner.alpha(), 0, "bbox corner outside the triangle must stay clear" );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ cfg( test ) ]
|
||||||
|
mod pixel_snap_tests
|
||||||
|
{
|
||||||
|
// The GLES backend rounds glyph pen positions and image destinations to
|
||||||
|
// integer pixels for crisp 1:1 sampling; these check the software backend
|
||||||
|
// now matches that snapping so both backends place content identically.
|
||||||
|
use super::Canvas;
|
||||||
|
use crate::types::{ Color, Rect };
|
||||||
|
|
||||||
|
fn red_4x4() -> Vec<u8>
|
||||||
|
{
|
||||||
|
[ 255u8, 0, 0, 255 ].repeat( 16 )
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn image_dest_below_half_snaps_to_the_same_pixels_as_integer_dest()
|
||||||
|
{
|
||||||
|
let red = red_4x4();
|
||||||
|
let mut a = Canvas::new( 32, 32 );
|
||||||
|
a.draw_image_data( &red, 4, 4, Rect { x: 5.0, y: 5.0, width: 4.0, height: 4.0 }, 1.0 );
|
||||||
|
let mut b = Canvas::new( 32, 32 );
|
||||||
|
b.draw_image_data( &red, 4, 4, Rect { x: 5.4, y: 5.4, width: 4.0, height: 4.0 }, 1.0 );
|
||||||
|
|
||||||
|
let Canvas::Software( sa ) = &a else { panic!( "software canvas" ) };
|
||||||
|
let Canvas::Software( sb ) = &b else { panic!( "software canvas" ) };
|
||||||
|
assert_eq!( sa.pixmap.data(), sb.pixmap.data(), "a sub-half fractional dest snaps to the integer origin" );
|
||||||
|
assert!( sa.pixmap.pixel( 5, 5 ).unwrap().red() > 200, "the block lands on pixel (5,5)" );
|
||||||
|
assert_eq!( sa.pixmap.pixel( 4, 4 ).unwrap().alpha(), 0, "pixel (4,4) is outside the snapped block" );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn image_dest_at_or_above_half_rounds_to_the_next_pixel()
|
||||||
|
{
|
||||||
|
let red = red_4x4();
|
||||||
|
let mut c = Canvas::new( 32, 32 );
|
||||||
|
c.draw_image_data( &red, 4, 4, Rect { x: 5.6, y: 5.6, width: 4.0, height: 4.0 }, 1.0 );
|
||||||
|
let Canvas::Software( sc ) = &c else { panic!( "software canvas" ) };
|
||||||
|
assert!( sc.pixmap.pixel( 6, 6 ).unwrap().red() > 200, "round(5.6)=6 moves the block one pixel" );
|
||||||
|
assert_eq!( sc.pixmap.pixel( 5, 5 ).unwrap().alpha(), 0, "pixel (5,5) is now clear" );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn text_pen_position_is_rounded_not_truncated()
|
||||||
|
{
|
||||||
|
let draw = |x: f32| -> Vec<u8>
|
||||||
|
{
|
||||||
|
let mut c = Canvas::new( 64, 64 );
|
||||||
|
c.draw_text( "l", x, 40.0, 32.0, Color::rgba( 1.0, 1.0, 1.0, 1.0 ) );
|
||||||
|
let Canvas::Software( sc ) = &c else { panic!( "software canvas" ) };
|
||||||
|
sc.pixmap.data().to_vec()
|
||||||
|
};
|
||||||
|
|
||||||
|
let at_int = draw( 20.0 );
|
||||||
|
let at_low = draw( 20.4 );
|
||||||
|
let at_high = draw( 20.6 );
|
||||||
|
|
||||||
|
assert!( at_int.iter().any( |&b| b != 0 ), "the glyph must paint some pixels" );
|
||||||
|
assert_eq!( at_int, at_low, "x and x+0.4 round to the same pixel column" );
|
||||||
|
assert_ne!( at_int, at_high, "x+0.6 rounds up to the next column" );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
|
|
||||||
use tiny_skia::{ Paint, PathBuilder, Stroke, Transform };
|
use tiny_skia::{ Paint, PathBuilder, Stroke, Transform };
|
||||||
|
|
||||||
use crate::types::{ Color, Corners, Rect };
|
use crate::types::{ Color, Corners, PathCmd, Rect };
|
||||||
|
|
||||||
use super::helpers::build_rounded_rect;
|
use super::helpers::{ build_rounded_rect, build_ts_path };
|
||||||
use super::SoftwareCanvas;
|
use super::SoftwareCanvas;
|
||||||
|
|
||||||
impl SoftwareCanvas
|
impl SoftwareCanvas
|
||||||
@@ -100,4 +100,24 @@ impl SoftwareCanvas
|
|||||||
stroke.width = width;
|
stroke.width = width;
|
||||||
self.pixmap.stroke_path( &path, &paint, &stroke, Transform::identity(), self.clip_mask.as_ref() );
|
self.pixmap.stroke_path( &path, &paint, &stroke, Transform::identity(), self.clip_mask.as_ref() );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn fill_path( &mut self, cmds: &[ PathCmd ], color: Color )
|
||||||
|
{
|
||||||
|
let Some( path ) = build_ts_path( cmds ) else { return };
|
||||||
|
let mut paint = Paint::default();
|
||||||
|
paint.set_color( Color::rgba( color.r, color.g, color.b, color.a * self.global_alpha ).to_tiny_skia() );
|
||||||
|
paint.anti_alias = true;
|
||||||
|
self.pixmap.fill_path( &path, &paint, tiny_skia::FillRule::Winding, Transform::identity(), self.clip_mask.as_ref() );
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stroke_path( &mut self, cmds: &[ PathCmd ], color: Color, width: f32 )
|
||||||
|
{
|
||||||
|
let Some( path ) = build_ts_path( cmds ) else { return };
|
||||||
|
let mut paint = Paint::default();
|
||||||
|
paint.set_color( Color::rgba( color.r, color.g, color.b, color.a * self.global_alpha ).to_tiny_skia() );
|
||||||
|
paint.anti_alias = true;
|
||||||
|
let mut stroke = Stroke::default();
|
||||||
|
stroke.width = width;
|
||||||
|
self.pixmap.stroke_path( &path, &paint, &stroke, Transform::identity(), self.clip_mask.as_ref() );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,43 +5,16 @@
|
|||||||
//! / resize plus the font-registry installer and `blit`.
|
//! / resize plus the font-registry installer and `blit`.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{ Arc, OnceLock };
|
use std::sync::Arc;
|
||||||
|
|
||||||
use fontdue::{ Font, FontSettings };
|
use fontdue::Font;
|
||||||
use tiny_skia::{ Pixmap, PixmapPaint, Transform };
|
use tiny_skia::{ Pixmap, PixmapPaint, Transform };
|
||||||
|
|
||||||
use crate::system_fonts::FontHandle;
|
use crate::system_fonts::default_handle;
|
||||||
use crate::theme::{ FontRegistry, FontStyle };
|
use crate::theme::{ FontRegistry, FontStyle };
|
||||||
|
|
||||||
use super::helpers::load_default_font_bytes;
|
|
||||||
use super::SoftwareCanvas;
|
use super::SoftwareCanvas;
|
||||||
|
|
||||||
/// Process-wide cache of the default font face. The handle keeps the
|
|
||||||
/// raw bytes (`Arc<Vec<u8>>`) alongside the fontdue `Arc<Font>` so
|
|
||||||
/// the HarfBuzz shaper can be invoked without re-reading the file.
|
|
||||||
/// Sora is small (~50 KB) so the cost was minor in absolute terms —
|
|
||||||
/// but a layer shell that brings up a launcher overlay, a QS panel,
|
|
||||||
/// a calendar popup and a handful of toast surfaces would still pay
|
|
||||||
/// the parse cost a dozen times in a single session, all of which
|
|
||||||
/// is wasted work.
|
|
||||||
static DEFAULT_FONT: OnceLock<FontHandle> = OnceLock::new();
|
|
||||||
|
|
||||||
fn default_handle() -> FontHandle
|
|
||||||
{
|
|
||||||
DEFAULT_FONT.get_or_init( ||
|
|
||||||
{
|
|
||||||
let bytes = load_default_font_bytes();
|
|
||||||
let font = Font::from_bytes( bytes.as_slice(), FontSettings::default() )
|
|
||||||
.expect( "bad font" );
|
|
||||||
FontHandle
|
|
||||||
{
|
|
||||||
font: Arc::new( font ),
|
|
||||||
bytes: Arc::new( bytes ),
|
|
||||||
face: 0,
|
|
||||||
}
|
|
||||||
} ).clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SoftwareCanvas
|
impl SoftwareCanvas
|
||||||
{
|
{
|
||||||
/// Create a canvas of the given pixel dimensions, loading a system font.
|
/// Create a canvas of the given pixel dimensions, loading a system font.
|
||||||
|
|||||||
@@ -115,7 +115,9 @@ impl SoftwareCanvas
|
|||||||
// refused the font, missing bytes for the preferred face)
|
// refused the font, missing bytes for the preferred face)
|
||||||
// falls through to no-op — we'd rather not paint than risk
|
// falls through to no-op — we'd rather not paint than risk
|
||||||
// a corrupted line.
|
// a corrupted line.
|
||||||
let shaped = crate::text_shaping::shape_line( text, scaled, resolve );
|
let shaped = crate::text_shaping::shape_line_cached(
|
||||||
|
text, scaled, Arc::as_ptr( &canvas_handle.font ) as usize, resolve,
|
||||||
|
);
|
||||||
if shaped.is_empty() { return; }
|
if shaped.is_empty() { return; }
|
||||||
|
|
||||||
// Resolve every glyph's font into an `Arc<Font>` for
|
// Resolve every glyph's font into an `Arc<Font>` for
|
||||||
@@ -128,7 +130,7 @@ impl SoftwareCanvas
|
|||||||
{
|
{
|
||||||
fonts.push( ( primary_id, Arc::clone( &canvas_handle.font ) ) );
|
fonts.push( ( primary_id, Arc::clone( &canvas_handle.font ) ) );
|
||||||
}
|
}
|
||||||
for g in &shaped
|
for g in shaped.iter()
|
||||||
{
|
{
|
||||||
if fonts.iter().any( |( id, _ )| *id == g.font_id ) { continue; }
|
if fonts.iter().any( |( id, _ )| *id == g.font_id ) { continue; }
|
||||||
// Walk the fallback chain to find an Arc<Font> with this
|
// Walk the fallback chain to find an Arc<Font> with this
|
||||||
@@ -155,7 +157,7 @@ impl SoftwareCanvas
|
|||||||
let mut layout: Vec<( GlyphKey, f32, f32 )> = Vec::with_capacity( shaped.len() );
|
let mut layout: Vec<( GlyphKey, f32, f32 )> = Vec::with_capacity( shaped.len() );
|
||||||
{
|
{
|
||||||
let mut cursor_x = x;
|
let mut cursor_x = x;
|
||||||
for g in &shaped
|
for g in shaped.iter()
|
||||||
{
|
{
|
||||||
let Some( ( _, font_arc ) ) = fonts.iter().find( |( id, _ )| *id == g.font_id ) else
|
let Some( ( _, font_arc ) ) = fonts.iter().find( |( id, _ )| *id == g.font_id ) else
|
||||||
{
|
{
|
||||||
@@ -187,15 +189,19 @@ impl SoftwareCanvas
|
|||||||
let metrics = &entry.metrics;
|
let metrics = &entry.metrics;
|
||||||
let bitmap = &entry.bitmap;
|
let bitmap = &entry.bitmap;
|
||||||
if metrics.width == 0 || metrics.height == 0 { continue; }
|
if metrics.width == 0 || metrics.height == 0 { continue; }
|
||||||
|
// Round the pen position to the nearest pixel (matching the GLES
|
||||||
|
// backend) so a glyph at a fractional x/y lands on the same integer
|
||||||
|
// origin on both backends; the integer glyph metrics are added after.
|
||||||
|
let gx = cursor_x.round() as i32 + metrics.xmin;
|
||||||
|
let gy = ( y - glyph_y_offset ).round() as i32
|
||||||
|
- metrics.ymin as i32
|
||||||
|
- metrics.height as i32
|
||||||
|
+ 1;
|
||||||
for ( i, &alpha ) in bitmap.iter().enumerate()
|
for ( i, &alpha ) in bitmap.iter().enumerate()
|
||||||
{
|
{
|
||||||
if alpha == 0 { continue; }
|
if alpha == 0 { continue; }
|
||||||
let px = cursor_x as i32 + metrics.xmin + (i % metrics.width) as i32;
|
let px = gx + (i % metrics.width) as i32;
|
||||||
let py = ( y - glyph_y_offset ) as i32
|
let py = gy + (i / metrics.width) as i32;
|
||||||
- metrics.ymin as i32
|
|
||||||
- metrics.height as i32
|
|
||||||
+ 1
|
|
||||||
+ (i / metrics.width) as i32;
|
|
||||||
if px < 0 || py < 0 || px >= w || py >= h { continue; }
|
if px < 0 || py < 0 || px >= w || py >= h { continue; }
|
||||||
if let Some( ( md, mw ) ) = mask_data
|
if let Some( ( md, mw ) ) = mask_data
|
||||||
{
|
{
|
||||||
@@ -251,7 +257,9 @@ impl SoftwareCanvas
|
|||||||
if !canvas_handle.bytes.is_empty() { Some( canvas_handle.clone() ) } else { None }
|
if !canvas_handle.bytes.is_empty() { Some( canvas_handle.clone() ) } else { None }
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
let shaped = crate::text_shaping::shape_line( text, scaled, resolve );
|
let shaped = crate::text_shaping::shape_line_cached(
|
||||||
|
text, scaled, Arc::as_ptr( &canvas_handle.font ) as usize, resolve,
|
||||||
|
);
|
||||||
if shaped.is_empty()
|
if shaped.is_empty()
|
||||||
{
|
{
|
||||||
// Fallback: rustybuzz could not shape (no bytes for the
|
// Fallback: rustybuzz could not shape (no bytes for the
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
// SPDX-License-Identifier: LGPL-2.1-only
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
//! Lazy per-glyph fallback font resolution.
|
//! Process-wide font resolution for both backends: the primary UI font
|
||||||
|
//! ([`default_handle`] / [`primary_handle`], loaded once from the
|
||||||
|
//! [`SYSTEM_FONT_CANDIDATES`] chain) and lazy per-glyph fallback.
|
||||||
//!
|
//!
|
||||||
//! The default font ([`crate::theme::fallback::FALLBACK_FONT`], Sora)
|
//! The default font ([`crate::theme::fallback::FALLBACK_FONT`], Sora)
|
||||||
//! covers Latin and a portion of extended Latin; everything outside
|
//! covers Latin and a portion of extended Latin; everything outside
|
||||||
@@ -147,6 +149,76 @@ pub fn lookup( ch: char ) -> Option<Arc<Font>>
|
|||||||
lookup_handle( ch ).map( |h| h.font )
|
lookup_handle( ch ).map( |h| h.font )
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// System-font search chain for the primary UI font, ordered by preference.
|
||||||
|
/// Shared by [`find_font_opt`] and [`load_default_font_bytes`].
|
||||||
|
const SYSTEM_FONT_CANDIDATES: &[&str] =
|
||||||
|
&[
|
||||||
|
// Debian `fonts-sora` — the canonical path `ltk-theme-default`
|
||||||
|
// depends on. Listed first so Sora wins as the default font
|
||||||
|
// whenever the package is installed.
|
||||||
|
"/usr/share/fonts/opentype/sora/Sora-Regular.otf",
|
||||||
|
"/usr/share/fonts/truetype/sora/Sora-Regular.ttf",
|
||||||
|
"/usr/share/fonts/sora/Sora-Regular.ttf",
|
||||||
|
"/usr/share/fonts/TTF/Sora-Regular.ttf",
|
||||||
|
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
||||||
|
"/usr/share/fonts/liberation/LiberationSans-Regular.ttf",
|
||||||
|
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||||
|
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
|
||||||
|
"/usr/share/fonts/TTF/DejaVuSans.ttf",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Resolve the first system font available from [`SYSTEM_FONT_CANDIDATES`], or
|
||||||
|
/// `None` if none exist. Used by the font registry and by tests that skip
|
||||||
|
/// gracefully on images without the usual fonts installed.
|
||||||
|
pub ( crate ) fn find_font_opt() -> Option<String>
|
||||||
|
{
|
||||||
|
SYSTEM_FONT_CANDIDATES.iter()
|
||||||
|
.find( |p| std::path::Path::new( p ).exists() )
|
||||||
|
.copied()
|
||||||
|
.map( str::to_string )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the bytes of a default system font. Tries the candidate chain via
|
||||||
|
/// [`find_font_opt`]; falls back to the embedded
|
||||||
|
/// [`crate::theme::fallback::FALLBACK_FONT`] (Sora Regular, ~50 KB, OFL 1.1)
|
||||||
|
/// when nothing matches or the file cannot be read. Always returns usable bytes
|
||||||
|
/// so canvas construction never panics on a system without the expected fonts.
|
||||||
|
pub ( crate ) fn load_default_font_bytes() -> Vec<u8>
|
||||||
|
{
|
||||||
|
if let Some( path ) = find_font_opt()
|
||||||
|
{
|
||||||
|
if let Ok( bytes ) = std::fs::read( &path )
|
||||||
|
{
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crate::theme::fallback::FALLBACK_FONT.to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The process-wide primary UI font handle — the single default every canvas
|
||||||
|
/// loads. Cached so a layer shell bringing up several surfaces parses the small
|
||||||
|
/// Sora face once rather than per surface. The per-glyph fallback chain (Noto
|
||||||
|
/// Sans / CJK / Devanagari / …) is loaded lazily through [`lookup`], not here.
|
||||||
|
pub ( crate ) fn default_handle() -> FontHandle
|
||||||
|
{
|
||||||
|
static PRIMARY: OnceLock<FontHandle> = OnceLock::new();
|
||||||
|
PRIMARY.get_or_init( ||
|
||||||
|
{
|
||||||
|
let bytes = load_default_font_bytes();
|
||||||
|
let font = Font::from_bytes( bytes.as_slice(), FontSettings::default() ).expect( "primary font parses" );
|
||||||
|
FontHandle { font: Arc::new( font ), bytes: Arc::new( bytes ), face: 0 }
|
||||||
|
} ).clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The process-wide primary UI font — the same default a canvas loads — for
|
||||||
|
/// standalone text measurement (e.g. an embedded measure pass with no live
|
||||||
|
/// canvas). Falls back to the bundled font when no system font is found.
|
||||||
|
pub fn primary_handle() -> FontHandle
|
||||||
|
{
|
||||||
|
default_handle()
|
||||||
|
}
|
||||||
|
|
||||||
/// Bytes-aware variant of [`lookup`]. Returns the full
|
/// Bytes-aware variant of [`lookup`]. Returns the full
|
||||||
/// [`FontHandle`] (fontdue handle + raw bytes + face index) so
|
/// [`FontHandle`] (fontdue handle + raw bytes + face index) so
|
||||||
/// callers that need to invoke a HarfBuzz-style shaper can do so
|
/// callers that need to invoke a HarfBuzz-style shaper can do so
|
||||||
|
|||||||
@@ -126,6 +126,84 @@ where
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
thread_local!
|
||||||
|
{
|
||||||
|
/// Shaped-line cache. [`shape_line`] re-parses the font face
|
||||||
|
/// (`rustybuzz::Face::from_slice`) and re-runs HarfBuzz on every call;
|
||||||
|
/// during an animation the same labels are shaped every frame, at both
|
||||||
|
/// measure and draw time. Caching the visual-order glyph run by
|
||||||
|
/// `(font context, pixel size, text)` makes those repeats free. Keyed by
|
||||||
|
/// the resolver's leading font (its `Arc` pointer) since the output
|
||||||
|
/// depends on which face leads; the system fallback chain is
|
||||||
|
/// process-global and stable, so it need not be in the key.
|
||||||
|
static SHAPE_CACHE: std::cell::RefCell<
|
||||||
|
std::collections::HashMap<( usize, u32, String ), std::sync::Arc<Vec<PositionedGlyph>>>
|
||||||
|
> = std::cell::RefCell::new( std::collections::HashMap::new() );
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soft cap on cached shaped lines; cleared wholesale on overflow (the live
|
||||||
|
/// set of on-screen labels is far smaller than this).
|
||||||
|
const SHAPE_CACHE_CAP: usize = 8192;
|
||||||
|
|
||||||
|
/// Measure a single line of `text` at `size` px with the default UI font (and
|
||||||
|
/// the system fallback chain), returning `(width, line_height)` in pixels. For
|
||||||
|
/// laying text out without a live [`crate::Canvas`] — e.g. an embedded Android
|
||||||
|
/// measure pass that needs the same metrics the renderer will use.
|
||||||
|
pub fn measure_text( text: &str, size: f32 ) -> ( f32, f32 )
|
||||||
|
{
|
||||||
|
let primary = crate::system_fonts::primary_handle();
|
||||||
|
let line_h = primary.font.horizontal_line_metrics( size ).map( |m| m.new_line_size ).unwrap_or( size * 1.3 );
|
||||||
|
if text.is_empty()
|
||||||
|
{
|
||||||
|
return ( 0.0, line_h );
|
||||||
|
}
|
||||||
|
let resolver = primary.clone();
|
||||||
|
let glyphs = shape_line( text, size, move | ch |
|
||||||
|
{
|
||||||
|
if resolver.font.lookup_glyph_index( ch ) != 0
|
||||||
|
{
|
||||||
|
Some( resolver.clone() )
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
crate::system_fonts::lookup_handle( ch ).or_else( || Some( resolver.clone() ) )
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
let width: f32 = glyphs.iter().map( | g | g.x_advance ).sum();
|
||||||
|
( width, line_h )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cached wrapper over [`shape_line`]. `font_ctx` identifies the resolver's
|
||||||
|
/// leading font so two fonts shaping the same text don't collide — pass
|
||||||
|
/// `std::sync::Arc::as_ptr( &handle.font ) as usize`. `resolve_font` runs only
|
||||||
|
/// on a cache miss.
|
||||||
|
pub fn shape_line_cached<F>( text: &str, px: f32, font_ctx: usize, resolve_font: F ) -> std::sync::Arc<Vec<PositionedGlyph>>
|
||||||
|
where
|
||||||
|
F: FnMut( char ) -> Option<crate::system_fonts::FontHandle>,
|
||||||
|
{
|
||||||
|
if text.is_empty() { return std::sync::Arc::new( Vec::new() ); }
|
||||||
|
let key = ( font_ctx, px.to_bits(), text.to_owned() );
|
||||||
|
if let Some( hit ) = SHAPE_CACHE.with( |c| c.borrow().get( &key ).cloned() )
|
||||||
|
{
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
let shaped = std::sync::Arc::new( shape_line( text, px, resolve_font ) );
|
||||||
|
SHAPE_CACHE.with( |c|
|
||||||
|
{
|
||||||
|
let mut c = c.borrow_mut();
|
||||||
|
if c.len() >= SHAPE_CACHE_CAP { c.clear(); }
|
||||||
|
c.insert( key, std::sync::Arc::clone( &shaped ) );
|
||||||
|
} );
|
||||||
|
shaped
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop every cached shaped line. Called when the font registry changes so a
|
||||||
|
/// new face set can't be served stale glyph runs keyed by a reused pointer.
|
||||||
|
pub fn clear_shape_cache()
|
||||||
|
{
|
||||||
|
SHAPE_CACHE.with( |c| c.borrow_mut().clear() );
|
||||||
|
}
|
||||||
|
|
||||||
/// Shape a text run using rustybuzz (HarfBuzz-compatible shaping) and
|
/// Shape a text run using rustybuzz (HarfBuzz-compatible shaping) and
|
||||||
/// return the glyph sequence with ink positions.
|
/// return the glyph sequence with ink positions.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ pub fn active_mode() -> ThemeMode
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The currently loaded theme document. Use this for slot-typed lookups
|
/// The currently loaded theme document. Use this for slot-typed lookups
|
||||||
/// when the per-slot helpers ([`crate::theme::color`], [`crate::theme::surface`], …) are not
|
/// when the per-slot helpers ([`crate::theme::color`], [`crate::theme::surface()`], …) are not
|
||||||
/// expressive enough — e.g. iterating `mode.slots.entries`.
|
/// expressive enough — e.g. iterating `mode.slots.entries`.
|
||||||
pub fn active_document() -> Arc<ThemeDocument>
|
pub fn active_document() -> Arc<ThemeDocument>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,13 +7,21 @@ use std::fmt;
|
|||||||
use std::io;
|
use std::io;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// What went wrong while locating, reading or parsing a theme.
|
||||||
#[ derive( Debug ) ]
|
#[ derive( Debug ) ]
|
||||||
pub enum ThemeError
|
pub enum ThemeError
|
||||||
{
|
{
|
||||||
|
/// An I/O error reading the theme file at the given path.
|
||||||
Io( PathBuf, io::Error ),
|
Io( PathBuf, io::Error ),
|
||||||
|
/// The theme file at the given path was not valid JSON.
|
||||||
ParseJson( PathBuf, serde_json::Error ),
|
ParseJson( PathBuf, serde_json::Error ),
|
||||||
|
/// No theme with this id was found in any search path.
|
||||||
NotFound( String ),
|
NotFound( String ),
|
||||||
|
/// A colour literal was not a recognised form (`#RRGGBB`, `#RRGGBBAA` or
|
||||||
|
/// `rgb[a](…)`).
|
||||||
InvalidColor( String ),
|
InvalidColor( String ),
|
||||||
|
/// A `@name` reference was not defined in the top-level `colors`,
|
||||||
|
/// `gradients` or `inset_stacks`.
|
||||||
UnknownColorRef( String ),
|
UnknownColorRef( String ),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ mod tests
|
|||||||
/// without the usual system fonts).
|
/// without the usual system fonts).
|
||||||
fn system_font() -> Option<Arc<Font>>
|
fn system_font() -> Option<Arc<Font>>
|
||||||
{
|
{
|
||||||
let path = crate::render::helpers::find_font_opt()?;
|
let path = crate::system_fonts::find_font_opt()?;
|
||||||
let bytes = std::fs::read( path ).ok()?;
|
let bytes = std::fs::read( path ).ok()?;
|
||||||
let font = Font::from_bytes( bytes.as_slice(), FontSettings::default() ).ok()?;
|
let font = Font::from_bytes( bytes.as_slice(), FontSettings::default() ).ok()?;
|
||||||
Some( Arc::new( font ) )
|
Some( Arc::new( font ) )
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
//! state. Use [`set_active_document`] / [`set_active_mode`] to change it,
|
//! state. Use [`set_active_document`] / [`set_active_mode`] to change it,
|
||||||
//! and [`active_document`] / [`active_mode`] / [`active_theme_id`] to read
|
//! and [`active_document`] / [`active_mode`] / [`active_theme_id`] to read
|
||||||
//! it back. Per-slot shorthand accessors ([`color`], [`paint()`], [`surface()`],
|
//! it back. Per-slot shorthand accessors ([`color`], [`paint()`], [`surface()`],
|
||||||
//! [`palette`], …) cover the common patterns without going through the
|
//! [`palette()`], …) cover the common patterns without going through the
|
||||||
//! full document.
|
//! full document.
|
||||||
//!
|
//!
|
||||||
//! There is **no in-code fallback**: if `ensure_active` cannot locate the
|
//! There is **no in-code fallback**: if `ensure_active` cannot locate the
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ impl Palette
|
|||||||
/// (`bg-page`, `surface`, `surface-alt`, `text-primary`,
|
/// (`bg-page`, `surface`, `surface-alt`, `text-primary`,
|
||||||
/// `text-secondary`, `accent`, `divider`, `icon`). Missing slots
|
/// `text-secondary`, `accent`, `divider`, `icon`). Missing slots
|
||||||
/// fall back to a documented sensible default so downstream widgets
|
/// fall back to a documented sensible default so downstream widgets
|
||||||
/// never see uninitialised colours. Used by [`crate::theme::palette`] and
|
/// never see uninitialised colours. Used by [`crate::theme::palette()`] and
|
||||||
/// [`crate::theme::window_controls`].
|
/// [`crate::theme::window_controls`].
|
||||||
pub fn from_slots( slots: &SlotStore ) -> Self
|
pub fn from_slots( slots: &SlotStore ) -> Self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,15 +6,105 @@
|
|||||||
//! Designed around the **Sora** typeface (Google Fonts). If Sora is not
|
//! Designed around the **Sora** typeface (Google Fonts). If Sora is not
|
||||||
//! installed on the system, ltk falls back to Liberation Sans / DejaVu Sans;
|
//! installed on the system, ltk falls back to Liberation Sans / DejaVu Sans;
|
||||||
//! glyph metrics will differ slightly but the scale still reads correctly.
|
//! glyph metrics will differ slightly but the scale still reads correctly.
|
||||||
|
//!
|
||||||
|
//! Two scales coexist: the historic **px** constants (`H0` through
|
||||||
|
//! `BODY_XS`) for code that wants a frozen pixel size, and the new
|
||||||
|
//! **responsive** scale ([`h0`], [`h1`], …, [`body_xs`]) that returns
|
||||||
|
//! viewport-relative [`crate::Length`] values clamped to the same px
|
||||||
|
//! range that used to be the constant. Call sites can mix freely:
|
||||||
|
//! `.size( typography::H2 )` still resolves to `Length::px( 24.0 )` via
|
||||||
|
//! `From<f32>`, while `.size( typography::h2() )` scales with the
|
||||||
|
//! surface's smaller dimension.
|
||||||
|
|
||||||
|
use crate::types::Length;
|
||||||
|
|
||||||
|
/// Frozen px sizes for the typographic scale, largest (`H0`, a display
|
||||||
|
/// heading) to smallest (`BODY_XS`, fine print). `H0`…`H3` are the heading
|
||||||
|
/// ramp; `BODY` is running text, with `BODY_S` / `BODY_XS` for secondary and
|
||||||
|
/// caption text. Each is the px size; for sizes that scale with the surface
|
||||||
|
/// use the responsive [`h0`]…[`body_xs`] functions instead.
|
||||||
pub const H0: f32 = 50.0;
|
pub const H0: f32 = 50.0;
|
||||||
|
/// See [`H0`]. Px size of the second-largest heading level.
|
||||||
pub const H1: f32 = 34.0;
|
pub const H1: f32 = 34.0;
|
||||||
|
/// See [`H0`]. Px size of the third heading level.
|
||||||
pub const H2: f32 = 24.0;
|
pub const H2: f32 = 24.0;
|
||||||
|
/// See [`H0`]. Px size of the fourth heading level.
|
||||||
pub const H3: f32 = 20.0;
|
pub const H3: f32 = 20.0;
|
||||||
|
/// See [`H0`]. Px size of running body text.
|
||||||
pub const BODY: f32 = 16.0;
|
pub const BODY: f32 = 16.0;
|
||||||
|
/// See [`H0`]. Px size of small (secondary) body text.
|
||||||
pub const BODY_S: f32 = 14.0;
|
pub const BODY_S: f32 = 14.0;
|
||||||
|
/// See [`H0`]. Px size of the smallest (caption) text.
|
||||||
pub const BODY_XS: f32 = 12.0;
|
pub const BODY_XS: f32 = 12.0;
|
||||||
|
|
||||||
/// Interlineado (line-height) multiplier recommended by the kit. Apply as
|
/// Interlineado (line-height) multiplier recommended by the kit. Apply as
|
||||||
/// `size * LINE_HEIGHT` when laying out multi-line text blocks.
|
/// `size * LINE_HEIGHT` when laying out multi-line text blocks.
|
||||||
pub const LINE_HEIGHT: f32 = 1.5;
|
pub const LINE_HEIGHT: f32 = 1.5;
|
||||||
|
|
||||||
|
// ─── Responsive scale ────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The percentages are calibrated so the px clamps match the legacy constants
|
||||||
|
// at a 1000 px logical smaller side (a typical landscape tablet). On a Librem 5
|
||||||
|
// portrait (~720 px) headings round down sensibly; on a 4K desktop the upper
|
||||||
|
// clamp kicks in before display titles get absurd.
|
||||||
|
|
||||||
|
/// Responsive counterparts of the px constants: each returns a [`Length`]
|
||||||
|
/// that scales with the surface's smaller dimension (`vmin`) and is clamped
|
||||||
|
/// to a sensible px range, so the same level reads correctly from a portrait
|
||||||
|
/// phone to a 4K desktop. Same hierarchy as the constants — `h0` the largest
|
||||||
|
/// display heading down to `body_xs` the smallest caption. Largest display
|
||||||
|
/// heading: scales as 5% of `vmin`, clamped to `32..=80` px.
|
||||||
|
pub fn h0() -> Length { Length::vmin( 5.0 ).clamp( 32.0, 80.0 ) }
|
||||||
|
/// See [`h0`]. Second heading level: 3.4% of `vmin`, clamped to `24..=56` px.
|
||||||
|
pub fn h1() -> Length { Length::vmin( 3.4 ).clamp( 24.0, 56.0 ) }
|
||||||
|
/// See [`h0`]. Third heading level: 2.4% of `vmin`, clamped to `18..=40` px.
|
||||||
|
pub fn h2() -> Length { Length::vmin( 2.4 ).clamp( 18.0, 40.0 ) }
|
||||||
|
/// See [`h0`]. Fourth heading level: 2.0% of `vmin`, clamped to `16..=32` px.
|
||||||
|
pub fn h3() -> Length { Length::vmin( 2.0 ).clamp( 16.0, 32.0 ) }
|
||||||
|
/// See [`h0`]. Running body text: 1.6% of `vmin`, clamped to `14..=22` px.
|
||||||
|
pub fn body() -> Length { Length::vmin( 1.6 ).clamp( 14.0, 22.0 ) }
|
||||||
|
/// See [`h0`]. Small body text: 1.4% of `vmin`, clamped to `12..=18` px.
|
||||||
|
pub fn body_s() -> Length { Length::vmin( 1.4 ).clamp( 12.0, 18.0 ) }
|
||||||
|
/// See [`h0`]. Smallest caption text: 1.2% of `vmin`, clamped to `11..=15` px.
|
||||||
|
pub fn body_xs() -> Length { Length::vmin( 1.2 ).clamp( 11.0, 15.0 ) }
|
||||||
|
|
||||||
|
#[ cfg( test ) ]
|
||||||
|
mod tests
|
||||||
|
{
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// 360-px-wide portrait phone — Vmin ratio under the clamp's lower bound,
|
||||||
|
/// so every scale snaps to its `min_px`.
|
||||||
|
#[ test ]
|
||||||
|
fn responsive_scale_pins_to_min_on_narrow_phones()
|
||||||
|
{
|
||||||
|
let vp = ( 360.0, 720.0 );
|
||||||
|
let em = Length::EM_BASE_DEFAULT;
|
||||||
|
assert_eq!( h0().resolve( vp, em ), 32.0 );
|
||||||
|
assert_eq!( body().resolve( vp, em ), 14.0 );
|
||||||
|
assert_eq!( body_xs().resolve( vp, em ), 11.0 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 1000-px smaller side — the calibration point. Numbers should be
|
||||||
|
/// "around" the legacy px constants without exceeding the upper clamp.
|
||||||
|
#[ test ]
|
||||||
|
fn responsive_scale_centers_around_legacy_px_constants()
|
||||||
|
{
|
||||||
|
let vp = ( 1000.0, 1000.0 );
|
||||||
|
let em = Length::EM_BASE_DEFAULT;
|
||||||
|
assert_eq!( h0().resolve( vp, em ), 50.0 );
|
||||||
|
assert_eq!( h2().resolve( vp, em ), 24.0 );
|
||||||
|
assert_eq!( body().resolve( vp, em ), 16.0 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 4K-class smaller side — every scale should saturate to its upper
|
||||||
|
/// clamp instead of growing absurdly.
|
||||||
|
#[ test ]
|
||||||
|
fn responsive_scale_pins_to_max_on_large_displays()
|
||||||
|
{
|
||||||
|
let vp = ( 2160.0, 3840.0 );
|
||||||
|
let em = Length::EM_BASE_DEFAULT;
|
||||||
|
assert_eq!( h0().resolve( vp, em ), 80.0 );
|
||||||
|
assert_eq!( body().resolve( vp, em ), 22.0 );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
468
src/types.rs
468
src/types.rs
@@ -24,6 +24,8 @@
|
|||||||
//! `ltk::Rect`, …) so application code rarely needs the `ltk::types::`
|
//! `ltk::Rect`, …) so application code rarely needs the `ltk::types::`
|
||||||
//! prefix.
|
//! prefix.
|
||||||
|
|
||||||
|
use std::sync::atomic::{ AtomicU8, AtomicU32, Ordering };
|
||||||
|
|
||||||
/// An RGBA color with floating-point channels in the range `[0.0, 1.0]`.
|
/// An RGBA color with floating-point channels in the range `[0.0, 1.0]`.
|
||||||
#[ derive( Debug, Clone, Copy, PartialEq ) ]
|
#[ derive( Debug, Clone, Copy, PartialEq ) ]
|
||||||
pub struct Color
|
pub struct Color
|
||||||
@@ -271,6 +273,19 @@ impl From<( f32, f32, f32, f32 )> for Corners
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One command of a vector path, in canvas (surface) coordinates. Fed to
|
||||||
|
/// [`Canvas::fill_path`](crate::Canvas::fill_path) / `stroke_path` to render
|
||||||
|
/// arbitrary shapes (e.g. an Android `Path` / a Lottie frame).
|
||||||
|
#[ derive( Clone, Copy, Debug, PartialEq ) ]
|
||||||
|
pub enum PathCmd
|
||||||
|
{
|
||||||
|
MoveTo( f32, f32 ),
|
||||||
|
LineTo( f32, f32 ),
|
||||||
|
QuadTo( f32, f32, f32, f32 ),
|
||||||
|
CubicTo( f32, f32, f32, f32, f32, f32 ),
|
||||||
|
Close,
|
||||||
|
}
|
||||||
|
|
||||||
/// A stable widget identifier used for focus management.
|
/// A stable widget identifier used for focus management.
|
||||||
///
|
///
|
||||||
/// Assign an id to a widget with `.id( WidgetId("my_widget") )`, then request
|
/// Assign an id to a widget with `.id( WidgetId("my_widget") )`, then request
|
||||||
@@ -430,3 +445,456 @@ mod tests
|
|||||||
assert_eq!( r, e );
|
assert_eq!( r, e );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Length ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// One of the pure relative-or-absolute modes a [`Length`] can carry.
|
||||||
|
/// Split out so [`Length`] itself can stay `Copy` while still supporting
|
||||||
|
/// optional clamp bounds — the recursive `Clamp` variant of the original
|
||||||
|
/// sketch would have forced a `Box` allocation, which on a widget tree
|
||||||
|
/// that builds these values per frame is the wrong trade.
|
||||||
|
#[ derive( Debug, Clone, Copy, PartialEq ) ]
|
||||||
|
pub enum LengthBase
|
||||||
|
{
|
||||||
|
/// Absolute, in logical pixels.
|
||||||
|
Px( f32 ),
|
||||||
|
/// Percentage of the viewport's width (`Vw(10.0)` == 10 % of width).
|
||||||
|
Vw( f32 ),
|
||||||
|
/// Percentage of the viewport's height.
|
||||||
|
Vh( f32 ),
|
||||||
|
/// Percentage of the viewport's **smaller** dimension. The right
|
||||||
|
/// default for typography and gutters that must survive a
|
||||||
|
/// portrait/landscape rotation without growing absurd.
|
||||||
|
Vmin( f32 ),
|
||||||
|
/// Percentage of the viewport's **larger** dimension.
|
||||||
|
Vmax( f32 ),
|
||||||
|
/// Orientation-dependent percentage of the viewport's **short** side,
|
||||||
|
/// with a different proportion per orientation. In portrait (width ≤
|
||||||
|
/// height) it resolves to `portrait` % of the **width**; in landscape
|
||||||
|
/// (width > height) to `landscape` % of the **height**. Both axes are
|
||||||
|
/// the short side of their orientation, but the design proportion
|
||||||
|
/// differs — e.g. a logo that wants 40 % of the width when there is
|
||||||
|
/// vertical room to spare, but only 5 % of the (scarce) height when
|
||||||
|
/// laid out landscape.
|
||||||
|
Orient { portrait: f32, landscape: f32 },
|
||||||
|
/// Multiple of the root font size (typographic hierarchy: a heading
|
||||||
|
/// of `Em(2.0)` is twice the body size, regardless of viewport).
|
||||||
|
Em( f32 ),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LengthBase
|
||||||
|
{
|
||||||
|
fn resolve( &self, viewport: ( f32, f32 ), em_base: f32 ) -> f32
|
||||||
|
{
|
||||||
|
let ( vw, vh ) = viewport;
|
||||||
|
match self
|
||||||
|
{
|
||||||
|
LengthBase::Px( v ) => *v,
|
||||||
|
LengthBase::Vw( pct ) => vw * pct / 100.0,
|
||||||
|
LengthBase::Vh( pct ) => vh * pct / 100.0,
|
||||||
|
LengthBase::Vmin( pct ) => vw.min( vh ) * pct / 100.0,
|
||||||
|
LengthBase::Vmax( pct ) => vw.max( vh ) * pct / 100.0,
|
||||||
|
LengthBase::Orient { portrait, landscape } =>
|
||||||
|
{
|
||||||
|
if vw <= vh
|
||||||
|
{
|
||||||
|
vw * portrait / 100.0
|
||||||
|
} else {
|
||||||
|
vh * landscape / 100.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LengthBase::Em( mul ) => em_base * mul,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A size or distance value that may be expressed in absolute pixels or
|
||||||
|
/// relative to the rendering surface. Every widget API that used to take
|
||||||
|
/// `f32` for a size, padding, spacing or font height now takes
|
||||||
|
/// `impl Into<Length>`, so existing call sites keep compiling unchanged
|
||||||
|
/// while new code can switch to viewport-relative units for layouts that
|
||||||
|
/// must scale across screen sizes (portrait phone, landscape tablet,
|
||||||
|
/// 4K desktop) without per-target tweaks.
|
||||||
|
///
|
||||||
|
/// Resolution requires a viewport — passed in as `(width, height)` in
|
||||||
|
/// **logical** pixels — and an `em_base` (the body-text font size that
|
||||||
|
/// `Em` is a multiple of). All resolution funnels through
|
||||||
|
/// [`Length::resolve`], so widgets can stay backend-agnostic.
|
||||||
|
///
|
||||||
|
/// Construct directly via the [`LengthBase`] variants
|
||||||
|
/// (`Length::vmin( 18.0 )`, `Length::px( 24.0 )`, …) or implicitly from
|
||||||
|
/// `f32`/`i32`/`u32` for the px case so legacy `.size( 24.0 )` style
|
||||||
|
/// keeps compiling unchanged. Optionally chain `.clamp( min_px, max_px )`
|
||||||
|
/// to bound a relative value into a safe range.
|
||||||
|
#[ derive( Debug, Clone, Copy, PartialEq ) ]
|
||||||
|
pub struct Length
|
||||||
|
{
|
||||||
|
pub base: LengthBase,
|
||||||
|
/// Lower bound in absolute logical px. `None` means unbounded.
|
||||||
|
pub min_px: Option<f32>,
|
||||||
|
/// Upper bound in absolute logical px. `None` means unbounded.
|
||||||
|
pub max_px: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Length
|
||||||
|
{
|
||||||
|
/// Default font-size that [`LengthBase::Em`] is a multiple of. Matches
|
||||||
|
/// the `typography::BODY` constant of the default theme.
|
||||||
|
pub const EM_BASE_DEFAULT: f32 = 16.0;
|
||||||
|
|
||||||
|
pub const fn from_base( base: LengthBase ) -> Self
|
||||||
|
{
|
||||||
|
Self { base, min_px: None, max_px: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shorthand constructors. `Length::vmin( 18.0 )` reads better than
|
||||||
|
/// `Length::from_base( LengthBase::Vmin( 18.0 ) )` at every call site
|
||||||
|
/// and the brevity matters when these appear in tight view code.
|
||||||
|
pub const fn px( v: f32 ) -> Self { Self::from_base( LengthBase::Px( v ) ) }
|
||||||
|
pub const fn vw( v: f32 ) -> Self { Self::from_base( LengthBase::Vw( v ) ) }
|
||||||
|
pub const fn vh( v: f32 ) -> Self { Self::from_base( LengthBase::Vh( v ) ) }
|
||||||
|
pub const fn vmin( v: f32 ) -> Self { Self::from_base( LengthBase::Vmin( v ) ) }
|
||||||
|
pub const fn vmax( v: f32 ) -> Self { Self::from_base( LengthBase::Vmax( v ) ) }
|
||||||
|
pub const fn em( v: f32 ) -> Self { Self::from_base( LengthBase::Em( v ) ) }
|
||||||
|
|
||||||
|
/// Orientation-aware size: `portrait` % of the **width** when the
|
||||||
|
/// viewport is portrait, `landscape` % of the **height** when it is
|
||||||
|
/// landscape. See [`LengthBase::Orient`]. Chain `.clamp( lo, hi )` to
|
||||||
|
/// bound the result in px as with any relative length.
|
||||||
|
pub const fn orient( portrait: f32, landscape: f32 ) -> Self
|
||||||
|
{
|
||||||
|
Self::from_base( LengthBase::Orient { portrait, landscape } )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Fluid** design pixel (the [`WidgetScaling::Fluid`] mode). `px` is
|
||||||
|
/// the size at the reference surface set via [`set_fluid_reference`]
|
||||||
|
/// (defaults to 412 px — the eydos mobile reference width); the value
|
||||||
|
/// then scales as a fraction of the surface's **short** side (width in
|
||||||
|
/// portrait, height in landscape) and is auto-clamped to
|
||||||
|
/// `[px * `[`FLUID_MIN`]`, px * `[`FLUID_MAX`]`]` so it neither
|
||||||
|
/// collapses on a tiny surface nor balloons on a 4K one. A single
|
||||||
|
/// design number therefore yields a surface-proportional size with no
|
||||||
|
/// per-call percentages — this is how stock widgets stay fluid by
|
||||||
|
/// default. For explicit control use [`Length::vmin`] /
|
||||||
|
/// [`Length::orient`] with your own [`Length::clamp`].
|
||||||
|
pub fn fluid( px: f32 ) -> Self
|
||||||
|
{
|
||||||
|
let r = fluid_reference();
|
||||||
|
Length::vmin( px / r * 100.0 ).clamp( px * FLUID_MIN, px * FLUID_MAX )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Density-independent** pixel (the [`WidgetScaling::Physical`] mode).
|
||||||
|
/// `px` is multiplied by the process [`density`] (derived from the
|
||||||
|
/// output's DPI, or set with [`set_density`]) to yield a **constant
|
||||||
|
/// physical size** across displays — the mainstream `dp` of Android /
|
||||||
|
/// Flutter / CSS. Unlike [`Length::fluid`] it does **not** scale with
|
||||||
|
/// the surface size, only with pixel density. Density defaults to
|
||||||
|
/// `1.0`, so `dp( n )` == `n` px until a density is set.
|
||||||
|
pub fn dp( px: f32 ) -> Self
|
||||||
|
{
|
||||||
|
Length::px( px * density() )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a stock-widget design pixel through the process-wide
|
||||||
|
/// [`widget_scaling`] mode: [`Length::fluid`] in [`WidgetScaling::Fluid`]
|
||||||
|
/// (the default), [`Length::dp`] in [`WidgetScaling::Physical`]. Widgets
|
||||||
|
/// route their intrinsic geometry / font constants through this (see
|
||||||
|
/// [`crate::Canvas::geom_px`] / [`crate::Canvas::font_px`]) so a single
|
||||||
|
/// process-level switch picks the adaptation strategy for every stock
|
||||||
|
/// widget at once, while explicit [`Length`] overrides still win.
|
||||||
|
pub fn widget( px: f32 ) -> Self
|
||||||
|
{
|
||||||
|
match widget_scaling()
|
||||||
|
{
|
||||||
|
WidgetScaling::Fluid => Length::fluid( px ),
|
||||||
|
WidgetScaling::Physical => Length::dp( px ),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve to a concrete logical-pixel value given a viewport and an
|
||||||
|
/// `em_base` (the root font size that `Em` is a fraction of).
|
||||||
|
pub fn resolve( &self, viewport: ( f32, f32 ), em_base: f32 ) -> f32
|
||||||
|
{
|
||||||
|
let raw = self.base.resolve( viewport, em_base );
|
||||||
|
let lo = self.min_px;
|
||||||
|
let hi = self.max_px;
|
||||||
|
// If both bounds present, normalise their order so swapped args
|
||||||
|
// don't produce NaN out of f32::clamp.
|
||||||
|
let ( lo, hi ) = match ( lo, hi )
|
||||||
|
{
|
||||||
|
( Some( a ), Some( b ) ) if a > b => ( Some( b ), Some( a ) ),
|
||||||
|
other => other,
|
||||||
|
};
|
||||||
|
let v = match lo { Some( a ) => raw.max( a ), None => raw };
|
||||||
|
match hi { Some( b ) => v.min( b ), None => v }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cap the resolved value to `[min_px, max_px]`. Bounds are
|
||||||
|
/// absolute px because the typical use is "this Vmin should never
|
||||||
|
/// shrink past readable nor balloon past comfortable"; bounding
|
||||||
|
/// a relative value with another relative value is rare enough to
|
||||||
|
/// not justify boxing the type. If you swap min/max the resolver
|
||||||
|
/// tolerates it instead of panicking.
|
||||||
|
pub fn clamp( mut self, min_px: f32, max_px: f32 ) -> Length
|
||||||
|
{
|
||||||
|
self.min_px = Some( min_px );
|
||||||
|
self.max_px = Some( max_px );
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-sided bound: never resolve below `min_px`. Named `at_least`
|
||||||
|
/// (rather than `min`) to avoid clashing visually with `f32::min`,
|
||||||
|
/// which has the opposite semantics ("return the smaller of two").
|
||||||
|
pub fn at_least( mut self, min_px: f32 ) -> Length
|
||||||
|
{
|
||||||
|
self.min_px = Some( min_px );
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-sided bound: never resolve above `max_px`. Counterpart to
|
||||||
|
/// [`Self::at_least`].
|
||||||
|
pub fn at_most( mut self, max_px: f32 ) -> Length
|
||||||
|
{
|
||||||
|
self.max_px = Some( max_px );
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lower auto-clamp factor of [`Length::fluid`]: a fluid value never
|
||||||
|
/// resolves below `px * FLUID_MIN`, so it stays usable on a tiny surface.
|
||||||
|
pub const FLUID_MIN: f32 = 0.7;
|
||||||
|
/// Upper auto-clamp factor of [`Length::fluid`]: a fluid value never
|
||||||
|
/// resolves above `px * FLUID_MAX`, so it stays tasteful on a huge surface.
|
||||||
|
pub const FLUID_MAX: f32 = 1.5;
|
||||||
|
|
||||||
|
static FLUID_REFERENCE_BITS: AtomicU32 = AtomicU32::new( 412.0_f32.to_bits() );
|
||||||
|
|
||||||
|
/// Set the reference surface (short-side px) that [`Length::fluid`]
|
||||||
|
/// interprets its design pixels against. Call once at startup (e.g. before
|
||||||
|
/// [`crate::run`]) to align the fluid scale to the surface mock-up the app
|
||||||
|
/// was designed for. Default: 412 px.
|
||||||
|
pub fn set_fluid_reference( reference_vmin: f32 )
|
||||||
|
{
|
||||||
|
FLUID_REFERENCE_BITS.store( reference_vmin.to_bits(), Ordering::Relaxed );
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current reference used by [`Length::fluid`] — the short-side px at which
|
||||||
|
/// `fluid( n )` resolves to `n` px before clamping.
|
||||||
|
pub fn fluid_reference() -> f32
|
||||||
|
{
|
||||||
|
f32::from_bits( FLUID_REFERENCE_BITS.load( Ordering::Relaxed ) )
|
||||||
|
}
|
||||||
|
|
||||||
|
static DENSITY_BITS: AtomicU32 = AtomicU32::new( 1.0_f32.to_bits() );
|
||||||
|
|
||||||
|
/// Set the process-wide pixel density used by [`Length::dp`] (the
|
||||||
|
/// [`WidgetScaling::Physical`] mode). Typically derived from the output's
|
||||||
|
/// physical DPI so `dp` sizes stay physically constant across displays.
|
||||||
|
/// Default: `1.0`.
|
||||||
|
pub fn set_density( d: f32 )
|
||||||
|
{
|
||||||
|
DENSITY_BITS.store( d.max( 0.0 ).to_bits(), Ordering::Relaxed );
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current pixel density — the factor [`Length::dp`] multiplies its design
|
||||||
|
/// pixels by. `1.0` until [`set_density`] is called.
|
||||||
|
pub fn density() -> f32
|
||||||
|
{
|
||||||
|
f32::from_bits( DENSITY_BITS.load( Ordering::Relaxed ) )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a stock widget adapts its intrinsic geometry to the display when the
|
||||||
|
/// app does not override it. The two modes ltk offers, chosen per process
|
||||||
|
/// with [`set_widget_scaling`]:
|
||||||
|
///
|
||||||
|
/// - [`WidgetScaling::Fluid`] — sizes scale as a fraction of the surface
|
||||||
|
/// (via [`Length::fluid`]): the design breathes with the screen, and a
|
||||||
|
/// size tracks the **short** side (width in portrait, height in
|
||||||
|
/// landscape). The default.
|
||||||
|
/// - [`WidgetScaling::Physical`] — sizes stay a constant physical size
|
||||||
|
/// (via [`Length::dp`] and [`density`]), the mainstream HiDPI model.
|
||||||
|
///
|
||||||
|
/// Both leave explicit [`Length`] overrides (`vmin` / `orient` / `dp` / …)
|
||||||
|
/// on individual widgets untouched — the mode only picks the meaning of the
|
||||||
|
/// theme's default design pixels.
|
||||||
|
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
|
||||||
|
pub enum WidgetScaling
|
||||||
|
{
|
||||||
|
/// Surface-proportional defaults. See [`Length::fluid`].
|
||||||
|
Fluid,
|
||||||
|
/// Constant-physical-size defaults. See [`Length::dp`].
|
||||||
|
Physical,
|
||||||
|
}
|
||||||
|
|
||||||
|
static WIDGET_SCALING_BITS: AtomicU8 = AtomicU8::new( 0 );
|
||||||
|
|
||||||
|
/// Set the process-wide [`WidgetScaling`] mode for stock-widget defaults.
|
||||||
|
/// Call once at startup. Default: [`WidgetScaling::Fluid`].
|
||||||
|
pub fn set_widget_scaling( mode: WidgetScaling )
|
||||||
|
{
|
||||||
|
let v = match mode { WidgetScaling::Fluid => 0, WidgetScaling::Physical => 1 };
|
||||||
|
WIDGET_SCALING_BITS.store( v, Ordering::Relaxed );
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current [`WidgetScaling`] mode. [`WidgetScaling::Fluid`] until
|
||||||
|
/// [`set_widget_scaling`] changes it.
|
||||||
|
pub fn widget_scaling() -> WidgetScaling
|
||||||
|
{
|
||||||
|
match WIDGET_SCALING_BITS.load( Ordering::Relaxed )
|
||||||
|
{
|
||||||
|
1 => WidgetScaling::Physical,
|
||||||
|
_ => WidgetScaling::Fluid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<f32> for Length
|
||||||
|
{
|
||||||
|
fn from( v: f32 ) -> Self { Length::px( v ) }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<i32> for Length
|
||||||
|
{
|
||||||
|
fn from( v: i32 ) -> Self { Length::px( v as f32 ) }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<u32> for Length
|
||||||
|
{
|
||||||
|
fn from( v: u32 ) -> Self { Length::px( v as f32 ) }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<LengthBase> for Length
|
||||||
|
{
|
||||||
|
fn from( base: LengthBase ) -> Self { Length::from_base( base ) }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ cfg( test ) ]
|
||||||
|
mod length_tests
|
||||||
|
{
|
||||||
|
use super::Length;
|
||||||
|
use crate::TEST_GLOBALS_LOCK as GLOBALS_LOCK;
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn px_is_passthrough()
|
||||||
|
{
|
||||||
|
assert_eq!( Length::px( 42.0 ).resolve( ( 800.0, 600.0 ), 16.0 ), 42.0 );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn vw_vh_are_percent_of_viewport()
|
||||||
|
{
|
||||||
|
assert_eq!( Length::vw( 50.0 ).resolve( ( 800.0, 600.0 ), 16.0 ), 400.0 );
|
||||||
|
assert_eq!( Length::vh( 25.0 ).resolve( ( 800.0, 600.0 ), 16.0 ), 150.0 );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn vmin_picks_smaller_side()
|
||||||
|
{
|
||||||
|
assert_eq!( Length::vmin( 10.0 ).resolve( ( 800.0, 600.0 ), 16.0 ), 60.0 );
|
||||||
|
assert_eq!( Length::vmin( 10.0 ).resolve( ( 600.0, 800.0 ), 16.0 ), 60.0 );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn vmax_picks_larger_side()
|
||||||
|
{
|
||||||
|
assert_eq!( Length::vmax( 10.0 ).resolve( ( 800.0, 600.0 ), 16.0 ), 80.0 );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn orient_uses_width_pct_in_portrait_and_height_pct_in_landscape()
|
||||||
|
{
|
||||||
|
// Portrait 1080×2400: 40 % of the width.
|
||||||
|
assert_eq!( Length::orient( 40.0, 5.0 ).resolve( ( 1080.0, 2400.0 ), 16.0 ), 432.0 );
|
||||||
|
// Landscape 2400×1080: 5 % of the height.
|
||||||
|
assert_eq!( Length::orient( 40.0, 5.0 ).resolve( ( 2400.0, 1080.0 ), 16.0 ), 54.0 );
|
||||||
|
// Square viewport counts as portrait (width ≤ height).
|
||||||
|
assert_eq!( Length::orient( 10.0, 20.0 ).resolve( ( 500.0, 500.0 ), 16.0 ), 50.0 );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn em_uses_em_base()
|
||||||
|
{
|
||||||
|
assert_eq!( Length::em( 2.0 ).resolve( ( 800.0, 600.0 ), 18.0 ), 36.0 );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn clamp_bounds_relative_value()
|
||||||
|
{
|
||||||
|
// 50 % of the smaller side (= 300) capped to [100, 200] → 200.
|
||||||
|
let l = Length::vmin( 50.0 ).clamp( 100.0, 200.0 );
|
||||||
|
assert_eq!( l.resolve( ( 800.0, 600.0 ), 16.0 ), 200.0 );
|
||||||
|
|
||||||
|
// 1 % of the smaller side (= 6) lifted to the min of 50.
|
||||||
|
let l2 = Length::vmin( 1.0 ).clamp( 50.0, 200.0 );
|
||||||
|
assert_eq!( l2.resolve( ( 800.0, 600.0 ), 16.0 ), 50.0 );
|
||||||
|
|
||||||
|
// Caller swapped min/max — resolver tolerates without panic.
|
||||||
|
let l3 = Length::vmin( 50.0 ).clamp( 200.0, 100.0 );
|
||||||
|
assert_eq!( l3.resolve( ( 800.0, 600.0 ), 16.0 ), 200.0 );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn f32_converts_to_px()
|
||||||
|
{
|
||||||
|
let l: Length = 24.0_f32.into();
|
||||||
|
assert_eq!( l.base, super::LengthBase::Px( 24.0 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn fluid_equals_design_px_at_reference_surface()
|
||||||
|
{
|
||||||
|
// At a surface whose short side is the 412 px reference, fluid( n ) == n.
|
||||||
|
assert_eq!( Length::fluid( 48.0 ).resolve( ( 412.0, 900.0 ), 16.0 ), 48.0 );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn fluid_scales_with_surface_and_auto_clamps()
|
||||||
|
{
|
||||||
|
// Twice the reference short side → would double, but the +50 % cap
|
||||||
|
// (48 * FLUID_MAX = 72) holds it.
|
||||||
|
assert_eq!( Length::fluid( 48.0 ).resolve( ( 824.0, 1600.0 ), 16.0 ), 72.0 );
|
||||||
|
// A tiny surface → the -30 % floor (48 * FLUID_MIN = 33.6) holds it.
|
||||||
|
assert_eq!( Length::fluid( 48.0 ).resolve( ( 200.0, 400.0 ), 16.0 ), 33.6 );
|
||||||
|
// Fluid tracks the short side: same result portrait or landscape.
|
||||||
|
let p = Length::fluid( 48.0 ).resolve( ( 412.0, 1000.0 ), 16.0 );
|
||||||
|
let l = Length::fluid( 48.0 ).resolve( ( 1000.0, 412.0 ), 16.0 );
|
||||||
|
assert_eq!( p, l );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn dp_is_identity_at_default_density()
|
||||||
|
{
|
||||||
|
let _g = GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() );
|
||||||
|
// Density defaults to 1.0, so dp( n ) resolves to n regardless of viewport.
|
||||||
|
assert_eq!( super::density(), 1.0 );
|
||||||
|
assert_eq!( Length::dp( 48.0 ).resolve( ( 412.0, 900.0 ), 16.0 ), 48.0 );
|
||||||
|
assert_eq!( Length::dp( 48.0 ).resolve( ( 3840.0, 2160.0 ), 16.0 ), 48.0 );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialised: this is the only test that mutates the process-wide density
|
||||||
|
// and widget-scaling globals, so it owns them start-to-finish and restores
|
||||||
|
// the defaults, keeping the other (read-only-default) tests deterministic.
|
||||||
|
#[ test ]
|
||||||
|
fn density_and_widget_scaling_modes()
|
||||||
|
{
|
||||||
|
use super::{ density, set_density, widget_scaling, set_widget_scaling, WidgetScaling };
|
||||||
|
|
||||||
|
let _g = GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() );
|
||||||
|
|
||||||
|
// Defaults.
|
||||||
|
assert_eq!( density(), 1.0 );
|
||||||
|
assert_eq!( widget_scaling(), WidgetScaling::Fluid );
|
||||||
|
assert_eq!( Length::widget( 48.0 ), Length::fluid( 48.0 ) );
|
||||||
|
|
||||||
|
// Density scales dp.
|
||||||
|
set_density( 3.0 );
|
||||||
|
assert_eq!( Length::dp( 48.0 ).resolve( ( 412.0, 900.0 ), 16.0 ), 144.0 );
|
||||||
|
|
||||||
|
// Physical mode routes widget() through dp.
|
||||||
|
set_widget_scaling( WidgetScaling::Physical );
|
||||||
|
assert_eq!( Length::widget( 48.0 ), Length::dp( 48.0 ) );
|
||||||
|
|
||||||
|
// Restore defaults for the rest of the suite.
|
||||||
|
set_density( 1.0 );
|
||||||
|
set_widget_scaling( WidgetScaling::Fluid );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,12 +32,12 @@ mod tests;
|
|||||||
pub struct AnchoredOverlay<Msg: Clone>
|
pub struct AnchoredOverlay<Msg: Clone>
|
||||||
{
|
{
|
||||||
/// The element to draw at the anchored position.
|
/// The element to draw at the anchored position.
|
||||||
pub child: Box<Element<Msg>>,
|
pub( crate ) child: Box<Element<Msg>>,
|
||||||
/// Stable identifier of the widget whose rect provides the anchor.
|
/// Stable identifier of the widget whose rect provides the anchor.
|
||||||
pub anchor_id: WidgetId,
|
pub( crate ) anchor_id: WidgetId,
|
||||||
/// Vertical pixel gap between the bottom of the anchor and the top
|
/// Vertical pixel gap between the bottom of the anchor and the top
|
||||||
/// of the child.
|
/// of the child.
|
||||||
pub gap: f32,
|
pub( crate ) gap: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<Msg: Clone> AnchoredOverlay<Msg>
|
impl<Msg: Clone> AnchoredOverlay<Msg>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use crate::types::{ Color, Rect, WidgetId };
|
use crate::types::{ Color, Length, Rect, WidgetId };
|
||||||
use crate::render::Canvas;
|
use crate::render::Canvas;
|
||||||
use super::Element;
|
use super::Element;
|
||||||
|
|
||||||
@@ -47,34 +47,45 @@ pub enum ButtonContent
|
|||||||
pub struct Button<Msg: Clone>
|
pub struct Button<Msg: Clone>
|
||||||
{
|
{
|
||||||
/// The visual content of this button.
|
/// The visual content of this button.
|
||||||
pub content: ButtonContent,
|
pub( crate ) content: ButtonContent,
|
||||||
/// Message emitted when the button is pressed, or `None` if disabled.
|
/// Message emitted when the button is pressed, or `None` if disabled.
|
||||||
pub on_press: Option<Msg>,
|
pub( crate ) on_press: Option<Msg>,
|
||||||
/// Message emitted when the user holds the button for
|
/// Message emitted when the user holds the button for
|
||||||
/// [`App::long_press_duration`](crate::app::App::long_press_duration)
|
/// [`App::long_press_duration`](crate::app::App::long_press_duration)
|
||||||
/// without moving past the tolerance, OR when the user right-clicks
|
/// without moving past the tolerance, OR when the user right-clicks
|
||||||
/// with the mouse. `None` leaves the button without a context-menu
|
/// with the mouse. `None` leaves the button without a context-menu
|
||||||
/// equivalent. The fire does NOT by itself put the gesture into
|
/// equivalent. The fire does NOT by itself put the gesture into
|
||||||
/// drag mode — that is governed by [`Self::on_drag_start`].
|
/// drag mode — that is governed by [`Self::on_drag_start`].
|
||||||
pub on_long_press: Option<Msg>,
|
pub( crate ) on_long_press: Option<Msg>,
|
||||||
/// Drag-arm message. Fires when the press transitions into a drag:
|
/// Drag-arm message. Fires when the press transitions into a drag:
|
||||||
/// touch on hold-timer expiry (in addition to `on_long_press`),
|
/// touch on hold-timer expiry (in addition to `on_long_press`),
|
||||||
/// mouse on motion past the drag-promotion threshold (without
|
/// mouse on motion past the drag-promotion threshold (without
|
||||||
/// firing the menu). Independent of `on_long_press` so a button
|
/// firing the menu). Independent of `on_long_press` so a button
|
||||||
/// can open a menu without becoming draggable, or be draggable
|
/// can open a menu without becoming draggable, or be draggable
|
||||||
/// without showing a menu.
|
/// without showing a menu.
|
||||||
pub on_drag_start: Option<Msg>,
|
pub( crate ) on_drag_start: Option<Msg>,
|
||||||
/// Visual variant controlling colors and borders.
|
/// Visual variant controlling colors and borders.
|
||||||
pub variant: ButtonVariant,
|
pub( crate ) variant: ButtonVariant,
|
||||||
/// Width and height in pixels for icon buttons. Defaults to `48.0`.
|
/// Width and height in pixels for icon buttons. The `0.0` default
|
||||||
pub icon_size: f32,
|
/// follows the process [`crate::WidgetScaling`] mode (via
|
||||||
|
/// [`crate::Canvas::geom_px`]); any positive value pins an explicit size.
|
||||||
|
pub( crate ) icon_size: f32,
|
||||||
|
/// Optional label font size for text buttons. `None` uses the theme's
|
||||||
|
/// default (`theme::FONT_SIZE`); a [`Length`] scales the label with the
|
||||||
|
/// surface (e.g. `Length::vmin( 2.2 ).clamp( 14.0, 22.0 )`).
|
||||||
|
pub( crate ) font_size: Option<Length>,
|
||||||
|
/// Optional height for text buttons. `None` uses the theme's default
|
||||||
|
/// (`theme::HEIGHT`); a [`Length`] scales the button box with the
|
||||||
|
/// surface so it does not stay frozen while the rest of a fluid layout
|
||||||
|
/// grows. Resolved in physical layout space, like all geometry.
|
||||||
|
pub( crate ) height: Option<Length>,
|
||||||
/// Optional stable identifier for focus management.
|
/// Optional stable identifier for focus management.
|
||||||
pub id: Option<WidgetId>,
|
pub( crate ) id: Option<WidgetId>,
|
||||||
/// Whether this button participates in keyboard focus (Tab). Default: `true`.
|
/// Whether this button participates in keyboard focus (Tab). Default: `true`.
|
||||||
pub focusable: bool,
|
pub( crate ) focusable: bool,
|
||||||
/// Override the pointer cursor shape on hover. `None` falls back
|
/// Override the pointer cursor shape on hover. `None` falls back
|
||||||
/// to the `Pointer` (hand) default for clickable widgets.
|
/// to the `Pointer` (hand) default for clickable widgets.
|
||||||
pub cursor: Option<crate::types::CursorShape>,
|
pub( crate ) cursor: Option<crate::types::CursorShape>,
|
||||||
/// When `true`, holding the button down auto-fires the
|
/// When `true`, holding the button down auto-fires the
|
||||||
/// `on_press` message: one immediate fire on press, then an
|
/// `on_press` message: one immediate fire on press, then an
|
||||||
/// initial delay (≈ 500 ms — same as the keyboard) followed by
|
/// initial delay (≈ 500 ms — same as the keyboard) followed by
|
||||||
@@ -83,8 +94,8 @@ pub struct Button<Msg: Clone>
|
|||||||
/// target). The runtime cancels the timer on release, on touch
|
/// target). The runtime cancels the timer on release, on touch
|
||||||
/// cancel, and on long-press promotion. Default `false` — most
|
/// cancel, and on long-press promotion. Default `false` — most
|
||||||
/// buttons fire on tap only.
|
/// buttons fire on tap only.
|
||||||
pub repeating: bool,
|
pub( crate ) repeating: bool,
|
||||||
pub tooltip: Option<String>,
|
pub( crate ) tooltip: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<Msg: Clone> Button<Msg>
|
impl<Msg: Clone> Button<Msg>
|
||||||
@@ -99,7 +110,9 @@ impl<Msg: Clone> Button<Msg>
|
|||||||
on_long_press: None,
|
on_long_press: None,
|
||||||
on_drag_start: None,
|
on_drag_start: None,
|
||||||
variant: ButtonVariant::Primary,
|
variant: ButtonVariant::Primary,
|
||||||
icon_size: theme::HEIGHT,
|
icon_size: 0.0,
|
||||||
|
font_size: None,
|
||||||
|
height: None,
|
||||||
id: None,
|
id: None,
|
||||||
focusable: true,
|
focusable: true,
|
||||||
cursor: None,
|
cursor: None,
|
||||||
@@ -134,7 +147,9 @@ impl<Msg: Clone> Button<Msg>
|
|||||||
on_long_press: None,
|
on_long_press: None,
|
||||||
on_drag_start: None,
|
on_drag_start: None,
|
||||||
variant: ButtonVariant::Tertiary,
|
variant: ButtonVariant::Tertiary,
|
||||||
icon_size: theme::HEIGHT,
|
icon_size: 0.0,
|
||||||
|
font_size: None,
|
||||||
|
height: None,
|
||||||
id: None,
|
id: None,
|
||||||
focusable: true,
|
focusable: true,
|
||||||
cursor: None,
|
cursor: None,
|
||||||
@@ -222,6 +237,52 @@ impl<Msg: Clone> Button<Msg>
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the label font size for text buttons. Accepts logical `f32`
|
||||||
|
/// pixels or any [`Length`] (e.g. `Length::vmin( 2.2 ).clamp( 14.0,
|
||||||
|
/// 22.0 )` to scale with the surface). No-op for icon buttons.
|
||||||
|
pub fn font_size( mut self, size: impl Into<Length> ) -> Self
|
||||||
|
{
|
||||||
|
self.font_size = Some( size.into() );
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the button height for text buttons. Accepts logical `f32` pixels
|
||||||
|
/// or any [`Length`] (e.g. `Length::vmin( 7.0 ).clamp( 40.0, 64.0 )` to
|
||||||
|
/// scale the box with the surface). No-op for icon buttons, which are
|
||||||
|
/// sized by [`Self::icon_size`].
|
||||||
|
pub fn height( mut self, h: impl Into<Length> ) -> Self
|
||||||
|
{
|
||||||
|
self.height = Some( h.into() );
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the label font size against the canvas viewport, matching how
|
||||||
|
/// [`text`](crate::text) sizes its glyphs. An explicit override bypasses
|
||||||
|
/// the mode; the default follows the process [`crate::WidgetScaling`].
|
||||||
|
fn label_font_size( &self, canvas: &Canvas ) -> f32
|
||||||
|
{
|
||||||
|
self.font_size
|
||||||
|
.map( |l| l.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) )
|
||||||
|
.unwrap_or_else( || canvas.font_px( theme::FONT_SIZE ) )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the button height against the physical layout viewport (like
|
||||||
|
/// all geometry). An explicit override bypasses the mode; the default
|
||||||
|
/// follows the process [`crate::WidgetScaling`].
|
||||||
|
fn resolved_height( &self, canvas: &Canvas ) -> f32
|
||||||
|
{
|
||||||
|
self.height
|
||||||
|
.map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) )
|
||||||
|
.unwrap_or_else( || canvas.geom_px( theme::HEIGHT ) )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the icon-button size: a positive [`Self::icon_size`] pins it,
|
||||||
|
/// the `0.0` sentinel follows the widget-scaling mode.
|
||||||
|
fn resolved_icon_size( &self, canvas: &Canvas ) -> f32
|
||||||
|
{
|
||||||
|
if self.icon_size > 0.0 { self.icon_size } else { canvas.geom_px( theme::HEIGHT ) }
|
||||||
|
}
|
||||||
|
|
||||||
/// Assign a stable identifier for focus management.
|
/// Assign a stable identifier for focus management.
|
||||||
pub fn id( mut self, id: WidgetId ) -> Self
|
pub fn id( mut self, id: WidgetId ) -> Self
|
||||||
{
|
{
|
||||||
@@ -268,13 +329,13 @@ impl<Msg: Clone> Button<Msg>
|
|||||||
{
|
{
|
||||||
ButtonContent::Text( label ) =>
|
ButtonContent::Text( label ) =>
|
||||||
{
|
{
|
||||||
let text_w = canvas.measure_text( label, theme::FONT_SIZE );
|
let text_w = canvas.measure_text( label, self.label_font_size( canvas ) );
|
||||||
let w = (text_w + theme::PAD_H * 2.0).min( max_width );
|
let w = (text_w + canvas.geom_px( theme::PAD_H ) * 2.0).min( max_width );
|
||||||
( w, theme::HEIGHT )
|
( w, self.resolved_height( canvas ) )
|
||||||
}
|
}
|
||||||
ButtonContent::Icon { .. } =>
|
ButtonContent::Icon { .. } =>
|
||||||
{
|
{
|
||||||
let s = self.icon_size.min( max_width );
|
let s = self.resolved_icon_size( canvas ).min( max_width );
|
||||||
( s, s )
|
( s, s )
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -302,7 +363,8 @@ impl<Msg: Clone> Button<Msg>
|
|||||||
fn draw_text_button( &self, canvas: &mut Canvas, rect: Rect, focused: bool, label: &str )
|
fn draw_text_button( &self, canvas: &mut Canvas, rect: Rect, focused: bool, label: &str )
|
||||||
{
|
{
|
||||||
let is_disabled = self.on_press.is_none();
|
let is_disabled = self.on_press.is_none();
|
||||||
let text_y = rect.y + (rect.height + theme::FONT_SIZE) / 2.0 - 2.0;
|
let fs = self.label_font_size( canvas );
|
||||||
|
let text_y = rect.y + (rect.height + fs) / 2.0 - 2.0;
|
||||||
|
|
||||||
match self.variant
|
match self.variant
|
||||||
{
|
{
|
||||||
@@ -326,12 +388,12 @@ impl<Msg: Clone> Button<Msg>
|
|||||||
theme::RADIUS + theme::FOCUS_W + 2.0,
|
theme::RADIUS + theme::FOCUS_W + 2.0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let text_w = canvas.measure_text( label, theme::FONT_SIZE );
|
let text_w = canvas.measure_text( label, fs );
|
||||||
canvas.draw_text(
|
canvas.draw_text(
|
||||||
label,
|
label,
|
||||||
rect.x + (rect.width - text_w) / 2.0,
|
rect.x + (rect.width - text_w) / 2.0,
|
||||||
text_y,
|
text_y,
|
||||||
theme::FONT_SIZE,
|
fs,
|
||||||
text_c,
|
text_c,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -352,12 +414,12 @@ impl<Msg: Clone> Button<Msg>
|
|||||||
theme::RADIUS + theme::FOCUS_W + 2.0,
|
theme::RADIUS + theme::FOCUS_W + 2.0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let text_w = canvas.measure_text( label, theme::FONT_SIZE );
|
let text_w = canvas.measure_text( label, fs );
|
||||||
canvas.draw_text(
|
canvas.draw_text(
|
||||||
label,
|
label,
|
||||||
rect.x + (rect.width - text_w) / 2.0,
|
rect.x + (rect.width - text_w) / 2.0,
|
||||||
text_y,
|
text_y,
|
||||||
theme::FONT_SIZE,
|
fs,
|
||||||
text_c,
|
text_c,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -369,12 +431,12 @@ impl<Msg: Clone> Button<Msg>
|
|||||||
let ring = rect.expand( 2.0 );
|
let ring = rect.expand( 2.0 );
|
||||||
canvas.stroke_rect( ring, theme::focus_color(), theme::FOCUS_W, theme::RADIUS );
|
canvas.stroke_rect( ring, theme::focus_color(), theme::FOCUS_W, theme::RADIUS );
|
||||||
}
|
}
|
||||||
let text_w = canvas.measure_text( label, theme::FONT_SIZE );
|
let text_w = canvas.measure_text( label, fs );
|
||||||
canvas.draw_text(
|
canvas.draw_text(
|
||||||
label,
|
label,
|
||||||
rect.x + (rect.width - text_w) / 2.0,
|
rect.x + (rect.width - text_w) / 2.0,
|
||||||
text_y,
|
text_y,
|
||||||
theme::FONT_SIZE,
|
fs,
|
||||||
text_c,
|
text_c,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -446,6 +508,8 @@ impl<Msg: Clone> Button<Msg>
|
|||||||
on_drag_start: self.on_drag_start.map( |m| ( *f )( m ) ),
|
on_drag_start: self.on_drag_start.map( |m| ( *f )( m ) ),
|
||||||
variant: self.variant,
|
variant: self.variant,
|
||||||
icon_size: self.icon_size,
|
icon_size: self.icon_size,
|
||||||
|
font_size: self.font_size,
|
||||||
|
height: self.height,
|
||||||
id: self.id,
|
id: self.id,
|
||||||
focusable: self.focusable,
|
focusable: self.focusable,
|
||||||
cursor: self.cursor,
|
cursor: self.cursor,
|
||||||
|
|||||||
@@ -99,3 +99,54 @@ fn repeating_builder_sets_flag()
|
|||||||
let b = b.repeating( false );
|
let b = b.repeating( false );
|
||||||
assert!( !b.repeating );
|
assert!( !b.repeating );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn font_size_none_by_default_follows_widget_scaling()
|
||||||
|
{
|
||||||
|
let _g = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() );
|
||||||
|
let b = Button::<()>::new( "ok".into() );
|
||||||
|
let canvas = Canvas::new( 400, 800 );
|
||||||
|
assert!( b.font_size.is_none() );
|
||||||
|
// The default routes through the widget-scaling mode, not a raw constant.
|
||||||
|
assert_eq!( b.label_font_size( &canvas ), canvas.font_px( theme::FONT_SIZE ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn font_size_builder_resolves_length_against_viewport()
|
||||||
|
{
|
||||||
|
// vmin on a 400×800 surface → 10 % of the smaller side (400) = 40 px.
|
||||||
|
let b = Button::<()>::new( "ok".into() ).font_size( Length::vmin( 10.0 ) );
|
||||||
|
let canvas = Canvas::new( 400, 800 );
|
||||||
|
assert_eq!( b.label_font_size( &canvas ), 40.0 );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn font_size_survives_map_msg()
|
||||||
|
{
|
||||||
|
let f: super::super::MapFn<i32, ()> = std::sync::Arc::new( |_| () );
|
||||||
|
let b: Button<()> = Button::<i32>::new( "ok".into() )
|
||||||
|
.font_size( Length::px( 21.0 ) )
|
||||||
|
.map_msg( &f );
|
||||||
|
assert_eq!( b.font_size, Some( Length::px( 21.0 ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn height_none_by_default_follows_widget_scaling()
|
||||||
|
{
|
||||||
|
let _g = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() );
|
||||||
|
let b = Button::<()>::new( "ok".into() );
|
||||||
|
let canvas = Canvas::new( 400, 800 );
|
||||||
|
assert!( b.height.is_none() );
|
||||||
|
// The default routes through the widget-scaling mode, not a raw constant.
|
||||||
|
assert_eq!( b.preferred_size( 1000.0, &canvas ).1, canvas.geom_px( theme::HEIGHT ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn height_builder_scales_button_box_with_surface()
|
||||||
|
{
|
||||||
|
// vmin height on a 400×800 surface → 10 % of the smaller side (400) = 40 px,
|
||||||
|
// resolved in physical layout space (not divided by dpi_scale).
|
||||||
|
let b = Button::<()>::new( "ok".into() ).height( Length::vmin( 10.0 ) );
|
||||||
|
let canvas = Canvas::new( 400, 800 );
|
||||||
|
assert_eq!( b.preferred_size( 1000.0, &canvas ).1, 40.0 );
|
||||||
|
}
|
||||||
|
|||||||
166
src/widget/carousel/mod.rs
Normal file
166
src/widget/carousel/mod.rs
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
|
//! Horizontal carousel: each child occupies `focused_width_frac` of the
|
||||||
|
//! viewport width, neighbours peek out to the sides. The carousel itself
|
||||||
|
//! is a pure layout primitive — the caller owns the scroll offset and
|
||||||
|
//! drives drag / inertia / snap externally. This keeps the widget side
|
||||||
|
//! stateless and lets the host compositor reuse its existing touch
|
||||||
|
//! pipeline.
|
||||||
|
//!
|
||||||
|
//! ```rust,no_run
|
||||||
|
//! # use ltk::{ carousel, container, spacer, Element };
|
||||||
|
//! # #[ derive( Clone ) ] enum Msg { Select( usize ) }
|
||||||
|
//! # fn _ex( offset: f32 ) -> Element<Msg> {
|
||||||
|
//! carousel()
|
||||||
|
//! .focused_width_frac( 0.8 )
|
||||||
|
//! .gap( 16.0 )
|
||||||
|
//! .offset( offset )
|
||||||
|
//! .push( container( spacer() ) )
|
||||||
|
//! .push( container( spacer() ) )
|
||||||
|
//! .into()
|
||||||
|
//! # }
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use crate::render::Canvas;
|
||||||
|
use crate::types::{ Rect, WidgetId };
|
||||||
|
use crate::widget::Element;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
|
pub struct Carousel<Msg: Clone>
|
||||||
|
{
|
||||||
|
/// Child widgets in display order, left-to-right.
|
||||||
|
pub( crate ) children: Vec<Element<Msg>>,
|
||||||
|
/// Optional stable identifier — used by the host as the key for its
|
||||||
|
/// drag / inertia / snap state.
|
||||||
|
pub( crate ) id: Option<WidgetId>,
|
||||||
|
/// Each child's width as a fraction of the viewport width. 0.8 leaves
|
||||||
|
/// 10% on each side for the neighbours to peek out.
|
||||||
|
pub( crate ) focused_width_frac: f32,
|
||||||
|
/// Horizontal gap between adjacent children, in logical pixels.
|
||||||
|
pub( crate ) gap: f32,
|
||||||
|
/// Logical-pixel offset applied to every child. Positive values shift
|
||||||
|
/// children to the right (revealing later tiles). The host clamps,
|
||||||
|
/// snaps and animates this value.
|
||||||
|
pub( crate ) offset: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Msg: Clone> Carousel<Msg>
|
||||||
|
{
|
||||||
|
pub fn push( mut self, child: impl Into<Element<Msg>> ) -> Self
|
||||||
|
{
|
||||||
|
self.children.push( child.into() );
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn id( mut self, id: WidgetId ) -> Self
|
||||||
|
{
|
||||||
|
self.id = Some( id );
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn focused_width_frac( mut self, f: f32 ) -> Self
|
||||||
|
{
|
||||||
|
self.focused_width_frac = f.clamp( 0.05, 1.0 );
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn gap( mut self, g: f32 ) -> Self
|
||||||
|
{
|
||||||
|
self.gap = g.max( 0.0 );
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn offset( mut self, o: f32 ) -> Self
|
||||||
|
{
|
||||||
|
self.offset = o;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 )
|
||||||
|
{
|
||||||
|
if self.children.is_empty() { return ( max_width, 0.0 ); }
|
||||||
|
let child_w = ( max_width * self.focused_width_frac ).max( 1.0 );
|
||||||
|
let max_h = self.children.iter()
|
||||||
|
.map( |c| c.preferred_size( child_w, canvas ).1 )
|
||||||
|
.fold( 0.0_f32, f32::max );
|
||||||
|
( max_width, max_h )
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snap-target offset that centres `idx` in the viewport. Positive
|
||||||
|
/// values shift the carousel content right — i.e. the negation of
|
||||||
|
/// the natural "scroll x" so callers can pass it straight back into
|
||||||
|
/// [`offset()`](Self::offset).
|
||||||
|
pub fn snap_offset( &self, viewport_w: f32, idx: usize ) -> f32
|
||||||
|
{
|
||||||
|
if self.children.is_empty() { return 0.0; }
|
||||||
|
let child_w = ( viewport_w * self.focused_width_frac ).max( 1.0 );
|
||||||
|
let stride = child_w + self.gap;
|
||||||
|
-( idx as f32 ) * stride
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Index of the child whose centre is closest to the viewport centre,
|
||||||
|
/// given the current `offset`. Used by the host to decide which tile a
|
||||||
|
/// release should snap to and to compute the keyboard-navigation target.
|
||||||
|
pub fn focused_index( &self, viewport_w: f32 ) -> usize
|
||||||
|
{
|
||||||
|
if self.children.is_empty() { return 0; }
|
||||||
|
let child_w = ( viewport_w * self.focused_width_frac ).max( 1.0 );
|
||||||
|
let stride = child_w + self.gap;
|
||||||
|
let raw = -self.offset / stride;
|
||||||
|
raw.round().clamp( 0.0, ( self.children.len() - 1 ) as f32 ) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn layout( &self, rect: Rect, _canvas: &Canvas ) -> Vec<( Rect, usize )>
|
||||||
|
{
|
||||||
|
if self.children.is_empty() { return Vec::new(); }
|
||||||
|
let child_w = ( rect.width * self.focused_width_frac ).max( 1.0 );
|
||||||
|
let base_x = rect.x + ( rect.width - child_w ) / 2.0 + self.offset;
|
||||||
|
let stride = child_w + self.gap;
|
||||||
|
|
||||||
|
self.children.iter().enumerate().map( |( i, _ )|
|
||||||
|
{
|
||||||
|
let x = base_x + ( i as f32 ) * stride;
|
||||||
|
( Rect { x, y: rect.y, width: child_w, height: rect.height }, i )
|
||||||
|
}).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn draw( &self ) {}
|
||||||
|
|
||||||
|
pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> Carousel<U>
|
||||||
|
where
|
||||||
|
U: Clone + 'static,
|
||||||
|
Msg: 'static,
|
||||||
|
{
|
||||||
|
Carousel
|
||||||
|
{
|
||||||
|
children: self.children.into_iter().map( |c| c.map_arc( f ) ).collect(),
|
||||||
|
id: self.id,
|
||||||
|
focused_width_frac: self.focused_width_frac,
|
||||||
|
gap: self.gap,
|
||||||
|
offset: self.offset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Msg: Clone + 'static> From<Carousel<Msg>> for Element<Msg>
|
||||||
|
{
|
||||||
|
fn from( c: Carousel<Msg> ) -> Self
|
||||||
|
{
|
||||||
|
Element::Carousel( c )
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn carousel<Msg: Clone>() -> Carousel<Msg>
|
||||||
|
{
|
||||||
|
Carousel
|
||||||
|
{
|
||||||
|
children: Vec::new(),
|
||||||
|
id: None,
|
||||||
|
focused_width_frac: 0.8,
|
||||||
|
gap: 16.0,
|
||||||
|
offset: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
112
src/widget/carousel/tests.rs
Normal file
112
src/widget/carousel/tests.rs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::layout::spacer::spacer;
|
||||||
|
use crate::render::Canvas;
|
||||||
|
|
||||||
|
fn canvas() -> Canvas { Canvas::new( 1, 1 ) }
|
||||||
|
|
||||||
|
fn three_spacer_carousel() -> Carousel<()>
|
||||||
|
{
|
||||||
|
carousel()
|
||||||
|
.focused_width_frac( 0.8 )
|
||||||
|
.gap( 0.0 )
|
||||||
|
.push( spacer() )
|
||||||
|
.push( spacer() )
|
||||||
|
.push( spacer() )
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_layout_is_empty()
|
||||||
|
{
|
||||||
|
let c: Carousel<()> = carousel();
|
||||||
|
let rect = Rect { x: 0.0, y: 0.0, width: 100.0, height: 100.0 };
|
||||||
|
assert!( c.layout( rect, &canvas() ).is_empty() );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_child_centred_at_offset_zero()
|
||||||
|
{
|
||||||
|
let c = three_spacer_carousel();
|
||||||
|
let rect = Rect { x: 0.0, y: 0.0, width: 100.0, height: 200.0 };
|
||||||
|
let rects = c.layout( rect, &canvas() );
|
||||||
|
// child_w = 80, base_x = (100 - 80)/2 = 10
|
||||||
|
assert!( ( rects[0].0.x - 10.0 ).abs() < 0.01 );
|
||||||
|
assert!( ( rects[0].0.width - 80.0 ).abs() < 0.01 );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn children_strided_by_child_width_plus_gap()
|
||||||
|
{
|
||||||
|
let c = carousel::<()>()
|
||||||
|
.focused_width_frac( 0.5 )
|
||||||
|
.gap( 8.0 )
|
||||||
|
.push( spacer() )
|
||||||
|
.push( spacer() );
|
||||||
|
let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 100.0 };
|
||||||
|
let rects = c.layout( rect, &canvas() );
|
||||||
|
// child_w = 100; stride = 108; base_x = (200 - 100)/2 = 50
|
||||||
|
assert!( ( rects[1].0.x - ( rects[0].0.x + 108.0 ) ).abs() < 0.01 );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snap_offset_centres_target_index()
|
||||||
|
{
|
||||||
|
let c = three_spacer_carousel();
|
||||||
|
// child_w = 80, stride = 80; index 2 → offset -160 to land it centred.
|
||||||
|
assert!( ( c.snap_offset( 100.0, 2 ) - ( -160.0 ) ).abs() < 0.01 );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn focused_index_rounds_to_nearest_tile()
|
||||||
|
{
|
||||||
|
let mut c = three_spacer_carousel();
|
||||||
|
c.offset = -120.0; // halfway between index 1 (-80) and index 2 (-160) → rounds away from 0
|
||||||
|
assert_eq!( c.focused_index( 100.0 ), 2 );
|
||||||
|
c.offset = -100.0; // closer to index 1
|
||||||
|
assert_eq!( c.focused_index( 100.0 ), 1 );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn focused_index_clamps_to_valid_range()
|
||||||
|
{
|
||||||
|
let mut c = three_spacer_carousel();
|
||||||
|
c.offset = 500.0;
|
||||||
|
assert_eq!( c.focused_index( 100.0 ), 0 );
|
||||||
|
c.offset = -10_000.0;
|
||||||
|
assert_eq!( c.focused_index( 100.0 ), 2 );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn offset_shifts_all_children_horizontally()
|
||||||
|
{
|
||||||
|
let c = three_spacer_carousel().offset( -25.0 );
|
||||||
|
let rect = Rect { x: 0.0, y: 0.0, width: 100.0, height: 200.0 };
|
||||||
|
let rects = c.layout( rect, &canvas() );
|
||||||
|
// child_w=80, base_x_at_offset_0 = 10. With offset = -25, base_x = -15.
|
||||||
|
assert!( ( rects[0].0.x - ( -15.0 ) ).abs() < 0.01 );
|
||||||
|
// Stride = 80, so each subsequent child is +80 of the previous.
|
||||||
|
assert!( ( rects[1].0.x - rects[0].0.x - 80.0 ).abs() < 0.01 );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn focused_width_frac_is_clamped_to_unit_range()
|
||||||
|
{
|
||||||
|
let c: Carousel<()> = carousel().focused_width_frac( 5.0 ).push( spacer() );
|
||||||
|
assert!( c.focused_width_frac <= 1.0 );
|
||||||
|
let c2: Carousel<()> = carousel().focused_width_frac( -3.0 ).push( spacer() );
|
||||||
|
assert!( c2.focused_width_frac > 0.0 );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn children_use_full_rect_height()
|
||||||
|
{
|
||||||
|
let c = three_spacer_carousel();
|
||||||
|
let rect = Rect { x: 0.0, y: 0.0, width: 100.0, height: 200.0 };
|
||||||
|
let rects = c.layout( rect, &canvas() );
|
||||||
|
for ( r, _ ) in &rects {
|
||||||
|
assert!( ( r.height - 200.0 ).abs() < 0.01 );
|
||||||
|
assert!( r.y.abs() < 0.01 );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,13 +37,13 @@ pub struct Checkbox<Msg: Clone>
|
|||||||
{
|
{
|
||||||
/// Current checked state. Drawn from this field every frame; the
|
/// Current checked state. Drawn from this field every frame; the
|
||||||
/// runtime never mutates it.
|
/// runtime never mutates it.
|
||||||
pub checked: bool,
|
pub( crate ) checked: bool,
|
||||||
/// Message emitted on activation. `None` leaves the checkbox inert.
|
/// Message emitted on activation. `None` leaves the checkbox inert.
|
||||||
pub on_toggle: Option<Msg>,
|
pub( crate ) on_toggle: Option<Msg>,
|
||||||
/// Optional label drawn to the right of the box.
|
/// Optional label drawn to the right of the box.
|
||||||
pub label: Option<String>,
|
pub( crate ) label: Option<String>,
|
||||||
/// Optional stable identifier for focus management.
|
/// Optional stable identifier for focus management.
|
||||||
pub id: Option<WidgetId>,
|
pub( crate ) id: Option<WidgetId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<Msg: Clone> Checkbox<Msg>
|
impl<Msg: Clone> Checkbox<Msg>
|
||||||
@@ -83,14 +83,15 @@ impl<Msg: Clone> Checkbox<Msg>
|
|||||||
|
|
||||||
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
|
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
|
||||||
{
|
{
|
||||||
|
let box_size = canvas.geom_px( theme::BOX_SIZE );
|
||||||
let w = if let Some( ref label ) = self.label
|
let w = if let Some( ref label ) = self.label
|
||||||
{
|
{
|
||||||
let text_w = canvas.measure_text( label, theme::FONT_SIZE );
|
let text_w = canvas.measure_text( label, canvas.font_px( theme::FONT_SIZE ) );
|
||||||
( theme::BOX_SIZE + theme::GAP + text_w ).min( max_width )
|
( box_size + canvas.geom_px( theme::GAP ) + text_w ).min( max_width )
|
||||||
} else {
|
} else {
|
||||||
theme::BOX_SIZE.min( max_width )
|
box_size.min( max_width )
|
||||||
};
|
};
|
||||||
( w, theme::HEIGHT )
|
( w, canvas.geom_px( theme::HEIGHT ) )
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Focus ring on the box extends `FOCUS_W + 2 + FOCUS_W/2 ≈ 6.5 px` beyond
|
/// Focus ring on the box extends `FOCUS_W + 2 + FOCUS_W/2 ≈ 6.5 px` beyond
|
||||||
@@ -102,21 +103,22 @@ impl<Msg: Clone> Checkbox<Msg>
|
|||||||
|
|
||||||
pub fn draw( &self, canvas: &mut Canvas, rect: Rect, focused: bool )
|
pub fn draw( &self, canvas: &mut Canvas, rect: Rect, focused: bool )
|
||||||
{
|
{
|
||||||
let box_y = rect.y + ( rect.height - theme::BOX_SIZE ) / 2.0;
|
let box_size = canvas.geom_px( theme::BOX_SIZE );
|
||||||
|
let box_y = rect.y + ( rect.height - box_size ) / 2.0;
|
||||||
let box_rect = Rect
|
let box_rect = Rect
|
||||||
{
|
{
|
||||||
x: rect.x,
|
x: rect.x,
|
||||||
y: box_y,
|
y: box_y,
|
||||||
width: theme::BOX_SIZE,
|
width: box_size,
|
||||||
height: theme::BOX_SIZE,
|
height: box_size,
|
||||||
};
|
};
|
||||||
|
|
||||||
if self.checked
|
if self.checked
|
||||||
{
|
{
|
||||||
canvas.fill_rect( box_rect, theme::box_checked(), theme::RADIUS );
|
canvas.fill_rect( box_rect, theme::box_checked(), theme::RADIUS );
|
||||||
let cx = rect.x + theme::BOX_SIZE / 2.0;
|
let cx = rect.x + box_size / 2.0;
|
||||||
let cy = box_y + theme::BOX_SIZE / 2.0;
|
let cy = box_y + box_size / 2.0;
|
||||||
let s = theme::BOX_SIZE * 0.3;
|
let s = box_size * 0.3;
|
||||||
canvas.draw_line( cx - s, cy, cx - s * 0.3, cy + s * 0.7, theme::check_color(), theme::CHECK_W );
|
canvas.draw_line( cx - s, cy, cx - s * 0.3, cy + s * 0.7, theme::check_color(), theme::CHECK_W );
|
||||||
canvas.draw_line( cx - s * 0.3, cy + s * 0.7, cx + s, cy - s * 0.5, theme::check_color(), theme::CHECK_W );
|
canvas.draw_line( cx - s * 0.3, cy + s * 0.7, cx + s, cy - s * 0.5, theme::check_color(), theme::CHECK_W );
|
||||||
} else {
|
} else {
|
||||||
@@ -131,9 +133,10 @@ impl<Msg: Clone> Checkbox<Msg>
|
|||||||
|
|
||||||
if let Some( ref label ) = self.label
|
if let Some( ref label ) = self.label
|
||||||
{
|
{
|
||||||
let text_x = rect.x + theme::BOX_SIZE + theme::GAP;
|
let fs = canvas.font_px( theme::FONT_SIZE );
|
||||||
let text_y = rect.y + ( rect.height + theme::FONT_SIZE ) / 2.0 - 2.0;
|
let text_x = rect.x + box_size + canvas.geom_px( theme::GAP );
|
||||||
canvas.draw_text( label, text_x, text_y, theme::FONT_SIZE, theme::label_color() );
|
let text_y = rect.y + ( rect.height + fs ) / 2.0 - 2.0;
|
||||||
|
canvas.draw_text( label, text_x, text_y, fs, theme::label_color() );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::types::Color;
|
use crate::types::{ Color, Length };
|
||||||
use crate::layout::column::column;
|
use crate::layout::column::column;
|
||||||
use crate::layout::row::row;
|
use crate::layout::row::row;
|
||||||
use crate::layout::spacer::spacer;
|
use crate::layout::spacer::spacer;
|
||||||
@@ -114,12 +114,12 @@ pub fn parse_hex( s: &str ) -> Option<Color>
|
|||||||
/// a continuous hue strip.
|
/// a continuous hue strip.
|
||||||
pub struct ColorPicker<Msg: Clone>
|
pub struct ColorPicker<Msg: Clone>
|
||||||
{
|
{
|
||||||
pub value: Color,
|
pub( crate ) value: Color,
|
||||||
pub on_change: Option<Arc<dyn Fn( Color ) -> Msg>>,
|
pub( crate ) on_change: Option<Arc<dyn Fn( Color ) -> Msg>>,
|
||||||
/// When `true` the alpha slider is shown and the hex input
|
/// When `true` the alpha slider is shown and the hex input
|
||||||
/// accepts `#RRGGBBAA`. Default: `false` — most "pick a theme
|
/// accepts `#RRGGBBAA`. Default: `false` — most "pick a theme
|
||||||
/// colour" flows are opaque.
|
/// colour" flows are opaque.
|
||||||
pub show_alpha: bool,
|
pub( crate ) show_alpha: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<Msg: Clone + 'static> ColorPicker<Msg>
|
impl<Msg: Clone + 'static> ColorPicker<Msg>
|
||||||
@@ -163,7 +163,7 @@ impl<Msg: Clone + 'static> ColorPicker<Msg>
|
|||||||
.radius( 12.0 )
|
.radius( 12.0 )
|
||||||
.padding( 0.0 )
|
.padding( 0.0 )
|
||||||
.into();
|
.into();
|
||||||
let mut preview_row = row::<Msg>().spacing( theme::SPACING ).push(
|
let mut preview_row = row::<Msg>().spacing( Length::widget( theme::SPACING ) ).push(
|
||||||
container::<Msg>( swatch )
|
container::<Msg>( swatch )
|
||||||
.padding( 0.0 )
|
.padding( 0.0 )
|
||||||
.radius( 12.0 ),
|
.radius( 12.0 ),
|
||||||
@@ -208,7 +208,7 @@ impl<Msg: Clone + 'static> ColorPicker<Msg>
|
|||||||
} );
|
} );
|
||||||
}
|
}
|
||||||
column::<Msg>().spacing( 4.0 )
|
column::<Msg>().spacing( 4.0 )
|
||||||
.push( text( label ).size( theme::LABEL_FS ).color( theme::text_muted() ) )
|
.push( text( label ).size( Length::widget( theme::LABEL_FS ) ).color( theme::text_muted() ) )
|
||||||
.push( s )
|
.push( s )
|
||||||
.into()
|
.into()
|
||||||
};
|
};
|
||||||
@@ -218,7 +218,7 @@ impl<Msg: Clone + 'static> ColorPicker<Msg>
|
|||||||
let b_build: Arc<dyn Fn( f32 ) -> Color> = Arc::new( move |v| Color::rgba( value.r, value.g, v, value.a ) );
|
let b_build: Arc<dyn Fn( f32 ) -> Color> = Arc::new( move |v| Color::rgba( value.r, value.g, v, value.a ) );
|
||||||
let a_build: Arc<dyn Fn( f32 ) -> Color> = Arc::new( move |v| Color::rgba( value.r, value.g, value.b, v ) );
|
let a_build: Arc<dyn Fn( f32 ) -> Color> = Arc::new( move |v| Color::rgba( value.r, value.g, value.b, v ) );
|
||||||
|
|
||||||
let mut sliders = column::<Msg>().spacing( theme::SPACING )
|
let mut sliders = column::<Msg>().spacing( Length::widget( theme::SPACING ) )
|
||||||
.push( chan_slider( "R", value.r, r_build ) )
|
.push( chan_slider( "R", value.r, r_build ) )
|
||||||
.push( chan_slider( "G", value.g, g_build ) )
|
.push( chan_slider( "G", value.g, g_build ) )
|
||||||
.push( chan_slider( "B", value.b, b_build ) );
|
.push( chan_slider( "B", value.b, b_build ) );
|
||||||
@@ -267,18 +267,18 @@ impl<Msg: Clone + 'static> ColorPicker<Msg>
|
|||||||
} );
|
} );
|
||||||
}
|
}
|
||||||
let hue_row: Element<Msg> = column::<Msg>().spacing( 4.0 )
|
let hue_row: Element<Msg> = column::<Msg>().spacing( 4.0 )
|
||||||
.push( text( "Hue" ).size( theme::LABEL_FS ).color( theme::text_muted() ) )
|
.push( text( "Hue" ).size( Length::widget( theme::LABEL_FS ) ).color( theme::text_muted() ) )
|
||||||
.push( hue_slider )
|
.push( hue_slider )
|
||||||
.into();
|
.into();
|
||||||
|
|
||||||
let body = column::<Msg>().spacing( theme::SPACING * 2.0 )
|
let body = column::<Msg>().spacing( Length::widget( theme::SPACING * 2.0 ) )
|
||||||
.push( preview_row )
|
.push( preview_row )
|
||||||
.push( sliders )
|
.push( sliders )
|
||||||
.push( hue_row );
|
.push( hue_row );
|
||||||
|
|
||||||
container::<Msg>( body )
|
container::<Msg>( body )
|
||||||
.background( theme::surface_alt() )
|
.background( theme::surface_alt() )
|
||||||
.padding( theme::PADDING )
|
.padding( Length::widget( theme::PADDING ) )
|
||||||
.radius( theme::RADIUS )
|
.radius( theme::RADIUS )
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user