Bump to 0.2.0: SW/GLES paint parity, shared font resolution, FrameState refactor, docs and packaging fixes
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled

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:
2026-06-25 12:43:40 +02:00
parent fb3552e9f7
commit d4d7ee742e
56 changed files with 936 additions and 549 deletions

34
CHANGELOG.md Normal file
View 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

View File

@@ -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"

View File

@@ -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
View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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,18 +110,7 @@ pub( crate ) fn draw_surface_full_gpu<Msg: Clone>(
canvas.present(); canvas.present();
ss.prev_focused = ss.focused_idx; commit_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, ctx );
ss.prev_hovered = ss.hovered_idx;
ss.prev_pressed = ss.gesture.pressed_idx;
ss.widget_rects = ctx.widget_rects;
ss.scroll_rects = ctx.scroll_rects;
ss.scroll_canvases = std::mem::take( &mut ctx.scroll_canvases );
ss.cursor_state = ctx.cursor_state;
ss.selection_anchor = ctx.selection_anchor;
ss.scroll_offsets = ctx.scroll_offsets;
ss.accessible_extras = ctx.accessible_extras;
ss.scroll_navigable_items = ctx.scroll_navigable_items;
ss.content_dirty = false; ss.content_dirty = false;
let wl_surface = ss.surface.wl_surface(); let wl_surface = ss.surface.wl_surface();
@@ -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,18 +183,7 @@ pub( crate ) fn draw_surface_partial_gpu<Msg: Clone>(
canvas.clear_clip(); canvas.clear_clip();
canvas.present(); canvas.present();
ss.prev_focused = ss.focused_idx; commit_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, ctx );
ss.prev_hovered = ss.hovered_idx;
ss.prev_pressed = ss.gesture.pressed_idx;
ss.widget_rects = ctx.widget_rects;
ss.scroll_rects = ctx.scroll_rects;
ss.scroll_canvases = std::mem::take( &mut ctx.scroll_canvases );
ss.cursor_state = ctx.cursor_state;
ss.selection_anchor = ctx.selection_anchor;
ss.scroll_offsets = ctx.scroll_offsets;
ss.accessible_extras = ctx.accessible_extras;
ss.scroll_navigable_items = ctx.scroll_navigable_items;
ss.content_dirty = false; ss.content_dirty = false;
let wl_surface = ss.surface.wl_surface(); let wl_surface = ss.surface.wl_surface();

View File

@@ -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

View File

@@ -29,7 +29,7 @@ use crate::render::Canvas;
use crate::types::{ Color, Rect }; use crate::types::{ Color, Rect };
use crate::widget::Element; use crate::widget::Element;
use super::{ DrawCtx, compute_damage, layout_and_draw }; use super::{ build_draw_ctx, commit_draw_ctx, compute_damage, layout_and_draw };
use super::chrome::{ apply_input_region, draw_fallback_banner, draw_titlebar }; use super::chrome::{ apply_input_region, draw_fallback_banner, draw_titlebar };
/// Full redraw path: clear the canvas, run layout+draw for every widget, then /// Full redraw path: clear the canvas, run layout+draw for every widget, then
@@ -85,23 +85,7 @@ pub( crate ) fn draw_surface_full<Msg: Clone>(
ss.titlebar_close_rect = draw_titlebar( canvas, &ss.titlebar_title, pw, tb_h, sf ); ss.titlebar_close_rect = draw_titlebar( canvas, &ss.titlebar_title, pw, tb_h, sf );
let mut ctx: DrawCtx<Msg> = DrawCtx let mut ctx = build_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, debug_layout );
{
focused_idx: ss.focused_idx,
hovered_idx: ss.hovered_idx,
pressed_idx: ss.gesture.pressed_idx,
cursor_state: std::mem::take( &mut ss.cursor_state ),
selection_anchor: std::mem::take( &mut ss.selection_anchor ),
widget_rects: Vec::new(),
debug_layout,
scroll_offsets: std::mem::take( &mut ss.scroll_offsets ),
scroll_rects: Vec::new(),
scroll_canvases: std::mem::take( &mut ss.scroll_canvases ),
scroll_navigable_items: std::mem::take( &mut ss.scroll_navigable_items ),
previous_widget_rects: ss.widget_rects.clone(),
accessible_extras: Vec::new(),
live_depth: 0,
};
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 ); layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
if ctx.debug_layout if ctx.debug_layout
@@ -126,11 +110,11 @@ pub( crate ) fn draw_surface_full<Msg: Clone>(
Vec::new() Vec::new()
} else { } else {
compute_damage( compute_damage(
&ss.widget_rects, &ss.frame.widget_rects,
&ctx.widget_rects, &ctx.widget_rects,
ss.prev_focused, ss.frame.prev_focused,
ss.prev_hovered, ss.frame.prev_hovered,
ss.prev_pressed, ss.frame.prev_pressed,
ss.focused_idx, ss.focused_idx,
ss.hovered_idx, ss.hovered_idx,
ss.gesture.pressed_idx, ss.gesture.pressed_idx,
@@ -138,18 +122,7 @@ pub( crate ) fn draw_surface_full<Msg: Clone>(
) )
}; };
ss.prev_focused = ss.focused_idx; commit_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, ctx );
ss.prev_hovered = ss.hovered_idx;
ss.prev_pressed = ss.gesture.pressed_idx;
ss.widget_rects = ctx.widget_rects;
ss.scroll_rects = ctx.scroll_rects;
ss.scroll_canvases = std::mem::take( &mut ctx.scroll_canvases );
ss.cursor_state = ctx.cursor_state;
ss.selection_anchor = ctx.selection_anchor;
ss.scroll_offsets = ctx.scroll_offsets;
ss.accessible_extras = ctx.accessible_extras;
ss.scroll_navigable_items = ctx.scroll_navigable_items;
ss.content_dirty = false; ss.content_dirty = false;
canvas.write_to_wayland_buf( canvas_buf, swap_rb ); canvas.write_to_wayland_buf( canvas_buf, swap_rb );
@@ -225,23 +198,7 @@ pub( crate ) fn draw_surface_partial<Msg: Clone>(
ss.titlebar_close_rect = draw_titlebar( canvas, &ss.titlebar_title, pw, tb_h, sf ); ss.titlebar_close_rect = draw_titlebar( canvas, &ss.titlebar_title, pw, tb_h, sf );
let mut ctx: DrawCtx<Msg> = DrawCtx let mut ctx = build_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, false );
{
focused_idx: ss.focused_idx,
hovered_idx: ss.hovered_idx,
pressed_idx: ss.gesture.pressed_idx,
cursor_state: std::mem::take( &mut ss.cursor_state ),
selection_anchor: std::mem::take( &mut ss.selection_anchor ),
widget_rects: Vec::new(),
debug_layout: false,
scroll_offsets: std::mem::take( &mut ss.scroll_offsets ),
scroll_rects: Vec::new(),
scroll_canvases: std::mem::take( &mut ss.scroll_canvases ),
scroll_navigable_items: std::mem::take( &mut ss.scroll_navigable_items ),
previous_widget_rects: ss.widget_rects.clone(),
accessible_extras: Vec::new(),
live_depth: 0,
};
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 ); layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
if let Some( ref menu ) = ss.context_menu if let Some( ref menu ) = ss.context_menu
@@ -258,18 +215,7 @@ pub( crate ) fn draw_surface_partial<Msg: Clone>(
canvas.clear_clip(); canvas.clear_clip();
ss.prev_focused = ss.focused_idx; commit_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, ctx );
ss.prev_hovered = ss.hovered_idx;
ss.prev_pressed = ss.gesture.pressed_idx;
ss.widget_rects = ctx.widget_rects;
ss.scroll_rects = ctx.scroll_rects;
ss.scroll_canvases = std::mem::take( &mut ctx.scroll_canvases );
ss.cursor_state = ctx.cursor_state;
ss.selection_anchor = ctx.selection_anchor;
ss.scroll_offsets = ctx.scroll_offsets;
ss.accessible_extras = ctx.accessible_extras;
ss.scroll_navigable_items = ctx.scroll_navigable_items;
ss.content_dirty = false; ss.content_dirty = false;
canvas.write_to_wayland_buf( canvas_buf, swap_rb ); canvas.write_to_wayland_buf( canvas_buf, swap_rb );

View File

@@ -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 }
} }

View File

@@ -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 ); }
} }

View File

@@ -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 ); }
} }

View File

@@ -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 );

View File

@@ -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 );
} }
} }
} }

View File

@@ -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,
); );

View File

@@ -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;

View File

@@ -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
{ {

View File

@@ -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;

View File

@@ -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(),

View File

@@ -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();
} }
} }

View File

@@ -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, .. } =>

View File

@@ -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
{ {

View File

@@ -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 )

View File

@@ -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;

View File

@@ -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

View File

@@ -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 );

View File

@@ -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()
}

View File

@@ -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;
} }

View File

@@ -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>,

View File

@@ -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

View File

@@ -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 )

View File

@@ -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 ) )

View File

@@ -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
{ {

View File

@@ -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
} }

View File

@@ -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() )
{ {

View File

@@ -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
{ {

View File

@@ -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 }
} ); } );

View File

@@ -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
{ {

View File

@@ -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

View File

@@ -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

View File

@@ -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() );

View File

@@ -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 }
} ); } );

View File

@@ -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:

View File

@@ -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()
}

View File

@@ -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! {

View File

@@ -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" );
}
}

View File

@@ -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.

View File

@@ -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
{ {

View File

@@ -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

View File

@@ -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 ),
} }

View File

@@ -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 ) )

View File

@@ -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 ) ]

View File

@@ -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 )

View 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" );
}

View File

@@ -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;