From d4d7ee742ea9aa05ffd29a794aad1e8777fc5878 Mon Sep 17 00:00:00 2001 From: "Pedro M. de Echanove Pasquin" Date: Thu, 25 Jun 2026 12:43:40 +0200 Subject: [PATCH] =?UTF-8?q?Bump=20to=200.2.0:=20SW/GLES=20paint=20parity,?= =?UTF-8?q?=20shared=20font=20resolution,=20FrameState=20refactor,=20docs?= =?UTF-8?q?=20and=20packaging=20fixes=20Rendering=20parity=20(software=20?= =?UTF-8?q?=E2=86=94=20GLES).=20The=20software=20backend=20now=20rounds=20?= =?UTF-8?q?glyph=20pen=20positions=20and=20image=20destinations=20to=20the?= =?UTF-8?q?=20nearest=20integer=20pixel,=20matching=20what=20the=20GLES=20?= =?UTF-8?q?backend=20already=20did;=20previously=20it=20truncated,=20so=20?= =?UTF-8?q?text=20and=201:1=20images=20could=20land=20up=20to=20half=20a?= =?UTF-8?q?=20pixel=20off=20between=20the=20two=20backends=20and=20the=20b?= =?UTF-8?q?ilinear=20sample=20read=20~1=20px=20softer=20than=20the=20sourc?= =?UTF-8?q?e.=20Gradients=20and=20shadows=20are=20deliberately=20left=20un?= =?UTF-8?q?implemented=20on=20the=20software=20backend,=20and=20the=20GLES?= =?UTF-8?q?=20multi-rect=20`glScissor`=20clip=20is=20left=20coarse=20on=20?= =?UTF-8?q?purpose:=20making=20it=20exact=20would=20need=20stencil=20bits?= =?UTF-8?q?=20the=20EGL=20config=20does=20not=20carry,=20or=20routing=20th?= =?UTF-8?q?e=20partial-redraw=20path=20through=20the=20offscreen=20clip=20?= =?UTF-8?q?layer,=20which=20would=20break=20its=20`fill`=20/=20`clear=5Fre?= =?UTF-8?q?cts=5Ftransparent`=20scissor=20semantics.=20Adds=20software-bac?= =?UTF-8?q?kend=20pixel=20tests=20covering=20the=20snapping.=20Font=20reso?= =?UTF-8?q?lution=20unification.=20The=20system-font=20candidate=20chain,?= =?UTF-8?q?=20`find=5Ffont=5Fopt`=20and=20`load=5Fdefault=5Ffont=5Fbytes`?= =?UTF-8?q?=20lived=20in=20two=20copies=20(`render/helpers`=20and=20`gles?= =?UTF-8?q?=5Frender/helpers`)=20that=20had=20already=20diverged=20?= =?UTF-8?q?=E2=80=94=20one=20resolved=20through=20`find=5Ffont=5Fopt`,=20t?= =?UTF-8?q?he=20other=20inlined=20the=20candidate=20loop=20=E2=80=94=20and?= =?UTF-8?q?=20now=20live=20once=20in=20`system=5Ffonts`.=20The=20two=20per?= =?UTF-8?q?-backend=20`OnceLock`=20default-font=20caches=20and=20`primary?= =?UTF-8?q?=5Fhandle`=20collapse=20into=20a=20single=20`system=5Ffonts::de?= =?UTF-8?q?fault=5Fhandle`.=20Module=20docs=20and=20the=20`font=5Fregistry?= =?UTF-8?q?`=20caller=20are=20updated=20accordingly.=20Shared=20image=20va?= =?UTF-8?q?lidation=20and=20rect=20inflation.=20`draw=5Fimage=5Fdata`'s=20?= =?UTF-8?q?dimension=20check=20and=20its=20one-line=20warning=20were=20byt?= =?UTF-8?q?e-duplicated=20across=20both=20backends=20and=20are=20now=20`re?= =?UTF-8?q?nder::helpers::validate=5Frgba=5Fdims`.=20The=20six=20manual=20?= =?UTF-8?q?symmetric=20`Rect`-inflate=20literals=20in=20the=20GLES=20primi?= =?UTF-8?q?tives=20reuse=20the=20existing=20`Rect::expand`.=20FrameState?= =?UTF-8?q?=20and=20DrawCtx=20de-duplication.=20The=20eleven=20`SurfaceSta?= =?UTF-8?q?te`=20fields=20the=20draw=20pass=20owns=20and=20threads=20throu?= =?UTF-8?q?gh=20`DrawCtx`=20=E2=80=94=20`widget=5Frects`,=20the=20cursor?= =?UTF-8?q?=20/=20selection=20maps,=20the=20scroll=20state,=20`accessible?= =?UTF-8?q?=5Fextras`,=20`prev=5Ffocused`=20/=20`prev=5Fhovered`=20/=20`pr?= =?UTF-8?q?ev=5Fpressed`=20=E2=80=94=20move=20into=20a=20`FrameState`=20su?= =?UTF-8?q?b-struct.=20The=20per-frame=20`build=5Fdraw=5Fctx`=20/=20`commi?= =?UTF-8?q?t=5Fdraw=5Fctx`=20helpers=20can=20then=20borrow=20`&mut=20ss.fr?= =?UTF-8?q?ame`,=20disjoint=20from=20`ss.canvas`=20and=20`ss.pool`,=20so?= =?UTF-8?q?=20the=20four=20frame=20paths=20(software=20/=20GLES=20=C3=97?= =?UTF-8?q?=20full=20/=20partial)=20replace=20their=20duplicated=20`DrawCt?= =?UTF-8?q?x`=20construction=20and=20write-back=20with=20a=20single=20help?= =?UTF-8?q?er=20call=20each.=20A=20whole-`SurfaceState`=20borrow=20could?= =?UTF-8?q?=20not=20express=20this=20(partial=20borrows=20do=20not=20cross?= =?UTF-8?q?=20function=20boundaries),=20which=20is=20why=20the=20helpers?= =?UTF-8?q?=20take=20the=20sub-struct.=20`content=5Fdirty`=20stays=20on=20?= =?UTF-8?q?`SurfaceState`=20=E2=80=94=20it=20is=20an=20invalidation=20flag?= =?UTF-8?q?,=20not=20frame=20state=20=E2=80=94=20and=20is=20reset=20at=20t?= =?UTF-8?q?he=20call=20site.=20rich=5Ftext=20tests.=20Adds=20the=20previou?= =?UTF-8?q?sly-missing=20`tests.rs`=20for=20the=20`RichText`=20widget:=20o?= =?UTF-8?q?ne=20hit=20rect=20per=20visual=20line=20a=20link=20spans=20(the?= =?UTF-8?q?=20widget's=20core=20invariant),=20the=20single-line=20and=20no?= =?UTF-8?q?-link=20cases,=20preferred-size=20growth=20with=20hard=20line?= =?UTF-8?q?=20breaks,=20and=20`map=5Fmsg`=20range=20preservation=20?= =?UTF-8?q?=E2=80=94=20all=20headless=20against=20a=20software=20`Canvas`,?= =?UTF-8?q?=20with=20line=20counts=20forced=20by=20`\n`=20so=20they=20do?= =?UTF-8?q?=20not=20depend=20on=20any=20system=20font's=20measured=20width?= =?UTF-8?q?.=20Documentation.=20Fills=20the=20rustdoc=20gaps=20on=20the=20?= =?UTF-8?q?embedder-facing=20surface:=20`core::UiSurface`=20accessors,=20t?= =?UTF-8?q?he=20`egl=5Fcontext`=20public=20API,=20the=20`GlesCanvas`=20met?= =?UTF-8?q?hods,=20`theme::typography`=20and=20`theme::error`,=20and=20the?= =?UTF-8?q?=20`RichText`=20/=20`Text`=20builders;=20adds=20a=20crate-level?= =?UTF-8?q?=20"Rendering=20backends"=20overview.=20`CHANGELOG.md`=20is=20a?= =?UTF-8?q?dded=20(0.2.0=20/=200.1.0),=20and=20`docs/widgets.md`=20/=20`do?= =?UTF-8?q?cs/cookbook.md`=20gain=20`rich=5Ftext`,=20`external`,=20and=20t?= =?UTF-8?q?he=20CPU-draw=20/=20path-clip=20/=20externally-laid-out-tree=20?= =?UTF-8?q?recipes.=20`debian/changelog`=20gets=20the=200.2.0-1=20entry.?= =?UTF-8?q?=20Private=20intra-doc=20links=20to=20`system=5Ffonts`=20are=20?= =?UTF-8?q?demoted=20to=20code=20spans=20so=20`cargo=20doc`=20is=20warning?= =?UTF-8?q?-free.=20Packaging.=20The=20`libltk-dev`=20registry=20crate=20s?= =?UTF-8?q?hipped=20a=20`Cargo.toml`=20declaring=20the=20`lookup`=20bench?= =?UTF-8?q?=20while=20the=20`Makefile`=20install=20copied=20only=20`src/`,?= =?UTF-8?q?=20so=20Cargo=20refused=20to=20parse=20the=20manifest=20over=20?= =?UTF-8?q?a=20missing=20`benches/lookup.rs`;=20the=20install=20now=20ship?= =?UTF-8?q?s=20`benches/`=20as=20well=20(the=20file=20alone=20satisfies=20?= =?UTF-8?q?the=20parse=20=E2=80=94=20criterion=20is=20a=20dev-dependency?= =?UTF-8?q?=20and=20is=20not=20resolved=20when=20the=20crate=20is=20consum?= =?UTF-8?q?ed=20as=20a=20library).=20`Cargo.toml`=20is=20bumped=20to=200.2?= =?UTF-8?q?.0=20to=20match=20the=20package=20version=20and=20the=20`ltk-0.?= =?UTF-8?q?2.0`=20registry=20directory.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 34 +++++++ Cargo.toml | 4 +- Makefile | 2 +- debian/changelog | 6 ++ docs/cookbook.md | 96 ++++++++++++++++++ docs/widgets.md | 66 +++++++++++- src/core.rs | 3 + src/draw/gles.rs | 68 ++----------- src/draw/mod.rs | 59 +++++++++++ src/draw/software.rs | 76 ++------------ src/egl_context.rs | 25 +++++ src/event_loop/clipboard.rs | 2 +- src/event_loop/context_menu.rs | 8 +- src/event_loop/cursor_shape.rs | 2 +- src/event_loop/focus.rs | 16 +-- src/event_loop/frame.rs | 6 +- src/event_loop/mod.rs | 2 +- src/event_loop/overlays_reconcile.rs | 2 +- src/event_loop/run.rs | 30 +++--- src/event_loop/surface.rs | 99 +++++++++++------- src/event_loop/text_editing/cursor.rs | 50 ++++----- src/event_loop/text_editing/ime.rs | 2 +- src/event_loop/text_editing/insert_delete.rs | 52 +++++----- src/event_loop/text_editing/selection.rs | 46 ++++----- src/event_loop/tooltip.rs | 2 +- src/gles_render/clip.rs | 8 ++ src/gles_render/framebuffer.rs | 3 + src/gles_render/helpers.rs | 45 +-------- src/gles_render/image.rs | 7 +- src/gles_render/mod.rs | 6 +- src/gles_render/primitives.rs | 66 ++++-------- src/gles_render/setup.rs | 49 ++++----- src/gles_render/text.rs | 13 +++ src/input/dispatch/password_toggle.rs | 2 +- src/input/keyboard/nav.rs | 8 +- src/input/keyboard/shortcuts.rs | 12 +-- src/input/keyboard/text_keys.rs | 14 +-- src/input/pointer/motion.rs | 10 +- src/input/pointer/press.rs | 12 +-- src/input/pointer/release.rs | 2 +- src/input/pointer/scroll.rs | 4 +- src/input/repeat/button.rs | 2 +- src/input/touch/mod.rs | 12 +-- src/lib.rs | 4 + src/render/helpers.rs | 75 ++++---------- src/render/image.rs | 18 ++-- src/render/mod.rs | 69 ++++++++++++- src/render/setup.rs | 33 +----- src/render/text.rs | 16 +-- src/system_fonts.rs | 70 +++++++++++-- src/theme/error.rs | 8 ++ src/theme/font_registry.rs | 2 +- src/theme/typography.rs | 23 +++++ src/widget/rich_text/mod.rs | 15 +++ src/widget/rich_text/tests.rs | 101 +++++++++++++++++++ src/widget/text/mod.rs | 18 ++++ 56 files changed, 936 insertions(+), 549 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 src/widget/rich_text/tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1553d1c --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/Cargo.toml b/Cargo.toml index fddc7c2..00173c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,10 @@ [package] name = "ltk" -version = "0.1.0" +version = "0.2.0" edition = "2021" 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" 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" diff --git a/Makefile b/Makefile index adcace4..88c9c1f 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,7 @@ doc: install: doc 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 install -d $(DOCDIR) cp -r target/doc/* $(DOCDIR)/ diff --git a/debian/changelog b/debian/changelog index e9a533a..645d380 100644 --- a/debian/changelog +++ b/debian/changelog @@ -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 Thu, 25 Jun 2026 09:49:35 +0200 + ltk (0.1.0-1) unstable; urgency=low * Initial Debian packaging. diff --git a/docs/cookbook.md b/docs/cookbook.md index 49583af..075b26f 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -23,6 +23,8 @@ reference, see [`docs/widgets.md`](./widgets.md). For theme JSON, see - [Tab navigation between widgets](#tab-navigation-between-widgets) - [Multi-screen app via sub-state pattern](#multi-screen-app-via-sub-state-pattern) - [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 the full set of supported operations, [`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 { +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 } +# fn external_layout_pass() -> Vec { Vec::new() } +# fn _ex() -> Element { +// 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. diff --git a/docs/widgets.md b/docs/widgets.md index 96cb452..d020b4a 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -18,9 +18,9 @@ patterns built from these widgets, see [`docs/cookbook.md`](./cookbook.md). - [Continuous controls](#continuous-controls) - [`slider`](#slider) · [`vslider`](#vslider) · [`progress_bar`](#progress_bar) - [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) - - [`container`](#container) · [`separator`](#separator) · [`img_widget`](#img_widget) + - [`container`](#container) · [`separator`](#separator) · [`img_widget`](#img_widget) · [`external`](#external) - [Clipping wrappers](#clipping-wrappers) - [`scroll`](#scroll) · [`viewport`](#viewport) · [`flex`](#flex) · [`carousel`](#carousel) - [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 [`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 { +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 @@ -436,6 +465,39 @@ explicit display dimensions. **See also**: [`Image::from_path`](../src/widget/image.rs) helper for 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 { +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 diff --git a/src/core.rs b/src/core.rs index 6b02c81..7bdfdf9 100644 --- a/src/core.rs +++ b/src/core.rs @@ -319,8 +319,11 @@ impl UiSurface self.pressed_idx = idx; } + /// Flat index of the keyboard-focused widget, or `None` when nothing holds focus. pub fn focused( &self ) -> Option { self.focused_idx } + /// Flat index of the hovered widget, or `None` when the pointer is over no interactive widget. pub fn hovered( &self ) -> Option { self.hovered_idx } + /// Flat index of the pressed widget, or `None` when no widget is held down. pub fn pressed( &self ) -> Option { self.pressed_idx } /// Render `element` into the backing canvas. diff --git a/src/draw/gles.rs b/src/draw/gles.rs index a5bb6dc..1e8aaf4 100644 --- a/src/draw/gles.rs +++ b/src/draw/gles.rs @@ -30,7 +30,7 @@ use crate::render::Canvas; use crate::types::{ Color, Rect }; 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 }; /// GPU full-redraw path. Mirrors [`super::software::draw_surface_full`] @@ -88,23 +88,7 @@ pub( crate ) fn draw_surface_full_gpu( ss.titlebar_close_rect = draw_titlebar( canvas, &ss.titlebar_title, pw, tb_h, sf ); - let mut ctx: DrawCtx = DrawCtx - { - 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, - }; + let mut ctx = build_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, debug_layout ); layout_and_draw::( view, canvas, screen_rect, &mut ctx, 0 ); if ctx.debug_layout @@ -126,19 +110,8 @@ pub( crate ) fn draw_surface_full_gpu( canvas.present(); - ss.prev_focused = ss.focused_idx; - 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; + commit_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, ctx ); + ss.content_dirty = false; let wl_surface = ss.surface.wl_surface(); apply_input_region( wl_surface, compositor, input_region, scale ); @@ -195,23 +168,7 @@ pub( crate ) fn draw_surface_partial_gpu( ss.titlebar_close_rect = draw_titlebar( canvas, &ss.titlebar_title, pw, tb_h, sf ); - let mut ctx: DrawCtx = DrawCtx - { - 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, - }; + let mut ctx = build_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, false ); layout_and_draw::( view, canvas, screen_rect, &mut ctx, 0 ); if let Some( ref menu ) = ss.context_menu @@ -226,19 +183,8 @@ pub( crate ) fn draw_surface_partial_gpu( canvas.clear_clip(); canvas.present(); - ss.prev_focused = ss.focused_idx; - 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; + commit_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, ctx ); + ss.content_dirty = false; let wl_surface = ss.surface.wl_surface(); apply_input_region( wl_surface, compositor, input_region, scale ); diff --git a/src/draw/mod.rs b/src/draw/mod.rs index 8c2c627..a9e2b92 100644 --- a/src/draw/mod.rs +++ b/src/draw/mod.rs @@ -90,6 +90,65 @@ pub( crate ) struct DrawCtx 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( + frame: &mut crate::event_loop::FrameState, + focused_idx: Option, + hovered_idx: Option, + pressed_idx: Option, + debug_layout: bool, +) -> DrawCtx +{ + 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( + frame: &mut crate::event_loop::FrameState, + focused_idx: Option, + hovered_idx: Option, + pressed_idx: Option, + mut ctx: DrawCtx, +) +{ + 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 /// finished surface content. Called from the software and GLES draw /// paths right before `present()` so the menu sits above everything diff --git a/src/draw/software.rs b/src/draw/software.rs index 893b7b8..0a45039 100644 --- a/src/draw/software.rs +++ b/src/draw/software.rs @@ -29,7 +29,7 @@ use crate::render::Canvas; use crate::types::{ Color, Rect }; 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 }; /// Full redraw path: clear the canvas, run layout+draw for every widget, then @@ -85,23 +85,7 @@ pub( crate ) fn draw_surface_full( ss.titlebar_close_rect = draw_titlebar( canvas, &ss.titlebar_title, pw, tb_h, sf ); - let mut ctx: DrawCtx = DrawCtx - { - 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, - }; + let mut ctx = build_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, debug_layout ); layout_and_draw::( view, canvas, screen_rect, &mut ctx, 0 ); if ctx.debug_layout @@ -126,11 +110,11 @@ pub( crate ) fn draw_surface_full( Vec::new() } else { compute_damage( - &ss.widget_rects, + &ss.frame.widget_rects, &ctx.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, @@ -138,19 +122,8 @@ pub( crate ) fn draw_surface_full( ) }; - ss.prev_focused = ss.focused_idx; - 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; + commit_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, ctx ); + ss.content_dirty = false; canvas.write_to_wayland_buf( canvas_buf, swap_rb ); @@ -225,23 +198,7 @@ pub( crate ) fn draw_surface_partial( ss.titlebar_close_rect = draw_titlebar( canvas, &ss.titlebar_title, pw, tb_h, sf ); - let mut ctx: DrawCtx = DrawCtx - { - 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, - }; + let mut ctx = build_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, false ); layout_and_draw::( view, canvas, screen_rect, &mut ctx, 0 ); if let Some( ref menu ) = ss.context_menu @@ -258,19 +215,8 @@ pub( crate ) fn draw_surface_partial( canvas.clear_clip(); - ss.prev_focused = ss.focused_idx; - 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; + commit_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, ctx ); + ss.content_dirty = false; canvas.write_to_wayland_buf( canvas_buf, swap_rb ); diff --git a/src/egl_context.rs b/src/egl_context.rs index 2495546..b74506c 100644 --- a/src/egl_context.rs +++ b/src/egl_context.rs @@ -62,10 +62,17 @@ type SwapBuffersWithDamageFn = unsafe extern "system" fn( /// raw EGL handles are POD. pub struct EglContext { + /// Refcounted handle to the loaded `libEGL` instance. Shared with every + /// [`EglSurface`] this context creates. pub egl: Arc, + /// The initialised `EGLDisplay` derived from the Wayland connection. 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, + /// The `EGLContext` made current by [`Self::make_current`]. pub context: egl::Context, + /// The GLES major version actually obtained (ES3 preferred, ES2 fallback). pub version: GlesVersion, // Initialised lazily on the first `make_current`. Glow's constructor // eagerly calls `glGetString( GL_VERSION )`, which requires a current @@ -84,7 +91,11 @@ pub struct EglContext /// requiring a `&EglContext` at the call site. 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, + /// The `EGLSurface` bound to the Wayland surface, current target for GL + /// rendering once [`EglContext::make_current`] selects it. pub surface: egl::Surface, egl: Arc, display: egl::Display, @@ -303,6 +314,11 @@ impl EglContext 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> { self.egl.swap_buffers( self.display, surface.surface ) @@ -360,6 +376,11 @@ impl EglContext 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 ) { self.egl_window.resize( width.max( 1 ), height.max( 1 ), 0, 0 ); @@ -471,8 +492,12 @@ impl EglOffscreenContext ).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 { &self.gl } + /// The GLES major version obtained (ES3 preferred, ES2 fallback). pub fn version( &self ) -> GlesVersion { self.version } + /// The `EGLConfig` the context and pbuffer were created against. pub fn config( &self ) -> egl::Config { self.config } } diff --git a/src/event_loop/clipboard.rs b/src/event_loop/clipboard.rs index 95eeb5d..09b6e64 100644 --- a/src/event_loop/clipboard.rs +++ b/src/event_loop/clipboard.rs @@ -31,7 +31,7 @@ impl AppData } 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 ) ); if let Some( m ) = msg { self.pending_msgs.push( m ); } } diff --git a/src/event_loop/context_menu.rs b/src/event_loop/context_menu.rs index 72f7969..5e8ec9c 100644 --- a/src/event_loop/context_menu.rs +++ b/src/event_loop/context_menu.rs @@ -88,7 +88,7 @@ impl AppData let paste_offset = self.text_input_geometry( focus, widget_idx ) .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()?; Some( crate::widget::text_edit::byte_offset_at( canvas, rect, pos, &value, multiline, secure, cursor_pos, align, font_size, @@ -163,8 +163,8 @@ impl AppData if let Some( ofs ) = menu.paste_offset { let ss = self.surface_mut( focus ); - ss.cursor_state.insert( menu.widget_idx, ofs ); - ss.selection_anchor.insert( menu.widget_idx, ofs ); + ss.frame.cursor_state.insert( menu.widget_idx, ofs ); + ss.frame.selection_anchor.insert( menu.widget_idx, ofs ); } self.handle_paste( focus ); } @@ -176,7 +176,7 @@ impl AppData if !menu.has_selection { return true; } 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 ) ); if let Some( m ) = msg { self.pending_msgs.push( m ); } } diff --git a/src/event_loop/cursor_shape.rs b/src/event_loop/cursor_shape.rs index 5d663df..a876ef5 100644 --- a/src/event_loop/cursor_shape.rs +++ b/src/event_loop/cursor_shape.rs @@ -115,7 +115,7 @@ impl AppData let app_override = self.app.cursor_override(); let dragging = self.surface( focus ).gesture.dragging_slider.is_some(); 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 ) .unwrap_or( CursorShape::Default ); let resize_cursor = self.resize_edge_under_pointer( focus ).map( resize_edge_to_cursor ); diff --git a/src/event_loop/focus.rs b/src/event_loop/focus.rs index 90de517..adc4ceb 100644 --- a/src/event_loop/focus.rs +++ b/src/event_loop/focus.rs @@ -35,7 +35,7 @@ impl AppData // dismiss that races other intents (e.g. forge's topbar // taps that route a separate IPC into the same overlay). 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 ) ) .map( | w | w.rect ); let on_anchor = anchor_rect @@ -89,15 +89,15 @@ impl AppData let ss = self.surface_mut( focus ); 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() ) .unwrap_or( false ); 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, .. } ) ) .unwrap_or( false ); 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() ) .unwrap_or( false ); @@ -113,7 +113,7 @@ impl AppData ss.focused_idx = idx; ss.focused_id = idx.and_then( |i| { - ss.widget_rects.iter() + ss.frame.widget_rects.iter() .find( |w| w.flat_idx == i ) .and_then( |w| w.id ) } ); @@ -130,7 +130,7 @@ impl AppData { 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 .and_then( |h| h.current_value() ) .map( |v| v.len() ) @@ -140,8 +140,8 @@ impl AppData Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ) => 0, _ => cursor, }; - ss.cursor_state.insert( i, cursor ); - ss.selection_anchor.insert( i, anchor ); + ss.frame.cursor_state.insert( i, cursor ); + ss.frame.selection_anchor.insert( i, anchor ); } } } diff --git a/src/event_loop/frame.rs b/src/event_loop/frame.rs index eae5612..6a19c06 100644 --- a/src/event_loop/frame.rs +++ b/src/event_loop/frame.rs @@ -124,13 +124,13 @@ fn draw_surface( .unwrap_or( false ); let partial_eligible = !ss.content_dirty && canvas_ready - && !ss.widget_rects.is_empty(); + && !ss.frame.widget_rects.is_empty(); if partial_eligible { let dirty_rects = compute_interaction_dirty_rects( - &ss.widget_rects, - ss.prev_focused, ss.prev_hovered, ss.prev_pressed, + &ss.frame.widget_rects, + ss.frame.prev_focused, ss.frame.prev_hovered, ss.frame.prev_pressed, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, pw, ph, ); diff --git a/src/event_loop/mod.rs b/src/event_loop/mod.rs index a2749d1..dd38e46 100644 --- a/src/event_loop/mod.rs +++ b/src/event_loop/mod.rs @@ -22,7 +22,7 @@ pub( crate ) mod invalidation; pub( crate ) mod overlays_reconcile; 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( crate ) use run::{ run, try_run }; pub use invalidation::diff_overlay_ids; diff --git a/src/event_loop/overlays_reconcile.rs b/src/event_loop/overlays_reconcile.rs index e3e4afd..82982ee 100644 --- a/src/event_loop/overlays_reconcile.rs +++ b/src/event_loop/overlays_reconcile.rs @@ -120,7 +120,7 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) // Snapshot the previous-frame anchor lookup table so we can resolve // `anchor_widget_id` → `Rect` without holding a borrow on `data.main` // 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; for spec in &specs { diff --git a/src/event_loop/run.rs b/src/event_loop/run.rs index 28be50a..7f61a4d 100644 --- a/src/event_loop/run.rs +++ b/src/event_loop/run.rs @@ -638,12 +638,12 @@ pub( crate ) fn try_run( app: A ) -> Result<(), RunError> focus_id: 0, is_main: true, label: None, - widget_rects: &data.main.widget_rects, - extras: &data.main.accessible_extras, + widget_rects: &data.main.frame.widget_rects, + extras: &data.main.frame.accessible_extras, focused_idx: data.main.focused_idx, pressed_idx: data.main.gesture.pressed_idx, pending_text_values: &data.main.pending_text_values, - cursor_state: &data.main.cursor_state, + cursor_state: &data.main.frame.cursor_state, width: main_w, height: main_h, offset_x: 0.0, @@ -657,12 +657,12 @@ pub( crate ) fn try_run( app: A ) -> Result<(), RunError> focus_id: *fid, is_main: false, label: None, - widget_rects: &ss.widget_rects, - extras: &ss.accessible_extras, + widget_rects: &ss.frame.widget_rects, + extras: &ss.frame.accessible_extras, focused_idx: ss.focused_idx, pressed_idx: ss.gesture.pressed_idx, pending_text_values: &ss.pending_text_values, - cursor_state: &ss.cursor_state, + cursor_state: &ss.frame.cursor_state, width: ss.width as f32, height: ss.height as f32, offset_x: *ox, @@ -711,8 +711,8 @@ pub( crate ) fn try_run( app: A ) -> Result<(), RunError> }; let widget = match focus_target { - SurfaceFocus::Main => data.main.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::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.frame.widget_rects.iter().find( |w| w.flat_idx == idx ).cloned() ), }; match req.action { @@ -786,10 +786,10 @@ pub( crate ) fn try_run( app: A ) -> Result<(), RunError> }; 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 entry = ss.scroll_offsets.entry( scroll_idx ).or_insert( ( 0.0, 0.0 ) ); + let scroll_idx = ss.frame.scroll_rects.last().unwrap().1; + let entry = ss.frame.scroll_offsets.entry( scroll_idx ).or_insert( ( 0.0, 0.0 ) ); entry.0 = ( entry.0 + dx ).max( 0.0 ); entry.1 = ( entry.1 + dy ).max( 0.0 ); ss.request_redraw(); @@ -808,12 +808,12 @@ pub( crate ) fn try_run( app: A ) -> Result<(), RunError> { let target = w.rect; 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 ) ) .copied(); 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 + target.height > r.y + r.height { @@ -842,14 +842,14 @@ pub( crate ) fn try_run( app: A ) -> Result<(), RunError> let focus_id = data.app.take_focus_request().or_else( || data.focus_retry.take() ); if let Some( id ) = focus_id { - let mut hit: Option<( SurfaceFocus, usize )> = data.main.widget_rects.iter() + let mut hit: Option<( SurfaceFocus, usize )> = data.main.frame.widget_rects.iter() .find( |w| w.id == Some( id ) ) .map( |w| ( SurfaceFocus::Main, w.flat_idx ) ); if hit.is_none() { 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 ) ); break; diff --git a/src/event_loop/surface.rs b/src/event_loop/surface.rs index b7f64c4..1e05c3b 100644 --- a/src/event_loop/surface.rs +++ b/src/event_loop/surface.rs @@ -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 +{ + pub widget_rects: Vec>, + pub cursor_state: HashMap, + /// 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, + pub accessible_extras: Vec, + /// 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, + pub scroll_rects: Vec<( Rect, usize, crate::widget::scroll::ScrollAxis )>, + pub scroll_canvases: HashMap, + /// 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>, + /// Previous frame interaction state for damage tracking. + pub prev_focused: Option, + pub prev_hovered: Option, + pub prev_pressed: Option, +} + +impl FrameState +{ + 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. /// /// Each Wayland surface managed by the event loop (main surface and any @@ -177,31 +232,11 @@ pub( crate ) struct SurfaceState pub focused_idx: Option, pub focused_id: Option, pub hovered_idx: Option, - pub widget_rects: Vec>, - pub cursor_state: HashMap, - /// 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, pub pending_text_values: HashMap, - /// 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 accessible_extras: Vec, - pub scroll_offsets: HashMap, - pub scroll_rects: Vec<( Rect, usize, crate::widget::scroll::ScrollAxis )>, - pub scroll_canvases: HashMap, - /// 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>, + /// Per-frame draw state: laid-out rects, carried scroll / text-edit / a11y + /// state, and the previous frame's interaction indices. Grouped so the draw + /// helpers can borrow it as one unit, disjoint from `canvas` / `pool`. + pub frame: FrameState, /// Built-in Copy / Cut / Paste context menu when shown on this /// surface. `None` means no menu is up. pub context_menu: Option, @@ -223,10 +258,6 @@ pub( crate ) struct SurfaceState /// `wl_touch.up` does not carry a position) and to keep state /// across `motion` events between dispatchers. pub touch_slots: HashMap, - /// Previous frame interaction state for damage tracking - pub prev_focused: Option, - pub prev_hovered: Option, - pub prev_pressed: Option, /// Height of the client-side title bar (0 for layer-shell surfaces). pub titlebar_height: f32, /// Title text shown in the client-side title bar. @@ -275,22 +306,12 @@ impl SurfaceState focused_idx: None, focused_id: None, hovered_idx: None, - widget_rects: Vec::new(), - cursor_state: HashMap::new(), - selection_anchor: HashMap::new(), pending_text_values: HashMap::new(), - accessible_extras: Vec::new(), - scroll_offsets: HashMap::new(), - scroll_rects: Vec::new(), - scroll_navigable_items: HashMap::new(), + frame: FrameState::new(), primary_touch_id: None, touch_slots: HashMap::new(), context_menu: None, - scroll_canvases: HashMap::new(), gesture: GestureState::new(), - prev_focused: None, - prev_hovered: None, - prev_pressed: None, titlebar_height, titlebar_title, titlebar_close_rect: Rect::default(), diff --git a/src/event_loop/text_editing/cursor.rs b/src/event_loop/text_editing/cursor.rs index e3d2d19..8193ba1 100644 --- a/src/event_loop/text_editing/cursor.rs +++ b/src/event_loop/text_editing/cursor.rs @@ -17,7 +17,7 @@ impl AppData { pending.clone() } else { - crate::tree::find_handlers( &self.surface( focus ).widget_rects, idx )? + crate::tree::find_handlers( &self.surface( focus ).frame.widget_rects, idx )? .current_value() .map( |s| s.to_string() ) .unwrap_or_default() @@ -32,17 +32,17 @@ impl AppData 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 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() ); - 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() ); // Without shift: if a selection exists, collapse to its start. if !extend && anchor != cursor { let s = cursor.min( anchor ); let ss = self.surface_mut( focus ); - *ss.cursor_state.entry( idx ).or_insert( 0 ) = s; - *ss.selection_anchor.entry( idx ).or_insert( 0 ) = s; + *ss.frame.cursor_state.entry( idx ).or_insert( 0 ) = s; + *ss.frame.selection_anchor.entry( idx ).or_insert( 0 ) = s; ss.request_redraw(); return; } @@ -51,8 +51,8 @@ impl AppData let step = prev_char.map( |c| c.len_utf8() ).unwrap_or( 1 ); let new_cursor = cursor.saturating_sub( step ); let ss = self.surface_mut( focus ); - *ss.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor; - if !extend { *ss.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; } + *ss.frame.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor; + if !extend { *ss.frame.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; } ss.request_redraw(); } @@ -60,16 +60,16 @@ impl AppData 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 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() ); - 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() ); if !extend && anchor != cursor { let e = cursor.max( anchor ); let ss = self.surface_mut( focus ); - *ss.cursor_state.entry( idx ).or_insert( 0 ) = e; - *ss.selection_anchor.entry( idx ).or_insert( 0 ) = e; + *ss.frame.cursor_state.entry( idx ).or_insert( 0 ) = e; + *ss.frame.selection_anchor.entry( idx ).or_insert( 0 ) = e; ss.request_redraw(); return; } @@ -78,8 +78,8 @@ impl AppData let step = next_char.map( |c| c.len_utf8() ).unwrap_or( 1 ); let new_cursor = ( cursor + step ).min( value.len() ); let ss = self.surface_mut( focus ); - *ss.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor; - if !extend { *ss.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; } + *ss.frame.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor; + if !extend { *ss.frame.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; } ss.request_redraw(); } @@ -96,7 +96,7 @@ impl AppData 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 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() ); let ( rect, _, multiline, secure, _align, _font_size ) = match self.text_input_geometry( focus, idx ) { @@ -111,8 +111,8 @@ impl AppData None => return false, }; let ss = self.surface_mut( focus ); - *ss.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor; - if !extend { *ss.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; } + *ss.frame.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor; + if !extend { *ss.frame.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; } ss.request_redraw(); true } @@ -122,7 +122,7 @@ impl AppData 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 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() ); let ( rect, _, multiline, secure, _align, _font_size ) = match self.text_input_geometry( focus, idx ) { @@ -137,8 +137,8 @@ impl AppData None => return false, }; let ss = self.surface_mut( focus ); - *ss.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor; - if !extend { *ss.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; } + *ss.frame.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor; + if !extend { *ss.frame.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; } ss.request_redraw(); true } @@ -151,7 +151,7 @@ impl AppData 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 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() ); let new_cursor = match self.text_input_geometry( focus, idx ) { @@ -163,8 +163,8 @@ impl AppData _ => value[..cursor].rfind( '\n' ).map( |p| p + 1 ).unwrap_or( 0 ), }; let ss = self.surface_mut( focus ); - *ss.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor; - if !extend { *ss.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; } + *ss.frame.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor; + if !extend { *ss.frame.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; } ss.request_redraw(); } @@ -174,7 +174,7 @@ impl AppData 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 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() ); let new_cursor = match self.text_input_geometry( focus, idx ) { @@ -186,8 +186,8 @@ impl AppData _ => value[cursor..].find( '\n' ).map( |p| cursor + p ).unwrap_or( value.len() ), }; let ss = self.surface_mut( focus ); - *ss.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor; - if !extend { *ss.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; } + *ss.frame.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor; + if !extend { *ss.frame.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; } ss.request_redraw(); } } diff --git a/src/event_loop/text_editing/ime.rs b/src/event_loop/text_editing/ime.rs index 5639cc1..a26d9d2 100644 --- a/src/event_loop/text_editing/ime.rs +++ b/src/event_loop/text_editing/ime.rs @@ -77,7 +77,7 @@ impl AppData ) -> Option<( Rect, String, bool, bool, crate::widget::text::TextAlign, f32 )> { 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 { WidgetHandlers::TextEdit { value, multiline, secure, align, font_size, .. } => diff --git a/src/event_loop/text_editing/insert_delete.rs b/src/event_loop/text_editing/insert_delete.rs index 96e539f..2cda99d 100644 --- a/src/event_loop/text_editing/insert_delete.rs +++ b/src/event_loop/text_editing/insert_delete.rs @@ -22,7 +22,7 @@ impl AppData { pending.clone() } else { - find_handlers( &self.surface( focus ).widget_rects, idx ) + find_handlers( &self.surface( focus ).frame.widget_rects, idx ) .and_then( |h| h.current_value() ) .map( |s| s.to_string() ) .unwrap_or_default() @@ -41,26 +41,26 @@ impl AppData // a second digit would insert into the middle of the // normalised string ("0|2" + "3" → "032" → 32 instead of 23). 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, .. } ), ); let new_value; { 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 mut v = current_value.clone(); v.insert_str( safe_cursor, text ); 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.selection_anchor.entry( idx ).or_insert( 0 ) = next_cursor; + *ss.frame.cursor_state.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.request_redraw(); 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 ) ); if let Some( m ) = msg { @@ -78,7 +78,7 @@ impl AppData 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 ) ); if let Some( m ) = msg { self.pending_msgs.push( m ); } return; @@ -88,13 +88,13 @@ impl AppData { pending.clone() } else { - find_handlers( &self.surface( focus ).widget_rects, idx ) + find_handlers( &self.surface( focus ).frame.widget_rects, idx ) .and_then( |h| h.current_value() ) .map( |s| s.to_string() ) .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() ); let safe_cursor_pre = cursor_val.min( current_value.len() ); if safe_cursor_pre >= current_value.len() { return; } @@ -108,7 +108,7 @@ impl AppData // post-update displayed value via the `usize::MAX` sentinel — // see `handle_text_insert` for the reasoning. 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, .. } ), ); let next_cursor = if select_on_focus { usize::MAX } else { safe_cursor_pre }; @@ -116,13 +116,13 @@ impl AppData let ss = self.surface_mut( focus ); // Cursor stays put — the char to its right is gone, so the // remaining tail shifts left under it. - *ss.cursor_state.entry( idx ).or_insert( 0 ) = next_cursor; - *ss.selection_anchor.entry( idx ).or_insert( 0 ) = next_cursor; + *ss.frame.cursor_state.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.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 ) ); if let Some( m ) = msg { @@ -148,13 +148,13 @@ impl AppData { pending.clone() } else { - find_handlers( &self.surface( focus ).widget_rects, idx ) + find_handlers( &self.surface( focus ).frame.widget_rects, idx ) .and_then( |h| h.current_value() ) .map( |s| s.to_string() ) .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() ); let safe_cursor = cursor_val.min( current_value.len() ); @@ -190,20 +190,20 @@ impl AppData new_value.replace_range( start_byte..end_byte, "" ); 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, .. } ), ); let next_cursor = if select_on_focus { usize::MAX } else { start_byte }; { let ss = self.surface_mut( focus ); - *ss.cursor_state.entry( idx ).or_insert( 0 ) = next_cursor; - *ss.selection_anchor.entry( idx ).or_insert( 0 ) = next_cursor; + *ss.frame.cursor_state.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.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 ) ); if let Some( m ) = msg { @@ -220,7 +220,7 @@ impl AppData // here so the app sees a single text update. 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 ) ); if let Some( m ) = msg { self.pending_msgs.push( m ); } return; @@ -230,7 +230,7 @@ impl AppData { pending.clone() } else { - find_handlers( &self.surface( focus ).widget_rects, idx ) + find_handlers( &self.surface( focus ).frame.widget_rects, idx ) .and_then( |h| h.current_value() ) .map( |s| s.to_string() ) .unwrap_or_default() @@ -238,7 +238,7 @@ impl AppData // Scoped block to release the immutable borrow of `self` (via // `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() ); if cursor_val == 0 { return; } let safe_cursor = cursor_val.min( current_value.len() ); @@ -253,19 +253,19 @@ impl AppData // post-update displayed value via the `usize::MAX` sentinel — // see `handle_text_insert` for the reasoning. 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, .. } ), ); let next_cursor = if select_on_focus { usize::MAX } else { new_cursor }; { let ss = self.surface_mut( focus ); - *ss.cursor_state.entry( idx ).or_insert( 0 ) = next_cursor; - *ss.selection_anchor.entry( idx ).or_insert( 0 ) = next_cursor; + *ss.frame.cursor_state.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.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 ) ); if let Some( m ) = msg { diff --git a/src/event_loop/text_editing/selection.rs b/src/event_loop/text_editing/selection.rs index bbb3eaf..0d13b44 100644 --- a/src/event_loop/text_editing/selection.rs +++ b/src/event_loop/text_editing/selection.rs @@ -87,7 +87,7 @@ impl AppData // already the natural selection unit, so a double-click does // not need to add a word-bound selection on top. 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, .. } ), ); if select_on_focus { return; } @@ -97,7 +97,7 @@ impl AppData None => 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 ss = self.surface( focus ); let canvas = match ss.canvas.as_ref() { Some( c ) => c, None => return }; @@ -107,8 +107,8 @@ impl AppData }; let ( start, end ) = word_bounds_at( &value, click_byte ); let ss = self.surface_mut( focus ); - ss.selection_anchor.insert( idx, start ); - ss.cursor_state.insert( idx, end ); + ss.frame.selection_anchor.insert( idx, start ); + ss.frame.cursor_state.insert( idx, end ); ss.request_redraw(); } @@ -124,7 +124,7 @@ impl AppData // this guard the click-to-position below would collapse the // selection right after `set_focus` produced it. 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, .. } ), ); if select_on_focus { return; } @@ -133,7 +133,7 @@ impl AppData Some( v ) => v, 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 ss = self.surface( focus ); @@ -143,8 +143,8 @@ impl AppData ) }; let ss = self.surface_mut( focus ); - ss.cursor_state.insert( idx, byte_offset ); - ss.selection_anchor.insert( idx, byte_offset ); + ss.frame.cursor_state.insert( idx, byte_offset ); + ss.frame.selection_anchor.insert( idx, byte_offset ); ss.request_redraw(); } @@ -158,7 +158,7 @@ impl AppData // short-form field stays whole-selected even if the user // drags the pointer over the digits. 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, .. } ), ); if select_on_focus { return; } @@ -167,7 +167,7 @@ impl AppData Some( v ) => v, 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 ss = self.surface( focus ); @@ -177,7 +177,7 @@ impl AppData ) }; let ss = self.surface_mut( focus ); - ss.cursor_state.insert( idx, byte_offset ); + ss.frame.cursor_state.insert( idx, byte_offset ); ss.request_redraw(); } @@ -187,8 +187,8 @@ impl AppData 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 cursor = self.surface( focus ).cursor_state.get( &idx ).copied(); - let anchor = self.surface( focus ).selection_anchor.get( &idx ).copied(); + let cursor = self.surface( focus ).frame.cursor_state.get( &idx ).copied(); + let anchor = self.surface( focus ).frame.selection_anchor.get( &idx ).copied(); let ( c, a ) = match ( cursor, anchor ) { ( Some( c ), Some( a ) ) if c != a => ( c, a ), @@ -196,7 +196,7 @@ impl AppData }; let _ = a; let ss = self.surface_mut( focus ); - ss.selection_anchor.insert( idx, c ); + ss.frame.selection_anchor.insert( idx, c ); ss.request_redraw(); true } @@ -207,8 +207,8 @@ impl AppData { let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return }; let ss = self.surface_mut( focus ); - *ss.cursor_state.entry( idx ).or_insert( 0 ) = value.len(); - *ss.selection_anchor.entry( idx ).or_insert( 0 ) = 0; + *ss.frame.cursor_state.entry( idx ).or_insert( 0 ) = value.len(); + *ss.frame.selection_anchor.entry( idx ).or_insert( 0 ) = 0; ss.request_redraw(); } @@ -218,9 +218,9 @@ impl AppData pub( crate ) fn focused_selection_text( &self, focus: SurfaceFocus ) -> Option { 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() ); - 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() ); if anchor == cursor { return None; } let s = cursor.min( anchor ); @@ -236,9 +236,9 @@ impl AppData pub( crate ) fn delete_selection( &mut self, focus: SurfaceFocus ) -> Option { 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() ); - 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() ); if anchor == cursor { return None; } let s = cursor.min( anchor ); @@ -249,13 +249,13 @@ impl AppData // post-update displayed value via the `usize::MAX` sentinel — // see `handle_text_insert` for the reasoning. 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, .. } ), ); let next_cursor = if select_on_focus { usize::MAX } else { s }; let ss = self.surface_mut( focus ); - *ss.cursor_state.entry( idx ).or_insert( 0 ) = next_cursor; - *ss.selection_anchor.entry( idx ).or_insert( 0 ) = next_cursor; + *ss.frame.cursor_state.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.request_redraw(); Some( new_value ) diff --git a/src/event_loop/tooltip.rs b/src/event_loop/tooltip.rs index fef0a25..2649d33 100644 --- a/src/event_loop/tooltip.rs +++ b/src/event_loop/tooltip.rs @@ -31,7 +31,7 @@ impl AppData pub( crate ) fn arm_tooltip( &mut self, focus: SurfaceFocus, flat_idx: usize ) { 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 { self.tooltip_pending = None; diff --git a/src/gles_render/clip.rs b/src/gles_render/clip.rs index a49beab..8007a21 100644 --- a/src/gles_render/clip.rs +++ b/src/gles_render/clip.rs @@ -19,6 +19,12 @@ use super::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] ) { // 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 } ); } + /// 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 ) { // SAFETY: see `primitives.rs` module doc. `disable( SCISSOR_TEST )` is diff --git a/src/gles_render/framebuffer.rs b/src/gles_render/framebuffer.rs index 6394d45..3af1569 100644 --- a/src/gles_render/framebuffer.rs +++ b/src/gles_render/framebuffer.rs @@ -20,6 +20,9 @@ use super::{ BorrowedGlesTexture, 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 ) { self.blit_fade_bottom( src, dest_x, dest_y, 0.0 ); diff --git a/src/gles_render/helpers.rs b/src/gles_render/helpers.rs index 8adfc6f..43edd63 100644 --- a/src/gles_render/helpers.rs +++ b/src/gles_render/helpers.rs @@ -2,10 +2,10 @@ // Copyright (C) 2026 Liberux Labs, S. L. //! Backend-neutral free helpers for the GLES renderer: MVP matrix -//! construction, shader compilation, FBO / texture allocation, -//! system-font lookup, small typed-handle extractors. Visible only -//! within `crate::gles_render` — callers always go through -//! `GlesCanvas`'s public methods. +//! construction, shader compilation, FBO / texture allocation, small +//! typed-handle extractors. Visible only within `crate::gles_render` — +//! callers always go through `GlesCanvas`'s public methods. System-font +//! resolution lives in `crate::system_fonts`. use glow::HasContext; @@ -218,40 +218,3 @@ pub( super ) fn native_framebuffer_id( framebuffer: glow::Framebuffer ) -> u32 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 -{ - 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() -} diff --git a/src/gles_render/image.rs b/src/gles_render/image.rs index 52b1745..4618cb5 100644 --- a/src/gles_render/image.rs +++ b/src/gles_render/image.rs @@ -68,13 +68,8 @@ impl GlesCanvas /// 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 ) { - let expected = ( img_w as usize ).saturating_mul( img_h as usize ).saturating_mul( 4 ); - if img_w == 0 || img_h == 0 || rgba_data.len() != expected + if !crate::render::helpers::validate_rgba_dims( "GlesCanvas", rgba_data, img_w, img_h ) { - eprintln!( - "[ltk] GlesCanvas::draw_image_data: refusing draw — {}×{} declared, {} bytes provided, expected {}", - img_w, img_h, rgba_data.len(), expected, - ); return; } diff --git a/src/gles_render/mod.rs b/src/gles_render/mod.rs index ed526eb..782272e 100644 --- a/src/gles_render/mod.rs +++ b/src/gles_render/mod.rs @@ -35,8 +35,8 @@ //! * `image` — `GlesCanvas::draw_image_data`. //! * `shaders` — GLSL ES 1.00 shader sources (const strings). //! * `helpers` — free functions: `ortho_rect`, `compile_program`, -//! `alloc_fbo_tex`, `upload_*_texture`, handle extractors, -//! `find_font` + `SYSTEM_FONT_CANDIDATES`. +//! `alloc_fbo_tex`, `upload_*_texture`, handle extractors. +//! System-font resolution lives in `crate::system_fonts`. //! * `raii` — `FboBinding` / `ProgramBinding` scoped guards for the //! handful of operations that change global GL state for a scope //! and must guarantee restoration even on early return / panic @@ -135,7 +135,7 @@ pub struct GlesCanvas { pub gl: Arc, 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 /// theme registry. pub font: Arc, diff --git a/src/gles_render/primitives.rs b/src/gles_render/primitives.rs index cbadcf6..bdd884b 100644 --- a/src/gles_render/primitives.rs +++ b/src/gles_render/primitives.rs @@ -51,11 +51,17 @@ impl GlesCanvas r_x1 <= clip.x || c_x1 <= r_x0 || r_y1 <= clip.y || c_y1 <= r_y0 } + /// Fill an arbitrary vector path (commands in surface coordinates) with a + /// solid colour. CPU fallback: rasterised with tiny-skia into a bbox-sized + /// pixmap and blitted as a transient texture — there is no GPU path shader. pub fn fill_path( &mut self, cmds: &[ PathCmd ], color: Color ) { self.rasterise_path( cmds, color, None ); } + /// Stroke an arbitrary vector path (commands in surface coordinates) with a + /// centered stroke of `width` px. Same tiny-skia-into-texture CPU fallback as + /// [`Self::fill_path`]. pub fn stroke_path( &mut self, cmds: &[ PathCmd ], color: Color, width: f32 ) { self.rasterise_path( cmds, color, Some( width ) ); @@ -114,6 +120,10 @@ impl GlesCanvas 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 ) { 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 // remap `v_uv` from the larger quad back into rect-local space. let pad = 1.0_f32; - let expanded = Rect - { - x: rect.x - pad, - y: rect.y - pad, - width: rect.width + 2.0 * pad, - height: rect.height + 2.0 * pad, - }; + let expanded = rect.expand( pad ); let mvp = ortho_rect( self.width, self.height, expanded ); let alpha = color.a * self.global_alpha; // SAFETY: see module doc. `u_rect_stroke = 0.0` triggers the fill @@ -221,13 +225,7 @@ impl GlesCanvas self.activate_target(); // See fill_rect for the rationale on the 1 px quad pad. let pad = 1.0_f32; - let expanded = Rect - { - x: rect.x - pad, - y: rect.y - pad, - width: rect.width + 2.0 * pad, - height: rect.height + 2.0 * pad, - }; + let expanded = rect.expand( pad ); let mvp = ortho_rect( self.width, self.height, expanded ); // SAFETY: see module doc. `tex` is the cached LUT for `g.stops` // produced by `ensure_lut_texture` above (RGBA8, sampler unit 0); @@ -269,13 +267,7 @@ impl GlesCanvas self.activate_target(); // See fill_rect for the rationale on the 1 px quad pad. let pad = 1.0_f32; - let expanded = Rect - { - x: rect.x - pad, - y: rect.y - pad, - width: rect.width + 2.0 * pad, - height: rect.height + 2.0 * pad, - }; + let expanded = rect.expand( pad ); let mvp = ortho_rect( self.width, self.height, expanded ); // SAFETY: see module doc. Same LUT contract as the linear path // 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 // snapshot only needs to cover the intersection. let pad = 1.0_f32; - let snap_rect = Rect - { - x: target.x - pad, - y: target.y - pad, - width: target.width + 2.0 * pad, - height: target.height + 2.0 * pad, - }; + let snap_rect = target.expand( pad ); self.snapshot_fbo_region_tight( snap_rect ); self.activate_target(); - let expanded = Rect - { - x: target.x - pad, - y: target.y - pad, - width: target.width + 2.0 * pad, - height: target.height + 2.0 * pad, - }; + let expanded = target.expand( pad ); let mvp = ortho_rect( self.width, self.height, expanded ); let aux_tex = self.aux_a.expect( "snapshotted" ).1; // 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 // fill_rect. `u_size` / `u_radii` stay anchored to `target`. let pad = 1.0_f32; - let expanded = Rect - { - x: target.x - pad, - y: target.y - pad, - width: target.width + 2.0 * pad, - height: target.height + 2.0 * pad, - }; + let expanded = target.expand( pad ); let mvp = ortho_rect( self.width, self.height, expanded ); // SAFETY: see module doc. We swap the global blend state for the // 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 // (radius = size/2), turn the result into a rounded square. let pad = half + 1.0; - let expanded = Rect - { - x: rect.x - pad, - y: rect.y - pad, - width: rect.width + 2.0 * pad, - height: rect.height + 2.0 * pad, - }; + let expanded = rect.expand( pad ); let mvp = ortho_rect( self.width, self.height, expanded ); let alpha = color.a * self.global_alpha; // SAFETY: see module doc. `u_rect_stroke = width > 0.0` triggers diff --git a/src/gles_render/setup.rs b/src/gles_render/setup.rs index b1363d8..99b3744 100644 --- a/src/gles_render/setup.rs +++ b/src/gles_render/setup.rs @@ -12,14 +12,14 @@ //! elided (programs, VAO, default font all come from the parent). 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 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:: { BACKDROP_BLUR_H_FRAG_SRC, BACKDROP_COMPOSITE_FRAG_SRC, @@ -35,34 +35,19 @@ use super::shaders:: }; 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 = 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 { + /// 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, 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_bytes = font_handle.bytes.clone(); 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 ) } /// 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 } + /// Set the DPI scale factor applied to font sizes. 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 } + /// Set the global alpha multiplier applied to every draw. 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 } /// 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 ) ) } + /// 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 { 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 { self.font.horizontal_line_metrics( size ) diff --git a/src/gles_render/text.rs b/src/gles_render/text.rs index 99de9b1..2480b8c 100644 --- a/src/gles_render/text.rs +++ b/src/gles_render/text.rs @@ -34,11 +34,19 @@ fn font_id( font: &Arc ) -> usize 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 ) { 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 ) { 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 ); } } + /// 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 { 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 ) -> f32 { self.measure_inner( text, size, Some( font ) ) diff --git a/src/input/dispatch/password_toggle.rs b/src/input/dispatch/password_toggle.rs index 937ec4a..af17588 100644 --- a/src/input/dispatch/password_toggle.rs +++ b/src/input/dispatch/password_toggle.rs @@ -23,7 +23,7 @@ impl AppData pos: Point, ) -> 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 ) .and_then( |w| match &w.handlers { diff --git a/src/input/keyboard/nav.rs b/src/input/keyboard/nav.rs index 0575cf9..0cd7f3a 100644 --- a/src/input/keyboard/nav.rs +++ b/src/input/keyboard/nav.rs @@ -21,10 +21,10 @@ impl AppData // Find the topmost scroll that has a navigable item list. let scroll_meta = { let ss = self.surface( focus ); - ss.scroll_rects.iter().rev() + ss.frame.scroll_rects.iter().rev() .find_map( |( rect, idx, _ax )| { - ss.scroll_navigable_items.get( idx ) + ss.frame.scroll_navigable_items.get( idx ) .filter( |list| !list.is_empty() ) .map( |list| ( *rect, *idx, list.clone() ) ) } ) @@ -50,7 +50,7 @@ impl AppData // list-style); the X offset is preserved as-is. let viewport_h = scroll_rect.height; 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 ) ); let new_y = if content_y < current_y { @@ -67,7 +67,7 @@ impl AppData let ss = self.surface_mut( focus ); 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(); true } diff --git a/src/input/keyboard/shortcuts.rs b/src/input/keyboard/shortcuts.rs index ae84ee4..4a84475 100644 --- a/src/input/keyboard/shortcuts.rs +++ b/src/input/keyboard/shortcuts.rs @@ -14,7 +14,7 @@ impl AppData { let focused = self.surface( focus ).focused_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 ); if is_text { self.handle_select_all( focus ); } } @@ -23,7 +23,7 @@ impl AppData { let focused = self.surface( focus ).focused_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 ); if is_text { self.handle_copy( focus ); } } @@ -32,7 +32,7 @@ impl AppData { let focused = self.surface( focus ).focused_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 ); if is_text { self.handle_cut( focus ); } } @@ -41,7 +41,7 @@ impl AppData { let focused = self.surface( focus ).focused_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 ); if is_text { self.handle_paste( focus ); } } @@ -57,7 +57,7 @@ impl AppData else { 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 { self.set_focus( focus, Some( next_idx ), qh ); @@ -84,7 +84,7 @@ impl AppData } else if self.collapse_selection_if_any( focus ) { // 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() .find_map( |w| w.handlers.escape_msg() ) { diff --git a/src/input/keyboard/text_keys.rs b/src/input/keyboard/text_keys.rs index 05cde38..31a33d5 100644 --- a/src/input/keyboard/text_keys.rs +++ b/src/input/keyboard/text_keys.rs @@ -47,7 +47,7 @@ impl AppData // literal `\n` into the buffer instead of submitting, so // the user can compose paragraphs. 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 ); if is_multi { @@ -57,7 +57,7 @@ impl AppData let mut handled = false; 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() ) ); if let Some( m ) = msg { @@ -79,7 +79,7 @@ impl AppData { let focused = self.surface( focus ).focused_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 ); let reverse = event.keysym == Keysym::Up; let extend = self.shift_pressed; @@ -106,7 +106,7 @@ impl AppData { let focused = self.surface( focus ).focused_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 ); let extend = self.shift_pressed; let intercepted = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed ); @@ -129,7 +129,7 @@ impl AppData { let focused = self.surface( focus ).focused_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 ); if is_text { self.handle_cursor_home( focus, self.shift_pressed ); } } @@ -138,7 +138,7 @@ impl AppData { let focused = self.surface( focus ).focused_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 ); if is_text { self.handle_cursor_end( focus, self.shift_pressed ); } } @@ -148,7 +148,7 @@ impl AppData let focused = self.surface( focus ).focused_idx; 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() ); if let Some( msg ) = press { diff --git a/src/input/pointer/motion.rs b/src/input/pointer/motion.rs index d010d44..e09b7e7 100644 --- a/src/input/pointer/motion.rs +++ b/src/input/pointer/motion.rs @@ -33,12 +33,12 @@ impl AppData // Hover tracking — pointer-only (touch has no hover). // Runs before the gesture motion so the cache-dirty // 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; if new_hover != old_hover { - let redraw = hover_affects_paint( &self.surface( focus ).widget_rects, old_hover ) - || hover_affects_paint( &self.surface( focus ).widget_rects, new_hover ); + let redraw = hover_affects_paint( &self.surface( focus ).frame.widget_rects, old_hover ) + || hover_affects_paint( &self.surface( focus ).frame.widget_rects, new_hover ); { let ss = self.surface_mut( focus ); ss.hovered_idx = new_hover; @@ -115,7 +115,7 @@ impl AppData let outcome = { 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 ); @@ -129,7 +129,7 @@ impl AppData let pressed_text = self.surface( focus ).gesture.pressed_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 ); if is_text { Some( idx ) } else { None } } ); diff --git a/src/input/pointer/press.rs b/src/input/pointer/press.rs index df6f9c7..90d6568 100644 --- a/src/input/pointer/press.rs +++ b/src/input/pointer/press.rs @@ -43,9 +43,9 @@ impl AppData let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 ); self.pointer_pos = pos; 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| - find_handlers( &self.surface( focus ).widget_rects, idx ) + find_handlers( &self.surface( focus ).frame.widget_rects, idx ) .and_then( |h| h.long_press_msg() ) ); if let Some( msg ) = lp_msg { @@ -53,7 +53,7 @@ impl AppData self.surface_mut( focus ).request_redraw(); } else { 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 ); if is_text { @@ -170,7 +170,7 @@ impl AppData let outcome = { 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 // gesture machine's 6 px stray-cancel skips // the drag-start / long-press slots — mouse @@ -199,7 +199,7 @@ impl AppData if let Some( idx ) = outcome.hit_idx { 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, .. } ) ) { handlers.and_then( |h| h.press_msg() ) @@ -222,7 +222,7 @@ impl AppData // under the cursor instead of just positioning. 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 ); if is_text { diff --git a/src/input/pointer/release.rs b/src/input/pointer/release.rs index 8f112bf..64f766d 100644 --- a/src/input/pointer/release.rs +++ b/src/input/pointer/release.rs @@ -33,7 +33,7 @@ impl AppData { let ss = self.surface_mut( focus ); 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 ); // Cancel any held-button repeat — the press is diff --git a/src/input/pointer/scroll.rs b/src/input/pointer/scroll.rs index 0a439d2..d327281 100644 --- a/src/input/pointer/scroll.rs +++ b/src/input/pointer/scroll.rs @@ -33,7 +33,7 @@ impl AppData let scroll_hit = { let ss = self.surface( focus ); - ss.scroll_rects.iter() + ss.frame.scroll_rects.iter() .find( |( r, _, _ )| r.contains( pos ) ) .map( |( _, idx, ax )| ( *idx, *ax ) ) }; @@ -47,7 +47,7 @@ impl AppData let step_x = horizontal.absolute as f32 * multiplier; let step_y = vertical.absolute as f32 * multiplier; 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 // whichever axis the viewport allows. A pure horizontal // viewport translates a vertical wheel into horizontal diff --git a/src/input/repeat/button.rs b/src/input/repeat/button.rs index 6cf78a0..d78bd28 100644 --- a/src/input/repeat/button.rs +++ b/src/input/repeat/button.rs @@ -51,7 +51,7 @@ impl AppData let token = self.loop_handle.insert_source( timer, move |_, _, data: &mut AppData| { let live_msg = crate::tree::find_handlers( - &data.surface( focus ).widget_rects, + &data.surface( focus ).frame.widget_rects, idx, ) .and_then( |h| h.press_msg() ); diff --git a/src/input/touch/mod.rs b/src/input/touch/mod.rs index b4091ca..4e7b6b2 100644 --- a/src/input/touch/mod.rs +++ b/src/input/touch/mod.rs @@ -109,7 +109,7 @@ impl TouchHandler for AppData let outcome = { 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; result }; @@ -122,7 +122,7 @@ impl TouchHandler for AppData if let Some( idx ) = outcome.hit_idx { 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, .. } ) ) { handlers.and_then( |h| h.press_msg() ) @@ -138,7 +138,7 @@ impl TouchHandler for AppData // and double-tap selects the word under the press. 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 ); if is_text { @@ -208,7 +208,7 @@ impl TouchHandler for AppData let ss = self.surface_mut( focus ); ss.needs_redraw = true; 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.stop_button_repeat(); @@ -250,7 +250,7 @@ impl TouchHandler for AppData let outcome = { 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 ); @@ -258,7 +258,7 @@ impl TouchHandler for AppData let pressed_text = self.surface( focus ).gesture.pressed_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 ); if is_text { Some( idx ) } else { None } } ); diff --git a/src/lib.rs b/src/lib.rs index 15004e0..5fe961d 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -69,6 +69,10 @@ //! are concept-oriented landing pages that `cargo doc` exposes for the //! 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 //! //! The interactive and decorative leaves of the [`Element`] tree: diff --git a/src/render/helpers.rs b/src/render/helpers.rs index 587b70f..7faf170 100644 --- a/src/render/helpers.rs +++ b/src/render/helpers.rs @@ -1,8 +1,9 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -//! Backend-neutral helpers for the software renderer: rounded-rect -//! path construction + system-font lookup. +//! Backend-neutral helpers for the software renderer: vector-path and +//! rounded-rect construction. System-font resolution lives in +//! `crate::system_fonts`. use tiny_skia::{ Path, PathBuilder }; @@ -31,6 +32,25 @@ pub ( crate ) fn build_ts_path( cmds: &[ PathCmd ] ) -> Option 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 /// using cubic bezier curves. Each corner is clamped against the /// 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.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 -{ - 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 -{ - if let Some( path ) = find_font_opt() - { - if let Ok( bytes ) = std::fs::read( &path ) - { - return bytes; - } - } - crate::theme::fallback::FALLBACK_FONT.to_vec() -} diff --git a/src/render/image.rs b/src/render/image.rs index 535cb46..9f1d037 100644 --- a/src/render/image.rs +++ b/src/render/image.rs @@ -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 ) { - let expected = ( img_w as usize ).saturating_mul( img_h as usize ).saturating_mul( 4 ); - if img_w == 0 || img_h == 0 || rgba_data.len() != expected + if !super::helpers::validate_rgba_dims( "SoftwareCanvas", rgba_data, img_w, img_h ) { - eprintln!( - "[ltk] SoftwareCanvas::draw_image_data: refusing draw — {}×{} declared, {} bytes provided, expected {}", - img_w, img_h, rgba_data.len(), expected, - ); 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 }; thread_local! { diff --git a/src/render/mod.rs b/src/render/mod.rs index 6f026c1..4aa8408 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -27,8 +27,9 @@ //! * [`text`] — `SoftwareCanvas::{draw_text, measure_text}`. //! * [`image`] — `SoftwareCanvas::{draw_image_data, //! write_to_wayland_buf}`. -//! * [`helpers`] — free functions: `build_rounded_rect`, -//! `find_font`, `find_font_opt`, `SYSTEM_FONT_CANDIDATES`. +//! * [`helpers`] — free functions: `build_ts_path`, +//! `build_rounded_rect`. System-font resolution lives in +//! `crate::system_fonts`. use std::cell::Cell; 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 /// specific family through [`SoftwareCanvas::font_for`] keep /// working. Populated from - /// [`crate::render::helpers::find_font`] at construction time. + /// `crate::system_fonts::default_handle` at construction time. pub font: Arc, /// Raw bytes of the default font. Kept alongside `font` so the /// 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" ); } } + +#[ 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 + { + [ 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 + { + 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" ); + } +} diff --git a/src/render/setup.rs b/src/render/setup.rs index b9400b3..697ae72 100644 --- a/src/render/setup.rs +++ b/src/render/setup.rs @@ -5,43 +5,16 @@ //! / resize plus the font-registry installer and `blit`. 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 crate::system_fonts::FontHandle; +use crate::system_fonts::default_handle; use crate::theme::{ FontRegistry, FontStyle }; -use super::helpers::load_default_font_bytes; use super::SoftwareCanvas; -/// Process-wide cache of the default font face. The handle keeps the -/// raw bytes (`Arc>`) alongside the fontdue `Arc` 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 = 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 { /// Create a canvas of the given pixel dimensions, loading a system font. diff --git a/src/render/text.rs b/src/render/text.rs index 6754aa4..204e30e 100644 --- a/src/render/text.rs +++ b/src/render/text.rs @@ -189,15 +189,19 @@ impl SoftwareCanvas let metrics = &entry.metrics; let bitmap = &entry.bitmap; 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() { if alpha == 0 { continue; } - let px = cursor_x as i32 + metrics.xmin + (i % metrics.width) as i32; - let py = ( y - glyph_y_offset ) as i32 - - metrics.ymin as i32 - - metrics.height as i32 - + 1 - + (i / metrics.width) as i32; + let px = gx + (i % metrics.width) as i32; + let py = gy + (i / metrics.width) as i32; if px < 0 || py < 0 || px >= w || py >= h { continue; } if let Some( ( md, mw ) ) = mask_data { diff --git a/src/system_fonts.rs b/src/system_fonts.rs index 9deb6af..c211b25 100644 --- a/src/system_fonts.rs +++ b/src/system_fonts.rs @@ -1,7 +1,9 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -//! 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) //! covers Latin and a portion of extended Latin; everything outside @@ -147,20 +149,76 @@ pub fn lookup( ch: char ) -> Option> lookup_handle( ch ).map( |h| h.font ) } -/// The process-wide primary UI font — the same default a canvas loads — cached -/// 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 +/// System-font search chain for the primary UI font, ordered by preference. +/// Shared by [`find_font_opt`] and [`load_default_font_bytes`]. +const SYSTEM_FONT_CANDIDATES: &[&str] = +&[ + // Debian `fonts-sora` — the canonical path `ltk-theme-default` + // depends on. Listed first so Sora wins as the default font + // whenever the package is installed. + "/usr/share/fonts/opentype/sora/Sora-Regular.otf", + "/usr/share/fonts/truetype/sora/Sora-Regular.ttf", + "/usr/share/fonts/sora/Sora-Regular.ttf", + "/usr/share/fonts/TTF/Sora-Regular.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + "/usr/share/fonts/liberation/LiberationSans-Regular.ttf", + "/usr/share/fonts/truetype/freefont/FreeSans.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/TTF/DejaVuSans.ttf", +]; + +/// Resolve the first system font available from [`SYSTEM_FONT_CANDIDATES`], or +/// `None` if none exist. Used by the font registry and by tests that skip +/// gracefully on images without the usual fonts installed. +pub ( crate ) fn find_font_opt() -> Option +{ + 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 +{ + 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 = OnceLock::new(); 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" ); FontHandle { font: Arc::new( font ), bytes: Arc::new( bytes ), face: 0 } } ).clone() } +/// The process-wide primary UI font — the same default a canvas loads — for +/// standalone text measurement (e.g. an embedded measure pass with no live +/// canvas). Falls back to the bundled font when no system font is found. +pub fn primary_handle() -> FontHandle +{ + default_handle() +} + /// Bytes-aware variant of [`lookup`]. Returns the full /// [`FontHandle`] (fontdue handle + raw bytes + face index) so /// callers that need to invoke a HarfBuzz-style shaper can do so diff --git a/src/theme/error.rs b/src/theme/error.rs index 4faa40c..199afbe 100644 --- a/src/theme/error.rs +++ b/src/theme/error.rs @@ -7,13 +7,21 @@ use std::fmt; use std::io; use std::path::PathBuf; +/// What went wrong while locating, reading or parsing a theme. #[ derive( Debug ) ] pub enum ThemeError { + /// An I/O error reading the theme file at the given path. Io( PathBuf, io::Error ), + /// The theme file at the given path was not valid JSON. ParseJson( PathBuf, serde_json::Error ), + /// No theme with this id was found in any search path. NotFound( String ), + /// A colour literal was not a recognised form (`#RRGGBB`, `#RRGGBBAA` or + /// `rgb[a](…)`). InvalidColor( String ), + /// A `@name` reference was not defined in the top-level `colors`, + /// `gradients` or `inset_stacks`. UnknownColorRef( String ), } diff --git a/src/theme/font_registry.rs b/src/theme/font_registry.rs index ae78d70..d43bdaa 100644 --- a/src/theme/font_registry.rs +++ b/src/theme/font_registry.rs @@ -262,7 +262,7 @@ mod tests /// without the usual system fonts). fn system_font() -> Option> { - let path = crate::render::helpers::find_font_opt()?; + let path = crate::system_fonts::find_font_opt()?; let bytes = std::fs::read( path ).ok()?; let font = Font::from_bytes( bytes.as_slice(), FontSettings::default() ).ok()?; Some( Arc::new( font ) ) diff --git a/src/theme/typography.rs b/src/theme/typography.rs index f786a27..d6ca7da 100644 --- a/src/theme/typography.rs +++ b/src/theme/typography.rs @@ -18,12 +18,23 @@ 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; +/// See [`H0`]. Px size of the second-largest heading level. pub const H1: f32 = 34.0; +/// See [`H0`]. Px size of the third heading level. pub const H2: f32 = 24.0; +/// See [`H0`]. Px size of the fourth heading level. pub const H3: f32 = 20.0; +/// See [`H0`]. Px size of running body text. pub const BODY: f32 = 16.0; +/// See [`H0`]. Px size of small (secondary) body text. pub const BODY_S: f32 = 14.0; +/// See [`H0`]. Px size of the smallest (caption) text. pub const BODY_XS: f32 = 12.0; /// 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 // clamp kicks in before display titles get absurd. +/// Responsive counterparts of the px constants: each returns a [`Length`] +/// that scales with the surface's smaller dimension (`vmin`) and is clamped +/// to a sensible px range, so the same level reads correctly from a portrait +/// phone to a 4K desktop. Same hierarchy as the constants — `h0` the largest +/// display heading down to `body_xs` the smallest caption. Largest display +/// heading: scales as 5% of `vmin`, clamped to `32..=80` px. pub fn h0() -> Length { Length::vmin( 5.0 ).clamp( 32.0, 80.0 ) } +/// See [`h0`]. Second heading level: 3.4% of `vmin`, clamped to `24..=56` px. pub fn h1() -> Length { Length::vmin( 3.4 ).clamp( 24.0, 56.0 ) } +/// See [`h0`]. Third heading level: 2.4% of `vmin`, clamped to `18..=40` px. pub fn h2() -> Length { Length::vmin( 2.4 ).clamp( 18.0, 40.0 ) } +/// See [`h0`]. Fourth heading level: 2.0% of `vmin`, clamped to `16..=32` px. pub fn h3() -> Length { Length::vmin( 2.0 ).clamp( 16.0, 32.0 ) } +/// See [`h0`]. Running body text: 1.6% of `vmin`, clamped to `14..=22` px. pub fn body() -> Length { Length::vmin( 1.6 ).clamp( 14.0, 22.0 ) } +/// See [`h0`]. Small body text: 1.4% of `vmin`, clamped to `12..=18` px. pub fn body_s() -> Length { Length::vmin( 1.4 ).clamp( 12.0, 18.0 ) } +/// See [`h0`]. Smallest caption text: 1.2% of `vmin`, clamped to `11..=15` px. pub fn body_xs() -> Length { Length::vmin( 1.2 ).clamp( 11.0, 15.0 ) } #[ cfg( test ) ] diff --git a/src/widget/rich_text/mod.rs b/src/widget/rich_text/mod.rs index 447b781..64d3fb4 100644 --- a/src/widget/rich_text/mod.rs +++ b/src/widget/rich_text/mod.rs @@ -15,6 +15,9 @@ use crate::types::{ Color, Length, Rect }; use crate::render::Canvas; use super::{ Element, MapFn }; +#[ cfg( test ) ] +mod tests; + /// A clickable range `[start, end)` (byte offsets into the content) and the /// message to emit when it is tapped. pub struct LinkSpan @@ -24,6 +27,10 @@ pub struct LinkSpan 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 { pub content: String, @@ -36,6 +43,8 @@ pub struct RichText impl RichText { + /// 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 ) -> Self { Self @@ -49,24 +58,29 @@ impl RichText } } + /// Set the font size. pub fn size( mut self, s: impl Into ) -> Self { self.size = s.into(); self } + /// Set the colour of non-link text. pub fn color( mut self, c: Color ) -> Self { self.color = c; self } + /// Set the colour of link ranges (drawn underlined). pub fn link_color( mut self, c: Color ) -> Self { self.link_color = c; 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, weight: u16, style: FontStyle ) -> Self { self.font = Some( ( family.into(), weight, style ) ); @@ -316,6 +330,7 @@ impl From> for Element } } +/// Free-function shorthand for [`RichText::new`]. pub fn rich_text( content: impl Into ) -> RichText { RichText::new( content ) diff --git a/src/widget/rich_text/tests.rs b/src/widget/rich_text/tests.rs new file mode 100644 index 0000000..c5105ae --- /dev/null +++ b/src/widget/rich_text/tests.rs @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! 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::( "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::( "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::( "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::( "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::( "a" ).preferred_size( 1000.0, &c ); + let three = rich_text::( "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::( "" ).preferred_size( 1000.0, &c ); + let one = rich_text::( "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::( "alpha beta" ).link( 0, 5, 11 ).link( 6, 10, 22 ); + let f: std::sync::Arc 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" ); +} diff --git a/src/widget/text/mod.rs b/src/widget/text/mod.rs index c416a89..3e02d12 100644 --- a/src/widget/text/mod.rs +++ b/src/widget/text/mod.rs @@ -1,6 +1,10 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. +//! 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 fontdue::Font; @@ -13,14 +17,20 @@ use super::Element; #[ cfg( test ) ] mod tests; +/// Horizontal alignment of text within its layout rect. #[ derive( Debug, Clone, Copy, PartialEq ) ] pub enum TextAlign { + /// Align to the left edge. Left, + /// Centre within the rect. Center, + /// Align to the right edge. 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 content: String, @@ -45,6 +55,8 @@ pub struct 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 ) -> Self { Self @@ -68,6 +80,8 @@ impl Text 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 { self.truncate = false; @@ -93,24 +107,28 @@ impl Text self } + /// Set the font size. pub fn size( mut self, s: impl Into ) -> Self { self.size = s.into(); self } + /// Set the text colour. pub fn color( mut self, c: Color ) -> Self { self.color = c; self } + /// Set the horizontal alignment. pub fn align( mut self, a: TextAlign ) -> Self { self.align = a; self } + /// Shorthand for [`Self::align`] with [`TextAlign::Center`]. pub fn align_center( mut self ) -> Self { self.align = TextAlign::Center;