Bump to 0.2.0: SW/GLES paint parity, shared font resolution, FrameState refactor, docs and packaging fixes
Rendering parity (software ↔ GLES). The software backend now rounds glyph pen positions and image destinations to the nearest integer pixel, matching what the GLES backend already did; previously it truncated, so text and 1:1 images could land up to half a pixel off between the two backends and the bilinear sample read ~1 px softer than the source. Gradients and shadows are deliberately left unimplemented on the software backend, and the GLES multi-rect `glScissor` clip is left coarse on purpose: making it exact would need stencil bits the EGL config does not carry, or routing the partial-redraw path through the offscreen clip layer, which would break its `fill` / `clear_rects_transparent` scissor semantics. Adds software-backend pixel tests covering the snapping. Font resolution unification. The system-font candidate chain, `find_font_opt` and `load_default_font_bytes` lived in two copies (`render/helpers` and `gles_render/helpers`) that had already diverged — one resolved through `find_font_opt`, the other inlined the candidate loop — and now live once in `system_fonts`. The two per-backend `OnceLock` default-font caches and `primary_handle` collapse into a single `system_fonts::default_handle`. Module docs and the `font_registry` caller are updated accordingly. Shared image validation and rect inflation. `draw_image_data`'s dimension check and its one-line warning were byte-duplicated across both backends and are now `render::helpers::validate_rgba_dims`. The six manual symmetric `Rect`-inflate literals in the GLES primitives reuse the existing `Rect::expand`. FrameState and DrawCtx de-duplication. The eleven `SurfaceState` fields the draw pass owns and threads through `DrawCtx` — `widget_rects`, the cursor / selection maps, the scroll state, `accessible_extras`, `prev_focused` / `prev_hovered` / `prev_pressed` — move into a `FrameState` sub-struct. The per-frame `build_draw_ctx` / `commit_draw_ctx` helpers can then borrow `&mut ss.frame`, disjoint from `ss.canvas` and `ss.pool`, so the four frame paths (software / GLES × full / partial) replace their duplicated `DrawCtx` construction and write-back with a single helper call each. A whole-`SurfaceState` borrow could not express this (partial borrows do not cross function boundaries), which is why the helpers take the sub-struct. `content_dirty` stays on `SurfaceState` — it is an invalidation flag, not frame state — and is reset at the call site. rich_text tests. Adds the previously-missing `tests.rs` for the `RichText` widget: one hit rect per visual line a link spans (the widget's core invariant), the single-line and no-link cases, preferred-size growth with hard line breaks, and `map_msg` range preservation — all headless against a software `Canvas`, with line counts forced by `\n` so they do not depend on any system font's measured width. Documentation. Fills the rustdoc gaps on the embedder-facing surface: `core::UiSurface` accessors, the `egl_context` public API, the `GlesCanvas` methods, `theme::typography` and `theme::error`, and the `RichText` / `Text` builders; adds a crate-level "Rendering backends" overview. `CHANGELOG.md` is added (0.2.0 / 0.1.0), and `docs/widgets.md` / `docs/cookbook.md` gain `rich_text`, `external`, and the CPU-draw / path-clip / externally-laid-out-tree recipes. `debian/changelog` gets the 0.2.0-1 entry. Private intra-doc links to `system_fonts` are demoted to code spans so `cargo doc` is warning-free. Packaging. The `libltk-dev` registry crate shipped a `Cargo.toml` declaring the `lookup` bench while the `Makefile` install copied only `src/`, so Cargo refused to parse the manifest over a missing `benches/lookup.rs`; the install now ships `benches/` as well (the file alone satisfies the parse — criterion is a dev-dependency and is not resolved when the crate is consumed as a library). `Cargo.toml` is bumped to 0.2.0 to match the package version and the `ltk-0.2.0` registry directory.
This commit is contained in:
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
|
||||||
@@ -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"
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -29,7 +29,7 @@ doc:
|
|||||||
|
|
||||||
install: doc
|
install: doc
|
||||||
install -d $(REGISTRY)
|
install -d $(REGISTRY)
|
||||||
cp -r src Cargo.toml liberux.toml $(REGISTRY)/
|
cp -r src benches 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)/
|
||||||
|
|||||||
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.
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ 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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -823,3 +825,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.
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ 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) · [`carousel`](#carousel)
|
- [`scroll`](#scroll) · [`viewport`](#viewport) · [`flex`](#flex) · [`carousel`](#carousel)
|
||||||
- [Overlays and feedback](#overlays-and-feedback)
|
- [Overlays and feedback](#overlays-and-feedback)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,19 +110,8 @@ 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.content_dirty = false;
|
||||||
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;
|
|
||||||
|
|
||||||
let wl_surface = ss.surface.wl_surface();
|
let wl_surface = ss.surface.wl_surface();
|
||||||
apply_input_region( wl_surface, compositor, input_region, scale );
|
apply_input_region( wl_surface, compositor, input_region, scale );
|
||||||
@@ -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,19 +183,8 @@ 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.content_dirty = false;
|
||||||
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;
|
|
||||||
|
|
||||||
let wl_surface = ss.surface.wl_surface();
|
let wl_surface = ss.surface.wl_surface();
|
||||||
apply_input_region( wl_surface, compositor, input_region, scale );
|
apply_input_region( wl_surface, compositor, input_region, scale );
|
||||||
|
|||||||
@@ -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,19 +122,8 @@ 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.content_dirty = false;
|
||||||
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;
|
|
||||||
|
|
||||||
canvas.write_to_wayland_buf( canvas_buf, swap_rb );
|
canvas.write_to_wayland_buf( canvas_buf, swap_rb );
|
||||||
|
|
||||||
@@ -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,19 +215,8 @@ 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.content_dirty = false;
|
||||||
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;
|
|
||||||
|
|
||||||
canvas.write_to_wayland_buf( canvas_buf, swap_rb );
|
canvas.write_to_wayland_buf( canvas_buf, swap_rb );
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -89,15 +89,15 @@ impl<A: App> AppData<A>
|
|||||||
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
|
secure = idx
|
||||||
.and_then( |i| find_handlers( &ss.widget_rects, i ) )
|
.and_then( |i| find_handlers( &ss.frame.widget_rects, i ) )
|
||||||
.map( |h| matches!( h, WidgetHandlers::TextEdit { secure: true, .. } ) )
|
.map( |h| matches!( h, WidgetHandlers::TextEdit { secure: true, .. } ) )
|
||||||
.unwrap_or( false );
|
.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 );
|
||||||
|
|
||||||
@@ -113,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 )
|
||||||
} );
|
} );
|
||||||
@@ -130,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() )
|
||||||
@@ -140,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 );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ 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 };
|
||||||
pub use invalidation::diff_overlay_ids;
|
pub use invalidation::diff_overlay_ids;
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
|
|||||||
// 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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -638,12 +638,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,
|
||||||
@@ -657,12 +657,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,
|
||||||
@@ -711,8 +711,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
|
||||||
{
|
{
|
||||||
@@ -786,10 +786,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();
|
||||||
@@ -808,12 +808,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
|
||||||
{
|
{
|
||||||
@@ -842,14 +842,14 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
|||||||
let focus_id = data.app.take_focus_request().or_else( || data.focus_retry.take() );
|
let focus_id = data.app.take_focus_request().or_else( || data.focus_retry.take() );
|
||||||
if let Some( id ) = focus_id
|
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;
|
||||||
|
|||||||
@@ -149,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
|
||||||
@@ -177,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>,
|
||||||
@@ -223,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.
|
||||||
@@ -275,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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,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, .. } =>
|
||||||
|
|||||||
@@ -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 )
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ 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.
|
// A rect clip replaces any active path clip: flush its layer first.
|
||||||
@@ -56,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
|
||||||
|
|||||||
@@ -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 );
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,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
|
||||||
@@ -135,7 +135,7 @@ 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>,
|
||||||
|
|||||||
@@ -51,11 +51,17 @@ 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 )
|
pub fn fill_path( &mut self, cmds: &[ PathCmd ], color: Color )
|
||||||
{
|
{
|
||||||
self.rasterise_path( cmds, color, None );
|
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 )
|
pub fn stroke_path( &mut self, cmds: &[ PathCmd ], color: Color, width: f32 )
|
||||||
{
|
{
|
||||||
self.rasterise_path( cmds, color, Some( width ) );
|
self.rasterise_path( cmds, color, Some( width ) );
|
||||||
@@ -114,6 +120,10 @@ impl GlesCanvas
|
|||||||
unsafe { self.gl.delete_texture( tex ); }
|
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; }
|
||||||
@@ -124,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
|
||||||
@@ -221,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);
|
||||||
@@ -269,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
|
||||||
@@ -422,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
|
||||||
@@ -475,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
|
||||||
@@ -550,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,
|
||||||
@@ -35,34 +35,19 @@ use super::shaders::
|
|||||||
};
|
};
|
||||||
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;
|
||||||
@@ -703,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
|
||||||
@@ -723,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
|
||||||
@@ -765,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 )
|
||||||
|
|||||||
@@ -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 ) );
|
||||||
@@ -287,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 ) )
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,7 +47,7 @@ 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
|
||||||
{
|
{
|
||||||
@@ -57,7 +57,7 @@ impl<A: App> AppData<A>
|
|||||||
let mut handled = false;
|
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
|
||||||
{
|
{
|
||||||
@@ -79,7 +79,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 reverse = event.keysym == Keysym::Up;
|
let reverse = event.keysym == Keysym::Up;
|
||||||
let extend = self.shift_pressed;
|
let extend = self.shift_pressed;
|
||||||
@@ -106,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 );
|
||||||
@@ -129,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 ); }
|
||||||
}
|
}
|
||||||
@@ -138,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 ); }
|
||||||
}
|
}
|
||||||
@@ -148,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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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()
|
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() );
|
||||||
|
|||||||
@@ -109,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
|
||||||
};
|
};
|
||||||
@@ -122,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() )
|
||||||
@@ -138,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
|
||||||
{
|
{
|
||||||
@@ -208,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();
|
||||||
@@ -250,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 );
|
||||||
|
|
||||||
@@ -258,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 }
|
||||||
} );
|
} );
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -1,8 +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>
|
||||||
|
|
||||||
//! 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 };
|
||||||
|
|
||||||
@@ -31,6 +32,25 @@ pub ( crate ) fn build_ts_path( cmds: &[ PathCmd ] ) -> Option<Path>
|
|||||||
pb.finish()
|
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,
|
||||||
@@ -78,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 ( 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()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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! {
|
||||||
|
|||||||
@@ -27,8 +27,9 @@
|
|||||||
//! * [`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;
|
||||||
@@ -120,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
|
||||||
@@ -869,3 +870,65 @@ mod clip_path_tests
|
|||||||
assert_eq!( corner.alpha(), 0, "bbox corner outside the triangle must stay clear" );
|
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" );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -189,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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,20 +149,76 @@ pub fn lookup( ch: char ) -> Option<Arc<Font>>
|
|||||||
lookup_handle( ch ).map( |h| h.font )
|
lookup_handle( ch ).map( |h| h.font )
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The process-wide primary UI font — the same default a canvas loads — cached
|
/// System-font search chain for the primary UI font, ordered by preference.
|
||||||
/// for standalone text measurement (e.g. an embedded measure pass with no live
|
/// Shared by [`find_font_opt`] and [`load_default_font_bytes`].
|
||||||
/// canvas). Falls back to the bundled font when no system font is found.
|
const SYSTEM_FONT_CANDIDATES: &[&str] =
|
||||||
pub fn primary_handle() -> FontHandle
|
&[
|
||||||
|
// 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();
|
static PRIMARY: OnceLock<FontHandle> = OnceLock::new();
|
||||||
PRIMARY.get_or_init( ||
|
PRIMARY.get_or_init( ||
|
||||||
{
|
{
|
||||||
let bytes = crate::render::helpers::load_default_font_bytes();
|
let bytes = load_default_font_bytes();
|
||||||
let font = Font::from_bytes( bytes.as_slice(), FontSettings::default() ).expect( "primary font parses" );
|
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 }
|
FontHandle { font: Arc::new( font ), bytes: Arc::new( bytes ), face: 0 }
|
||||||
} ).clone()
|
} ).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
|
||||||
|
|||||||
@@ -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 ) )
|
||||||
|
|||||||
@@ -18,12 +18,23 @@
|
|||||||
|
|
||||||
use crate::types::Length;
|
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
|
||||||
@@ -37,12 +48,24 @@ pub const LINE_HEIGHT: f32 = 1.5;
|
|||||||
// portrait (~720 px) headings round down sensibly; on a 4K desktop the upper
|
// portrait (~720 px) headings round down sensibly; on a 4K desktop the upper
|
||||||
// clamp kicks in before display titles get absurd.
|
// 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 ) }
|
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 ) }
|
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 ) }
|
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 ) }
|
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 ) }
|
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 ) }
|
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 ) }
|
pub fn body_xs() -> Length { Length::vmin( 1.2 ).clamp( 11.0, 15.0 ) }
|
||||||
|
|
||||||
#[ cfg( test ) ]
|
#[ cfg( test ) ]
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ use crate::types::{ Color, Length, Rect };
|
|||||||
use crate::render::Canvas;
|
use crate::render::Canvas;
|
||||||
use super::{ Element, MapFn };
|
use super::{ Element, MapFn };
|
||||||
|
|
||||||
|
#[ cfg( test ) ]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
/// A clickable range `[start, end)` (byte offsets into the content) and the
|
/// A clickable range `[start, end)` (byte offsets into the content) and the
|
||||||
/// message to emit when it is tapped.
|
/// message to emit when it is tapped.
|
||||||
pub struct LinkSpan<Msg>
|
pub struct LinkSpan<Msg>
|
||||||
@@ -24,6 +27,10 @@ pub struct LinkSpan<Msg>
|
|||||||
pub msg: Msg,
|
pub msg: Msg,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A wrapped paragraph with clickable link ranges — the ltk counterpart of an
|
||||||
|
/// Android `Spanned` carrying `URLSpan` / `ClickableSpan`. Each [`LinkSpan`]
|
||||||
|
/// pairs a byte range with a `Msg` emitted on tap; the layout pass yields one
|
||||||
|
/// hit rect per link line so taps land on the link rather than the paragraph.
|
||||||
pub struct RichText<Msg: Clone>
|
pub struct RichText<Msg: Clone>
|
||||||
{
|
{
|
||||||
pub content: String,
|
pub content: String,
|
||||||
@@ -36,6 +43,8 @@ pub struct RichText<Msg: Clone>
|
|||||||
|
|
||||||
impl<Msg: Clone> RichText<Msg>
|
impl<Msg: Clone> RichText<Msg>
|
||||||
{
|
{
|
||||||
|
/// A paragraph of `content` with no links: white text, the default blue
|
||||||
|
/// link colour, default 16 px size and the canvas default font.
|
||||||
pub fn new( content: impl Into<String> ) -> Self
|
pub fn new( content: impl Into<String> ) -> Self
|
||||||
{
|
{
|
||||||
Self
|
Self
|
||||||
@@ -49,24 +58,29 @@ impl<Msg: Clone> RichText<Msg>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the font size.
|
||||||
pub fn size( mut self, s: impl Into<Length> ) -> Self
|
pub fn size( mut self, s: impl Into<Length> ) -> Self
|
||||||
{
|
{
|
||||||
self.size = s.into();
|
self.size = s.into();
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the colour of non-link text.
|
||||||
pub fn color( mut self, c: Color ) -> Self
|
pub fn color( mut self, c: Color ) -> Self
|
||||||
{
|
{
|
||||||
self.color = c;
|
self.color = c;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the colour of link ranges (drawn underlined).
|
||||||
pub fn link_color( mut self, c: Color ) -> Self
|
pub fn link_color( mut self, c: Color ) -> Self
|
||||||
{
|
{
|
||||||
self.link_color = c;
|
self.link_color = c;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Override the font with a `(family, weight, style)` triple resolved
|
||||||
|
/// through the active theme's font registry on draw.
|
||||||
pub fn font( mut self, family: impl Into<String>, weight: u16, style: FontStyle ) -> Self
|
pub fn font( mut self, family: impl Into<String>, weight: u16, style: FontStyle ) -> Self
|
||||||
{
|
{
|
||||||
self.font = Some( ( family.into(), weight, style ) );
|
self.font = Some( ( family.into(), weight, style ) );
|
||||||
@@ -316,6 +330,7 @@ impl<Msg: Clone + 'static> From<RichText<Msg>> for Element<Msg>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Free-function shorthand for [`RichText::new`].
|
||||||
pub fn rich_text<Msg: Clone>( content: impl Into<String> ) -> RichText<Msg>
|
pub fn rich_text<Msg: Clone>( content: impl Into<String> ) -> RichText<Msg>
|
||||||
{
|
{
|
||||||
RichText::new( content )
|
RichText::new( content )
|
||||||
|
|||||||
101
src/widget/rich_text/tests.rs
Normal file
101
src/widget/rich_text/tests.rs
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
|
//! Headless tests for the `rich_text` layout logic, run against a software
|
||||||
|
//! `Canvas` (no GL needed). Line counts are forced with hard `\n` breaks so the
|
||||||
|
//! assertions do not depend on the measured width of any particular system font.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::render::Canvas;
|
||||||
|
use crate::types::Rect;
|
||||||
|
|
||||||
|
fn canvas() -> Canvas
|
||||||
|
{
|
||||||
|
Canvas::new( 800, 600 )
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rect( w: f32, h: f32 ) -> Rect
|
||||||
|
{
|
||||||
|
Rect { x: 0.0, y: 0.0, width: w, height: h }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn link_spanning_two_lines_yields_one_rect_per_line()
|
||||||
|
{
|
||||||
|
let c = canvas();
|
||||||
|
// Hard break splits the paragraph into two visual lines regardless of font
|
||||||
|
// metrics; the single link covers both words.
|
||||||
|
let rt = rich_text::<i32>( "first\nsecond" ).link( 0, 12, 7 );
|
||||||
|
let rects = rt.link_rects( rect( 1000.0, 200.0 ), &c );
|
||||||
|
|
||||||
|
assert_eq!( rects.len(), 2, "a link crossing two lines must emit one rect per line" );
|
||||||
|
assert!( rects.iter().all( |( _, m )| *m == 7 ), "every line rect carries the link message" );
|
||||||
|
assert!( rects[ 1 ].0.y > rects[ 0 ].0.y, "the second line's rect sits below the first" );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn single_line_link_yields_one_rect()
|
||||||
|
{
|
||||||
|
let c = canvas();
|
||||||
|
// "world" is bytes [6, 11). A generous width keeps the paragraph on one line.
|
||||||
|
let rt = rich_text::<i32>( "hello world" ).link( 6, 11, 1 );
|
||||||
|
let rects = rt.link_rects( rect( 10_000.0, 100.0 ), &c );
|
||||||
|
|
||||||
|
assert_eq!( rects.len(), 1 );
|
||||||
|
assert_eq!( rects[ 0 ].1, 1 );
|
||||||
|
assert!( rects[ 0 ].0.width > 0.0, "the link rect spans the measured substring" );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn paragraph_without_links_has_no_hit_rects()
|
||||||
|
{
|
||||||
|
let c = canvas();
|
||||||
|
let rt = rich_text::<i32>( "plain paragraph, no links here" );
|
||||||
|
assert!( rt.link_rects( rect( 1000.0, 100.0 ), &c ).is_empty() );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn link_off_a_line_is_not_reported_on_that_line()
|
||||||
|
{
|
||||||
|
let c = canvas();
|
||||||
|
// Link only covers "first" (bytes [0, 5)); the second line must report nothing.
|
||||||
|
let rt = rich_text::<i32>( "first\nsecond" ).link( 0, 5, 9 );
|
||||||
|
let rects = rt.link_rects( rect( 1000.0, 200.0 ), &c );
|
||||||
|
assert_eq!( rects.len(), 1, "a link confined to one line yields exactly one rect" );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn preferred_size_height_grows_with_hard_breaks()
|
||||||
|
{
|
||||||
|
let c = canvas();
|
||||||
|
let one = rich_text::<i32>( "a" ).preferred_size( 1000.0, &c );
|
||||||
|
let three = rich_text::<i32>( "a\nb\nc" ).preferred_size( 1000.0, &c );
|
||||||
|
|
||||||
|
assert_eq!( one.0, 1000.0, "preferred width echoes the max width" );
|
||||||
|
assert!( one.1 > 0.0, "a single line has positive height" );
|
||||||
|
assert!( three.1 > one.1 * 2.0, "three lines are taller than one ({:?} vs {:?})", three.1, one.1 );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn preferred_size_empty_content_is_one_line()
|
||||||
|
{
|
||||||
|
let c = canvas();
|
||||||
|
let empty = rich_text::<i32>( "" ).preferred_size( 1000.0, &c );
|
||||||
|
let one = rich_text::<i32>( "a" ).preferred_size( 1000.0, &c );
|
||||||
|
assert!( empty.1 > 0.0, "empty content still reserves one line" );
|
||||||
|
assert!( ( empty.1 - one.1 ).abs() < 0.5, "empty and single-char are both one line tall" );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn map_msg_preserves_link_ranges_and_count()
|
||||||
|
{
|
||||||
|
let rt = rich_text::<i32>( "alpha beta" ).link( 0, 5, 11 ).link( 6, 10, 22 );
|
||||||
|
let f: std::sync::Arc<dyn Fn( i32 ) -> String> = std::sync::Arc::new( |m| format!( "msg-{m}" ) );
|
||||||
|
let mapped = rt.map_msg( &f );
|
||||||
|
|
||||||
|
assert_eq!( mapped.links.len(), 2 );
|
||||||
|
assert_eq!( ( mapped.links[ 0 ].start, mapped.links[ 0 ].end ), ( 0, 5 ) );
|
||||||
|
assert_eq!( ( mapped.links[ 1 ].start, mapped.links[ 1 ].end ), ( 6, 10 ) );
|
||||||
|
assert_eq!( mapped.links[ 0 ].msg, "msg-11" );
|
||||||
|
assert_eq!( mapped.links[ 1 ].msg, "msg-22" );
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
// 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>
|
||||||
|
|
||||||
|
//! Single- or multi-line text: a font-sized, coloured, aligned string that
|
||||||
|
//! either stays on one line (truncating with an ellipsis on overflow) or
|
||||||
|
//! word-wraps to the layout width.
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use fontdue::Font;
|
use fontdue::Font;
|
||||||
@@ -13,14 +17,20 @@ use super::Element;
|
|||||||
#[ cfg( test ) ]
|
#[ cfg( test ) ]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|
||||||
|
/// Horizontal alignment of text within its layout rect.
|
||||||
#[ derive( Debug, Clone, Copy, PartialEq ) ]
|
#[ derive( Debug, Clone, Copy, PartialEq ) ]
|
||||||
pub enum TextAlign
|
pub enum TextAlign
|
||||||
{
|
{
|
||||||
|
/// Align to the left edge.
|
||||||
Left,
|
Left,
|
||||||
|
/// Centre within the rect.
|
||||||
Center,
|
Center,
|
||||||
|
/// Align to the right edge.
|
||||||
Right,
|
Right,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A run of text rendered with a size, colour, alignment and optional font,
|
||||||
|
/// either word-wrapped or kept on one line with ellipsis truncation.
|
||||||
pub struct Text
|
pub struct Text
|
||||||
{
|
{
|
||||||
pub content: String,
|
pub content: String,
|
||||||
@@ -45,6 +55,8 @@ pub struct Text
|
|||||||
|
|
||||||
impl Text
|
impl Text
|
||||||
{
|
{
|
||||||
|
/// A left-aligned, non-wrapping, ellipsis-truncated white label at the
|
||||||
|
/// default 16 px size with the canvas default font.
|
||||||
pub fn new( content: impl Into<String> ) -> Self
|
pub fn new( content: impl Into<String> ) -> Self
|
||||||
{
|
{
|
||||||
Self
|
Self
|
||||||
@@ -68,6 +80,8 @@ impl Text
|
|||||||
self.size.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT )
|
self.size.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT )
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Paint the full string even when it overflows, instead of truncating
|
||||||
|
/// with an ellipsis.
|
||||||
pub fn no_truncate( mut self ) -> Self
|
pub fn no_truncate( mut self ) -> Self
|
||||||
{
|
{
|
||||||
self.truncate = false;
|
self.truncate = false;
|
||||||
@@ -93,24 +107,28 @@ impl Text
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the font size.
|
||||||
pub fn size( mut self, s: impl Into<Length> ) -> Self
|
pub fn size( mut self, s: impl Into<Length> ) -> Self
|
||||||
{
|
{
|
||||||
self.size = s.into();
|
self.size = s.into();
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the text colour.
|
||||||
pub fn color( mut self, c: Color ) -> Self
|
pub fn color( mut self, c: Color ) -> Self
|
||||||
{
|
{
|
||||||
self.color = c;
|
self.color = c;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the horizontal alignment.
|
||||||
pub fn align( mut self, a: TextAlign ) -> Self
|
pub fn align( mut self, a: TextAlign ) -> Self
|
||||||
{
|
{
|
||||||
self.align = a;
|
self.align = a;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shorthand for [`Self::align`] with [`TextAlign::Center`].
|
||||||
pub fn align_center( mut self ) -> Self
|
pub fn align_center( mut self ) -> Self
|
||||||
{
|
{
|
||||||
self.align = TextAlign::Center;
|
self.align = TextAlign::Center;
|
||||||
|
|||||||
Reference in New Issue
Block a user