Compare commits

..

34 Commits

Author SHA1 Message Date
ce893ac776 responsive fluid/physical scaling, widget-API stabilization, and perf guardrails
Some checks are pending
CI / build + test (push) Waiting to run
CI / cargo audit (push) Waiting to run
Responsive scaling. ltk now offers two first-class ways to size a UI so it adapts across screens, chosen per process via `WidgetScaling { Fluid, Physical }` (`set_widget_scaling` / `widget_scaling`, default `Fluid`). Fluid sizing (`Length::fluid( px )`) makes a design pixel a proportion of the surface's smaller side, calibrated against a reference width (`set_fluid_reference` / `fluid_reference`, 412 px default) and bounded by `FLUID_MIN` / `FLUID_MAX`; physical sizing (`Length::dp( px )`) is a constant-physical-size pixel scaled by display density (`set_density` / `density`). `Length` gains `orient( portrait, landscape )` — resolve one value in portrait, another in landscape — plus `widget( px )`, which picks fluid or dp per the active mode. Canvas exposes `geom_px` (geometry, resolved in physical layout space) and `font_px` (font size, bridging logical / physical per mode) so widgets and apps share one resolution path. Note the rename: `set_design_reference` / `design_reference` became `set_fluid_reference` / `fluid_reference`, and `Length::dp` changed meaning — the old surface-proportional behaviour now lives on `Length::fluid`.
Widgets. Every stock widget resolves its default geometry and font through the widget-scaling mode instead of frozen pixels, so a whole UI scales coherently without per-call units. New size builders where they were missing: `button` gains `font_size` / `height`, `text_edit` gains `height` / `font_size_fluid`, `separator` gains `pad_v`, and assorted widgets accept a `Length` where they previously took only `f32`.
Overlays. `OverlaySpec::size` is now `( Length, Length )` instead of `( u32, u32 )`, resolved against the main surface when the overlay is materialized, so overlays can scale with the display; `Length::px( … )` reproduces the old fixed sizing.
API stabilization (toward 1.0). Widget struct fields are now `pub( crate )` — they are configured through builders, not field access — except the value / state types apps genuinely read or construct (`Time`, `Date`, `ComboState`), which stay public. The internal `test_support` helpers move behind a `test-support` Cargo feature (off by default, so third-party builds never see them; ltk's own `make test` enables it). `Separator` drops its `0.0`-means-mode sentinel for `Option<Length>`, so an explicit `pad_v( 0.0 )` is a real flush divider distinct from the mode-following default.
Performance guardrails. Opt-in diagnostics via `LTK_PERF_WARN=1` warn about stuck animations, sustained software-render animation, and low `poll_interval`; software-rendered animation is capped near 30 Hz to spare CPU on machines that fall back off EGL. Apps can override the cap with `App::cap_software_animation`.
Docs and build. The two scaling modes are documented in README, onboarding and architecture, with the earlier gradient / backdrop doc drift cleaned up. The Makefile now ships the `locales/` directory into the packaged crate (fixing i18n keys rendering raw for downstreams), builds the new `responsive` example, and runs tests with `--features test-support`.
2026-07-07 17:40:33 +02:00
d4d7ee742e Bump to 0.2.0: SW/GLES paint parity, shared font resolution, FrameState refactor, docs and packaging fixes
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
Rendering parity (software ↔ GLES). The software backend now rounds glyph pen positions and image destinations to the nearest integer pixel, matching what the GLES backend already did; previously it truncated, so text and 1:1 images could land up to half a pixel off between the two backends and the bilinear sample read ~1 px softer than the source. Gradients and shadows are deliberately left unimplemented on the software backend, and the GLES multi-rect `glScissor` clip is left coarse on purpose: making it exact would need stencil bits the EGL config does not carry, or routing the partial-redraw path through the offscreen clip layer, which would break its `fill` / `clear_rects_transparent` scissor semantics. Adds software-backend pixel tests covering the snapping.
Font resolution unification. The system-font candidate chain, `find_font_opt` and `load_default_font_bytes` lived in two copies (`render/helpers` and `gles_render/helpers`) that had already diverged — one resolved through `find_font_opt`, the other inlined the candidate loop — and now live once in `system_fonts`. The two per-backend `OnceLock` default-font caches and `primary_handle` collapse into a single `system_fonts::default_handle`. Module docs and the `font_registry` caller are updated accordingly.
Shared image validation and rect inflation. `draw_image_data`'s dimension check and its one-line warning were byte-duplicated across both backends and are now `render::helpers::validate_rgba_dims`. The six manual symmetric `Rect`-inflate literals in the GLES primitives reuse the existing `Rect::expand`.
FrameState and DrawCtx de-duplication. The eleven `SurfaceState` fields the draw pass owns and threads through `DrawCtx` — `widget_rects`, the cursor / selection maps, the scroll state, `accessible_extras`, `prev_focused` / `prev_hovered` / `prev_pressed` — move into a `FrameState` sub-struct. The per-frame `build_draw_ctx` / `commit_draw_ctx` helpers can then borrow `&mut ss.frame`, disjoint from `ss.canvas` and `ss.pool`, so the four frame paths (software / GLES × full / partial) replace their duplicated `DrawCtx` construction and write-back with a single helper call each. A whole-`SurfaceState` borrow could not express this (partial borrows do not cross function boundaries), which is why the helpers take the sub-struct. `content_dirty` stays on `SurfaceState` — it is an invalidation flag, not frame state — and is reset at the call site.
rich_text tests. Adds the previously-missing `tests.rs` for the `RichText` widget: one hit rect per visual line a link spans (the widget's core invariant), the single-line and no-link cases, preferred-size growth with hard line breaks, and `map_msg` range preservation — all headless against a software `Canvas`, with line counts forced by `\n` so they do not depend on any system font's measured width.
Documentation. Fills the rustdoc gaps on the embedder-facing surface: `core::UiSurface` accessors, the `egl_context` public API, the `GlesCanvas` methods, `theme::typography` and `theme::error`, and the `RichText` / `Text` builders; adds a crate-level "Rendering backends" overview. `CHANGELOG.md` is added (0.2.0 / 0.1.0), and `docs/widgets.md` / `docs/cookbook.md` gain `rich_text`, `external`, and the CPU-draw / path-clip / externally-laid-out-tree recipes. `debian/changelog` gets the 0.2.0-1 entry. Private intra-doc links to `system_fonts` are demoted to code spans so `cargo doc` is warning-free.
Packaging. The `libltk-dev` registry crate shipped a `Cargo.toml` declaring the `lookup` bench while the `Makefile` install copied only `src/`, so Cargo refused to parse the manifest over a missing `benches/lookup.rs`; the install now ships `benches/` as well (the file alone satisfies the parse — criterion is a dev-dependency and is not resolved when the crate is consumed as a library). `Cargo.toml` is bumped to 0.2.0 to match the package version and the `ltk-0.2.0` registry directory.
2026-06-25 12:43:40 +02:00
fb3552e9f7 Embedder primitives: placed-child clipping, offscreen RGBA readback, standalone text measurement
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
Three additions an embedder needs to drive ltk as the render backend for a retained, externally-owned widget tree, each kept general rather than tied to one consumer.
Stack placed-child clipping. `Stack::push_placed_clipped(e, rect, clip)` places a child at an exact rect like `push_placed` but clips its subtree's drawing to `clip` (in the Stack's coordinate space) — Android's clipChildren: content that overflows, such as a scrolled list row reaching above the list or an inner card past a rounded bubble, is not painted. The Stack child tuple grows an 8th `Option<Rect>` for the clip, and `layout_and_draw` brackets a clipped child with `set_clip_rects`/restore around the recursion. The clip is shifted by the Stack's own origin to match the placed rect (which `Stack::layout` already offsets), so a Stack laid out at a non-zero origin clips in the right place rather than off by that origin.
Offscreen RGBA readback. `Canvas::read_rgba_pixels(out)` reads any canvas into tightly packed straight-alpha RGBA8, top-left row first. Unlike `read_gles_rgba_pixels` it also serves the software backend, un-premultiplying its pixmap, so an offscreen software canvas (an embedder's scratch bitmap) can be read back into a straight-alpha buffer. `Canvas::is_software()` lets a caller branch on the backend — e.g. to honour a real path clip on software but only a bounding rect on GLES.
Standalone text measurement. `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 produce the same metrics the renderer will later use. It is backed by `system_fonts::primary_handle()`, a process-wide cached handle for the primary UI font (the same default a canvas loads, with the bundled-font fallback), which widens `render::helpers::load_default_font_bytes` to `pub(crate)`.
2026-06-24 22:42:50 +02:00
yamabush1
8809313be1 theme: replace Figma-exported launcher SVGs with renderer-compatible versions
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
The original exports used foreignObject (CSS backdrop-filter) and
feComposite in2="hardAlpha" filter chains — Figma-specific constructs
that resvg ignores, making all nine dots invisible at runtime.

Replace both dark and light variants with plain 3×3 grids of rx=3
rounded rects (white in dark mode, #0A032E in light mode). No filters,
no defs, fully renderable by resvg/tiny-skia.
2026-06-21 11:27:03 +02:00
588a3f7e36 Fix vertically-flipped content in the GLES path-clip composite
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
The clip-layer composite shader sampled the offscreen layer at the wrong vertical position, so path-clipped content (e.g. a circular avatar) came out upside down on the GLES backend.
`v_uv.y` runs bottom-to-top in screen space — the layer FBO has GL's lower-left origin, and `ortho_rect` plus the texture shader's flip establish that `v_uv.y == 1` is the top edge. The composite computed the fragment's screen Y as `bbox.y + v_uv.y * bbox.h`, which is the inverted Y, so it read the layer mirrored about the horizontal axis. Compute it as `bbox.y + (1 - v_uv.y) * bbox.h` instead. The mask sampling was already correct (and a symmetric circle hid the flip in the example; a photo reveals it). The software backend is unaffected — it clips through a coverage mask with no layer round-trip.
2026-06-19 00:04:24 +02:00
f8c45f0e30 Add Canvas::set_clip_path — anti-aliased arbitrary-path clipping on both backends
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
Add `Canvas::set_clip_path(&[PathCmd])`, clipping subsequent draws to an arbitrary vector path with an anti-aliased edge, on both the software and GLES backends. It complements the existing rect clip (`set_clip_rects`) and is what an embedder needs to render a shaped clip — a circular avatar, a rounded card, a `VectorDrawable` mask — rather than a bounding box. Kept general rather than tied to any one consumer.
Software backend: rasterise the path into an anti-aliased tiny-skia coverage `Mask` (Winding fill) and install it as the active clip mask. Every software primitive already threads `clip_mask` through tiny-skia (fills, strokes, lines, paths, images, text, blit), so the path clip applies uniformly with smooth edges. `clip_bounds` reports the path's bounding box while it is active.
GLES backend: a 1-bit stencil would clip exactly but leave a hard, aliased edge, so instead the clipped draws are captured into an offscreen layer and composited back through an anti-aliased coverage mask. `set_clip_path` rasterises the path coverage (tiny-skia, anti-aliased), uploads it as a mask texture, allocates a full-canvas layer FBO on first use, and redirects subsequent draws to it via `activate_target`. Ending the clip (`clear_clip` / `set_clip_rects` / a new `set_clip_path`) composites the layer back onto the canvas FBO with a new two-sampler program (`CLIP_COMPOSITE_FRAG_SRC`) that multiplies the layer colour by the mask coverage and blends it premultiplied-over. The layer attaches to the canvas's own shadow FBO, so it needs no stencil bits in the EGL config; it is freed and reallocated on resize and freed on drop, and shared programs/uniforms are copied to sub-canvases like the rest.
Usage: a path clip is bracketed — `set_clip_path` then, after the clipped draws, `clear_clip` or `set_clip_rects` to flush it (on GLES this is when the layer is composited). Snapshot the prior clip with `clip_bounds` beforehand and restore it with `set_clip_rects` to compose with an outer clip without leaking state.
Add an `examples/clip_path.rs` demo (rounded rect, circle, triangle — same smooth result on both backends) and software-backend unit tests covering the bounding box, the empty-path clear, and a pixel-level check that a triangular clip masks a fill to the path silhouette rather than its bounding box. The GLES layer-composite path needs a live GL context and is exercised by the example.
Also fix three rustdoc intra-doc-link warnings surfaced along the way: a private-item link in `app.rs` (`scroll`) and the new GLES doc (`SoftwareCanvas::set_clip_path`) demoted to code spans, and a redundant explicit link target in `chassis.rs`.
2026-06-18 23:59:38 +02:00
b00cf460bb Mature ltk to host an externally-laid-out Android view tree
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
Add the primitives rustdroid needs to project an Android view hierarchy onto an ltk surface, all kept general rather than Android-specific.
Canvas gains arbitrary vector path fill and stroke: `Canvas::fill_path` / `stroke_path` over a new `PathCmd` command list (MoveTo/LineTo/QuadTo/CubicTo/Close in surface coordinates). The software backend rasterises directly with tiny-skia; the GLES backend rasterises into a tiny-skia pixmap and blits it (CPU fallback, no GPU path shader). This is what renders an Android `Path`, a `VectorDrawable`, or a Lottie frame.
`ExternalSource::Cpu` (and the `External::cpu` constructor) adds an immediate-mode CPU drawing closure, invoked once per frame with the canvas and the widget's laid-out rect, working on both the GLES and software backends. It 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.
`Stack::push_placed` appends a child at an exact rect, bypassing alignment and intrinsic sizing. This lets a view tree whose geometry is computed elsewhere — Android's measure/layout pass, which yields an absolute rect per view — be projected onto a Stack in paint order.
New `RichText` widget: wrapped paragraph text carrying a message per clickable link range, with the layout pass emitting one hit rect per link line so taps land on the link rather than the whole paragraph. It is the ltk side of an Android `Spanned` carrying `URLSpan` / `ClickableSpan`.
gesture: only drive the horizontal pager once the axis locks horizontal
`on_move` emitted horizontal swipe progress whenever `swipe_axis != Vertical`, which includes the pre-lock window where `swipe_axis` is still `None` (the first ~8 px of travel). But `horizontal_drag_started` only flips once `dx.abs() > 8`. A vertical gesture that opened with a few pixels of lateral drift — a swipe-up to the launcher, a scroll — therefore emitted a tiny horizontal progress sample, which armed the consumer's pager, then locked vertical without `dx` ever passing 8, so `horizontal_drag_started` stayed false. On release the horizontal branch was skipped, no `on_swipe_horizontal_progress(0.0)` fired, and the pager stayed stuck active — on the crustace homescreen that froze the surface (the main stays motion-only behind stale page subsurfaces) until an unrelated gesture reset it.
Emit horizontal progress only once `swipe_axis == Some(Horizontal)`. Locking onto the horizontal axis already implies `dx.abs() > 8`, so `horizontal_drag_started` is set in the same step, restoring the invariant that a frame which drives the pager always has a matching release event to settle it. The cost is that the first ~8 px of a horizontal drag no longer move the page, which is the same deadband the axis lock already imposes on the vertical panels. Adds `pre_lock_lateral_drift_does_not_drive_horizontal_pager`.
2026-06-12 22:14:02 +02:00
df8fcbf757 app: client-side xdg-activation token requests
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
Adds an outbound path so an app can obtain an `xdg-activation-v1` token from the compositor and hand it to a child it is about to launch (the `$XDG_ACTIVATION_TOKEN` convention). Until now ltk only honoured inbound activation (self-activating the main surface from an inherited token); requesting a token for another app was out of scope.
Two new `App` trait methods, both defaulting to no-op: `take_activation_requests` returns the tags the app wants tokens for this iteration, and `on_activation_token` delivers the issued token paired with its tag. The run loop drains the requests right after `poll_external` and calls `ActivationState::request_token`, carrying the tag in `RequestData::app_id`; `ActivationHandler::new_token` reads the tag back out and routes the token to the app through `on_activation_token`. When the compositor never advertised the activation global, each request is answered immediately with an empty token so the caller still proceeds and can fall back to its own matching instead of stalling.
2026-06-09 23:53:31 +02:00
cfa0faff26 ltk: subsurface slides over overlays, axis-locked swipes, physical-space layout, touch reset on resume
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
Subsurfaces can now be parented to an overlay surface, not just the main surface. `SubsurfaceSpec` gains `parent: SubsurfaceParent { Main, Overlay(id) }` so a sliding panel can ride above app windows the way an overlay panel does, and an optional `gpu: bool` so content that uses the `surface-panel` backdrop-filter glass (a GLES-only pass) keeps it while sliding instead of dropping to the software rasteriser. `reconcile_subsurfaces` resolves each spec's parent independently — skipping an `Overlay` parent that is absent, unconfigured, or zero-sized — tracks the rastered size on the slot, and commits each touched parent once per frame. The ~14 GLES shader programs are compiled once into a shared `AppData::subsurface_gles_canvas` reused across every subsurface, so a lazily re-created sliding panel never recompiles them (hundreds of ms on a mobile GPU).
Vertical and horizontal swipes are now mutually exclusive: a gesture locks onto its dominant axis within the first 8 px of travel (new `SwipeAxis`) and ignores the perpendicular axis for the rest of the gesture, so a vertical swipe that drifts sideways no longer also drives the pager (and vice-versa). The upward swipe progress is no longer clamped at 1.0 — follow-the-finger panels can keep tracking the finger past the commit threshold — and a release below threshold delivers a final `progress = 0.0` cancellation pulse. A vertical swipe also no longer re-rasters the full-screen main surface on every motion event: only the overlays it drives are refreshed, while the horizontal pager (which does move the main surface) still redraws it. Re-rastering the main on every frame of a vertical drag was wasted work that stalled the loop and made the gesture feel laggy to start on a slow GPU.
Layout-affecting `Length` values (widths, paddings, gaps, widget sizes, including `Vw` / `Vh`) now resolve against the physical viewport via the new `Canvas::viewport_layout()` — the space the layout tree is actually computed in — so a `Vw(100)` fills the surface on a HiDPI (scale 2) output instead of covering half of it. Font sizes are unchanged: they still resolve against the logical viewport and are scaled at raster time. Switched column, row, spacer, wrap_grid, container, image, and vslider over to it; `img_widget` and `vslider` now document `Length::vw` / `vh` sizing and the showcase example demonstrates a viewport-relative image.
Touch gesture state is reset when the touch capability is added or removed — suspend / resume on devices that power the touchscreen down. A yanked capability never delivers the pending `up` / `cancel`, so the shared `reset_touch_state()` (also used by the `wl_touch.cancel` handler) drops the stranded `primary_touch_id` / slot state across the main surface and every overlay, keeping the first post-resume gesture clean. Also drops an accidental duplicate `on_scale_changed` call from the scale-change handler.
2026-06-07 16:45:59 +02:00
yamabush1
68c6a87bf6 Support viewport-relative widget sizing
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
2026-06-04 12:38:59 +02:00
ae8380b1ac event_loop: retry focus requests that land before the view is rebuilt
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
`take_focus_request()` looks up the target widget by `WidgetId` in `widget_rects`. Read-only `TextEdit` widgets are not tracked as interactive and therefore absent from `widget_rects`. A swipe-reveal flips the fields from read-only to interactive on `SlideRevealed`, but an intervening input event (e.g. the touch-up at the end of the swipe) can cause `dispatch()` to return before the vblank frame callback clears `frame_pending`, skipping the draw that would have rebuilt the widget tree. When the focus request fires in that same iteration it finds nothing and is consumed without effect.
`AppData` gains a `focus_retry` field. When `take_focus_request()` (or a previous retry) returns an id but the widget is not in `widget_rects`, the id is stored in `focus_retry`, `view_dirty` is set and a redraw is requested. The next time around the view has been rebuilt with interactive fields and the widget is present.
2026-06-02 14:10:37 +02:00
yamabush1
e40ab637a6 bench: add missing LaidOutWidget accessibility fields to lookup bench
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
benches/lookup.rs constructed LaidOutWidget without the accessible_label
and is_live_region fields added for accessibility, so cargo check
--all-targets failed to compile the bench. Set them to None / false.
2026-06-02 08:43:32 +02:00
d221d5a0cd app: add App::subsurface_motion_only so a slide-to-reveal panel animates without re-rastering the main surface
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
An app whose gesture/animation only moves an input-transparent subsurface (a slide-to-reveal panel over a static main surface) still paid a full main-surface re-raster on every frame of the slide, because the runtime force-dirties the main view on two paths it cannot tell apart from a real content change: `MoveOutcome::Swipe` calls `dirty_caches()` + `request_redraw()` per motion sample, and the `WlCallback` Main handler sets `view_dirty` + `request_redraw()` on every frame callback while `is_animating`. The subsurface reconcile already repositions the panel with a cheap `set_position` + bare parent commit, so the main re-raster is wasted work — and on slow targets (Librem5) it competes with the reposition and makes the slide stutter.
New opt-in `App::subsurface_motion_only` (default `false`, so existing apps are unaffected). When it returns `true`:
- the swipe dispatch (`input/dispatch/outcomes.rs`) still calls the `on_swipe_progress` family but skips `dirty_caches()` / `request_redraw()`; incoming motion events pump the loop and the per-iteration `reconcile_subsurfaces` carries the move.
- the frame-callback animation pump (`event_loop/handlers.rs`) keeps the vsync cadence without a re-raster by requesting a bare `wl.frame()` + `commit()` on the main surface (no buffer attach) instead of dirtying the view; `poll_external` advances the animation and the reconcile repositions each frame.
The main surface still redraws for genuine content changes (messages, resize, the one-shot redraw on swipe release) — only the per-frame slide raster is dropped.
2026-06-01 21:53:40 +02:00
b8a32cfb96 Added pkg-config debian dependency
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
2026-06-01 20:52:14 +02:00
yamabush1
34b3e76ac1 test: make button-paints-content render check theme-robust
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
The render_pixels button test rendered on a black canvas and asserted some
pixel differed from black. With no theme dir configured the active theme is
the embedded B/W fallback, whose Light mode defines accent (the button fill)
and text-primary as pure black on a white page — so a fallback button is
black-on-black and the assertion spuriously failed. Render on white instead:
the black fallback button stands out, and a real theme's saturated accent
also differs from white, keeping the structural check theme-independent.
2026-06-01 11:33:35 +02:00
48c5a89712 touch: treat a compositor re-grab down as a drag continuation
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
When the surface that owns a touch drag is destroyed mid-gesture, the compositor re-opens the touch grab by issuing a fresh `down` on the surface now under the finger (smithay pins touch focus at `down` and won't re-target on motion). That `down` arrives for a slot the main surface already holds as its primary, with the drag state already migrated here. Handling it through the normal `down` path would either restart the gesture or, since the slot is taken, demote it to an auxiliary touch — both abandon the in-flight drag.
`TouchHandler::down` now early-returns when the focused surface's `primary_touch_id` already equals the incoming slot, so the redundant `down` is a no-op and the following motion/up keep driving the migrated drag. Completes the cross-surface drag fix whose other half (migrating `primary_touch_id` on overlay teardown) already landed.
2026-06-01 00:19:49 +02:00
582f9e1a37 Removed extra debug prints
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
2026-05-31 02:14:34 +02:00
042652ec73 windows: centralise input focus on a single FocusTarget, raise+focus on every window interaction
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
Focus was scattered across three stores — `Layouts::focus`, `Layers::focus` and `Windows::activated` — plus a hack that smuggled X11 surfaces through `Layers::focus`, with the keyboard target derived each frame from a `layers.focus.or(layout)` precedence. As a result "focused", "activated" and "has the keyboard" could drift apart: a window could be raised without taking the keyboard, the recurring "click a console window, then click it again before you can actually type" symptom.
New `windows/focus.rs` with `FocusTarget { Layout(Weak<Window>), Layer(WlSurface, app_id), X11(WlSurface) }`. `Windows` now holds one `focus_target: Option<FocusTarget>` as the single source of truth; `activated` survives only as a reconciliation cache for the xdg `Activated` state and is never set by hand. `set_focus` takes one `Option<FocusTarget>` and is the only entry point for changing focus: it brings the target to the front (`z_promote` + desktop stacking), refreshes the MRU and stores the target, and keyboard focus plus activation are applied from it on the next `focus()`. `focus()` resolves a baseline (the active layout's window, which carries `Activated`) and an overlay (a focused layer/X11 surface that takes the keyboard over the baseline without de-activating it), mirroring the old precedence but from one field, so keyboard focus follows focus by construction. `Layers::focus` is removed and X11 no longer rides inside it; the exclusive-keyboard layer path and `reap_layer` drive `focus_target` directly, and every click/touch/activation site collapses to `set_focus(Some(FocusTarget::…))`.
surface_at popup tie-break. When a layout hit is a popup or subsurface, its `wl_surface` is not tracked in `toplevel_z_order`, so the layout-vs-X11 z compare resolved to `None` and the X window underneath wrongly won — clicking a GTK menu (e.g. Firefox's) over an X11 window pressed the X window instead of the menu item. The tie-break now compares the owning toplevel's root z, taken from the hit's `FocusTarget::Layout` weak, so the popup's parent stacking decides.
Raise and focus on every window interaction, matching standard desktop behaviour. Client-driven (un)maximize, the SSD maximize/minimize/close buttons and the maximize keybind now bring the window to the front in both the render z-order and the hit-test stacking through a new `raise_toplevel` helper; previously only the stacking was updated, so an unmaximised window could stay visually behind another. Starting a move or resize — including a press on the resize edge — now routes through `set_focus`, so the window comes to the front and takes the keyboard before the drag instead of being resized or moved while it stays behind.
Input robustness around grabs and drags. A press under an active pointer grab (Wayland popup, drag-and-drop or explicit client grab) is routed to the grab instead of forge's own surface_at handling, so a click cannot leak to a window underneath a grabbing popup and a click outside the popup dismisses it cleanly. Only the press is blocked, never the release, so an in-flight move/resize drag or topbar swipe always reaches its end handler and can never stay glued to the cursor if a grab appears mid-gesture. `on_touch_up` now ends any active move/resize drag before its other early-return paths (armed SSD button, topbar swipe, app switcher) and drops a stale armed button, fixing a race where pressing a second, overlapping window left the drag running forever after release.
fix(input): keep the primary touch slot when migrating a drag across surfaces
A touch drag started on an overlay that hides mid-gesture (e.g. the app launcher when dropping an icon onto the dock or the homescreen) froze: it neither moved nor dropped. Destroying the origin surface migrated the drag state (long_press_fired / long_press_origin) and touch_focus to the main surface, but not its primary_touch_id; subsequent touch motion/up events then fell through the auxiliary path and never reached the gesture machine, leaving on_drag_move and on_drop uncalled.
reconcile_overlays and discard_overlay now adopt the destroyed overlay's primary_touch_id when a drag is in flight, so the rest of the touch sequence keeps driving the main surface. Touch-only; the mouse already migrated correctly via pointer_focus.
ltk: add a chassis module for full-screen ambient surfaces
New `src/chassis.rs`, re-exported from the crate root, gathers the scaffolding that every full-screen ambient surface — greeter, lock screen, kiosk — otherwise repeats by hand over the existing theme and `WallpaperBundle` primitives. `set_default_theme(mode)` finds, installs and activates the `default` theme document, returning the failure message instead of exiting so the caller decides how to abort. `theme_logo_rgba(size)` decodes the active theme's horizontal logo to RGBA, and `theme_icon_tinted(name, size, tint)` loads a symbolic theme icon and tints it. `branding_bundle_or_solid(name)` resolves a theme branding image (`"wallpaper"`, `"lockscreen"`, …) to a `WallpaperBundle`, falling back to a solid fill of the palette background when the theme ships none; `wallpaper_bundle_or_solid()` is the `"wallpaper"` convenience. `backdrop(content, &wallpaper, w, h)` stacks content over the wallpaper resolved for the surface size. No new capability — thin convenience over what the theme module and `WallpaperBundle` already expose — but it removes the per-application `load_theme_logo` / `build_wallpaper_bundle` / theme-bootstrap duplication.
ltk: animatable, input-transparent child surfaces via App::subsurfaces()
New `App::subsurfaces() -> Vec<SubsurfaceSpec<Msg>>` (default empty) describes input-transparent child surfaces composited over the main surface, with `SubsurfaceSpec { id, view, x, y, content_version }` and the stable `SubsurfaceId`. The motivating use is a slide/reveal that tracks a finger without the per-frame full-screen CPU re-raster a single-surface opacity or translate would cost: the content buffer is rasterised once and the compositor moves it.
`SubcompositorState` is bound in `event_loop/run.rs` from the compositor's `wl_compositor` (absent → `App::subsurfaces` silently degrades to none); `delegate_subcompositor!` is added on `AppData`, which gains a `subcompositor` binding and a `subsurfaces` map. New `event_loop/subsurface.rs` reconciles the live subsurfaces against the specs: each spec becomes a `wl_subsurface` sized to the main surface with an empty input region, so all pointer/touch falls through to the parent and the host keeps a single gesture/input model. The content — its own SHM pool and `Canvas`, drawn through the existing `DrawCtx` / `layout_and_draw` path — is rasterised only when the surface size or the spec's `content_version` changes; a position-only change emits `wl_subsurface.set_position` and commits the child surface, then a bare parent commit for placement. Committing the child is deliberate: desync subsurface state (the position) is applied on the child surface's own commit, not the parent's, so without it the move is queued but never lands. Positions are given in layout (physical) pixels and divided by the surface scale for the logical `set_position`.
The reconcile pass runs on every run-loop iteration rather than being gated behind a main-surface redraw, so a finger-driven move repositions at input-event rate, decoupled from the frame-callback cadence that paces full redraws — without this the subsurface only moved when the main surface happened to redraw, so a drag over a static background froze the panel in place.
2026-05-29 23:28:48 +02:00
9ca3b60f3a ltk: responsive padding/spacing and scrolling, expanded theme palette, and bundled Adwaita cursors
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
A mixed pass over the default theme and the layout/input core, plus the toolkit's own cursor set. Grouped by area below.
== Responsive sizing ==
Add `Length::dp( px )` — a "design pixel". It interprets `px` against a configurable reference vmin (default 412 px, the eydos mobile reference width) and returns `Vmin( px / reference * 100 ).clamp( px * 0.7, px * 1.5 )`, so a value authored against a mock-up scales with the surface without collapsing on tiny screens or ballooning on a 4K desktop. The reference is process-global, set via `set_design_reference()` and read via `design_reference()` (stored as f32 bits in an AtomicU32); both are re-exported from `lib.rs`.
Make container and grid insets relative. `Container`'s four padding fields become `Length` instead of `f32`; every setter (`padding`, `padding_h`, `padding_v`, `padding_top`/`right`/`bottom`/`left`) now takes `impl Into<Length>`, so existing `f32` call sites keep compiling via the `From<f32>` shim. The values are resolved against the viewport in `Container::preferred_size` and in the container draw path (`draw/layout.rs`). `WrapGrid`'s `spacing_x`, `spacing_y` and `padding` get the same treatment, with a `resolved( canvas )` helper funnelling the per-frame resolution and `grid()` seeding `Length::px` defaults. Container tests now compare against `Length::px( … )`.
== Scrolling ==
`Scroll::preferred_size` is now axis-aware. A horizontal-only scroll reports its child's natural height rather than claiming all remaining vertical space, so it no longer steals Y from its siblings when it sits inside a `Column`; vertical and both-axis scrolls keep the spacer-like `( max_width, 0.0 )`. `Column`'s space-distribution correspondingly treats a `Scroll` as a vertical space-claimer only when its axis allows Y.
Disambiguate nested scroll viewports by direction. On press the gesture state now collects every scroll viewport under the point (`scroll_candidates`, innermost first) instead of committing to one; on the first 8 px of motion it locks onto the candidate whose axis matches the dominant direction (`scroll_locked`), so a horizontal scroller nested inside a vertical list no longer grabs the wrong axis. The pointer scroll hit test is aligned to the same innermost-first ordering.
== Theme palette ==
`themes/default/theme.json` gains named colours (green / green-deep, yellow, orange / orange-deep, pink / pink-soft, sky-deep, error / error-soft, neutral-tertiary) and new semantic slots in both light and dark modes: `danger`, `text-tertiary`, `accept`, `chip` / `chip-active` / `chip-active-fg`, and `avatar-1` … `avatar-9`.
== Cursors ==
Bundle GNOME's Adwaita cursor theme — the cursors GNOME Shell uses — into `themes/default/cursors/` so a Wayland compositor can draw consistent, complete pointers for ltk applications without the toolkit rasterising cursors itself and without depending on adwaita-icon-theme being installed on the target. The cursors are copied verbatim in XCursor binary format: 35 image files, one per CSS/freedesktop cursor name (default, text, pointer, *-resize, …), plus 27 customary X11 alias symlinks (arrow → default, hand2 → pointer, …); a sibling `cursor.theme` makes the tree a valid XCursor theme. The existing `ltk-theme-default.install` copies `themes/default` recursively, so the directory ships with no packaging change. Applications keep declaring a `CursorShape` per widget over `wp_cursor_shape_v1`; the compositor resolves it against the active theme's `cursors/` directory by name, and the set covers all 34 `CursorShape` variants.
Document the set in `themes/default/cursors/README.md` (what it is, the XCursor layout, the full shape list, how the compositor consumes it, guidance for forks) and `themes/default/cursors/LICENSE.md` (attribution and licence options, modelled on the icons catalogue LICENSE). `lib.rs` lists the cursors in its third-party-assets section.
Close out licensing in `debian/copyright`: a `Files: themes/default/cursors/*` paragraph records the upstream dual offer (CC-BY-SA-3.0 or LGPL-3, and CC-BY-SA-4.0 for the newer assets) attributed to the GNOME Project, with standalone CC-BY-SA-3.0, CC-BY-SA-4.0 and LGPL-3 paragraphs (summary-plus-canonical-URL for the CC licences, matching the existing CC-BY-4.0 entry; LGPL-3 referencing /usr/share/common-licenses/LGPL-3). The files are unmodified from upstream, so there is nothing to declare under the ShareAlike "indicate if changes were made" clause.
Add `tests/cursor_assets.rs`: every `CursorShape` name resolves to a valid XCursor file (Xcur magic, following symlinks), `cursor.theme` is present, no entry is a dangling symlink, and the expected-name list stays in sync with the enum's 34 variants.
2026-05-28 23:11:14 +02:00
3d8523533c text-input: re-sync on enter and flag secure fields as Password
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
LTK drives the on-screen keyboard through zwp_text_input_v3: focusing a text widget enables text-input so the compositor's input-method (squeekboard) brings the keyboard up. Two gaps are fixed here.
Handle the `enter` event by re-emitting enable + content type + commit. The compositor sends `enter` when it (re)focuses the text input — notably when an input-method connects after the field has already enabled text-input (a startup race). LTK previously ignored `enter`, so that activation was lost and the keyboard never appeared; it now re-declares its state and the OSK comes up.
Declare the content type from the field's `secure` flag: secure fields are flagged `ContentPurpose::Password` with `ContentHint::SensitiveData | HiddenText` (so the IME/OSK skips prediction, autocorrect and storing the value), everything else stays `Normal`/`None`. The content type is also refreshed when focus moves between two text fields (e.g. username → password) without re-creating the text-input object, and a new `AppData::text_input_secure` lets the `enter` re-emit path preserve the current field's type.
Correct the docs: `TextEdit::secure()` and SECURITY.md claimed secure "skips text-input-v3 registration". It does not — the field still activates text-input so the OSK works on it; the protection is the Password / sensitive flagging, and the value still reaches a trusted compositor/IME.
2026-05-27 22:31:36 +02:00
1e2cb836f4 ltk: convert physical sizes to logical for overlays and input regions on HiDPI
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
Overlay `set_size` and input regions were handed physical (layout-space) pixels straight to layer-shell and `wl_region`, both of which expect logical coordinates. They only coincide at scale 1, so on a scale-2 output every overlay requested a surface twice its intended size and the input region covered the wrong area.
`apply_input_region` now takes the surface scale and divides each `Rect` down to logical before adding it to the region; the four draw paths (software and gles, full and partial) forward their scale. `reconcile_overlays` converts `OverlaySpec::size` to logical for both `set_size` and the initial `OverlayConfig` (0 = fill survives the divide), and seeds the new surface's `scale_factor` from the parent so the first configure allocates a HiDPI buffer instead of rendering at scale 1 until `scale_factor_changed` lands a frame or two later.
2026-05-26 22:22:18 +02:00
fc045a9c22 ltk: ext-session-lock-v1 client surface mode, plus a read-only mode for text fields
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
Add a third Wayland surface type to the runtime so an ltk `App` can be a screen locker, alongside the existing xdg-shell window and wlr-layer-shell surfaces. A new `ShellMode::SessionLock` makes `run()` bind `ext_session_lock_manager_v1` and request the lock at startup; the lock surface itself is created in the new `SessionLockHandler::locked` callback (one surface on the first advertised output) and replaces the `SurfaceKind::PendingLock` placeholder the main surface holds until the compositor grants the lock. The `configure` event routes through the same `on_configure` path as layer and xdg surfaces, so sizing and rendering are unchanged, and `finished` (the compositor denied or ended the lock) tears the loop down. The whole thing is additive and opt-in: the `Window` and `Layer` paths are untouched and nothing enters lock mode unless an `App` returns `ShellMode::SessionLock`, so existing apps are unaffected — the only non-additive edits are the two exhaustive `match`es on `SurfaceKind` (`wl_surface` / `try_wl_surface`), which gain arms for the two new variants.
Doing the locker as a first-class surface rather than compositing a static texture into an offscreen `UiSurface` is the whole point: the compositor gives the lock surface keyboard focus, so ltk's existing text-input, editing, focus and IME machinery works inside the lock exactly as on any other surface — cursor, click-to-focus, Tab, character input. A locker built on top of this is just a normal interactive ltk app that happens to be presented on the lock layer, with no special input plumbing on the compositor or the app side.
`App::requested_exit()` is the new way an app asks the runtime to tear the surface down and leave the loop; it is polled after every batch of `update`s. It exists because of the one hard invariant of `ext-session-lock-v1`: a locker that disconnects without sending `unlock` leaves the compositor's outputs blanked forever — that is the protocol's deliberate anti-bypass guarantee. So when `requested_exit()` returns true and the surface is a session lock, the loop calls `session_lock.unlock()` and round-trips the connection before setting `exit_requested`, lifting the lock cleanly; for a `Window` or `Layer` surface there is no lock and it simply exits. The consequence for lock apps is that they must stop calling `process::exit` from the lock path and instead flip a flag they return from `requested_exit()`.
`text_edit` gains a `read_only( bool )` builder. A read-only field still renders its box and value in the normal field style but takes no keyboard focus and accepts no input: `Element::is_focusable` and `Element::is_text_input` now return false for a read-only `TextEdit`, which keeps it out of the Tab cycle, off the keyboard-edit path, and stops the cursor from ever being drawn on it. The flag is carried through `map_msg` so it survives `Element::map`. This is for presenting a known, non-editable value in the same visual idiom as the editable fields beside it — for example the already-known user shown on a session lock, where letting that field take focus or blink a cursor would be wrong.
The `shell_mode()` doc comment and the README now list the `SessionLock` surface type and point at `requested_exit()` for the unlock. Two warnings are cleared along the way: the runtime no longer stores the `SessionLockState` after requesting the lock — it has no `Drop`, so the manager object outlives the dropped handle inside the connection and the lock lifecycle runs entirely off the returned `SessionLock`, which removes a never-read field — and a pre-existing rustdoc `private_intra_doc_links` warning in `list_item` (a public doc comment linking to the private `theme::ICON_SIZE`) is downgraded to plain code formatting.
2026-05-26 00:11:33 +02:00
cff4b12a4a event_loop: drop the duplicate on_resize that clobbered physical dimensions with logical ones
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
Both `LayerShellHandler::configure` and `WindowHandler::configure` called `self.on_configure( w, h )` — which already forwards `app.on_resize( w * sf, h * sf )` in physical pixels — and then immediately called `self.app.on_resize( w, h )` again with the surface-local logical size. The second call won, so on any scale > 1 surface the app saw logical dimensions while the layout pass kept working in physical pixels. On a Librem 5 at scale 2 that meant `screen_width`/`screen_height` came through as 360×720 against a 720×1440 layout: homescreen icons rendered at half their intended footprint and the mobile wallpaper, sized explicitly via `.size( iw_at_h, sh )`, filled only the top half of the screen (the launcher / lockscreen / greeter use `.cover()` so they were unaffected).
Remove the redundant `app.on_resize` from both handlers; `on_configure` is the single source of truth for the physical dimensions the app and the layout both expect.
2026-05-25 11:46:01 +02:00
yamabush1
f7ef932976 list_item: optional leading icon
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
Add a leading-icon slot to ListItem so settings-style rows can pair
a 24 px symbolic glyph with the label. `.icon( rgba, w, h )` takes
the same shape the rest of the toolkit uses for raw pixmaps; the
draw path reserves `ICON_SIZE + ICON_GAP` on the left and shifts
the label / subtitle text origin so existing icon-less rows render
unchanged.
2026-05-25 09:00:00 +02:00
24f4d2703a ltk: introduce viewport-relative Length so any size, padding, spacing or font height can scale with the surface instead of being frozen at a px constant, fix text::preferred_size to honour the font-declared line gap, and add a responsive typographic scale
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
The motivating bug was a lockscreen in a downstream app (eydos-loginmanager) where the clock at 87 px overlapped the date at 24 px on a Pinephone but not on a winit dev screen. The root cause split in two: the layout was wired with a single `f32` spacing constant that worked at the dev resolution and broke at the smaller one, and `text::Text::preferred_size` was returning `ascent - descent` for the line height — fontdue's terminology for "the minimum bounding box of an unaccented line", which deliberately drops the `line_gap` that every typographic renderer (Pango, CoreText, DirectWrite) reserves between adjacent rows. At Sora's 200/em line gap, an 87 px row was visually 17 px taller than the rect the column allocated for it; stacked tight against the row above, the descenders bled into the row below. This commit fixes both halves at the toolkit level so every consumer benefits without bolting on a per-screen `Sizing` helper in their own view code.
`types::Length` (with the `LengthBase` enum behind it) is the new currency for any "how big" or "how far apart" parameter. Six variants — `Px`, `Vw`, `Vh`, `Vmin`, `Vmax`, `Em` — cover the cases a real UI hits: absolute pixels for fixed-chrome decisions, viewport-relative percentages for sizes that have to survive a portrait/landscape rotation, and root-font-size multiples for typographic hierarchy. Optional `min_px` / `max_px` bounds attach to the same `Length` value via `.clamp( lo, hi )` (both ends), `.at_least( lo )` and `.at_most( hi )` (one-sided); the names are intentionally divergent from `f32::min`/`f32::max` to avoid being read with the opposite semantics (`x.min(24)` in std means "the smaller of x and 24", which is the inverse of what a min bound expresses). The bounds are stored as raw `f32` rather than nested `Length` values, which keeps `Length` `Copy` and avoids a `Box` allocation per widget per frame — the bounded-by-relative case (`Vmin(20).clamp(Vmin(10), Vmin(40))`) is rare enough that the trade is the right one. `From<f32>`, `From<i32>` and `From<u32>` are implemented so every legacy `.size( 24.0 )` / `.padding( 8.0 )` / `.spacing( 4.0 )` call keeps compiling unchanged; the migration is opt-in per call site. The `EM_BASE_DEFAULT = 16.0` constant matches `theme::typography::BODY` so `Length::em( 2.0 )` resolves consistently with the body-text default; a future change can thread a theme-supplied em base through without breaking the resolver shape.
The resolver — `Length::resolve( viewport: ( f32, f32 ), em_base: f32 ) -> f32` — runs at layout time against a viewport supplied by the renderer. `Canvas::viewport_logical()` is the new helper that exposes that viewport: it divides the canvas's physical size by `dpi_scale` and falls back to physical size when `dpi_scale <= 0.0`, guarding the misconfigured-canvas path so a Vmin call doesn't poison every downstream measurement with `NaN` or `inf`. The viewport is in **logical** pixels — matching what every wayland `xdg_toplevel.configure` event already hands the client — so `Length::vmin( 18.0 )` on a 360×720-logical Librem 5 portrait surface resolves to 64.8 px and the same expression on a 1600×900 dev screen resolves to 162 px, automatically.
Every widget setter that took an `f32` size, padding, spacing, max-width, or fixed dimension now takes `impl Into<Length>` and stores the value as `Length`:
- `widget::text::Text::size( impl Into<Length> )`; the `size` field is now `Length`. `Text::resolved_size( &Canvas )` is the internal accessor that every measurement / drawing path routes through, so the field can stay `Length` without churning the call sites. `preferred_size` and `draw` now read `new_line_size = ascent - descent + line_gap` from fontdue's `LineMetrics` (the fix for the original bug) — the baseline placement is unchanged, only the row height grows by the font's declared leading, which is what every stacked layout was implicitly relying on.
- `layout::Spacer::height( impl Into<Length> )` / `.width( impl Into<Length> )`; `fixed_height` / `fixed_width` are now `Option<Length>`. New `resolved_height( &Canvas )` / `resolved_width( &Canvas )` helpers replace the direct `s.fixed_height.unwrap_or( 0.0 )` reads in `layout::column`, `layout::row` and `layout::stack`. `Spacer::preferred_size` grows a `&Canvas` parameter for the same reason; `Element::preferred_size` passes the canvas through.
- `layout::Column::spacing` / `.padding` / `.max_width`, `layout::Row::spacing` / `.padding` — all take `impl Into<Length>` and store `Length`. Internal `resolved_spacing( &Canvas )`, `resolved_padding( &Canvas )`, `resolved_max_width( &Canvas )` helpers funnel every read, so the layout code paths stay readable. The column's `inner_w` private helper picks up a `&Canvas` argument; the test that used it directly is updated.
`theme::typography` keeps its historic `f32` constants (`H0`…`BODY_XS`, plus `LINE_HEIGHT`) so the migration is gradual, and adds a parallel responsive scale exposed as functions returning `Length`: `h0()`, `h1()`, `h2()`, `h3()`, `body()`, `body_s()`, `body_xs()`. Each is a `Length::vmin( pct ).clamp( min_px, max_px )` whose percentage is calibrated against a 1000-px smaller side reproducing the legacy px constant exactly, and whose px clamps protect both ends of the spectrum — a 360-px Pinephone hits the lower clamp on the larger headings, a 4K desktop hits the upper one. The tests in `theme::typography` exercise all three regimes (narrow phone, calibration point, large display) so future drift in the percentages or clamps is caught immediately.
`Canvas::viewport_logical` is the only render-surface API touched. None of the existing per-frame paths (`draw_text`, `measure_text`, `font_line_metrics`) change shape, so backends and external embedders aren't disturbed. The `dpi_scale` accessor already existed; this commit only adds the convenience that ratios it against the surface size to return the unit layout actually wants.
Test coverage rounds out the addition rather than just smoke-testing the happy path: 22 new tests, broken down as `types::length_tests` (7 — every variant, clamp with relative value, clamp with swapped bounds, `From<f32>`), `render::viewport_tests` (3 — scale 1, scale 2, scale 0 fallback), `theme::typography::tests` (3 — phone-clamped, calibrated, 4K-clamped), `layout::spacer::tests` (4 — px height, vmin height, vw width, flex spacer reports `None`), `layout::column::tests` (3 new — vmin spacing accumulates, vmin padding, vmin max-width caps inner-w), `layout::row::tests` (2 new — vmin padding, vmin spacing produces correct visible gap between non-flex children regardless of the row's centering anchor), and `widget::text::tests` (3 updated/new — defaults compare against `Length::px(16.0)`, `.size( f32 )` and `.size( Length )` both verified). The existing integration test in `tests/layout_stack_spacer.rs` is updated to call `Spacer::preferred_size( &canvas )` and compare `fixed_height` / `fixed_width` against `Some( Length::px( n ) )`.
Documentation is updated end-to-end so the new API is discoverable from `cargo doc` without grepping the source: `lib.rs` gets a new entry for `Length` under the **Types** section and a new **Designing for multiple resolutions** section that lists the three patterns (relative `Length` for sizing, responsive typography for hierarchy, `view()`-level branching on surface dimensions only when the structure itself must change). `Canvas::viewport_logical` ships with a runnable `assert_eq!` example covering the scale-2 case. The module-level docstrings for `Spacer`, `Column` and `Row` now show both an `f32` example (legacy, still valid) and a `Length::vmin( ... ).clamp( ... )` example for the responsive variant — `cargo doc` renders both side by side so the upgrade path is obvious.
Out of scope for this commit, deliberate: `WrapGrid::spacing_x` / `spacing_y` / `padding`, `widget::text_edit::TextEdit::font_size`, and `widget::image::Image::size` still take `f32`. None of them are on a critical responsive path right now, the `From<f32>` shim means migrating later is a one-line setter signature change per widget, and keeping this commit focused on the widgets the lockscreen actually uses keeps the diff reviewable. The line-gap fix in `text::preferred_size` already benefits `TextEdit` indirectly because its caret/row math reads from the same metrics helpers.
2026-05-24 00:12:50 +02:00
c553c4df4b themes/default: drop the inner shadows from the launcher.svg glyph
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
In both `themes/default/branding/dark/launcher.svg` and `themes/default/branding/light/launcher.svg` the launcher glyph is built from nine cells, each with its own `filterN_diiii_4479_*`. Those filters layered four successive inner-shadow passes (`effect2..effect5_innerShadow_*`) on top of the outer drop shadow `effect1_dropShadow_*`: pairs of `feColorMatrix in="SourceAlpha"` + `feOffset` + `feGaussianBlur` + `feComposite operator="arithmetic"` + `feBlend`, with offsets `(-3.6,-3.6)`, `(1.8,1.8)`, `(0.45,0.45)` and `(1.8,1.8)` and blend modes `plus-lighter`, `overlay` and `normal` to reproduce the light-above / dark-below bevel and inner halo of the original Figma export. That is twenty-four lines per cell and nine cells per file — 216 lines per SVG, 432 in total.
This change removes those four inner-shadow passes in both files; the outer drop shadow (`effect1_dropShadow_*`), the `clipPath` with `bgblur`, the `rect` elements defining each cell and the rest of the document are kept verbatim. The launcher silhouette and its placement do not change: the icon still occupies the same `viewBox` and produces the same clip; what disappears is the specular highlight and dark inner contour of each cell, leaving flat rectangles on the background with their projected shadow. The diff is pure deletions, with no added lines, and the existing difference between the `dark` and `light` variants is preserved (only the filter identifiers differ, `_38862` versus `_38700`).
2026-05-23 00:53:13 +02:00
88385e14b2 add Carousel widget and WrapGrid::centre_last_row
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
Forge's app switcher needs two layouts the existing widget set didn't cover. The desktop grid wants a partial last row centred under the rows above (3 tiles → row 1: two, row 2: one centred) so a 7-of-9 leftover band reads balanced rather than left-aligned. The mobile variant wants a horizontal carousel where the focused tile sits centred in the viewport at a configurable fraction of its width and its neighbours peek out on the sides at a fixed gap.
Extend `WrapGrid` with `centre_last_row( bool )`. When set, layout offsets a row that has fewer than `columns` children by `(missing * (cell_w + spacing)) / 2` so it stays centred inside the content rect. Defaults to false; every existing call site continues to land tiles flush-left. Covered by three layout tests (centred partial row, full row no-op, off-by-default).
Add the `Carousel` widget at `src/widget/carousel/`. It is a pure layout primitive: `focused_width_frac` (0.05–1.0, clamped), `gap` and `offset` are owned by the caller, leaving drag / inertia / snap policy to the host so the compositor can plug in its existing touch pipeline. Each child gets a rect at `base_x + idx * (child_w + gap)` and the full viewport height; `snap_offset( viewport_w, idx )` translates index to centring offset and `focused_index( viewport_w )` rounds the current offset back to the nearest tile. Plumbed into `Element::Carousel` with the matching arms in `widget/element.rs` and walker in `draw/layout.rs`; re-exported as `ltk::{ Carousel, carousel }`. Covered by nine unit tests (layout, offset shift, snap / focus round-trip, frac clamp, child height) plus a `cargo run --example carousel` demo with Prev / Next / arrow-key navigation against an external offset state. The example is wired into the `examples` Makefile target.
Updates the widget catalogue and the `widget/mod.rs` landing comment to list the carousel under "Clipping wrappers" and to mention `centre_last_row` in the grid section.
2026-05-22 19:38:48 +02:00
0e52274053 app, event_loop: first-frame-committed hook and foreign_toplevel app_id only
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
Two independent changes, both blockers for a working desktop session through the loginmanager-daemon handoff and the dock's running-app icons.
`App::on_first_frame_committed` is a new trait hook fired exactly once, immediately after the very first `wl_surface.commit` of a rendered buffer on the main surface. `AppData` grows a `first_frame_committed: bool`, `draw_frame` now returns whether this call performed that first commit, and `try_run` invokes the hook after the borrows held during the draw are released. Used by the loginmanager-daemon-aware crustace path to signal "ready to be presented" back to the daemon as soon as the GPU has the first frame — the actual present can still be deferred under VT switching (no DRM master yet), but the client-side commit is the right edge for handoff.
`toplevel_display_id` in the foreign-toplevel-list handler no longer falls back to `info.title` or `info.identifier` when `info.app_id` is empty. Smithay creates each `ext-foreign-toplevel-list-v1` handle with `app_id = ""` and `init_new_instance` flushes a `done` immediately, so subscribers used to see the protocol-level identifier (a 32-char `Alphanumeric` random token) as the "app id" of every new toplevel — and remained stuck on it whenever the client's real `set_app_id` arrived between done events but the subscribing app's matcher couldn't resolve it to a `.desktop` entry. Returning the raw `app_id` (empty or not) makes that first transient `done` ignorable by the consumer's own empty-string guard; the second `done`, carrying the real app id, is processed normally.
2026-05-21 01:51:19 +02:00
78a7ae151c layout/stack: opt-in Stack::fit_content() so a wrapping container can adopt the stack's intrinsic size
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
`Stack::preferred_size` unconditionally reported `(max_width, max_h)` — every stack claimed the full width its parent offered, regardless of what its children actually needed. That is the right default for a FrameLayout-style overlay (the existing callers all rely on it) but it makes the natural "pin a stack to a fixed-size child (e.g. a spacer of `card_w × card_h`) and centre other children on top of it" pattern impossible: a container wrapping such a stack always inherited the full parent width and ignored the spacer's footprint. The new `Stack::fit_content()` builder mirrors the `Column::fit_content` flag — when set, `preferred_size` returns the max of children's intrinsic widths and heights instead of claiming the parent's `max_width`, with the same "skip filler widgets that themselves claim `max_width`" exclusion list (`Spacer` with no `fixed_width`, `Separator`, `Scroll`, `ProgressBar`, `Slider`, `TextEdit` without `fixed_width`) so the flag is not defeated by a flexible child slipping into the stack. Default behaviour is unchanged.
2026-05-20 15:26:41 +02:00
757063694e layout/stack: opt-in Stack::fit_content() so a wrapping container can adopt the stack's intrinsic size
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
`Stack::preferred_size` unconditionally reported `(max_width, max_h)` — every stack claimed the full width its parent offered, regardless of what its children actually needed. That is the right default for a FrameLayout-style overlay (the existing callers all rely on it) but it makes the natural "pin a stack to a fixed-size child (e.g. a spacer of `card_w × card_h`) and centre other children on top of it" pattern impossible: a container wrapping such a stack always inherited the full parent width and ignored the spacer's footprint. The new `Stack::fit_content()` builder mirrors the `Column::fit_content` flag — when set, `preferred_size` returns the max of children's intrinsic widths and heights instead of claiming the parent's `max_width`, with the same "skip filler widgets that themselves claim `max_width`" exclusion list (`Spacer` with no `fixed_width`, `Separator`, `Scroll`, `ProgressBar`, `Slider`, `TextEdit` without `fixed_width`) so the flag is not defeated by a flexible child slipping into the stack. Default behaviour is unchanged.
2026-05-20 15:24:34 +02:00
640db23de2 doc: silence rustdoc intra-doc warnings (private-item links and palette/surface ambiguities)
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
`cargo doc --no-deps` was emitting eight warnings about intra-doc links resolving to private items or ambiguous fn/module names. None reflect a behaviour bug; they were just noise that cluttered the docs build output.
Five of the warnings came from doc comments that cross-referenced items not in the rendered public API. `widget::scroll::Scroll` (struct), `Scroll::horizontal`, `Scroll::both`, `event_loop::text_editing` (module) and `text_shaping` (module) are all `pub` in their own modules, but the `widget` and `event_loop` parents are private to the crate root, so rustdoc treats them as private when resolving links from items that ARE on the public surface (`ScrollAxis`, the `scroll` constructor, `font_bytes`, the `text_edit` module docs). The fix is to drop the link syntax for those references: keep the identifier in backticks (so it still renders as code) but remove the surrounding `[...]` so rustdoc doesn't try to resolve it. Where the cross-reference had no semantic load beyond "see this module", the prose is rephrased to name the module without trying to link to it (e.g. "the `event_loop::text_editing` private module", "see the `text_shaping` private module").
The remaining three warnings were `palette` / `surface` linking ambiguously between a module of that name and a function of the same name within `crate::theme`. Adding `()` after the identifier inside the brackets (`palette` → `palette()`, `surface` → `surface()`) disambiguates to the function, which is the intent in all three sites — the surrounding text talks about "per-slot shorthand accessors", which is what the functions are.
After this `cargo doc --no-deps` runs clean with no warnings.
2026-05-19 22:41:42 +02:00
a8bbd1e35c input/keyboard: fall through to App::on_key when the Enter dispatch target widget has no submit/press message
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
`handle_key_return` previously routed `Return` to either the focused or hovered widget's submit/press message and, only when neither index existed, fell through to `app.on_key_with_modifiers`. If a stale `hovered_idx` (left over from a prior screen) pointed at a widget that exists in the new `widget_rects` but exposes no submit or press handler, `target.is_some()` was true and the message-less dispatch silently swallowed the key — the `else` branch never ran and `App::on_key` never saw the keysym. This manifested in the eydos-loginmanager greeter as `Enter` on the Lock screen failing to fire `Message::Unlock` after a pause/resume cycle, until the user clicked something to refresh `hovered_idx`. Track whether the widget path actually pushed a message and, when it didn't, fall through to the app-level handler so the `Return` keysym still gets a chance to be interpreted by the application's `on_key`. No behaviour change when the focused/hovered widget does provide a `submit_msg` or `press_msg`.
2026-05-19 21:40:20 +02:00
yamabush1
9cc65e70ea fix(event_loop): walk error source chain for BrokenPipe in OtherError
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
The previous fix checked downcast_ref::<io::Error> directly on the
OtherError payload, but wayland-client wraps the io::Error inside
WaylandError::Io — one level deeper. Downcast failed, is_closed
stayed false, and the panic arm fired anyway.

Replace the single downcast with a source()-chain walk that finds
the io::Error at any depth, matching the same BrokenPipe /
ConnectionReset check already used for the direct IoError arm.
2026-05-18 20:48:35 +02:00
yamabush1
750eae7a93 fix(event_loop): exit cleanly on OtherError(BrokenPipe) from compositor
calloop surfaces the closed-socket condition either as
Error::IoError(BrokenPipe) — already handled — or as
Error::OtherError wrapping an std::io::Error with kind BrokenPipe,
which previously fell through to panic!(). Add a matching arm that
downcasts the inner error and treats both forms the same: log and
set exit_requested so the run loop terminates gracefully instead
of unwinding.
2026-05-17 18:31:12 +02:00
225 changed files with 7420 additions and 2065 deletions

34
CHANGELOG.md Normal file
View File

@@ -0,0 +1,34 @@
# Changelog
All notable changes to `ltk` are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.2.0] — 2026-06-25
This release adds the primitives an embedder needs to drive ltk as the render backend for a retained, externally-owned widget tree (for example projecting an Android view hierarchy onto an ltk surface). Each is kept general rather than tied to one consumer.
### Added
- **`Canvas::set_clip_path`** — anti-aliased clipping to an arbitrary vector path (`&[PathCmd]`) on both backends. The software backend installs a tiny-skia coverage mask; the GLES backend captures the clipped draws into an offscreen layer and composites them back through an anti-aliased coverage mask. Complements the existing rect clip (`set_clip_rects`) for shaped clips such as a circular avatar, a rounded card or a `VectorDrawable` mask. See `examples/clip_path.rs`.
- **`Canvas::fill_path` / `Canvas::stroke_path`** over a new `PathCmd` command list (`MoveTo` / `LineTo` / `QuadTo` / `CubicTo` / `Close`, in surface coordinates) — renders an arbitrary vector path, a `Path`, a `VectorDrawable` or a Lottie frame. The software backend rasterises directly with tiny-skia; the GLES backend rasterises into a tiny-skia pixmap and uploads it, so both backends share the same path rasteriser and stay at visual parity.
- **`Canvas::read_rgba_pixels`** — reads any canvas into tightly packed straight-alpha RGBA8 (top-left row first), on both the GLES and software backends (the software path un-premultiplies its pixmap). **`Canvas::is_software`** lets a caller branch on the backend (e.g. honour a real path clip on software but only a bounding rect on GLES).
- **`measure_text( text, size )`** (re-exported at the crate root) — measures one line with the default UI font and the system fallback chain, returning `(width, line_height)` in pixels without a live `Canvas`, for an embedder's measure pass that must match the renderer's metrics. Backed by a process-wide cached primary-font handle.
- **`Stack::push_placed`** — appends a child at an exact rect, bypassing alignment and intrinsic sizing, so a view tree whose geometry is computed elsewhere can be projected onto a Stack in paint order. **`Stack::push_placed_clipped`** additionally clips the child's subtree to a rect (Android's `clipChildren`): overflowing content is not painted.
- **`ExternalSource::Cpu`** and the **`External::cpu`** constructor — an immediate-mode CPU drawing closure invoked once per frame with the canvas and the widget's laid-out rect, working on both backends. Hosts a custom `View.onDraw` straight onto the ltk canvas without a GL texture round-trip, unlike the existing `Texture` source which only renders on GLES.
- **`RichText`** widget — wrapped paragraph text carrying a `Msg` per clickable link range; the layout pass emits one hit rect per link line so taps land on the link rather than the whole paragraph. The ltk side of an Android `Spanned` carrying `URLSpan` / `ClickableSpan`.
### Fixed
- GLES clip-layer composite no longer renders path-clipped content vertically flipped: the offscreen layer was sampled at the inverted screen Y. The software backend was unaffected.
- Gesture: the horizontal pager is driven only once the swipe axis locks horizontal. Previously the pre-lock lateral drift of a vertical gesture could arm the consumer's pager without a matching release event, which could freeze the surface until an unrelated gesture reset it.
- Software/GLES parity: the software backend now snaps glyph pen positions and image destinations to integer pixels (rounding to nearest), matching the GLES backend. Previously it truncated, so text and 1:1 images could land up to half a pixel off between backends and sample ~1 px softer.
### Changed
- Default theme launcher SVGs replaced with renderer-compatible versions.
## [0.1.0] — 2026-03-10
- Initial release.
[0.2.0]: https://github.com/liberux/ltk/releases/tag/v0.2.0
[0.1.0]: https://github.com/liberux/ltk/releases/tag/v0.1.0

View File

@@ -1,8 +1,10 @@
[package]
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"
@@ -20,6 +22,13 @@ exclude = [
"TODO",
]
[features]
# Exposes `ltk::test_support` — internal helpers (widget handlers, laid-out
# widget lookup, slider math) used only by ltk's own integration tests. Not
# part of the stable public API; off by default so third-party builds never
# see it. `make test` enables it.
test-support = []
[dependencies]
chrono = { version = "0.4", features = ["clock"] }
serde = { version = "1.0", features = ["derive"] }

View File

@@ -11,7 +11,7 @@ all:
cargo build --release
test:
cargo test
cargo test --features test-support
# Typecheck the `no_run` snippets in docs/*.md by feeding each markdown
# file to `rustdoc --test`. Catches API drift in cookbook + widget
@@ -29,13 +29,14 @@ doc:
install: doc
install -d $(REGISTRY)
cp -r src Cargo.toml liberux.toml $(REGISTRY)/
cp -r src benches locales Cargo.toml liberux.toml $(REGISTRY)/
cp debian/cargo-checksum.json $(REGISTRY)/.cargo-checksum.json
install -d $(DOCDIR)
cp -r target/doc/* $(DOCDIR)/
examples:
LTK_THEMES_DIR=themes cargo run --release --example showcase
LTK_THEMES_DIR=themes cargo run --release --example responsive
LTK_THEMES_DIR=themes cargo run --release --example inputs
LTK_THEMES_DIR=themes cargo run --release --example scroll
LTK_THEMES_DIR=themes cargo run --release --example sliders
@@ -44,6 +45,7 @@ examples:
LTK_THEMES_DIR=themes cargo run --release --example widgets
LTK_THEMES_DIR=themes cargo run --release --example pickers
LTK_THEMES_DIR=themes cargo run --release --example dialog
LTK_THEMES_DIR=themes cargo run --release --example carousel
clean:
dh_clean

View File

@@ -66,7 +66,7 @@ model is "public Wayland toolkit with production consumers" rather than
- low idle wakeups and event-driven redraws
- partial redraws and damage tracking
- a simple declarative tree instead of retained widgets
- direct support for normal windows and layer-shell surfaces
- direct support for normal windows, layer-shell, and ext-session-lock surfaces
- a runtime-free core (`ltk::core::UiSurface`) for embedding layout and drawing
without `ltk::run()`
@@ -175,6 +175,7 @@ cargo run --example showcase
Useful entry points in this repository:
- `cargo run --example showcase`
- `cargo run --example responsive`
- `cargo run --example widgets`
- `cargo run --example inputs`
- `cargo run --example scroll`
@@ -187,6 +188,7 @@ Useful entry points in this repository:
In general:
- start with `showcase` for a regular app window
- use `responsive` to see the fluid vs physical modes on stock widgets
- use `widgets` to see the core controls
- use `mini_shell` if you need overlays, theme switching, or shell-style
composition
@@ -211,6 +213,25 @@ More advanced APIs are available when needed:
- `core::UiSurface`
- runtime theme APIs
## Responsive Design
`ltk` offers **two** first-class ways to make an interface adapt to the
display, chosen per value or per process:
- **Fluid** — sizes are a fraction of the surface, tracking the short side
(width in portrait, height in landscape). Best for full-screen system
surfaces. Written with `Length::vmin` / `orient` / `fluid` and bounded with
`.clamp`.
- **Physical** — sizes stay a constant real-world size across displays (the
mainstream HiDPI `dp` model). Best for conventional windowed apps. Written
with `Length::dp` plus `set_density`.
Stock widgets follow the process-wide mode (`set_widget_scaling`, fluid by
default); an explicit `Length` on a widget always overrides it. The full
mechanics live in [`docs/architecture.md`](docs/architecture.md#responsive-sizing),
a walkthrough in [`docs/onboarding.md`](docs/onboarding.md), and the per-item
reference in the `Length` / `WidgetScaling` rustdoc.
## Windows and Shell Surfaces
By default, `ltk` creates a regular `xdg-shell` window.
@@ -230,6 +251,11 @@ Switch to layer-shell only when you are building shell surfaces such as:
- greeters
- lock screens
For a screen locker, use `ShellMode::SessionLock` instead of layer-shell: it
presents an `ext-session-lock-v1` surface that the compositor keeps on top of
everything until the app returns `true` from `requested_exit()`, which makes
the runtime call `unlock` and lift the lock.
## Performance Notes
`ltk` is designed to sleep when idle and redraw only on real work.
@@ -252,13 +278,17 @@ The library already provides:
## Backend Differences
The public API is the same across backends, but visual parity is not perfect
yet. The widget tree, layout, hit-testing, text, images, fills, strokes,
clipping and gradients all paint identically on both paths. The gap is in the
yet. The widget tree, layout, hit-testing, text, images, fills, strokes and
clipping all paint identically on both paths. The gaps are in gradients and the
shadow / backdrop pipeline.
Effects that currently render only on the **GLES** backend, and are silent
no-ops on the **Software** backend:
Effects that currently render only on the **GLES** backend, and degrade on the
**Software** backend:
- **Gradients** (linear and radial, via `Canvas::fill_paint_rect`) — rendered with
dedicated shaders on GLES; on software they collapse to a flat fill from the
first stop (tiny-skia can render gradients natively, but that is not wired up
yet).
- **Outer drop shadows** (`Canvas::fill_shadow_outer`) — themed surfaces that
declare a `Shadow` slot show the soft halo on GLES and a flat fill on
software.

View File

@@ -71,8 +71,13 @@ message and we will reply with it before sharing any sensitive details.
replaces the previous frame's snapshot, so the typical lifecycle is
"one frame on the heap, then overwritten on drop". Single-line and
multiline are mutually exclusive with secure (passwords have no line
breaks); the text-input-v3 IME path is also skipped in secure mode so
preedit / commit strings never reach the compositor's IME stack.
breaks). Note: secure does **not** skip the text-input-v3 path — the
field still activates it (so the on-screen keyboard works on password
fields) but is flagged `Password` with `SensitiveData | HiddenText`,
which asks the IME / OSK not to predict, autocorrect or store the
value. The value can still reach a trusted compositor/IME; closing
that path would mean not activating text-input there (and losing the
OSK on the field).
### What `TextEdit::secure` does **not** cover

View File

@@ -43,6 +43,8 @@ fn build_widgets( n: usize ) -> Vec<LaidOutWidget<()>>
keyboard_focusable: true,
cursor: ltk::CursorShape::Default,
tooltip: None,
accessible_label: None,
is_live_region: false,
}
} ).collect()
}

6
debian/changelog vendored
View File

@@ -1,3 +1,9 @@
ltk (0.2.0-1) unstable; urgency=low
* New upstream release: embedder primitives for hosting an externally-laid-out widget tree (path/rect clipping, RGBA readback, standalone text measurement, CPU draw source, RichText).
-- Pedro M. de Echanove Pasquin <pedro.echanove@liberux.net> Thu, 25 Jun 2026 09:49:35 +0200
ltk (0.1.0-1) unstable; urgency=low
* Initial Debian packaging.

2
debian/control vendored
View File

@@ -2,7 +2,7 @@ Source: ltk
Section: libdevel
Priority: optional
Maintainer: Pedro M. de Echanove Pasquin <pedro.echanove@liberux.net>
Build-Depends: debhelper-compat (= 13), libxkbcommon-dev
Build-Depends: debhelper-compat (= 13), libxkbcommon-dev, pkg-config
Rules-Requires-Root: no
Standards-Version: 4.7.0
Homepage: https://liberux.net

58
debian/copyright vendored
View File

@@ -62,6 +62,27 @@ Comment:
sibling `Sora-LICENSE.txt`. Upstream:
https://github.com/sora-xor/sora-font
Files: themes/default/cursors/*
Copyright: 2002-2014 The GNOME Project and the Adwaita icon theme authors
License: CC-BY-SA-3.0 or LGPL-3, and CC-BY-SA-4.0
Comment:
Pointer cursors under themes/default/cursors/ are GNOME's *Adwaita*
cursor theme — the cursors GNOME Shell ships — bundled verbatim from
the adwaita-icon-theme distribution so the default ltk theme is
self-contained (it does not depend on adwaita-icon-theme being
installed). The files are in XCursor binary format, one per CSS cursor
name (default, text, pointer, *-resize, …) plus the customary X11 alias
symlinks (arrow → default, hand2 → pointer, …); the sibling
cursor.theme makes the tree a valid XCursor theme. They are unmodified
from upstream. A Wayland compositor draws them in response to
wp_cursor_shape_v1, so ltk applications get them without bundling their
own. Upstream offers the artwork under the disjunction in the License
field; redistributing under any one of the options satisfies the
licence. Attribution shorthand: "Cursors by the GNOME Project". The
full functional and licence notes live in
themes/default/cursors/README.md and themes/default/cursors/LICENSE.md.
Upstream: https://gitlab.gnome.org/GNOME/adwaita-icon-theme
License: LGPL-2.1-only
This library is free software; you can redistribute it and/or modify it
under the terms of the GNU Lesser General Public License as published by
@@ -196,3 +217,40 @@ License: CC-BY-4.0
The licence text above is a summary; the canonical legal text at the URL
is what governs use, distribution and modification of the Licensed
Material referenced under `Files: themes/default/icons/catalogue/*`.
License: CC-BY-SA-3.0
Creative Commons Attribution-ShareAlike 3.0 (CC BY-SA 3.0).
.
You are free to share and adapt the material for any purpose, even
commercially, provided you give appropriate credit, provide a link to
the licence, indicate if changes were made, and distribute your
contributions under the same licence as the original (ShareAlike).
.
The canonical legal text is at:
https://creativecommons.org/licenses/by-sa/3.0/legalcode
.
A human-readable summary (which is not a substitute for the licence) is
available at:
https://creativecommons.org/licenses/by-sa/3.0/
.
The summary above governs the Adwaita cursor files referenced under
`Files: themes/default/cursors/*` when distributed under the 3.0 option.
License: CC-BY-SA-4.0
Creative Commons Attribution-ShareAlike 4.0 International (CC BY-SA 4.0).
.
You are free to share and adapt the material for any purpose, even
commercially, under the same Attribution and ShareAlike terms as the
CC BY-SA 3.0 paragraph above.
.
The canonical legal text is at:
https://creativecommons.org/licenses/by-sa/4.0/legalcode
.
A human-readable summary (which is not a substitute for the licence) is
available at:
https://creativecommons.org/licenses/by-sa/4.0/
License: LGPL-3
The Adwaita cursors are alternatively available under the GNU Lesser
General Public License version 3. On Debian systems the complete text
of the LGPL version 3 is in `/usr/share/common-licenses/LGPL-3`.

View File

@@ -151,6 +151,19 @@ For many third-party apps, theming is optional at first. It is reasonable to
start with the default theme and come back to the runtime theme APIs later as
part of the `ltk::runtime` layer.
## Responsive sizing
Every size in a widget tree is a `Length`, resolved to concrete pixels at layout time against the surface. Two coordinate spaces matter. **Geometry** (widths, heights, paddings, gaps, box sizes) is computed in *physical* pixels — the layout root rect is `pw × ph` — so geometry `Length` values resolve against `Canvas::viewport_layout()` (physical). **Font sizes** are the exception: they resolve against `Canvas::viewport_logical()` (physical ÷ `dpi_scale`) and are multiplied by `dpi_scale` again at raster time, so a `vmin` font ends up as a fraction of the *physical* short side regardless of `dpi_scale`. Keep this split in mind when adding a widget: resolve a geometry constant with `Canvas::geom_px(n)` and a font constant with `Canvas::font_px(n)` — the two helpers hide the difference.
`ltk` offers two adaptation strategies, and both live in the same `Length` type so an app can mix them per value:
- **Fluid** (`Length::fluid(n)`, and the raw `vmin` / `vmax` / `vw` / `vh` / `orient` units): surface-proportional. `fluid(n)` reads a single design pixel `n` as `vmin(n / fluid_reference() * 100).clamp(n * FLUID_MIN, n * FLUID_MAX)` — at a surface whose short side equals the reference (412 px by default) it is exactly `n`, and it scales with the short side elsewhere, auto-clamped to `[0.7n, 1.5n]`. This tracks the width in portrait and the height in landscape, because the short side *is* the width in portrait and the height in landscape. `orient(portrait, landscape)` is the escape hatch for a different percentage per orientation.
- **Physical** (`Length::dp(n)`): constant physical size. `dp(n)` is `n × density()`, where `density()` is a process-wide factor (default `1.0`, typically set from the output DPI via `set_density`). It does not scale with the surface, only with pixel density — the mainstream HiDPI `dp`.
Stock widgets do not hard-code either strategy. Each carries a design pixel per dimension (e.g. `button` height 48, font 16) and resolves it through the process-wide `WidgetScaling` mode: `Length::widget(n)` returns `fluid(n)` under `WidgetScaling::Fluid` (the default) or `dp(n)` under `WidgetScaling::Physical`. `set_widget_scaling(mode)` flips it once for the whole app. An explicit `Length` on an individual widget (`button.height(...)`, `text_edit.height(...)`, `font_size(...)`) bypasses the mode entirely — the mode only decides the meaning of the *default* design pixels, never an override the app wrote on purpose.
Both `density()` and `widget_scaling()` are process globals read during layout; set them at startup (or, for density, whenever the surface moves to an output with a different DPI). Because they are global, ltk's own test suite serialises the tests that touch them.
## Animations
The render loop is event-driven by default: it sleeps until input arrives, a `poll_interval` ticks, or `set_channel_sender` is woken from a thread. To run a tween, override `is_animating()`:
@@ -228,10 +241,12 @@ The cheap things and the expensive things, in rough order:
- *Avoid `is_animating() = true` when nothing is moving.* It pegs the loop at 60 Hz and burns battery on the mobile target.
- *Lower `poll_interval()` is not free.* Crustace polls every 30 s because the clock only shows HH:MM. If your UI shows seconds, `Some(Duration::from_secs(1))` is fine; if it shows nothing time-sensitive, leave it `None`.
- *Scroll viewports own a sub-canvas.* They're slightly more expensive to draw than a plain column. Use them when you need clipping or actual scrolling, not as a wrapper.
- *GPU vs software*: the GLES path is selected automatically when EGL is available; both render the same pixels (see the recent commits for the alpha/SDF parity work). There is no API-level difference for the application.
- *GPU vs software*: the GLES path is selected automatically when EGL is available, with no API-level difference for the application. The two backends are not yet pixel-identical — gradients and the shadow / backdrop pipeline degrade under software; the authoritative list of gaps is the README's *Backend Differences* section.
When a redraw feels sluggish: add a one-line print at the top of `view()` and confirm it's not being called more often than expected. The single most common mistake is leaving `is_animating()` returning `true` after the animation finished.
**Runtime guardrails.** The rules above are the app's responsibility, but the runtime also helps catch and blunt the common footguns. Set `LTK_PERF_WARN=1` to get one-shot stderr diagnostics during development when `is_animating()` stays true for 10 s (a settled animation that forgot to return `false`), when `poll_interval()` is under 100 ms (defeats the idle model), or when the software backend animates continuously for seconds (mobile CPU sink). Independently, animation on the **software** renderer is capped to ~30 Hz by default — GLES is never capped — since 60 fps software rasterization is a battery drain with no GPU offload; override [`App::cap_software_animation`] to keep the full rate. These live in `event_loop/perf.rs`.
## Where to look in the consumer repos
| Pattern | File |

View File

@@ -13,6 +13,7 @@ reference, see [`docs/widgets.md`](./widgets.md). For theme JSON, see
## Table of contents
- [Responsive sizing across mobile / tablet / desktop](#responsive-sizing-across-mobile--tablet--desktop)
- [Slide-in panel](#slide-in-panel)
- [Password field with PAM submit](#password-field-with-pam-submit)
- [Swipe-to-dismiss overlay](#swipe-to-dismiss-overlay)
@@ -23,6 +24,47 @@ reference, see [`docs/widgets.md`](./widgets.md). For theme JSON, see
- [Tab navigation between widgets](#tab-navigation-between-widgets)
- [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)
---
## Responsive sizing across mobile / tablet / desktop
Size everything as a fraction of the surface so one view reads
coherently from a portrait phone to a landscape desktop window,
without per-target forks. The rule is *fluid but clamped*: a `vmin`
percentage tracks the surface's smaller side, and a px `clamp` keeps
it from collapsing on a tiny screen or ballooning on a 4K monitor.
Use `Length::orient( portrait, landscape )` when the *proportion*
itself should differ between orientations, and `Image::short_side` to
size a logo along the screen's short side while preserving its aspect
ratio.
```rust,no_run
# use std::sync::Arc;
# use ltk::{ column, img_widget, text, Length, Element };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex( logo: Arc<Vec<u8>>, lw: u32, lh: u32 ) -> Element<Msg> {
column::<Msg>()
.padding( Length::vmin( 4.0 ).clamp( 16.0, 48.0 ) )
.spacing( Length::vmin( 2.0 ).clamp( 8.0, 24.0 ) )
// Logo: 40 % of the width in portrait, 5 % of the height in landscape.
.push( img_widget( logo, lw, lh ).short_side( Length::orient( 40.0, 5.0 ) ) )
// Heading: fluid, but never below 20 px nor above 44 px.
.push( text( "Welcome" ).size( Length::vmin( 6.0 ).clamp( 20.0, 44.0 ) ) )
.into()
# }
```
Fluid units scale with the screen's pixels, not real-world
millimetres. For body text that must stay physically legible and
honour the user's font-size preference across an open-ended device
set, prefer `Length::dp` or `Length::em` over raw `vmin`; the
[`typography`](../src/theme/typography.rs) scale (`h0`…`body_xs`)
gives clamped-`vmin` sizes tuned for running text. Reserve
`view()`-level branching on `surface_width` / `surface_height` for
genuine layout restructuring (sidebar → bottom tabs), not for sizing.
---
@@ -34,7 +76,7 @@ edge does not knife-cut against the layer below.
```rust,no_run
# use std::time::Instant;
# use ltk::{ container, text, viewport, Anchor, Element, Layer, OverlayId, OverlaySpec };
# use ltk::{ container, text, viewport, Anchor, Element, Layer, Length, OverlayId, OverlaySpec };
# const OVERLAY_QS: OverlayId = OverlayId( 1 );
# const SLIDE_DURATION: f32 = 0.25;
# #[ derive( Clone ) ] enum Msg { CloseQs }
@@ -69,7 +111,7 @@ fn build_quick_settings_overlay( &self ) -> OverlaySpec<Msg>
id: OVERLAY_QS,
layer: Layer::Overlay,
anchor: Anchor::TOP,
size: ( self.surface_width, visible_h as u32 ),
size: ( Length::px( self.surface_width as f32 ), Length::px( visible_h ) ),
exclusive_zone: 0,
keyboard_exclusive: false,
input_region: None,
@@ -209,7 +251,7 @@ A modal panel that closes when the user swipes down past a threshold or
taps outside the panel.
```rust,no_run
# use ltk::{ column, container, spacer, text, Anchor, Element, Layer, OverlayId, OverlaySpec };
# use ltk::{ column, container, spacer, text, Anchor, Element, Layer, Length, OverlayId, OverlaySpec };
# const OVERLAY_MODAL: OverlayId = OverlayId( 2 );
# #[ derive( Clone ) ] enum Msg { CloseModal }
# struct App { modal_open: bool, modal_drag_progress: f32 }
@@ -237,7 +279,7 @@ fn overlays( &self ) -> Vec<OverlaySpec<Msg>>
id: OVERLAY_MODAL,
layer: Layer::Overlay,
anchor: Anchor::ALL,
size: ( 0, 0 ),
size: ( Length::px( 0.0 ), Length::px( 0.0 ) ),
exclusive_zone: 0,
keyboard_exclusive: false,
input_region: None, // accept input
@@ -486,7 +528,7 @@ blocking other UI.
```rust,no_run
# use std::time::Instant;
# use ltk::{ container, text, App, Anchor, Color, Element, Layer, OverlayId, OverlaySpec };
# use ltk::{ container, text, App, Anchor, Color, Element, Layer, Length, OverlayId, OverlaySpec };
# const OVERLAY_TOAST: OverlayId = OverlayId( 3 );
# #[ derive( Clone ) ] enum Msg {}
struct AppState
@@ -537,7 +579,7 @@ impl App for AppState
id: OVERLAY_TOAST,
layer: Layer::Overlay,
anchor: Anchor::BOTTOM,
size: ( 0, 0 ),
size: ( Length::px( 0.0 ), Length::px( 0.0 ) ),
exclusive_zone: 0,
keyboard_exclusive: false,
input_region: Some( vec![] ), // pass-through
@@ -823,3 +865,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<Msg> {
External::cpu( 96.0, 96.0, |canvas, rect|
{
// Background, unclipped.
canvas.fill_rect( rect, Color::rgb( 0.10, 0.10, 0.12 ), 0.0 );
// Snapshot the outer clip so the path clip does not leak into the
// rest of the frame, then clip the foreground to a circle.
let saved = canvas.clip_bounds();
let r = rect.width.min( rect.height ) / 2.0;
let ( cx, cy ) = ( rect.x + rect.width / 2.0, rect.y + rect.height / 2.0 );
let k = r * 0.5523;
canvas.set_clip_path( &[
PathCmd::MoveTo( cx, cy - r ),
PathCmd::CubicTo( cx + k, cy - r, cx + r, cy - k, cx + r, cy ),
PathCmd::CubicTo( cx + r, cy + k, cx + k, cy + r, cx, cy + r ),
PathCmd::CubicTo( cx - k, cy + r, cx - r, cy + k, cx - r, cy ),
PathCmd::CubicTo( cx - r, cy - k, cx - k, cy - r, cx, cy - r ),
PathCmd::Close,
] );
canvas.fill_rect( rect, Color::rgb( 0.20, 0.50, 0.95 ), 0.0 );
canvas.set_clip_rects( &saved );
} ).into()
# }
```
The path clip is bracketed: `set_clip_path` installs it and
`set_clip_rects( &saved )` flushes it (on GLES this is when the offscreen
layer is composited). `clip_bounds` snapshots the prior clip first so an
outer clip is restored rather than dropped. `fill_path` / `stroke_path`
take the same `PathCmd` list to paint a path instead of clipping to it.
**See also**: [`examples/clip_path.rs`](../examples/clip_path.rs) (rounded
rect, circle, triangle — same smooth result on both backends).
---
## Projecting an externally-laid-out view tree
Hosting a widget tree whose geometry is computed elsewhere — an Android
measure/layout pass, say, which yields one absolute rect per view —
projected onto an ltk `stack` in paint order. `measure_text` gives the
external layout the same metrics the renderer will use, without a live
`Canvas`; `push_placed` drops each child at its exact rect; and
`push_placed_clipped` clips a child's overflow like Android's
`clipChildren`.
```rust,no_run
# use ltk::{ measure_text, stack, text, Element, Rect };
# #[ derive( Clone ) ] enum Msg {}
# struct View { label: String, rect: Rect, clip: Option<Rect> }
# fn external_layout_pass() -> Vec<View> { Vec::new() }
# fn _ex() -> Element<Msg> {
// The external layout pass measures text with the renderer's own metrics.
let ( w, line_h ) = measure_text( "Inbox", 16.0 );
let _ = ( w, line_h );
let views = external_layout_pass();
let mut s = stack();
for v in views
{
let child = text( v.label );
s = match v.clip
{
Some( clip ) => s.push_placed_clipped( child, v.rect, clip ),
None => s.push_placed( child, v.rect ),
};
}
s.into()
# }
```
To rasterise the result into a caller-owned buffer instead of presenting
it, render through a [`core::UiSurface`](#embedding-ltk-without-ltkrun)
and call `Canvas::read_rgba_pixels( &mut buf )` — it returns tightly
packed straight-alpha RGBA8 (top-left row first) from either backend
(the software path un-premultiplies for you). Branch on
`Canvas::is_software()` when a draw must honour a real path clip on
software but only a bounding rect on GLES.

View File

@@ -322,6 +322,28 @@ enum AppMsg
This is the pattern used by `examples/mini_shell.rs`.
## Responsive sizing: fluid vs physical
`ltk` gives you two ways to make an interface adapt to the display, and you can
mix them per value. **Fluid** sizes are a fraction of the surface (best for
full-screen system surfaces); **physical** sizes stay a constant real-world
size (best for conventional windowed apps). In practice you write a size as a
`Length` and bound it with `.clamp`:
```rust
use ltk::{ text, Length };
// Fluid: 6 % of the surface's short side, never below 20 px nor above 44 px.
text("Welcome").size(Length::vmin(6.0).clamp(20.0, 44.0));
```
Stock widgets follow a process-wide mode (`set_widget_scaling`, fluid by
default); an explicit `Length` on a widget overrides it. For the units
(`vmin` / `orient` / `dp` / …), the clamp discipline and how it all resolves,
see the *Responsive sizing* section of
[`architecture.md`](architecture.md#responsive-sizing) and the `Length`
rustdoc.
## Recommended learning order
If you are new to the library, this order minimizes confusion:

View File

@@ -18,11 +18,11 @@ 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)
- [`scroll`](#scroll) · [`viewport`](#viewport) · [`flex`](#flex) · [`carousel`](#carousel)
- [Overlays and feedback](#overlays-and-feedback)
- [`spinner`](#spinner) · [`toast`](#toast) · [`tooltip`](#tooltip) · [`combo`](#combo) · [`tabs`](#tabs) · [`notebook`](#notebook) · [`dialog`](#dialog)
- [Pickers](#pickers)
@@ -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<Msg> {
let body = "By continuing you accept the Terms and the Privacy Policy.";
rich_text( body )
.size( 14.0 )
.link( 30, 35, Msg::OpenTerms ) // byte range of "Terms"
.link( 44, 58, Msg::OpenPrivacy ) // byte range of "Privacy Policy"
.into()
# }
```
Link ranges are **byte** offsets into the content `[start, end)`, drawn
underlined in `link_color`. `color` sets the non-link text colour, `size`
the font size (any `Length`), and `font( family, weight, style )` the
typeface resolved through the active theme on draw.
---
## Decoration and chrome
@@ -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<Msg> {
External::cpu( 120.0, 120.0, |canvas, rect|
{
canvas.fill_rect( rect, ltk::Color::rgb( 0.1, 0.1, 0.12 ), 8.0 );
// any Canvas primitive: fill_path, set_clip_path, draw_text, …
} ).into()
# }
```
**See also**: the CPU-drawing and path-clip recipe in
[`docs/cookbook.md`](./cookbook.md#custom-cpu-drawing-and-path-clipping).
---
## Clipping wrappers
@@ -522,6 +584,47 @@ spacer siblings.
**See also**: [`spacer`](#spacer) for invisible fillers,
[`column`](#column) and [`row`](#row).
### `carousel`
Horizontal carousel: the focused child sits centred in the viewport at
`focused_width_frac` of the viewport width and its neighbours peek out
on the left / right at `gap` separation. The widget is a pure layout
primitive — the `offset` (positive shifts content right) is owned by
the caller, so drag / inertia / snap-ease live in the host (compositor,
gesture recogniser, etc.).
**When**: mobile-style app switchers, image / story strips, any
single-focus horizontal navigation where neighbours hint at the next /
previous tile.
```rust,no_run
# use ltk::{ button, carousel, container, Element, Color, Corners };
# #[ derive( Clone ) ] enum Msg { Open( usize ) }
# fn _ex( offset: f32 ) -> Element<Msg> {
carousel()
.focused_width_frac( 0.8 )
.gap( 16.0 )
.offset( offset )
.push( container( button::<Msg>( "Tile 1" ).on_press( Msg::Open( 0 ) ) )
.background( Color::rgb( 0.95, 0.4, 0.4 ) )
.radius( Corners::all( 12.0 ) ) )
.push( container( button::<Msg>( "Tile 2" ).on_press( Msg::Open( 1 ) ) )
.background( Color::rgb( 0.95, 0.85, 0.3 ) )
.radius( Corners::all( 12.0 ) ) )
.into()
# }
```
Helpers on the widget translate between offsets and indices:
`snap_offset( viewport_w, idx )` returns the offset that centres tile
`idx`; `focused_index( viewport_w )` rounds the current offset to the
nearest tile. The runnable demo at
[`examples/carousel.rs`](../examples/carousel.rs) shows Prev / Next
buttons and arrow-key navigation against an external `offset` state.
**See also**: [`scroll`](#scroll) for free vertical / horizontal panning
of arbitrary content; [`tabs`](#tabs) for a non-touch alternative.
---
## Overlays and feedback
@@ -834,6 +937,11 @@ grid( 4 )
Wrap inside [`scroll`](#scroll) when the grid may overflow.
`centre_last_row( true )` shifts a partial last row so its tiles sit
centred under the full rows above instead of left-aligned — useful for
app switchers and gallery layouts where a 7-of-9 leftover band reads
better balanced.
### `spacer`
An invisible flexible filler. Inside a column / row, absorbs leftover

182
examples/carousel.rs Normal file
View File

@@ -0,0 +1,182 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! `cargo run --example carousel`
//!
//! Demonstrates the `carousel()` widget: the focused tile sits centred
//! in the viewport at `focused_width_frac` of its width, and its
//! neighbours peek out on the left / right at `gap` separation.
//!
//! The carousel widget itself is a stateless layout primitive — the
//! `offset` (positive shifts content right) is owned by the host. The
//! example mutates that offset directly when Prev / Next / arrow keys
//! change the focused index; in a real touch-driven app the host would
//! drive it from drag / inertia / snap-ease.
//!
//! Esc quits. Arrow keys = Prev / Next.
//!
//! NOTE: ltk is a Wayland layer-shell toolkit. This example needs a
//! running Wayland compositor.
use ltk::{
App, ButtonVariant, Color, Corners, Element, Keysym,
button, carousel, column, container, row, spacer, text,
};
#[ derive( Clone ) ]
enum Message
{
Prev,
Next,
Tile( usize ),
}
struct CarouselApp
{
focused: usize,
offset: f32,
last_msg: String,
}
const TILE_COUNT: usize = 7;
/// Approximate viewport width used to translate "focused index → offset".
/// Real apps would derive this from the laid-out viewport rect; for an
/// example the constant keeps the focus / step math obvious.
const VIEWPORT_W: f32 = 800.0;
const FOCUSED_FRAC: f32 = 0.7;
const GAP: f32 = 16.0;
const COLORS: [( f32, f32, f32 ); TILE_COUNT] = [
( 0.95, 0.40, 0.40 ),
( 0.95, 0.65, 0.30 ),
( 0.95, 0.85, 0.30 ),
( 0.50, 0.85, 0.35 ),
( 0.35, 0.80, 0.85 ),
( 0.45, 0.55, 0.95 ),
( 0.75, 0.45, 0.90 ),
];
impl CarouselApp
{
fn new() -> Self
{
Self { focused: 0, offset: 0.0, last_msg: String::new() }
}
fn snap_offset_for( &self, focused: usize ) -> f32
{
let stride = VIEWPORT_W * FOCUSED_FRAC + GAP;
-( focused as f32 ) * stride
}
}
impl App for CarouselApp
{
type Message = Message;
fn view( &self ) -> Element<Message>
{
let palette = ltk::theme_palette();
let primary = palette.text_primary;
let secondary = palette.text_secondary;
let mut car = carousel::<Message>()
.focused_width_frac( FOCUSED_FRAC )
.gap( GAP )
.offset( self.offset );
for i in 0..TILE_COUNT
{
let ( r, g, b ) = COLORS[i];
let tile = container::<Message>(
column::<Message>()
.padding( 24.0 )
.spacing( 12.0 )
.push( text( format!( "Tile {}", i + 1 ) ).size( 28.0 ).color( Color::WHITE ).align_center() )
.push( spacer() )
.push(
button::<Message>( "Activate" )
.variant( ButtonVariant::Primary )
.on_press( Message::Tile( i ) ),
),
)
.background( Color::rgb( r, g, b ) )
.radius( Corners::all( 12.0 ) );
car = car.push( tile );
}
let status_line = if self.last_msg.is_empty()
{
text( "← / → cycle · click Activate to fire Message::Tile · Esc quits" )
.size( 12.0 )
.color( secondary )
.align_center()
} else {
text( &self.last_msg )
.size( 12.0 )
.color( secondary )
.align_center()
};
column::<Message>()
.padding( 16.0 )
.spacing( 12.0 )
.push( text( "ltk — carousel showcase" ).size( 22.0 ).color( primary ).align_center() )
.push(
row::<Message>()
.spacing( 8.0 )
.push( button::<Message>( "◀ Prev" ).on_press( Message::Prev ) )
.push( spacer() )
.push( text( format!( "Focused: {} / {}", self.focused + 1, TILE_COUNT ) ).size( 14.0 ).color( secondary ) )
.push( spacer() )
.push( button::<Message>( "Next ▶" ).on_press( Message::Next ) ),
)
.push( car )
.push( status_line )
.into()
}
fn update( &mut self, msg: Message )
{
match msg
{
Message::Prev =>
{
if self.focused > 0
{
self.focused -= 1;
self.offset = self.snap_offset_for( self.focused );
}
}
Message::Next =>
{
if self.focused + 1 < TILE_COUNT
{
self.focused += 1;
self.offset = self.snap_offset_for( self.focused );
}
}
Message::Tile( i ) =>
{
self.last_msg = format!( "Pressed tile {}", i + 1 );
}
}
}
fn on_key( &mut self, keysym: Keysym ) -> Option<Message>
{
match keysym
{
Keysym::Escape => { std::process::exit( 0 ); }
Keysym::Left => Some( Message::Prev ),
Keysym::Right => Some( Message::Next ),
_ => None,
}
}
}
fn main()
{
ltk::run( CarouselApp::new() );
}

131
examples/clip_path.rs Normal file
View File

@@ -0,0 +1,131 @@
//! `cargo run --example clip_path`
//!
//! Demonstrates [`Canvas::set_clip_path`] — real per-path clipping. Each cell
//! fills its whole rect with a background colour, then installs a path clip and
//! fills again with a foreground colour: only the pixels inside the path are
//! repainted, so the shape's silhouette appears (not its bounding box).
//!
//! Both backends clip with smooth, anti-aliased edges: the software backend
//! with a tiny-skia coverage mask, the GLES backend by compositing an offscreen
//! layer through an equivalent coverage mask. The circle and rounded corners
//! look the same on both. Esc exits.
use ltk::{ column, row, text, App, Color, Element, External, Keysym, PathCmd, Rect };
#[ derive( Clone, Debug ) ]
enum Msg {}
struct Demo;
const CELL: f32 = 220.0;
const MARGIN: f32 = 24.0;
fn bg() -> Color { Color::rgba( 0.16, 0.18, 0.22, 1.0 ) }
fn fg() -> Color { Color::rgba( 0.30, 0.68, 0.90, 1.0 ) }
/// Rounded rectangle inset by `MARGIN`, corners of `radius`, built from lines
/// and quadratic corners.
fn rounded_rect_path( r: Rect, radius: f32 ) -> Vec<PathCmd>
{
let l = r.x + MARGIN;
let t = r.y + MARGIN;
let right = r.x + r.width - MARGIN;
let b = r.y + r.height - MARGIN;
vec![
PathCmd::MoveTo( l + radius, t ),
PathCmd::LineTo( right - radius, t ),
PathCmd::QuadTo( right, t, right, t + radius ),
PathCmd::LineTo( right, b - radius ),
PathCmd::QuadTo( right, b, right - radius, b ),
PathCmd::LineTo( l + radius, b ),
PathCmd::QuadTo( l, b, l, b - radius ),
PathCmd::LineTo( l, t + radius ),
PathCmd::QuadTo( l, t, l + radius, t ),
PathCmd::Close,
]
}
/// Circle centred in the cell, drawn as four cubic-Bézier quadrants.
fn circle_path( r: Rect ) -> Vec<PathCmd>
{
let cx = r.x + r.width / 2.0;
let cy = r.y + r.height / 2.0;
let rad = ( r.width.min( r.height ) ) / 2.0 - MARGIN;
let k = rad * 0.552_284_75; // 4/3 * tan(pi/8): cubic circle approximation
vec![
PathCmd::MoveTo( cx + rad, cy ),
PathCmd::CubicTo( cx + rad, cy + k, cx + k, cy + rad, cx, cy + rad ),
PathCmd::CubicTo( cx - k, cy + rad, cx - rad, cy + k, cx - rad, cy ),
PathCmd::CubicTo( cx - rad, cy - k, cx - k, cy - rad, cx, cy - rad ),
PathCmd::CubicTo( cx + k, cy - rad, cx + rad, cy - k, cx + rad, cy ),
PathCmd::Close,
]
}
/// Downward triangle inset by `MARGIN` — a polygon clip.
fn triangle_path( r: Rect ) -> Vec<PathCmd>
{
vec![
PathCmd::MoveTo( r.x + r.width / 2.0, r.y + MARGIN ),
PathCmd::LineTo( r.x + r.width - MARGIN, r.y + r.height - MARGIN ),
PathCmd::LineTo( r.x + MARGIN, r.y + r.height - MARGIN ),
PathCmd::Close,
]
}
/// A cell that paints `bg()` everywhere, then `fg()` clipped to `shape`. The
/// previous clip is snapshotted and restored so the clip does not leak to the
/// rest of the frame.
fn clipped_cell( shape: fn( Rect ) -> Vec<PathCmd> ) -> Element<Msg>
{
External::cpu( CELL, CELL, move |canvas, rect|
{
canvas.fill_rect( rect, bg(), 0.0 );
let saved = canvas.clip_bounds();
canvas.set_clip_path( &shape( rect ) );
canvas.fill_rect( rect, fg(), 0.0 );
canvas.set_clip_rects( &saved );
} ).into()
}
impl App for Demo
{
type Message = Msg;
fn view( &self ) -> Element<Msg>
{
column()
.spacing( 16.0 )
.padding( 24.0 )
.push( text( "Canvas::set_clip_path — rounded rect, circle, triangle" ) )
.push(
row()
.spacing( 16.0 )
.push( clipped_cell( |r| rounded_rect_path( r, 32.0 ) ) )
.push( clipped_cell( circle_path ) )
.push( clipped_cell( triangle_path ) )
)
.into()
}
fn update( &mut self, _msg: Msg ) {}
fn on_key( &mut self, keysym: Keysym ) -> Option<Msg>
{
if keysym == Keysym::Escape
{
std::process::exit( 0 );
}
None
}
fn background_color( &self ) -> Color
{
ltk::theme_palette().bg
}
}
fn main()
{
ltk::run( Demo );
}

View File

@@ -23,7 +23,7 @@ use std::time::{ Duration, Instant };
use ltk::
{
App, ButtonVariant, Color, Element, Keysym, OverlayId, OverlaySpec,
App, ButtonVariant, Color, Element, Keysym, Length, OverlayId, OverlaySpec,
ThemeMode,
button, column, container, progress_bar, row, slider, spacer, text, toggle,
};
@@ -190,7 +190,7 @@ impl App for AppState
id: OVERLAY_QUICK_SETTINGS,
layer: ltk::Layer::Overlay,
anchor: ltk::Anchor::ALL,
size: ( 0, 0 ),
size: ( Length::px( 0.0 ), Length::px( 0.0 ) ),
exclusive_zone: 0,
keyboard_exclusive: false,
input_region: None,
@@ -207,7 +207,7 @@ impl App for AppState
id: OVERLAY_CONFIRM,
layer: ltk::Layer::Overlay,
anchor: ltk::Anchor::ALL,
size: ( 0, 0 ),
size: ( Length::px( 0.0 ), Length::px( 0.0 ) ),
exclusive_zone: 0,
keyboard_exclusive: false,
input_region: None,
@@ -224,7 +224,7 @@ impl App for AppState
id: OVERLAY_TOAST,
layer: ltk::Layer::Overlay,
anchor: ltk::Anchor::ALL,
size: ( 0, 0 ),
size: ( Length::px( 0.0 ), Length::px( 0.0 ) ),
exclusive_zone: 0,
keyboard_exclusive: false,
// Pass-through: the OSD must not steal taps from anything below.

156
examples/responsive.rs Normal file
View File

@@ -0,0 +1,156 @@
//! `cargo run --example responsive`
//!
//! Demonstrates ltk's two responsive modes side by side on stock widgets.
//! None of the widgets below set an explicit size — they all follow the
//! process-wide [`ltk::WidgetScaling`] mode:
//!
//! - **Fluid** (the default): sizes are a fraction of the surface, so the
//! whole set grows and shrinks as you resize the window.
//! - **Physical**: sizes are a constant real-world size scaled by
//! [`ltk::density`], independent of the surface.
//!
//! Tap **Switch mode** to flip between them, and ** density** to change
//! the physical density. Watch the button, field, checkbox, switch, slider
//! and progress bar resize (or not) accordingly. Esc exits.
//!
//! NOTE: ltk is a Wayland layer-shell toolkit. This example requires a running
//! Wayland compositor (e.g. sway, labwc, or a full desktop session).
use ltk::
{
App, Element, Keysym, ButtonVariant, WidgetScaling,
button, checkbox, column, progress_bar, row, separator, slider, spacer, text, text_edit, toggle,
set_widget_scaling, set_density, density,
};
#[ derive( Clone ) ]
enum Message
{
SwitchMode,
DensityUp,
DensityDown,
NameChanged( String ),
ToggleCheck,
ToggleSwitch,
SliderChanged( f32 ),
}
struct ResponsiveApp
{
physical: bool,
name: String,
checked: bool,
switched: bool,
volume: f32,
}
impl ResponsiveApp
{
fn new() -> Self
{
// Start in the default fluid mode with a neutral density.
set_widget_scaling( WidgetScaling::Fluid );
set_density( 1.5 );
Self
{
physical: false,
name: String::new(),
checked: true,
switched: false,
volume: 0.4,
}
}
}
impl App for ResponsiveApp
{
type Message = Message;
fn view( &self ) -> Element<Message>
{
let palette = ltk::theme_palette();
let primary = palette.text_primary;
let secondary = palette.text_secondary;
let mode_label = if self.physical
{
format!( "Mode: Physical (density {:.1})", density() )
} else {
"Mode: Fluid (resize the window to see it scale)".to_string()
};
// Mode + density controls. Explicit sizes here so the controls stay
// stable while the demo widgets below react to the mode.
let controls = row::<Message>()
.spacing( 12.0 )
.push( button::<Message>( "Switch mode".to_string() )
.variant( ButtonVariant::Primary )
.font_size( 16.0 )
.height( 44.0 )
.on_press( Message::SwitchMode ) )
.push( button::<Message>( " density".to_string() )
.font_size( 16.0 )
.height( 44.0 )
.on_press( Message::DensityDown ) )
.push( button::<Message>( " density".to_string() )
.font_size( 16.0 )
.height( 44.0 )
.on_press( Message::DensityUp ) );
// The demo widgets — NO explicit sizes, so they follow the mode.
let demo = column::<Message>()
.spacing( 16.0 )
.max_width( 520.0 )
.push( button::<Message>( "A stock button".to_string() ).variant( ButtonVariant::Secondary ).on_press( Message::SwitchMode ) )
.push( text_edit( "A stock text field".to_string(), self.name.clone() ).on_change( Message::NameChanged ) )
.push( checkbox( self.checked ).label( "A stock checkbox".to_string() ).on_toggle( Message::ToggleCheck ) )
.push( toggle( self.switched ).label( "A stock switch".to_string() ).on_toggle( Message::ToggleSwitch ) )
.push( slider( self.volume ).on_change( Message::SliderChanged ) )
.push( progress_bar( self.volume ) );
column::<Message>()
.padding( 32.0 )
.spacing( 20.0 )
.center_y( true )
.push( text( "ltk responsive modes".to_string() ).size( 26.0 ).color( primary ).align_center() )
.push( text( mode_label ).size( 15.0 ).color( secondary ).align_center() )
.push( controls )
.push( separator() )
.push( demo )
.push( spacer().weight( 1 ) )
.push( text( "Esc to exit".to_string() ).size( 12.0 ).color( secondary ).align_center() )
.into()
}
fn update( &mut self, msg: Message )
{
match msg
{
Message::SwitchMode =>
{
self.physical = !self.physical;
set_widget_scaling( if self.physical { WidgetScaling::Physical } else { WidgetScaling::Fluid } );
}
Message::DensityUp => set_density( ( density() + 0.25 ).min( 4.0 ) ),
Message::DensityDown => set_density( ( density() - 0.25 ).max( 0.5 ) ),
Message::NameChanged( v ) => self.name = v,
Message::ToggleCheck => self.checked = !self.checked,
Message::ToggleSwitch => self.switched = !self.switched,
Message::SliderChanged( v ) => self.volume = v,
}
}
fn on_key( &mut self, keysym: Keysym ) -> Option<Message>
{
if keysym == Keysym::Escape
{
std::process::exit( 0 );
}
None
}
}
fn main()
{
ltk::run( ResponsiveApp::new() );
}

View File

@@ -1,23 +1,43 @@
//! `cargo run --example showcase`
//!
//! Shows button variants, container with background, a slider, plus the
//! newer additions: a tab strip, a spinner driven by the wall clock, a
//! multiline text edit, and on-demand toast / tooltip overlays.
//! Shows button variants, container with background, a slider, a
//! viewport-relative image, plus the newer additions: a tab strip, a spinner
//! driven by the wall clock, a multiline text edit, and on-demand toast /
//! tooltip overlays.
//! Tab / Shift+Tab cycles keyboard focus. Enter or Space activates the
//! focused button. Esc exits.
//!
//! NOTE: ltk is a Wayland layer-shell toolkit. This example requires a running
//! Wayland compositor (e.g. sway, labwc, or a full desktop session).
use std::sync::Arc;
use std::time::{ Duration, Instant };
use ltk::{
App, Element, Keysym, ButtonVariant, WidgetId,
App, Element, Keysym, ButtonVariant, Length, WidgetId,
OverlaySpec,
button, column, row, stack, text, text_edit, spacer, container, slider, scroll,
spinner, tabs, toast, tooltip,
spinner, tabs, toast, tooltip, img_widget,
};
const IMG_W: u32 = 64;
const IMG_H: u32 = 64;
fn gradient_rgba() -> Arc<Vec<u8>>
{
let mut data = Vec::with_capacity( ( IMG_W * IMG_H * 4 ) as usize );
for y in 0..IMG_H
{
for x in 0..IMG_W
{
let r = ( 255 * x / IMG_W ) as u8;
let g = ( 255 * y / IMG_H ) as u8;
data.extend_from_slice( &[ r, g, 180, 255 ] );
}
}
Arc::new( data )
}
// ── Messages ──────────────────────────────────────────────────────────────────
#[ derive( Clone ) ]
@@ -43,6 +63,7 @@ struct ShowcaseApp
started_at: Instant,
toast_until: Option<Instant>,
tooltip_visible: bool,
image: Arc<Vec<u8>>,
}
impl ShowcaseApp
@@ -58,6 +79,7 @@ impl ShowcaseApp
started_at: Instant::now(),
toast_until: None,
tooltip_visible: false,
image: gradient_rgba(),
}
}
@@ -127,6 +149,25 @@ impl App for ShowcaseApp
.radius( 12.0 )
.padding( 16.0 );
// Viewport-relative image: 30 % of the surface width by 10 % of its
// height, so it rescales with the window instead of staying fixed.
//
// The second image uses the orientation-aware helpers: `short_side`
// sizes it along the screen's short side (width in portrait, height
// in landscape) while `orient` gives that side a different proportion
// per orientation — 40 % of the width in portrait, 8 % of the height
// in landscape — with the other axis following the source aspect.
let banner = column::<Message>()
.spacing( 4.0 )
.push( text( "img_widget().size( vw 30, vh 10 )" ).size( 12.0 ).color( secondary ) )
.push( img_widget( self.image.clone(), IMG_W, IMG_H ).size( Length::vw( 30.0 ), Length::vh( 10.0 ) ) )
.push(
text( "short_side( orient( 40 %w, 8 %h ) ) — resize / rotate to compare" )
.size( Length::orient( 3.0, 2.2 ).clamp( 11.0, 16.0 ) )
.color( secondary )
)
.push( img_widget( self.image.clone(), IMG_W, IMG_H ).short_side( Length::orient( 40.0, 8.0 ) ) );
let vol_label = format!( "Volume: {:.0}%", self.slider_value * 100.0 );
// Spinner — phase comes straight from the wall clock so the arc
@@ -163,6 +204,7 @@ impl App for ShowcaseApp
.push( spacer() )
.push( buttons )
.push( card )
.push( banner )
.push( text( vol_label ).size( 13.0 ).color( secondary ) )
.push( slider( self.slider_value ).on_change( Message::SliderChanged ) )
.push( spin_row )

View File

@@ -33,6 +33,11 @@ pub enum ShellMode
/// Layer-shell surface at the specified layer.
/// Used for shell components like panels, backgrounds, overlays.
Layer( Layer ),
/// `ext-session-lock-v1` lock surface. The compositor blanks the outputs
/// and shows only this surface until the app requests exit (see
/// [`App::requested_exit`]). Used by the screen locker.
SessionLock,
}
/// Layer-shell layer position.
@@ -113,6 +118,24 @@ impl Anchor
#[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord )]
pub struct OverlayId( pub u32 );
/// Stable identifier for a subsurface, used to diff the list returned by
/// [`App::subsurfaces`] between frames the same way [`OverlayId`] diffs
/// overlays.
#[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord )]
pub struct SubsurfaceId( pub u32 );
/// Which surface a [`SubsurfaceSpec`] is composited as a child of. `Main`
/// (the default position) parents to the app's main surface; `Overlay(id)`
/// parents to one of the [`App::overlays`] surfaces, so a slide can ride
/// above app windows the way an overlay panel does. An `Overlay` parent that
/// is absent or not yet configured is skipped for that frame.
#[derive( Debug, Clone, Copy, PartialEq, Eq )]
pub enum SubsurfaceParent
{
Main,
Overlay( OverlayId ),
}
/// One of the surfaces an [`App`] can target with an invalidation. Used inside
/// [`InvalidationScope::Only`] to name the affected surfaces.
#[derive( Debug, Clone, Copy, PartialEq, Eq, Hash )]
@@ -190,9 +213,14 @@ pub struct OverlaySpec<Message: Clone>
/// Screen edges to anchor to.
pub anchor: Anchor,
/// Desired size `( width, height )` in logical pixels. `0` in either
/// component means "fill available space in that dimension".
pub size: ( u32, u32 ),
/// Desired size `( width, height )`, resolved to logical pixels against
/// the main surface (output) when the overlay is materialized. A
/// [`Length::px`](crate::Length::px) keeps a fixed size; a
/// [`Length::widget`](crate::Length::widget) / `vmin` / `dp` makes the
/// overlay surface scale with the display like any other sized element.
/// A component that resolves to `0` (e.g. `Length::px( 0.0 )`) means
/// "fill available space in that dimension".
pub size: ( crate::types::Length, crate::types::Length ),
/// Exclusive zone in pixels reserved from the anchored edge.
/// `-1` requests focus without reserving space, `0` is the default for
@@ -250,6 +278,53 @@ pub struct OverlaySpec<Message: Clone>
pub anchor_widget_id: Option<crate::types::WidgetId>,
}
/// An input-transparent child surface composited over the main surface and
/// moved by the compositor.
///
/// Returned from [`App::subsurfaces`]. The runtime creates one `wl_subsurface`
/// per active spec, full-size over the main surface, with an **empty input
/// region** — all pointer/touch falls through to the main surface, so the host
/// keeps a single input/gesture model. The win is in the move: the content
/// buffer is rasterised only when [`content_version`](Self::content_version)
/// (or the surface size) changes, while [`x`](Self::x) / [`y`](Self::y)
/// changes only emit `wl_subsurface.set_position` + a parent commit — no
/// re-raster, no buffer re-upload. This makes an animated reveal/slide track
/// the finger at the compositor's compositing cost rather than the client's
/// raster cost.
pub struct SubsurfaceSpec<Message: Clone>
{
/// Stable identifier, used to diff subsurfaces between frames.
pub id: SubsurfaceId,
/// Surface this subsurface is composited as a child of. Sized to that
/// parent; [`x`](Self::x) / [`y`](Self::y) are relative to its top-left.
pub parent: SubsurfaceParent,
/// Widget tree for this subsurface. Should paint its own opaque
/// background if it must cover the main surface beneath it.
pub view: Element<Message>,
/// Position of the subsurface relative to the parent's top-left, in
/// layout (physical) pixels — the same space as [`App::on_resize`] and
/// widget rects. The runtime divides by the surface scale to obtain the
/// logical position it hands to the compositor. Animate this for a cheap
/// compositor-side move.
pub x: i32,
pub y: i32,
/// Content revision. Bump it whenever [`view`](Self::view) would paint
/// differently; leave it unchanged for position-only updates so the
/// runtime skips the re-raster and only repositions.
pub content_version: u64,
/// Render through GLES (so `backdrop-filter` glass works) rather than the
/// software rasteriser. GLES is only worth it for content that actually
/// uses the blur: it pays a per-surface EGL-surface creation (and a
/// one-time shader compile) the cheap software path avoids, so a panel
/// without glass should leave this `false`.
pub gpu: bool,
}
/// Trait that application types must implement to integrate with ltk.
pub trait App: 'static
{
@@ -290,9 +365,35 @@ pub trait App: 'static
/// IDs that disappear cause the surface to be destroyed.
fn overlays( &self ) -> Vec<OverlaySpec<Self::Message>> { Vec::new() }
/// Describe the input-transparent child surfaces composited over the main
/// surface this frame. Each becomes a `wl_subsurface` the compositor moves
/// by position; see [`SubsurfaceSpec`]. Diffed across frames by
/// [`SubsurfaceSpec::id`]. Default empty.
fn subsurfaces( &self ) -> Vec<SubsurfaceSpec<Self::Message>> { Vec::new() }
/// Return any pending messages from external sources (timers, async, etc.).
fn poll_external( &mut self ) -> Vec<Self::Message> { vec![] }
/// Tags for which the app wants a fresh `xdg-activation-v1` token this
/// iteration. Drained once per loop; each tag is echoed back verbatim
/// through [`App::on_activation_token`] once the compositor issues the
/// token, so the app can hand it to a child it is about to spawn via the
/// `$XDG_ACTIVATION_TOKEN` launch convention. Default none.
fn take_activation_requests( &mut self ) -> Vec<String> { Vec::new() }
/// Deliver a token issued for an earlier [`App::take_activation_requests`]
/// tag. `tag` is the string the app handed out; `token` is the opaque
/// activation token to place in the launched child's environment.
fn on_activation_token( &mut self, _tag: String, _token: String ) -> Option<Self::Message> { None }
/// Called once, immediately after the first buffer for the main surface
/// has been rendered and committed to the compositor. Note: this is the
/// client-side commit, not the compositor's present — under VT switching
/// the actual present can be deferred (no DRM master yet), so a handoff
/// signal that fires here is enough to tell a parent "ready to be
/// presented as soon as my VT becomes active".
fn on_first_frame_committed( &mut self ) {}
/// Called when the surface is asked to close (compositor request, titlebar button,
/// layer-shell closed event). Return `true` to allow the application to exit,
/// or `false` to cancel.
@@ -320,11 +421,18 @@ pub trait App: 'static
/// `swipe_threshold()` × screen height and then releases.
fn on_swipe_up( &mut self ) -> Option<Self::Message> { None }
/// Called during an upward swipe gesture with progress 0.0..=1.0.
/// Called during an upward swipe gesture with progress ≥ 0.0.
///
/// Use this to create follow-the-finger animations. `progress` starts at 0.0
/// when the drag begins and increases as the finger moves upward, reaching 1.0
/// when the drag distance equals `swipe_threshold()` × screen height.
/// when the drag distance equals `swipe_threshold()` × screen height. It is
/// **not** clamped at 1.0 — the value keeps growing if the finger drags further
/// so that follow-the-finger panels can continue tracking the finger all the way
/// up the screen. Clamp inside the handler if you need a bounded `[0, 1]` range
/// for a visual indicator.
///
/// When the user releases without completing the gesture, this method is called
/// once more with `progress = 0.0` to signal cancellation.
fn on_swipe_progress( &mut self, _progress: f32 ) {}
/// Called when a sufficient downward swipe gesture is detected.
@@ -370,6 +478,22 @@ pub trait App: 'static
/// edge, matching the usual system-panel pull-down UX.
fn swipe_down_edge( &self ) -> f32 { 1.0 }
/// Called while a vertical `scroll` is pinned at
/// its top and the finger keeps pulling down. `progress` is the
/// accumulated overscroll as a fraction of the surface height (≥ 0.0,
/// unbounded). Use it to drive a pull-from-top-to-dismiss panel that
/// tracks the finger.
fn on_overscroll_down_progress( &mut self, _progress: f32 ) {}
/// Called when the finger releases after a top overscroll pull-down.
/// `committed` is `true` when the pull passed
/// [`overscroll_dismiss_threshold`](Self::overscroll_dismiss_threshold).
fn on_overscroll_down( &mut self, _committed: bool ) -> Option<Self::Message> { None }
/// Fraction of surface height a top overscroll pull-down must exceed to
/// commit a dismiss on release. Default: `0.25`.
fn overscroll_dismiss_threshold( &self ) -> f32 { 0.25 }
/// Called when a sufficient leftward swipe gesture is detected.
///
/// Return a message to handle the swipe (e.g., paging to the next
@@ -533,6 +657,13 @@ pub trait App: 'static
/// physical dimensions.
fn on_resize( &mut self, _width: u32, _height: u32 ) {}
/// Called when the compositor reports a new integer buffer scale for the
/// main surface. `scale` is the integer buffer scale (1 = standard DPI,
/// 2 = HiDPI ×2); physical pixels equal logical pixels × scale. The default
/// is inert so apps that only care about physical pixels can keep using
/// [`on_resize`](Self::on_resize).
fn on_scale_changed( &mut self, _scale: u32 ) {}
/// Called once at startup with a channel sender that can be used from any
/// thread to deliver messages into the event loop. Sending a message
/// immediately wakes the event loop — no polling delay.
@@ -596,16 +727,24 @@ pub trait App: 'static
/// The event loop will keep requesting redraws at ~60 fps until this returns `false`.
fn is_animating( &self ) -> bool { false }
/// Return `true` while the next frame should swap the expensive
/// Glass passes for cheap fallbacks — currently the
/// `backdrop-filter` blur drops from a 41-tap kernel to a 9-tap
/// kernel, with the snapshot region shrunk to match. The event
/// loop sets this on the renderer right before drawing through an
/// internal low-quality-paint flag.
///
/// Default: matches [`Self::is_animating`], so a settle animation
/// automatically downgrades. Override to include other "in motion"
/// states the runtime cannot observe — e.g. a finger-tracked
/// Cap [`Self::is_animating`] redraws to ~30 Hz when the **software**
/// renderer is active. Default `true`: 60 fps software rasterization is a
/// common battery sink on mobile (no GPU offload), and halving it is
/// usually imperceptible for the animations software fallback can afford.
/// The GLES backend is never capped. Return `false` to keep the full
/// display rate on software.
fn cap_software_animation( &self ) -> bool { true }
/// Return `true` when a finger-tracked swipe or an [`Self::is_animating`]
/// slide only repositions the app's input-transparent subsurfaces
/// ([`Self::subsurfaces`]) while the main surface buffer stays unchanged.
/// The runtime then skips the per-frame main re-raster that
/// [`Self::on_swipe_progress`] and `is_animating` would otherwise force,
/// keeping the animation pumped (motion events during the drag, a bare
/// frame callback while `is_animating`) and letting the per-frame
/// subsurface reconcile carry the motion. Use it for a slide-to-reveal
/// panel over a static main surface.
fn subsurface_motion_only( &self ) -> bool { false }
/// Background color for the canvas. Override to make the surface
/// transparent or to deviate from the theme. Default: the active
@@ -633,11 +772,20 @@ pub trait App: 'static
///
/// - [`ShellMode::Window`]: Normal application window (xdg-shell). **Default.**
/// - [`ShellMode::Layer`]: System component at a specific layer (layer-shell).
/// - [`ShellMode::SessionLock`]: `ext-session-lock-v1` lock surface (screen locker).
///
/// For regular applications, use the default `Window` mode.
/// For shell components (panels, backgrounds, overlays), use `Layer`.
/// For a screen locker, use `SessionLock` and request the unlock by
/// returning `true` from [`requested_exit`](Self::requested_exit).
fn shell_mode( &self ) -> ShellMode { ShellMode::Window }
/// Return `true` to tear the surface down and exit the event loop. For a
/// [`ShellMode::SessionLock`] surface the runtime calls `unlock` first, so
/// the compositor lifts the lock instead of leaving the outputs blanked.
/// Polled after every batch of `update`s.
fn requested_exit( &self ) -> bool { false }
/// Suggest an initial size for an xdg-shell window in logical pixels.
///
/// Returning `Some(( w, h ))` makes ltk call both

66
src/chassis.rs Normal file
View File

@@ -0,0 +1,66 @@
//! Scaffolding shared by full-screen ambient surfaces (greeter, lock screen,
//! kiosk): theme bring-up, branding/wallpaper loading and the wallpaper-backed
//! view stack. Thin convenience over [`theme`](crate::theme) and
//! [`WallpaperBundle`] — every app that paints a
//! wallpaper behind centred content repeats this otherwise.
use std::sync::Arc;
use crate::{ Color, Element, ImageData, ThemeMode, WallpaperBundle };
/// Find, install and activate the `default` theme document in `mode`. Returns
/// the failure message instead of exiting; the caller decides how to abort.
pub fn set_default_theme( mode: ThemeMode ) -> Result<(), String>
{
let doc = crate::ThemeDocument::find( "default" ).map_err( |e| format!( "{e}" ) )?;
crate::set_active_document( doc );
crate::set_active_mode( mode );
Ok( () )
}
/// Decode the active theme's horizontal logo to RGBA, `size` px on the longer
/// edge. `None` if the theme ships no logo or the SVG fails to rasterise.
pub fn theme_logo_rgba( size: u32 ) -> Option<ImageData>
{
let path = crate::theme_logo_horizontal()?;
let bytes = std::fs::read( &path ).ok()?;
crate::decode_svg_bytes( &bytes, size )
}
/// Load a symbolic theme icon to RGBA, tinted with `tint`. `None` if missing.
pub fn theme_icon_tinted( name: &str, size: u32, tint: Color ) -> Option<ImageData>
{
let ( rgba, w, h ) = crate::theme_icon_rgba( name, size )?;
Some( ( Arc::new( crate::tint_symbolic( &rgba, tint ) ), w, h ) )
}
/// The active theme's branding image `name` (e.g. `"wallpaper"`,
/// `"lockscreen"`) as a [`WallpaperBundle`], falling back to a solid fill of
/// the palette background when the theme ships none.
pub fn branding_bundle_or_solid( name: &str ) -> WallpaperBundle
{
let path = crate::theme_branding_image( name, 0, 0 );
let bg = crate::theme_palette().bg;
WallpaperBundle::from_path_or_solid(
path.as_deref(),
( bg.r * 255.0 ) as u8,
( bg.g * 255.0 ) as u8,
( bg.b * 255.0 ) as u8,
)
}
/// Convenience for [`branding_bundle_or_solid`] with `"wallpaper"`.
pub fn wallpaper_bundle_or_solid() -> WallpaperBundle
{
branding_bundle_or_solid( "wallpaper" )
}
/// Stack `content` over `wallpaper` resolved for the given surface size.
pub fn backdrop<M: Clone>( content: Element<M>, wallpaper: &WallpaperBundle, w: u32, h: u32 ) -> Element<M>
{
let ( rgba, iw, ih ) = wallpaper.for_size( w, h );
crate::stack::<M>()
.push( crate::img_widget( rgba, iw, ih ) )
.push( content )
.into()
}

View File

@@ -319,8 +319,11 @@ impl<Msg: Clone> UiSurface<Msg>
self.pressed_idx = idx;
}
/// Flat index of the keyboard-focused widget, or `None` when nothing holds focus.
pub fn focused( &self ) -> Option<usize> { self.focused_idx }
/// Flat index of the hovered widget, or `None` when the pointer is over no interactive widget.
pub fn hovered( &self ) -> Option<usize> { self.hovered_idx }
/// Flat index of the pressed widget, or `None` when no widget is held down.
pub fn pressed( &self ) -> Option<usize> { self.pressed_idx }
/// Render `element` into the backing canvas.

View File

@@ -84,15 +84,23 @@ pub( crate ) fn apply_input_region(
wl_surface: &WlSurface,
compositor: &CompositorState,
input_region: Option<&[Rect]>,
scale: u32,
)
{
if let Some( regions ) = input_region
{
if let Ok( region ) = Region::new( compositor )
{
// `Rect`s are physical (layout space); `wl_region` is logical.
let s = scale.max( 1 ) as f32;
for r in regions
{
region.add( r.x as i32, r.y as i32, r.width as i32, r.height as i32 );
region.add(
( r.x / s ).round() as i32,
( r.y / s ).round() as i32,
( r.width / s ).round() as i32,
( r.height / s ).round() as i32,
);
}
wl_surface.set_input_region( Some( region.wl_region() ) );
}

View File

@@ -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<Msg: Clone>(
ss.titlebar_close_rect = draw_titlebar( canvas, &ss.titlebar_title, pw, tb_h, sf );
let mut ctx: DrawCtx<Msg> = 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::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
if ctx.debug_layout
@@ -126,22 +110,11 @@ pub( crate ) fn draw_surface_full_gpu<Msg: Clone>(
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;
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 );
apply_input_region( wl_surface, compositor, input_region, scale );
// Frame callback request must precede the implicit commit done by
// `swap_buffers_with_damage`, so the compositor can attach it to this
// frame and not the previous one.
@@ -195,23 +168,7 @@ pub( crate ) fn draw_surface_partial_gpu<Msg: Clone>(
ss.titlebar_close_rect = draw_titlebar( canvas, &ss.titlebar_title, pw, tb_h, sf );
let mut ctx: DrawCtx<Msg> = 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::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
if let Some( ref menu ) = ss.context_menu
@@ -226,22 +183,11 @@ pub( crate ) fn draw_surface_partial_gpu<Msg: Clone>(
canvas.clear_clip();
canvas.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;
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 );
apply_input_region( wl_surface, compositor, input_region, scale );
request_frame( wl_surface );
// Convert top-left dirty rects to EGL bottom-left coords for the
// swap-with-damage call.

View File

@@ -53,7 +53,24 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
let mut idx = flat_idx;
for ( child_rect, child_i ) in child_rects
{
idx = layout_and_draw::<Msg>( &s.children[ child_i ].0, canvas, child_rect, ctx, idx );
let child = &s.children[ child_i ];
// A placed child may carry a clip rect (Android clipChildren): clip the
// canvas to it for the child's subtree, then restore the prior clip.
// The clip is in the Stack's coordinate space (like the placed rect),
// so it is shifted by the Stack's origin to match — otherwise a Stack
// laid out at a non-zero origin clips in the wrong place.
if let Some( clip ) = child.7
{
let saved = canvas.clip_bounds();
let clip = Rect { x: rect.x + clip.x, y: rect.y + clip.y, width: clip.width, height: clip.height };
canvas.set_clip_rects( &[ clip ] );
idx = layout_and_draw::<Msg>( &child.0, canvas, child_rect, ctx, idx );
canvas.set_clip_rects( &saved );
}
else
{
idx = layout_and_draw::<Msg>( &child.0, canvas, child_rect, ctx, idx );
}
}
idx
}
@@ -67,6 +84,16 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
}
idx
}
Element::Carousel( car ) =>
{
let child_rects = car.layout( rect, canvas );
let mut idx = flat_idx;
for ( child_rect, child_i ) in child_rects
{
idx = layout_and_draw::<Msg>( &car.children[child_i], canvas, child_rect, ctx, idx );
}
idx
}
Element::Flex( f ) =>
{
// `Flex` is invisible chrome: it claimed leftover width up at
@@ -185,12 +212,18 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
{
canvas.stroke_rect( rect, color, width, c.corners );
}
let vp = canvas.viewport_layout();
let em = crate::types::Length::EM_BASE_DEFAULT;
let pad_l = c.pad_left.resolve( vp, em );
let pad_r = c.pad_right.resolve( vp, em );
let pad_t = c.pad_top.resolve( vp, em );
let pad_b = c.pad_bottom.resolve( vp, em );
let inner = crate::types::Rect
{
x: rect.x + c.pad_left,
y: rect.y + c.pad_top,
width: ( rect.width - c.pad_left - c.pad_right ).max( 0.0 ),
height: ( rect.height - c.pad_top - c.pad_bottom ).max( 0.0 ),
x: rect.x + pad_l,
y: rect.y + pad_t,
width: ( rect.width - pad_l - pad_r ).max( 0.0 ),
height: ( rect.height - pad_t - pad_b ).max( 0.0 ),
};
let result = layout_and_draw::<Msg>( c.child.as_ref(), canvas, inner, ctx, flat_idx );
@@ -364,6 +397,38 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
canvas.blit_fade_bottom( &sub, rect.x as i32, rect.y as i32, v.fade_bottom );
next_idx
}
Element::RichText( r ) =>
{
r.draw( canvas, rect );
// One hit rect per link line, each its own flat_idx so hover/press
// state does not bleed between links sharing a paragraph.
let link_rects = r.link_rects( rect, canvas );
let consumed = link_rects.len().max( 1 );
for ( k, ( lr, msg ) ) in link_rects.into_iter().enumerate()
{
ctx.widget_rects.push( LaidOutWidget
{
rect: lr,
flat_idx: flat_idx + k,
id: None,
paint_rect: lr,
handlers: WidgetHandlers::Button
{
on_press: Some( msg ),
on_long_press: None,
on_drag_start: None,
on_escape: None,
repeating: false,
},
keyboard_focusable: false,
cursor: crate::types::CursorShape::Pointer,
tooltip: None,
accessible_label: None,
is_live_region: ctx.live_depth > 0,
} );
}
flat_idx + consumed
}
other =>
{
// Gate the focus ring by `is_focusable`: a widget that opts out
@@ -388,13 +453,22 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
other.draw( canvas, rect, is_focused, is_hovered, is_pressed, cursor_pos, sel_anchor );
if other.is_interactive()
{
let mut handlers = other.handlers();
// Horizontal sliders resolve their thumb size through the
// widget-scaling mode here — layout has the canvas — so the
// input path maps clicks against the same thumb the renderer
// drew (see `slider::value_from_x_in_rect`).
if matches!( other, Element::Slider( _ ) )
{
handlers.set_slider_thumb_px( crate::widget::slider::resolved_thumb_px( canvas ) );
}
ctx.widget_rects.push( LaidOutWidget
{
rect,
flat_idx,
id: widget_id,
paint_rect: other.paint_bounds( rect ),
handlers: other.handlers(),
handlers,
keyboard_focusable: other.is_focusable(),
cursor: other.cursor_shape(),
tooltip: other.tooltip().map( str::to_string ),

View File

@@ -90,6 +90,65 @@ pub( crate ) struct DrawCtx<Msg: Clone>
pub live_depth: u32,
}
/// Build the per-frame [`DrawCtx`] from the surface's [`FrameState`], moving the
/// carried maps (cursor / selection / scroll) out of `frame` into the context.
/// The interaction indices are passed by value (they live on `SurfaceState`, not
/// `frame`). `debug_layout` is `false` on the partial paths. Shared by all four
/// frame paths; [`commit_draw_ctx`] writes the produced state back.
pub( crate ) fn build_draw_ctx<Msg: Clone>(
frame: &mut crate::event_loop::FrameState<Msg>,
focused_idx: Option<usize>,
hovered_idx: Option<usize>,
pressed_idx: Option<usize>,
debug_layout: bool,
) -> DrawCtx<Msg>
{
DrawCtx
{
focused_idx,
hovered_idx,
pressed_idx,
cursor_state: std::mem::take( &mut frame.cursor_state ),
selection_anchor: std::mem::take( &mut frame.selection_anchor ),
widget_rects: Vec::new(),
debug_layout,
scroll_offsets: std::mem::take( &mut frame.scroll_offsets ),
scroll_rects: Vec::new(),
scroll_canvases: std::mem::take( &mut frame.scroll_canvases ),
scroll_navigable_items: std::mem::take( &mut frame.scroll_navigable_items ),
previous_widget_rects: frame.widget_rects.clone(),
accessible_extras: Vec::new(),
live_depth: 0,
}
}
/// Write the finished frame's [`DrawCtx`] back into the [`FrameState`]: snapshot
/// the interaction indices into `prev_*` and move the produced rect lists and
/// carried maps back. Shared by all four frame paths; the caller must have
/// already consumed anything it needs from `ctx` (e.g. `compute_damage`) before
/// calling this, and resets `content_dirty` itself (it lives on `SurfaceState`).
pub( crate ) fn commit_draw_ctx<Msg: Clone>(
frame: &mut crate::event_loop::FrameState<Msg>,
focused_idx: Option<usize>,
hovered_idx: Option<usize>,
pressed_idx: Option<usize>,
mut ctx: DrawCtx<Msg>,
)
{
frame.prev_focused = focused_idx;
frame.prev_hovered = hovered_idx;
frame.prev_pressed = pressed_idx;
frame.widget_rects = ctx.widget_rects;
frame.scroll_rects = ctx.scroll_rects;
frame.scroll_canvases = std::mem::take( &mut ctx.scroll_canvases );
frame.cursor_state = ctx.cursor_state;
frame.selection_anchor = ctx.selection_anchor;
frame.scroll_offsets = ctx.scroll_offsets;
frame.accessible_extras = ctx.accessible_extras;
frame.scroll_navigable_items = ctx.scroll_navigable_items;
}
/// Paint the built-in Copy / Cut / Paste context menu on top of the
/// finished surface content. Called from the software and GLES draw
/// paths right before `present()` so the menu sits above everything

View File

@@ -29,7 +29,7 @@ use crate::render::Canvas;
use crate::types::{ Color, Rect };
use crate::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<Msg: Clone>(
ss.titlebar_close_rect = draw_titlebar( canvas, &ss.titlebar_title, pw, tb_h, sf );
let mut ctx: DrawCtx<Msg> = 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::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
if ctx.debug_layout
@@ -126,11 +110,11 @@ pub( crate ) fn draw_surface_full<Msg: Clone>(
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,18 +122,7 @@ pub( crate ) fn draw_surface_full<Msg: Clone>(
)
};
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;
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 );
@@ -167,7 +140,7 @@ pub( crate ) fn draw_surface_full<Msg: Clone>(
}
}
apply_input_region( wl_surface, compositor, input_region );
apply_input_region( wl_surface, compositor, input_region, scale );
// Request a frame callback BEFORE commit so the compositor schedules it
// against this exact frame; sets `frame_pending` so the run loop won't
// try to draw this surface again until the callback fires.
@@ -225,23 +198,7 @@ pub( crate ) fn draw_surface_partial<Msg: Clone>(
ss.titlebar_close_rect = draw_titlebar( canvas, &ss.titlebar_title, pw, tb_h, sf );
let mut ctx: DrawCtx<Msg> = 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::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
if let Some( ref menu ) = ss.context_menu
@@ -258,18 +215,7 @@ pub( crate ) fn draw_surface_partial<Msg: Clone>(
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;
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 );
@@ -282,7 +228,7 @@ pub( crate ) fn draw_surface_partial<Msg: Clone>(
wl_surface.damage_buffer( r.x as i32, r.y as i32, r.width as i32, r.height as i32 );
}
apply_input_region( wl_surface, compositor, input_region );
apply_input_region( wl_surface, compositor, input_region, scale );
request_frame( wl_surface );
wl_surface.commit();
ss.frame_pending = true;

View File

@@ -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<EglInstance>,
/// 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<EglInstance>,
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<glow::Context> { &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 }
}

View File

@@ -9,6 +9,8 @@ use smithay_client_toolkit::
seat::SeatState,
shell::{ wlr_layer::LayerShell, xdg::XdgShell },
shm::Shm,
session_lock::SessionLock,
subcompositor::SubcompositorState,
};
use smithay_client_toolkit::reexports::client::
{
@@ -29,11 +31,12 @@ use wayland_protocols::wp::text_input::zv3::client::
use std::collections::HashMap;
use std::sync::Arc;
use crate::app::{ App, OverlayId };
use crate::app::{ App, OverlayId, SubsurfaceId };
use crate::egl_context::EglContext;
use crate::types::Point;
use super::repeat::{ ButtonRepeatState, KeyRepeatState };
use super::subsurface::SubsurfaceSlot;
use super::surface::{ SurfaceFocus, SurfaceState };
use super::tooltip::{ TooltipPending, TooltipVisible };
@@ -44,7 +47,11 @@ pub struct AppData<A: App>
pub seat_state: SeatState,
pub output_state: OutputState,
pub compositor_state: CompositorState,
/// `wl_subcompositor` binding for [`crate::app::App::subsurfaces`].
/// `None` when the compositor does not advertise the protocol.
pub subcompositor: Option<SubcompositorState>,
pub shm: Shm,
pub session_lock: Option<SessionLock>,
/// Process-wide EGL context (display + GLES context). `None` when EGL
/// failed to initialise or `LTK_FORCE_SOFTWARE=1` — every surface then
/// falls back to the SHM path.
@@ -80,6 +87,9 @@ pub struct AppData<A: App>
pub current_cursor_shape: Option<crate::types::CursorShape>,
pub text_input_manager: Option<ZwpTextInputManagerV3>,
pub text_input: Option<ZwpTextInputV3>,
/// Whether the currently focused text field is `secure` (password).
/// Drives the text-input-v3 content type.
pub text_input_secure: bool,
/// `xdg-activation-v1`. Present when the compositor advertises the
/// global. Used on first configure to honour an incoming
/// `XDG_ACTIVATION_TOKEN` (a launcher that spawned us wants the
@@ -208,6 +218,14 @@ pub struct AppData<A: App>
/// Populated and reconciled by the run loop from [`App::overlays`]. Always
/// empty until overlay diffing is wired up.
pub overlays: HashMap<OverlayId, SurfaceState<A::Message>>,
/// Input-transparent child surfaces keyed by their stable
/// [`SubsurfaceId`]. Reconciled each frame from [`App::subsurfaces`].
pub subsurfaces: HashMap<SubsurfaceId, SubsurfaceSlot>,
/// Shared GLES canvas reused across every subsurface raster. Held here
/// (not per-slot) so a subsurface that is dropped and re-created — e.g. a
/// lazily-emitted sliding panel — does not recompile the ~14 shader
/// programs (hundreds of ms on a mobile GPU) every time it reappears.
pub subsurface_gles_canvas: Option<crate::render::Canvas>,
/// Surface currently receiving pointer events (updated on each pointer
/// event via its `surface` field).
pub pointer_focus: SurfaceFocus,
@@ -232,6 +250,15 @@ pub struct AppData<A: App>
pub view_dirty: bool,
/// Counterpart of `view_dirty` for `cached_overlays`.
pub overlays_dirty: bool,
/// Set after the first commit of a rendered buffer on the main surface.
/// Used to drive `App::on_first_frame_committed` exactly once.
pub first_frame_committed: bool,
/// Focus request deferred because the target widget was absent from
/// `widget_rects` (e.g. read_only, not yet rebuilt). Retried next draw.
pub focus_retry: Option<crate::types::WidgetId>,
/// Runtime performance guardrails (stuck-animation / poll-interval
/// diagnostics and the software animation-rate cap). See [`super::perf`].
pub perf: super::perf::PerfState,
}
impl<A: App> AppData<A>
@@ -324,6 +351,11 @@ impl<A: App> AppData<A>
{
self.main.gesture.long_press_fired = true;
self.main.gesture.long_press_origin = ss.gesture.long_press_origin;
// Keep the primary touch slot with the migrated drag so motion / release still route through the gesture machine.
if self.main.primary_touch_id.is_none()
{
self.main.primary_touch_id = ss.primary_touch_id;
}
}
}
if let SurfaceFocus::Overlay( fid ) = self.pointer_focus
@@ -387,6 +419,7 @@ impl<A: App> AppData<A>
// configure.new_size is surface-local (logical), so multiply by the
// current buffer scale before handing the dimensions to the app.
let sf = self.main.scale_factor.max( 1 ) as u32;
self.app.on_scale_changed( sf );
self.app.on_resize( w * sf, h * sf );
// `on_resize` may flip app-state that the view depends on (apps that
// branch on the new dimensions, layout caches keyed by size, …), so

View File

@@ -31,7 +31,7 @@ impl<A: App> AppData<A>
}
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 ); }
}

View File

@@ -88,7 +88,7 @@ impl<A: App> AppData<A>
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<A: App> AppData<A>
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<A: App> AppData<A>
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 ); }
}

View File

@@ -115,7 +115,7 @@ impl<A: App> AppData<A>
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 );

View File

@@ -35,7 +35,7 @@ impl<A: App> AppData<A>
// 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
@@ -84,15 +84,20 @@ impl<A: App> AppData<A>
{
let was_text_input;
let is_text_input;
let secure;
{
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.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 );
@@ -108,7 +113,7 @@ impl<A: App> AppData<A>
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 )
} );
@@ -125,7 +130,7 @@ impl<A: App> AppData<A>
{
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() )
@@ -135,8 +140,8 @@ impl<A: App> AppData<A>
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 );
}
}
}
@@ -147,11 +152,17 @@ impl<A: App> AppData<A>
self.app.on_text_input_focused( false );
self.dirty_caches();
}
if is_text_input && !was_text_input
else if is_text_input && !was_text_input
{
self.activate_text_input( qh );
self.activate_text_input( qh, secure );
self.app.on_text_input_focused( true );
self.dirty_caches();
}
else if is_text_input
{
// Focus moved between text fields: refresh the content type so a
// password field is flagged even without re-activating.
self.activate_text_input( qh, secure );
}
}
}

View File

@@ -31,7 +31,10 @@ pub( crate ) fn pick_shm_format( shm: &Shm ) -> ( wl_shm::Format, bool )
}
}
pub( crate ) fn draw_frame<A: App>( data: &mut AppData<A> )
/// Returns `true` when this call performed the very first commit on the main
/// surface — the run loop uses that to fire `App::on_first_frame_committed`
/// exactly once, after the borrows held by draw_frame have been released.
pub( crate ) fn draw_frame<A: App>( data: &mut AppData<A> ) -> bool
{
let main_view = data.cached_view.as_ref().expect( "view cache populated" );
let overlays = data.cached_overlays.as_ref().expect( "overlays cache populated" );
@@ -43,6 +46,7 @@ pub( crate ) fn draw_frame<A: App>( data: &mut AppData<A> )
let egl_ctx = data.egl_context.as_ref();
let qh = &data.qh;
let mut first_commit_now = false;
if data.main.configured && data.main.needs_redraw && !data.main.frame_pending
{
let req_frame = | wl: &WlSurface | { let _ = wl.frame( qh, SurfaceFocus::Main ); };
@@ -60,6 +64,11 @@ pub( crate ) fn draw_frame<A: App>( data: &mut AppData<A> )
);
data.main.needs_redraw = false;
data.main.last_draw = std::time::Instant::now();
if !data.first_frame_committed
{
data.first_frame_committed = true;
first_commit_now = true;
}
}
for spec in overlays
@@ -86,6 +95,8 @@ pub( crate ) fn draw_frame<A: App>( data: &mut AppData<A> )
ss.last_draw = std::time::Instant::now();
}
}
first_commit_now
}
fn draw_surface<Msg: Clone>(
@@ -113,13 +124,13 @@ fn draw_surface<Msg: Clone>(
.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,
);

View File

@@ -4,10 +4,10 @@
use smithay_client_toolkit::
{
compositor::CompositorHandler,
delegate_compositor, delegate_foreign_toplevel_list, delegate_layer,
delegate_compositor, delegate_subcompositor, delegate_foreign_toplevel_list, delegate_layer,
delegate_output, delegate_registry, delegate_seat, delegate_keyboard,
delegate_pointer, delegate_touch, delegate_shm, delegate_xdg_popup,
delegate_xdg_shell, delegate_xdg_window,
delegate_xdg_shell, delegate_xdg_window, delegate_session_lock,
foreign_toplevel_list::{ ForeignToplevelList, ForeignToplevelListHandler },
output::{ OutputHandler, OutputState },
registry::{ ProvidesRegistryState, RegistryState },
@@ -22,6 +22,7 @@ use smithay_client_toolkit::
xdg::window::{ Window, WindowConfigure, WindowHandler },
},
shm::{ Shm, ShmHandler },
session_lock::{ SessionLock, SessionLockHandler, SessionLockSurface, SessionLockSurfaceConfigure },
};
use smithay_client_toolkit::reexports::protocols::ext::foreign_toplevel_list::v1::client::ext_foreign_toplevel_handle_v1::ExtForeignToplevelHandleV1;
use smithay_client_toolkit::reexports::client::
@@ -98,6 +99,7 @@ impl<A: App> CompositorHandler for AppData<A>
// `App::on_resize`.
if matches!( focus, super::SurfaceFocus::Main )
{
self.app.on_scale_changed( new_factor as u32 );
self.app.on_resize( pw, ph );
self.dirty_caches();
}
@@ -172,7 +174,6 @@ impl<A: App> LayerShellHandler for AppData<A>
Some( super::SurfaceFocus::Main ) | None =>
{
self.on_configure( w, h );
self.app.on_resize( w, h );
}
Some( super::SurfaceFocus::Overlay( id ) ) =>
{
@@ -226,7 +227,6 @@ impl<A: App> WindowHandler for AppData<A>
let h = configure.new_size.1.map( |v| v.get() ).unwrap_or( hint_h );
window.xdg_surface().set_window_geometry( 0, 0, w as i32, h as i32 );
self.on_configure( w, h );
self.app.on_resize( w, h );
}
}
@@ -324,6 +324,13 @@ impl<A: App> SeatHandler for AppData<A>
}
Capability::Touch if self.touch.is_none() =>
{
// Touch coming back (resume on devices that power the
// touchscreen down at suspend): clear anything the removal
// path didn't, so the first post-resume gesture starts clean.
if self.reset_touch_state()
{
eprintln!( "[ltk] touch capability re-added — cleared touch state stranded across the gap" );
}
self.touch = Some(
self.seat_state
.get_touch( qh, &seat )
@@ -346,7 +353,17 @@ impl<A: App> SeatHandler for AppData<A>
{
Capability::Keyboard => { self.keyboard = None; }
Capability::Pointer => { self.pointer = None; }
Capability::Touch => { self.touch = None; }
Capability::Touch =>
{
self.touch = None;
// The touchscreen is gone before any pending up / cancel for
// the last touch could arrive; drop the stale gesture state
// now rather than stranding it until the next clean release.
if self.reset_touch_state()
{
eprintln!( "[ltk] touch capability removed mid-gesture — reset stale touch state" );
}
}
_ => {}
}
}
@@ -398,7 +415,41 @@ impl<A: App> PopupHandler for AppData<A>
// --- Delegate macros ---
impl<A: App> SessionLockHandler for AppData<A>
{
fn locked( &mut self, _conn: &Connection, qh: &QueueHandle<Self>, session_lock: SessionLock )
{
if let Some( output ) = self.output_state.outputs().next()
{
let surface = self.compositor_state.create_surface( qh );
let lock_surface = session_lock.create_lock_surface( surface, &output, qh );
self.main.surface = super::SurfaceKind::Lock( lock_surface );
}
self.session_lock = Some( session_lock );
}
fn finished( &mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _session_lock: SessionLock )
{
// Compositor refused the lock or ended it; nothing left to show.
self.exit_requested = true;
}
fn configure(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_surface: SessionLockSurface,
configure: SessionLockSurfaceConfigure,
_serial: u32,
)
{
let ( w, h ) = configure.new_size;
self.on_configure( w.max( 1 ), h.max( 1 ) );
}
}
delegate_compositor!( @<A: App> AppData<A> );
delegate_subcompositor!( @<A: App> AppData<A> );
delegate_output!( @<A: App> AppData<A> );
delegate_shm!( @<A: App> AppData<A> );
delegate_seat!( @<A: App> AppData<A> );
@@ -406,6 +457,7 @@ delegate_keyboard!( @<A: App> AppData<A> );
delegate_pointer!( @<A: App> AppData<A> );
delegate_touch!( @<A: App> AppData<A> );
delegate_layer!( @<A: App> AppData<A> );
delegate_session_lock!( @<A: App> AppData<A> );
delegate_xdg_shell!( @<A: App> AppData<A> );
delegate_xdg_window!( @<A: App> AppData<A> );
delegate_xdg_popup!( @<A: App> AppData<A> );
@@ -416,16 +468,25 @@ smithay_client_toolkit::delegate_data_device!( @<A: App> AppData<A> );
// --- `xdg-activation-v1` handler ---
//
// We only honour inbound activation here: when a token issued for our
// own request gets delivered, we activate the main surface. Outbound
// requests (so the app can pass a token to another app) are out of
// scope for now — adding them only requires a new public method on
// `AppData` and an extra trait method on `App`.
// A token carrying a `RequestData::app_id` was requested by the app via
// [`App::take_activation_requests`]: echo the tag + token back through
// [`App::on_activation_token`] so the app can pass it to a child it is
// launching. A token with no tag was issued for our own surface, so we
// self-activate.
impl<A: App> smithay_client_toolkit::activation::ActivationHandler for AppData<A>
{
type RequestData = smithay_client_toolkit::activation::RequestData;
fn new_token( &mut self, token: String, _data: &Self::RequestData )
fn new_token( &mut self, token: String, data: &Self::RequestData )
{
use smithay_client_toolkit::activation::RequestDataExt;
if let Some( tag ) = data.app_id()
{
if let Some( msg ) = self.app.on_activation_token( tag.to_string(), token )
{
self.pending_msgs.push( msg );
}
return;
}
if let ( Some( ref activation ), Some( wl ) ) = ( self.activation_state.as_ref(), self.main.surface.try_wl_surface() )
{
activation.activate::<Self>( &wl, token );
@@ -468,10 +529,16 @@ fn toplevel_display_id(
handle: &ExtForeignToplevelHandleV1,
) -> String
{
// Only ever surface the client's `app_id`. The protocol-level
// `identifier` is a server-internal random opaque token, not an
// app handle; smithay creates the toplevel handle with an empty
// `app_id` and `init_new_instance` flushes a `done` immediately,
// so apps subscribing through this binding would otherwise see
// the random identifier as their "app id" until the client's
// real `set_app_id` lands. `title` is also unsuitable — it
// changes every time the window's title text updates.
let Some( info ) = list.info( handle ) else { return String::new(); };
if !info.app_id.is_empty() { return info.app_id; }
if !info.title.is_empty() { return info.title; }
info.identifier
info.app_id
}
impl<A: App> ForeignToplevelListHandler for AppData<A>
@@ -579,10 +646,45 @@ impl<A: App> Dispatch<WlCallback, super::SurfaceFocus> for AppData<A>
{
state.main.frame_pending = false;
if is_animating
{
if state.app.subsurface_motion_only()
{
// The main buffer is unchanged; only the subsurface
// moves (repositioned by the per-frame reconcile).
// Keep the vsync cadence with a bare frame callback +
// commit — no buffer attach, no re-raster.
if let Some( wl ) = state.main.surface.try_wl_surface().cloned()
{
let _ = wl.frame( &state.qh, super::SurfaceFocus::Main );
wl.commit();
state.main.frame_pending = true;
}
}
else
{
// Perf guardrails: run the opt-in diagnostics and apply
// the software animation-rate cap (~30 Hz). A throttled
// frame keeps the vsync cadence with a bare callback but
// skips the re-raster.
let software = crate::render::is_software_render();
let cap = state.app.cap_software_animation();
if state.perf.animated_frame( software, cap )
{
state.view_dirty = true;
state.main.request_redraw();
}
else if let Some( wl ) = state.main.surface.try_wl_surface().cloned()
{
let _ = wl.frame( &state.qh, super::SurfaceFocus::Main );
wl.commit();
state.main.frame_pending = true;
}
}
}
else
{
state.perf.animation_stopped();
}
}
super::SurfaceFocus::Overlay( id ) =>
{
@@ -635,6 +737,10 @@ impl<A: App> Dispatch<ZwpTextInputV3, ()> for AppData<A>
ss.request_redraw();
}
}
zwp_text_input_v3::Event::Enter { .. } =>
{
state.reenable_text_input();
}
_ => {}
}
}

View File

@@ -1,6 +1,7 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
#[ cfg( feature = "test-support" ) ]
use std::collections::HashSet;
use crate::app::{ App, InvalidationScope, SurfaceTarget };
@@ -62,6 +63,10 @@ pub( super ) fn apply_invalidation<A: App>( data: &mut AppData<A>, scope: Invali
///
/// `added` preserves the order of `next` (so creation order is deterministic);
/// `removed` is unordered (driven by HashMap iteration in the caller).
///
/// Exposed only for `test_support`; gated behind that feature so it does not
/// warn as dead code in ordinary builds.
#[ cfg( feature = "test-support" ) ]
pub fn diff_overlay_ids(
current: impl IntoIterator<Item = crate::app::OverlayId>,
next: &[ crate::app::OverlayId ],

View File

@@ -9,7 +9,9 @@ pub( crate ) mod cursor_shape;
pub( crate ) mod drag;
pub( crate ) mod focus;
mod handlers;
pub( crate ) mod perf;
pub( crate ) mod repeat;
pub( crate ) mod subsurface;
pub( crate ) mod surface;
pub( crate ) mod text_editing;
pub( crate ) mod tooltip;
@@ -21,7 +23,8 @@ 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 };
#[ cfg( feature = "test-support" ) ]
pub use invalidation::diff_overlay_ids;

View File

@@ -24,7 +24,7 @@ use wayland_protocols::xdg::shell::client::xdg_positioner::
};
use crate::app::App;
use crate::types::Rect;
use crate::types::{ Length, Rect };
use super::{ AppData, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState };
/// Sync `data.overlays` with the app's current `App::overlays()` spec list:
@@ -56,6 +56,11 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
{
data.main.gesture.long_press_fired = true;
data.main.gesture.long_press_origin = ss.gesture.long_press_origin;
// Keep the primary touch slot with the migrated drag so motion / release still route through the gesture machine.
if data.main.primary_touch_id.is_none()
{
data.main.primary_touch_id = ss.primary_touch_id;
}
}
}
}
@@ -99,14 +104,39 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
SurfaceKind::Window( ref w ) => Some( w.xdg_surface().clone() ),
_ => None,
};
let parent_scale = data.main.scale_factor.max( 1 ) as f32;
let parent_scale_i = data.main.scale_factor.max( 1 );
let parent_scale = parent_scale_i as f32;
// `OverlaySpec::size` carries `Length`s resolved against the main
// surface's physical viewport (the app's layout space), so an overlay
// sized with `Length::widget( … )` scales with the display exactly like
// any other widget. `Length::px( n )` keeps a fixed size.
let main_vp = ( data.main.physical_width() as f32, data.main.physical_height() as f32 );
let resolve_size = move | s: &( Length, Length ) | -> ( u32, u32 )
{
(
s.0.resolve( main_vp, Length::EM_BASE_DEFAULT ).max( 0.0 ).round() as u32,
s.1.resolve( main_vp, Length::EM_BASE_DEFAULT ).max( 0.0 ).round() as u32,
)
};
// `OverlaySpec::size` is physical pixels (the app's layout space); the
// layer-shell `set_size` is logical. They only coincide at scale 1, so
// convert here — without it a scale-2 overlay requests a surface twice
// its intended size. `0 = fill` survives the divide.
let to_logical_size = move | ( w, h ): ( u32, u32 ) | -> ( u32, u32 )
{
(
( w as f32 / parent_scale ).round() as u32,
( h as f32 / parent_scale ).round() as u32,
)
};
// Snapshot the previous-frame anchor lookup table so we can resolve
// `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
{
let resolved_size = resolve_size( &spec.size );
if let Some( ss ) = overlays_m.get_mut( &spec.id )
{
// Already-live overlay: propagate size changes to the layer
@@ -118,13 +148,14 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
// sends a configure; the usual `on_configure` path picks up the
// new dimensions and drives the redraw. Popups don't grow /
// shrink mid-life — close and reopen instead.
if spec.size != ss.last_requested_size
if resolved_size != ss.last_requested_size
{
if let SurfaceKind::Layer( ref layer_surface ) = ss.surface
{
layer_surface.set_size( spec.size.0, spec.size.1 );
let ( lw, lh ) = to_logical_size( resolved_size );
layer_surface.set_size( lw, lh );
layer_surface.commit();
ss.last_requested_size = spec.size;
ss.last_requested_size = resolved_size;
}
}
// Compare the anchor at integer logical-pixel resolution:
@@ -155,7 +186,7 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
if let Ok( positioner ) = XdgPositioner::new( xdg_shell )
{
let ( ax, ay, aw, ah ) = new_q;
let ( spec_w, spec_h ) = spec.size;
let ( spec_w, spec_h ) = resolved_size;
let popup_w = if spec_w == 0 { aw } else { spec_w.max( 1 ) as i32 };
let popup_h = spec_h.max( 1 ) as i32;
positioner.set_size( popup_w, popup_h );
@@ -218,7 +249,7 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
let ay = ( anchor_rect.y / parent_scale ).round() as i32;
let aw = ( anchor_rect.width / parent_scale ).round().max( 1.0 ) as i32;
let ah = ( anchor_rect.height / parent_scale ).round().max( 1.0 ) as i32;
let ( spec_w, spec_h ) = spec.size;
let ( spec_w, spec_h ) = resolved_size;
let popup_w = if spec_w == 0 { aw } else { spec_w.max( 1 ) as i32 };
let popup_h = spec_h.max( 1 ) as i32;
positioner.set_size( popup_w, popup_h );
@@ -256,7 +287,7 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
}
popup.wl_surface().commit();
let mut ss = SurfaceState::<A::Message>::new( SurfaceKind::Popup( popup ), 0.0, String::new() );
ss.last_requested_size = spec.size;
ss.last_requested_size = resolved_size;
ss.last_popup_anchor = Some( anchor_rect );
overlays_m.insert( spec.id, ss );
continue;
@@ -272,7 +303,7 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
layer: spec.layer.to_wlr_layer(),
exclusive_zone: spec.exclusive_zone,
anchor: spec.anchor,
size: spec.size,
size: to_logical_size( resolved_size ),
keyboard_exclusive: spec.keyboard_exclusive,
namespace: "ltk-overlay",
};
@@ -282,7 +313,12 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
surface.materialize( cs, layer_shell, qh, output );
}
let mut ss = SurfaceState::<A::Message>::new( surface, 0.0, String::new() );
ss.last_requested_size = spec.size;
// Inherit the parent's scale so the first configure allocates a
// HiDPI buffer and lays out at the right size from frame one,
// instead of rendering at scale 1 until `scale_factor_changed`
// lands a frame or two later.
ss.scale_factor = parent_scale_i;
ss.last_requested_size = resolved_size;
ss.layer_anchor = Some( spec.anchor );
overlays_m.insert( spec.id, ss );
}

173
src/event_loop/perf.rs Normal file
View File

@@ -0,0 +1,173 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Runtime performance guardrails.
//!
//! The idle/redraw model is efficient only if the app cooperates — a stuck
//! [`App::is_animating`](crate::App::is_animating), an aggressive
//! [`poll_interval`](crate::App::poll_interval), or continuous animation on
//! the software backend all quietly burn CPU/battery. The docs warn about
//! these, but the runtime can also **detect** them (opt-in dev diagnostics,
//! `LTK_PERF_WARN=1`) and **mitigate** one of them (cap animation to ~30 Hz
//! on the software renderer, opt-out via
//! [`App::cap_software_animation`](crate::App::cap_software_animation)).
use std::sync::OnceLock;
use std::time::{ Duration, Instant };
/// A settled animation should return `false` well before this; staying `true`
/// this long is almost always a forgotten `is_animating = false`.
const STUCK_THRESHOLD: Duration = Duration::from_secs( 10 );
/// Continuous software-rendered animation past this is worth flagging on
/// mobile, where it means sustained CPU with no GPU offload.
const SW_ANIM_THRESHOLD: Duration = Duration::from_secs( 3 );
/// Minimum gap between software-backend animation re-rasters (≈ 30 Hz).
const SOFTWARE_ANIM_MIN_INTERVAL: Duration = Duration::from_millis( 33 );
/// A `poll_interval` shorter than this defeats the event-driven idle model.
const POLL_WARN_THRESHOLD: Duration = Duration::from_millis( 100 );
/// `true` when `LTK_PERF_WARN` is set to a non-empty, non-`0` value. Cached.
fn perf_warn_enabled() -> bool
{
static ENABLED: OnceLock<bool> = OnceLock::new();
*ENABLED.get_or_init( ||
std::env::var( "LTK_PERF_WARN" )
.map( |v| !v.is_empty() && v != "0" )
.unwrap_or( false )
)
}
/// Per-runtime performance-guard state. Lives on `AppData`.
pub struct PerfState
{
/// When the current continuous animation began (`None` while idle).
animating_since: Option<Instant>,
/// Last software-backend animation re-raster, for the 30 Hz cap.
last_anim_draw: Instant,
warned_stuck: bool,
warned_software_anim: bool,
warned_poll: bool,
}
impl PerfState
{
pub fn new() -> Self
{
Self
{
animating_since: None,
// Start in the past so the first animated frame is never throttled.
// `checked_sub` guards against underflow shortly after boot.
last_anim_draw: Instant::now().checked_sub( SOFTWARE_ANIM_MIN_INTERVAL ).unwrap_or_else( Instant::now ),
warned_stuck: false,
warned_software_anim: false,
warned_poll: false,
}
}
/// Reset when the app stops animating, so the next animation starts fresh
/// (and can warn again if it, too, gets stuck).
pub fn animation_stopped( &mut self )
{
self.animating_since = None;
self.warned_stuck = false;
self.warned_software_anim = false;
}
/// Called on every animated main-surface frame callback. Runs the opt-in
/// diagnostics and applies the software animation-rate cap. Returns `true`
/// if this frame should actually re-raster, `false` to throttle-skip it
/// (the caller keeps the vsync cadence with a bare frame callback).
pub fn animated_frame( &mut self, software: bool, cap_software: bool ) -> bool
{
let since = *self.animating_since.get_or_insert_with( Instant::now );
if perf_warn_enabled()
{
if !self.warned_stuck && since.elapsed() >= STUCK_THRESHOLD
{
eprintln!(
"[ltk][perf] App::is_animating() has stayed true for {}s — a settled \
animation must return false, or the loop redraws at the display rate \
forever (battery drain).",
STUCK_THRESHOLD.as_secs(),
);
self.warned_stuck = true;
}
if software && !self.warned_software_anim && since.elapsed() >= SW_ANIM_THRESHOLD
{
eprintln!(
"[ltk][perf] continuous animation on the software renderer — sustained \
CPU with no GPU offload (costly on mobile). Prefer event-driven redraws, \
or a GLES-capable compositor.",
);
self.warned_software_anim = true;
}
}
if software && cap_software && self.last_anim_draw.elapsed() < SOFTWARE_ANIM_MIN_INTERVAL
{
return false;
}
self.last_anim_draw = Instant::now();
true
}
/// Warn once (under `LTK_PERF_WARN`) if the app's `poll_interval` is short
/// enough to defeat the idle model.
pub fn warn_poll_interval( &mut self, dur: Duration )
{
if perf_warn_enabled() && !self.warned_poll && dur < POLL_WARN_THRESHOLD
{
eprintln!(
"[ltk][perf] poll_interval() of {}ms defeats the event-driven idle model — \
wake the loop from your worker with set_channel_sender instead of polling.",
dur.as_millis(),
);
self.warned_poll = true;
}
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ test ]
fn gles_animation_is_never_capped()
{
let mut p = PerfState::new();
// Two back-to-back frames on the GPU backend both re-raster.
assert!( p.animated_frame( false, true ) );
assert!( p.animated_frame( false, true ) );
}
#[ test ]
fn software_animation_is_capped_between_rerasters()
{
let mut p = PerfState::new();
// First software frame draws; an immediate second is throttled
// (< 33 ms since the last re-raster).
assert!( p.animated_frame( true, true ) );
assert!( !p.animated_frame( true, true ) );
}
#[ test ]
fn software_cap_opt_out_keeps_full_rate()
{
let mut p = PerfState::new();
assert!( p.animated_frame( true, false ) );
assert!( p.animated_frame( true, false ) );
}
#[ test ]
fn stopping_resets_stuck_warning_state()
{
let mut p = PerfState::new();
let _ = p.animated_frame( false, true );
assert!( p.animating_since.is_some() );
p.animation_stopped();
assert!( p.animating_since.is_none() );
}
}

View File

@@ -18,6 +18,7 @@ use smithay_client_toolkit::
},
},
shm::Shm,
subcompositor::SubcompositorState,
};
use smithay_client_toolkit::reexports::client::Connection;
use smithay_client_toolkit::reexports::calloop::EventLoop;
@@ -70,6 +71,10 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
let shm = Shm::bind( &globals, &qh )
.map_err( |e| RunError::MissingProtocol { name: "wl_shm", detail: format!( "{e:?}" ) } )?;
// Optional: compositors lacking wl_subcompositor leave this None and
// App::subsurfaces() silently degrades to no subsurfaces.
let subcompositor = SubcompositorState::bind( compositor.wl_compositor().clone(), &globals, &qh ).ok();
// Try to bring EGL up. On failure (no libEGL, no compatible config,
// LTK_FORCE_SOFTWARE=1, ES2/ES3 context creation refused…) we log the
// reason and every surface falls back to the SHM path.
@@ -143,6 +148,11 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
use crate::app::ShellMode;
match app.shell_mode()
{
ShellMode::SessionLock => {
// Lock surface is created in SessionLockHandler::locked once the
// compositor grants the lock; until then a placeholder.
( SurfaceKind::PendingLock, None )
}
ShellMode::Window => {
let xdg = bind_xdg( &globals, &qh )?;
let surface = compositor.create_surface( &qh );
@@ -183,6 +193,20 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
}
};
// Bind the session-lock manager and request the lock. We don't keep the
// `SessionLockState` (the manager) around: it has no `Drop`, so the
// manager object persists in the connection, and the lock lifecycle runs
// entirely off the returned `SessionLock` + its surfaces.
let session_lock =
if force_window.is_none() && matches!( app.shell_mode(), crate::app::ShellMode::SessionLock )
{
smithay_client_toolkit::session_lock::SessionLockState::new( &globals, &qh )
.lock( &qh )
.ok()
} else {
None
};
let text_input_manager: Option<ZwpTextInputManagerV3> = globals.bind( &qh, 1..=1, () ).ok();
// wl_data_device_manager: optional. Required for cross-application
// copy/paste; absence means the clipboard stays process-local.
@@ -242,7 +266,9 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
seat_state: SeatState::new( &globals, &qh ),
output_state: OutputState::new( &globals, &qh ),
compositor_state: compositor,
subcompositor,
shm,
session_lock,
egl_context,
xdg_shell,
layer_shell: layer_shell_opt,
@@ -257,6 +283,7 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
current_cursor_shape: None,
text_input_manager,
text_input: None,
text_input_secure: false,
activation_state,
activation_token_pending,
data_device_manager,
@@ -293,6 +320,8 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
pending_size_hint_unpin,
main: SurfaceState::<A::Message>::new( surface_kind, titlebar_height, titlebar_title ),
overlays: std::collections::HashMap::new(),
subsurfaces: std::collections::HashMap::new(),
subsurface_gles_canvas: None,
pointer_focus: SurfaceFocus::Main,
keyboard_focus: SurfaceFocus::Main,
touch_focus: std::collections::HashMap::new(),
@@ -300,6 +329,9 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
cached_overlays: None,
view_dirty: true,
overlays_dirty: true,
first_frame_committed: false,
focus_retry: None,
perf: super::perf::PerfState::new(),
};
// Register a calloop channel so the app can send messages from any thread.
@@ -329,6 +361,7 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
// The timer fires independently of Wayland events, waking the event loop on schedule.
if let Some( dur ) = data.app.poll_interval()
{
data.perf.warn_poll_interval( dur );
event_loop.handle()
.insert_source(
Timer::from_duration( dur ),
@@ -383,6 +416,36 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
data.exit_requested = true;
continue;
}
Err( calloop::Error::OtherError( ref e ) ) =>
{
// wayland-client surfaces the closed-socket condition as an
// OtherError wrapping a WaylandError::Io(BrokenPipe) — one
// level deeper than a direct io::Error. Walk the source()
// chain so we catch it regardless of how many wrapper types
// sit between calloop and the raw io::Error.
let mut src: Option<&dyn std::error::Error> = Some( e.as_ref() );
let mut is_closed = false;
while let Some( err ) = src
{
if let Some( io ) = err.downcast_ref::<std::io::Error>()
{
if matches!( io.kind(),
std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::ConnectionReset )
{
is_closed = true;
break;
}
}
src = err.source();
}
if is_closed
{
eprintln!( "ltk: wayland connection lost; exiting" );
data.exit_requested = true;
continue;
}
panic!( "dispatch: {e:?}" );
}
Err( e ) => panic!( "dispatch: {e:?}" ),
}
// Any surface whose press has now crossed `long_press_duration`
@@ -395,6 +458,34 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
let ext: Vec<_> = data.app.poll_external();
data.pending_msgs.extend( ext );
// Issue any xdg-activation tokens the app asked for. The tag rides
// in `RequestData::app_id` and comes back through
// `ActivationHandler::new_token` (handlers.rs) as the token. When the
// compositor never advertised the global, answer with an empty token
// so the caller still proceeds (falling back to its own matching).
for tag in data.app.take_activation_requests()
{
match data.activation_state
{
Some( ref activation ) =>
{
let req = smithay_client_toolkit::activation::RequestData {
app_id: Some( tag ),
seat_and_serial: None,
surface: data.main.surface.try_wl_surface().cloned(),
};
activation.request_token::<AppData<A>>( &data.qh, req );
}
None =>
{
if let Some( msg ) = data.app.on_activation_token( tag, String::new() )
{
data.pending_msgs.push( msg );
}
}
}
}
// Drain any inbound clipboard payloads delivered by the
// data-device worker thread. Latest writer wins; the channel
// is unbounded but selection payloads are small (capped at
@@ -428,6 +519,20 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
let pf = data.pointer_focus;
data.dispatch_cursor_shape( pf );
}
// App asked to exit (e.g. the lockscreen authenticated). For a
// session-lock surface, unlock first — exiting without unlocking
// leaves the compositor's lock in place by design.
if data.app.requested_exit()
{
if let Some( lock ) = data.session_lock.take()
{
lock.unlock();
let _ = conn.roundtrip();
}
data.exit_requested = true;
}
// Seed `on_drag_move` with the long-press origin for any drag that
// just started. Must run *after* `update()` so the app's drag state
// has already been set up by the paired long-press message — the
@@ -495,7 +600,11 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
data.cached_overlays = Some( specs );
data.overlays_dirty = false;
}
draw_frame( &mut data );
let first_commit_now = draw_frame( &mut data );
if first_commit_now
{
data.app.on_first_frame_committed();
}
// Push the freshly-laid-out widget tree to AccessKit. The
// closure only runs when an AT client is actually
@@ -531,12 +640,12 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
focus_id: 0,
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,
@@ -550,12 +659,12 @@ pub( crate ) fn try_run<A: App>( 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,
@@ -568,6 +677,14 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
data.a11y = a11y_taken;
}
// Reconcile + reposition input-transparent subsurfaces every
// iteration, decoupled from the main redraw cadence: a finger drag
// emits motion events faster than frame callbacks clear
// `frame_pending`, so gating this on a main draw would pin the
// subsurface between frames. A position-only change is just
// set_position + a bare parent commit; no-ops when nothing moved.
super::subsurface::reconcile_subsurfaces( &mut data );
// Drain inbound AT-SPI2 actions (Orca pressing a button,
// switch-control focusing a node, etc.) and translate each
// into a synthetic press / focus on the matching widget.
@@ -596,8 +713,8 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
};
let widget = match focus_target
{
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
{
@@ -671,10 +788,10 @@ pub( crate ) fn try_run<A: App>( 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();
@@ -693,12 +810,12 @@ pub( crate ) fn try_run<A: App>( 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
{
@@ -724,16 +841,17 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
// Walks main + every overlay because the targeted widget can live
// on any of them (a search field on a launcher overlay, a text
// edit on a dialog modal, …).
if let Some( id ) = data.app.take_focus_request()
let focus_id = data.app.take_focus_request().or_else( || data.focus_retry.take() );
if let Some( id ) = focus_id
{
let mut hit: Option<( SurfaceFocus, usize )> = data.main.widget_rects.iter()
let mut hit: Option<( SurfaceFocus, usize )> = data.main.frame.widget_rects.iter()
.find( |w| w.id == Some( id ) )
.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;
@@ -755,6 +873,10 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
}
}
}
} else {
data.focus_retry = Some( id );
data.view_dirty = true;
data.main.request_redraw();
}
}
}

View File

@@ -0,0 +1,396 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Input-transparent child surfaces ([`crate::app::SubsurfaceSpec`]).
//!
//! Each spec maps to one `wl_subsurface` sized to the main surface, with an
//! empty input region so all input falls through to the parent — the host
//! keeps a single gesture/input model. The content buffer is rasterised only
//! when the size or the spec's `content_version` changes; a position change
//! emits `wl_subsurface.set_position` plus a bare parent commit, so an animated
//! slide costs a compositor recomposite, not a client re-raster.
use std::collections::HashMap;
use std::sync::Arc;
use smithay_client_toolkit::compositor::CompositorState;
use smithay_client_toolkit::shm::Shm;
use smithay_client_toolkit::shm::slot::SlotPool;
use smithay_client_toolkit::reexports::client::protocol::wl_shm;
use smithay_client_toolkit::reexports::client::protocol::wl_subsurface::WlSubsurface;
use smithay_client_toolkit::reexports::client::protocol::wl_surface::WlSurface;
use crate::app::{ App, SubsurfaceParent };
use crate::egl_context::{ EglContext, EglSurface };
use crate::draw::DrawCtx;
use crate::draw::chrome::apply_input_region;
use crate::draw::layout_and_draw;
use crate::render::Canvas;
use crate::types::Rect;
use crate::widget::Element;
use super::frame::pick_shm_format;
use super::AppData;
/// Convert a layout (physical) position to the logical position the
/// compositor expects for `wl_subsurface.set_position`.
fn to_logical( x: i32, y: i32, scale: u32 ) -> ( i32, i32 )
{
let s = scale.max( 1 ) as i32;
( x / s, y / s )
}
/// Live state for one subsurface: its Wayland objects, its own SHM pool and
/// canvas, and the last (size, version, position) we committed so the per-frame
/// pass can tell a re-raster from a cheap reposition.
pub( crate ) struct SubsurfaceSlot
{
subsurface: WlSubsurface,
surface: WlSurface,
/// SHM pool for the software raster path. `None` on the GLES path.
pool: Option<SlotPool>,
/// EGL window surface for the GLES raster path (so the `surface-panel`
/// backdrop blur, a GLES-only pass, renders). `None` on the software path.
egl_surface: Option<EglSurface>,
canvas: Option<Canvas>,
last_pos: ( i32, i32 ),
last_size: ( u32, u32 ),
last_version: u64,
rastered: bool,
parent: SubsurfaceParent,
}
impl SubsurfaceSlot
{
fn destroy( mut self )
{
// Drop the EGL surface before the wl_surface its wl_egl_window wraps.
self.egl_surface = None;
self.subsurface.destroy();
self.surface.destroy();
}
}
/// Resolve a subsurface's parent surface to `( wl_surface, phys_w, phys_h,
/// scale )`. Returns `None` when an [`SubsurfaceParent::Overlay`] target is
/// absent, not yet configured, or zero-sized — the caller skips the spec for
/// that frame. The `WlSurface` is cloned (an `Arc`) so the render / reposition
/// loop can run without holding a borrow on `data.overlays` / `data.main`.
fn resolve_parent<A: App>( data: &AppData<A>, parent: SubsurfaceParent ) -> Option<( WlSurface, u32, u32, u32 )>
{
let ss = match parent
{
SubsurfaceParent::Main => &data.main,
SubsurfaceParent::Overlay( id ) => data.overlays.get( &id )?,
};
if !ss.configured { return None; }
if ss.width == 0 || ss.height == 0 { return None; }
let surface = ss.surface.try_wl_surface()?.clone();
let scale = ss.scale_factor.max( 1 ) as u32;
Some( ( surface, ss.width * scale, ss.height * scale, scale ) )
}
/// Record a parent surface that needs a commit this frame, deduped by parent
/// identity so each is committed once.
fn mark_parent_dirty( list: &mut Vec<( SubsurfaceParent, WlSurface )>, parent: SubsurfaceParent, wl: &WlSurface )
{
if !list.iter().any( |( p, _ )| *p == parent )
{
list.push( ( parent, wl.clone() ) );
}
}
/// Reconcile the live subsurfaces against [`App::subsurfaces`], render content
/// where it changed and reposition where it moved. Cheap when nothing but a
/// position changed. Each spec is composited under its own parent surface
/// (main or an overlay), so a slide can ride above app windows. Must run after
/// the parent surface is configured.
pub( crate ) fn reconcile_subsurfaces<A: App>( data: &mut AppData<A> )
{
if data.subcompositor.is_none() { return; }
if !data.main.configured { return; }
let ( format, swap_rb ) = pick_shm_format( &data.shm );
let specs: Vec<crate::app::SubsurfaceSpec<A::Message>> = data.app.subsurfaces();
// Parents touched this frame; each committed once at the end so the
// placement / mapping actually lands.
let mut dirty_parents: Vec<( SubsurfaceParent, WlSurface )> = Vec::new();
// Drop subsurfaces whose id disappeared from the spec list.
let present: std::collections::HashSet<crate::app::SubsurfaceId> =
specs.iter().map( |s| s.id ).collect();
let stale: Vec<crate::app::SubsurfaceId> = data.subsurfaces.keys()
.copied()
.filter( |id| !present.contains( id ) )
.collect();
for id in stale
{
if let Some( slot ) = data.subsurfaces.remove( &id )
{
let parent = slot.parent;
slot.destroy();
if let Some( ( wl, _, _, _ ) ) = resolve_parent( data, parent )
{
mark_parent_dirty( &mut dirty_parents, parent, &wl );
}
}
}
for spec in &specs
{
let Some( ( parent_wl, pw, ph, scale ) ) = resolve_parent( data, spec.parent ) else { continue };
// Create on first sight.
if !data.subsurfaces.contains_key( &spec.id )
{
let ( subsurface, surface ) = {
let sc = data.subcompositor.as_ref().unwrap();
sc.create_subsurface( parent_wl.clone(), &data.qh )
};
// Independent buffer commits; placement still applies on the
// parent commit we issue below.
subsurface.set_desync();
let ( lx, ly ) = to_logical( spec.x, spec.y, scale );
subsurface.set_position( lx, ly );
data.subsurfaces.insert( spec.id, SubsurfaceSlot {
subsurface,
surface,
pool: None,
egl_surface: None,
canvas: None,
last_pos: ( spec.x, spec.y ),
last_size: ( 0, 0 ),
last_version: u64::MAX,
rastered: false,
parent: spec.parent,
} );
mark_parent_dirty( &mut dirty_parents, spec.parent, &parent_wl );
}
// Tracked on the slot (not the canvas) because the GLES canvas lives
// in `data.subsurface_gles_canvas`, shared across all subsurfaces.
let size_changed = data.subsurfaces.get( &spec.id )
.map( |s| s.last_size != ( pw, ph ) )
.unwrap_or( true );
let needs_raster = {
let slot = data.subsurfaces.get( &spec.id ).unwrap();
!slot.rastered || size_changed || slot.last_version != spec.content_version
};
if needs_raster
{
// GLES only when the spec asks for it (glass content); otherwise
// the cheaper-to-create software rasteriser.
let egl = if spec.gpu { data.egl_context.as_ref() } else { None };
let slot = data.subsurfaces.get_mut( &spec.id ).unwrap();
render_slot::<A::Message>(
slot, egl, &mut data.subsurface_gles_canvas,
&data.shm, &data.compositor_state, &spec.view,
pw, ph, scale, format, swap_rb, size_changed,
);
slot.rastered = true;
slot.last_version = spec.content_version;
slot.last_size = ( pw, ph );
mark_parent_dirty( &mut dirty_parents, spec.parent, &parent_wl );
}
let slot = data.subsurfaces.get_mut( &spec.id ).unwrap();
if slot.last_pos != ( spec.x, spec.y )
{
let ( lx, ly ) = to_logical( spec.x, spec.y, scale );
slot.subsurface.set_position( lx, ly );
// Desync subsurface state (the position) is applied on the child
// surface's own commit, not the parent's; commit it here so the
// move actually lands. The parent commit below applies placement.
slot.surface.commit();
slot.last_pos = ( spec.x, spec.y );
mark_parent_dirty( &mut dirty_parents, spec.parent, &parent_wl );
}
}
// Applies pending subsurface placement / mapping without re-attaching the
// parent buffer — the cheap per-frame move.
for ( _, wl ) in dirty_parents
{
wl.commit();
}
}
/// Input-transparent subsurfaces never carry focus / hover / scroll state,
/// so they draw with an empty context.
fn empty_draw_ctx<Msg: Clone>() -> DrawCtx<Msg>
{
DrawCtx
{
focused_idx: None,
hovered_idx: None,
pressed_idx: None,
cursor_state: HashMap::new(),
selection_anchor: HashMap::new(),
widget_rects: Vec::new(),
debug_layout: false,
scroll_offsets: HashMap::new(),
scroll_rects: Vec::new(),
scroll_canvases: HashMap::new(),
scroll_navigable_items: HashMap::new(),
previous_widget_rects: Vec::new(),
accessible_extras: Vec::new(),
live_depth: 0,
}
}
/// Raster a subsurface's content. Uses the GLES path when an [`EglContext`]
/// is available — only there does the `surface-panel` backdrop blur render;
/// the software fallback paints the same widgets without the blur.
#[ allow( clippy::too_many_arguments ) ]
fn render_slot<Msg: Clone>(
slot: &mut SubsurfaceSlot,
egl_ctx: Option<&Arc<EglContext>>,
gles_canvas: &mut Option<Canvas>,
shm: &Shm,
compositor: &CompositorState,
view: &Element<Msg>,
pw: u32,
ph: u32,
scale: u32,
shm_format: wl_shm::Format,
swap_rb: bool,
size_changed: bool,
)
{
if let Some( ctx ) = egl_ctx
{
render_slot_gpu::<Msg>( slot, ctx, gles_canvas, compositor, view, pw, ph, scale, size_changed );
}
else
{
render_slot_software::<Msg>( slot, shm, compositor, view, pw, ph, scale, shm_format, swap_rb, size_changed );
}
}
#[ allow( clippy::too_many_arguments ) ]
fn render_slot_gpu<Msg: Clone>(
slot: &mut SubsurfaceSlot,
egl_ctx: &Arc<EglContext>,
gles_canvas: &mut Option<Canvas>,
compositor: &CompositorState,
view: &Element<Msg>,
pw: u32,
ph: u32,
scale: u32,
size_changed: bool,
)
{
// (Re)create the EGL window surface on first sight or a size change.
if slot.egl_surface.is_none() || size_changed
{
match egl_ctx.create_surface( &slot.surface, pw as i32, ph as i32 )
{
Ok( es ) => slot.egl_surface = Some( es ),
Err( e ) =>
{
eprintln!( "ltk: subsurface EGL surface creation failed: {e}" );
return;
}
}
}
let es = slot.egl_surface.as_ref().unwrap();
if egl_ctx.make_current( es ).is_err() { return; }
// The canvas (and its compiled shader programs) is shared across all
// subsurface rasters, so a lazily re-created panel doesn't recompile.
let canvas = gles_canvas.get_or_insert_with( ||
{
let mut c = Canvas::new_gles( Arc::clone( egl_ctx.gl() ), egl_ctx.version, pw, ph );
c.set_dpi_scale( scale as f32 );
if let Some( reg ) = crate::theme::build_font_registry()
{
c.set_font_registry( Arc::new( reg ) );
}
c
} );
if canvas.size() != ( pw, ph )
{
canvas.resize( pw, ph );
canvas.set_dpi_scale( scale as f32 );
}
canvas.clear_clip();
canvas.clear();
let screen_rect = Rect { x: 0.0, y: 0.0, width: pw as f32, height: ph as f32 };
let mut ctx = empty_draw_ctx::<Msg>();
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
canvas.present();
let wl = &slot.surface;
wl.set_buffer_scale( scale as i32 );
// Empty input region: pointer/touch fall through to the parent.
apply_input_region( wl, compositor, Some( &[] ), scale );
// Implicitly commits the wl_surface with the freshly rendered buffer.
let _ = egl_ctx.swap_buffers_with_damage( es, &[ ( 0, 0, pw as i32, ph as i32 ) ] );
}
#[ allow( clippy::too_many_arguments ) ]
fn render_slot_software<Msg: Clone>(
slot: &mut SubsurfaceSlot,
shm: &Shm,
compositor: &CompositorState,
view: &Element<Msg>,
pw: u32,
ph: u32,
scale: u32,
shm_format: wl_shm::Format,
swap_rb: bool,
size_changed: bool,
)
{
if slot.pool.is_none() || size_changed
{
match SlotPool::new( ( pw * ph * 4 ) as usize, shm )
{
Ok( p ) => slot.pool = Some( p ),
Err( _ ) => return,
}
}
let pool = slot.pool.as_mut().unwrap();
let stride = pw * 4;
let ( buffer, canvas_buf ) = match pool.create_buffer(
pw as i32, ph as i32, stride as i32, shm_format,
)
{
Ok( r ) => r,
Err( _ ) => return,
};
let canvas = slot.canvas.get_or_insert_with( || {
let mut c = Canvas::new( pw, ph );
c.set_dpi_scale( scale as f32 );
if let Some( reg ) = crate::theme::build_font_registry()
{
c.set_font_registry( Arc::new( reg ) );
}
c
} );
if canvas.size() != ( pw, ph )
{
canvas.resize( pw, ph );
canvas.set_dpi_scale( scale as f32 );
}
canvas.clear_clip();
canvas.clear();
let screen_rect = Rect { x: 0.0, y: 0.0, width: pw as f32, height: ph as f32 };
let mut ctx = empty_draw_ctx::<Msg>();
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
canvas.write_to_wayland_buf( canvas_buf, swap_rb );
let wl = &slot.surface;
if buffer.attach_to( wl ).is_err() { return; }
wl.set_buffer_scale( scale as i32 );
wl.damage_buffer( 0, 0, pw as i32, ph as i32 );
// Empty input region: pointer/touch fall through to the parent.
apply_input_region( wl, compositor, Some( &[] ), scale );
wl.commit();
}

View File

@@ -17,6 +17,7 @@ use smithay_client_toolkit::reexports::client::
protocol::wl_surface::WlSurface,
QueueHandle,
};
use smithay_client_toolkit::session_lock::SessionLockSurface;
use std::collections::HashMap;
use std::sync::Arc;
@@ -70,6 +71,11 @@ pub( crate ) enum SurfaceKind
/// to an anchor rect specified at creation time and may flip it
/// (drop-up vs drop-down) when constrained.
Popup( Popup ),
/// `ext-session-lock-v1` lock surface, created in `SessionLockHandler::locked`.
Lock( SessionLockSurface ),
/// Session lock requested; waiting for the compositor's `locked` event
/// before the lock surface exists.
PendingLock,
}
impl SurfaceKind
@@ -81,9 +87,10 @@ impl SurfaceKind
SurfaceKind::Layer( l ) => l.wl_surface(),
SurfaceKind::Window( w ) => w.wl_surface(),
SurfaceKind::Popup( p ) => p.wl_surface(),
SurfaceKind::Lock( s ) => s.wl_surface(),
// Unreachable: draw_frame is only called when configured == true,
// which only becomes true after on_configure, which requires a real surface.
SurfaceKind::Pending( .. ) => unreachable!( "surface not yet created" ),
SurfaceKind::Pending( .. ) | SurfaceKind::PendingLock => unreachable!( "surface not yet created" ),
}
}
@@ -98,7 +105,8 @@ impl SurfaceKind
SurfaceKind::Layer( l ) => Some( l.wl_surface() ),
SurfaceKind::Window( w ) => Some( w.wl_surface() ),
SurfaceKind::Popup( p ) => Some( p.wl_surface() ),
SurfaceKind::Pending( .. ) => None,
SurfaceKind::Lock( s ) => Some( s.wl_surface() ),
SurfaceKind::Pending( .. ) | SurfaceKind::PendingLock => None,
}
}
@@ -141,6 +149,61 @@ impl SurfaceKind
}
}
/// The subset of per-surface state the draw pass owns and threads through
/// [`crate::draw::DrawCtx`]: the laid-out widget rects it produces, the scroll /
/// text-edit / a11y state carried across frames, and the previous frame's
/// interaction indices for damage tracking. Grouped into its own struct so the
/// draw helpers can borrow it as one unit, disjoint from `canvas` / `pool`.
pub( crate ) struct FrameState<Msg: Clone>
{
pub widget_rects: Vec<LaidOutWidget<Msg>>,
pub cursor_state: HashMap<usize, usize>,
/// Selection anchor (byte offset). When equal to the cursor, there is no
/// active selection. Mutated jointly with `cursor_state`: plain arrow keys
/// collapse the selection by setting anchor = cursor; Shift+arrow extends by
/// leaving anchor put while moving cursor; pointer drags set both ends.
pub selection_anchor: HashMap<usize, usize>,
pub accessible_extras: Vec<crate::a11y::tree::AccessibleExtra>,
/// Per-scroll-viewport offset, indexed by `flat_idx` of the `Scroll` widget.
/// Tuple is `(x, y)` in physical pixels: x is applied only when the widget's
/// axis allows horizontal scroll (and stays at `0` otherwise), y mirrors the
/// historic single-f32 behaviour for vertical scrolls.
pub scroll_offsets: HashMap<usize, ( f32, f32 )>,
pub scroll_rects: Vec<( Rect, usize, crate::widget::scroll::ScrollAxis )>,
pub scroll_canvases: HashMap<usize, Canvas>,
/// Per-scroll list of `(flat_idx, content_y, height)` entries for every
/// interactive item the scroll's child laid out, in document order. Includes
/// items currently scrolled off-screen so keyboard arrow handlers can step
/// `hovered_idx` item-by-item without regenerating the layout pass. Y is in
/// pre-translation, pre-offset content coordinates.
pub scroll_navigable_items: HashMap<usize, Vec<( usize, f32, f32 )>>,
/// Previous frame interaction state for damage tracking.
pub prev_focused: Option<usize>,
pub prev_hovered: Option<usize>,
pub prev_pressed: Option<usize>,
}
impl<Msg: Clone> FrameState<Msg>
{
pub( crate ) fn new() -> Self
{
Self
{
widget_rects: Vec::new(),
cursor_state: HashMap::new(),
selection_anchor: HashMap::new(),
accessible_extras: Vec::new(),
scroll_offsets: HashMap::new(),
scroll_rects: Vec::new(),
scroll_canvases: HashMap::new(),
scroll_navigable_items: HashMap::new(),
prev_focused: None,
prev_hovered: None,
prev_pressed: None,
}
}
}
/// Per-surface render + interaction state.
///
/// Each Wayland surface managed by the event loop (main surface and any
@@ -169,31 +232,11 @@ pub( crate ) struct SurfaceState<Msg: Clone>
pub focused_idx: Option<usize>,
pub focused_id: Option<WidgetId>,
pub hovered_idx: Option<usize>,
pub widget_rects: Vec<LaidOutWidget<Msg>>,
pub cursor_state: HashMap<usize, usize>,
/// Selection anchor (byte offset). When equal to the cursor, there
/// is no active selection. Mutated jointly with `cursor_state`:
/// plain arrow keys collapse the selection by setting anchor =
/// cursor; Shift+arrow extends by leaving anchor put while moving
/// cursor; pointer drags set both ends.
pub selection_anchor: HashMap<usize, usize>,
pub pending_text_values: HashMap<usize, String>,
/// 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<crate::a11y::tree::AccessibleExtra>,
pub scroll_offsets: HashMap<usize, ( f32, f32 )>,
pub scroll_rects: Vec<( Rect, usize, crate::widget::scroll::ScrollAxis )>,
pub scroll_canvases: HashMap<usize, Canvas>,
/// Per-scroll list of `(flat_idx, content_y, height)` entries for
/// every interactive item the scroll's child laid out, in document
/// order. Includes items currently scrolled off-screen so keyboard
/// arrow handlers can step `hovered_idx` item-by-item without
/// regenerating the layout pass. Y is in pre-translation,
/// pre-offset content coordinates.
pub scroll_navigable_items: HashMap<usize, Vec<( usize, f32, f32 )>>,
/// 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<Msg>,
/// Built-in Copy / Cut / Paste context menu when shown on this
/// surface. `None` means no menu is up.
pub context_menu: Option<super::context_menu::ContextMenu>,
@@ -215,10 +258,6 @@ pub( crate ) struct SurfaceState<Msg: Clone>
/// `wl_touch.up` does not carry a position) and to keep state
/// across `motion` events between dispatchers.
pub touch_slots: HashMap<i32, crate::types::Point>,
/// Previous frame interaction state for damage tracking
pub prev_focused: Option<usize>,
pub prev_hovered: Option<usize>,
pub prev_pressed: Option<usize>,
/// Height of the client-side title bar (0 for layer-shell surfaces).
pub titlebar_height: f32,
/// Title text shown in the client-side title bar.
@@ -267,22 +306,12 @@ impl<Msg: Clone> SurfaceState<Msg>
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(),

View File

@@ -17,7 +17,7 @@ impl<A: App> AppData<A>
{
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<A: App> AppData<A>
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<A: App> AppData<A>
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<A: App> AppData<A>
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<A: App> AppData<A>
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<A: App> AppData<A>
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<A: App> AppData<A>
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<A: App> AppData<A>
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<A: App> AppData<A>
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<A: App> AppData<A>
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<A: App> AppData<A>
_ => 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<A: App> AppData<A>
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<A: App> AppData<A>
_ => 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();
}
}

View File

@@ -13,21 +13,34 @@ use crate::widget::WidgetHandlers;
impl<A: App> AppData<A>
{
pub( crate ) fn activate_text_input( &mut self, qh: &QueueHandle<Self> )
pub( crate ) fn activate_text_input( &mut self, qh: &QueueHandle<Self>, secure: bool )
{
if let ( Some( manager ), None ) = ( &self.text_input_manager, &self.text_input )
self.text_input_secure = secure;
let ( hint, purpose ) = content_type( secure );
match ( &self.text_input_manager, &self.text_input )
{
( Some( manager ), None ) =>
{
let seats: Vec<_> = self.seat_state.seats().collect();
if let Some( seat ) = seats.into_iter().next()
{
let ti = manager.get_text_input( &seat, qh, () );
ti.enable();
ti.set_content_type(
zwp_text_input_v3::ContentHint::None,
zwp_text_input_v3::ContentPurpose::Normal,
);
ti.set_content_type( hint, purpose );
ti.commit();
self.text_input = Some( ti );
} else {
eprintln!( "ltk: activate_text_input: no seat available" );
}
}
( None, _ ) =>
eprintln!( "ltk: activate_text_input: no text_input_manager (compositor did not advertise zwp_text_input_manager_v3)" ),
// Focus moved between text fields: refresh the content type (e.g.
// flag a password field) without re-creating the object.
( Some( _ ), Some( ti ) ) =>
{
ti.set_content_type( hint, purpose );
ti.commit();
}
}
}
@@ -42,6 +55,17 @@ impl<A: App> AppData<A>
}
}
pub( crate ) fn reenable_text_input( &self )
{
if let Some( ti ) = &self.text_input
{
let ( hint, purpose ) = content_type( self.text_input_secure );
ti.enable();
ti.set_content_type( hint, purpose );
ti.commit();
}
}
/// Snapshot a focused-widget's geometry needed by the pointer
/// hit-testers for text editing. Returns `None` when the widget
/// isn't a TextEdit or its rect is missing — the helper above
@@ -53,7 +77,7 @@ impl<A: App> AppData<A>
) -> Option<( Rect, String, bool, bool, crate::widget::text::TextAlign, f32 )>
{
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, .. } =>
@@ -69,3 +93,19 @@ impl<A: App> AppData<A>
Some( ( widget.rect, value, multiline, secure, align, font_size ) )
}
}
/// Map a field's `secure` flag to the text-input-v3 content type. Secure
/// fields are flagged `Password` with `SensitiveData | HiddenText` so the
/// IME/OSK skips prediction, autocorrect and storing the value.
fn content_type( secure: bool ) -> ( zwp_text_input_v3::ContentHint, zwp_text_input_v3::ContentPurpose )
{
if secure
{
(
zwp_text_input_v3::ContentHint::SensitiveData | zwp_text_input_v3::ContentHint::HiddenText,
zwp_text_input_v3::ContentPurpose::Password,
)
} else {
( zwp_text_input_v3::ContentHint::None, zwp_text_input_v3::ContentPurpose::Normal )
}
}

View File

@@ -22,7 +22,7 @@ impl<A: App> AppData<A>
{
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<A: App> AppData<A>
// 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<A: App> AppData<A>
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<A: App> AppData<A>
{
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<A: App> AppData<A>
// 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<A: App> AppData<A>
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<A: App> AppData<A>
{
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<A: App> AppData<A>
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<A: App> AppData<A>
// 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<A: App> AppData<A>
{
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<A: App> AppData<A>
// 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<A: App> AppData<A>
// 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
{

View File

@@ -87,7 +87,7 @@ impl<A: App> AppData<A>
// 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<A: App> AppData<A>
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<A: App> AppData<A>
};
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<A: App> AppData<A>
// 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<A: App> AppData<A>
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<A: App> AppData<A>
)
};
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<A: App> AppData<A>
// 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<A: App> AppData<A>
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<A: App> AppData<A>
)
};
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<A: App> AppData<A>
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<A: App> AppData<A>
};
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<A: App> AppData<A>
{
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<A: App> AppData<A>
pub( crate ) fn focused_selection_text( &self, focus: SurfaceFocus ) -> Option<String>
{
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<A: App> AppData<A>
pub( crate ) fn delete_selection( &mut self, focus: SurfaceFocus ) -> Option<String>
{
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<A: App> AppData<A>
// 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 )

View File

@@ -4,7 +4,7 @@
use super::app_data::AppData;
use super::surface::SurfaceFocus;
use crate::app::App;
use crate::types::Rect;
use crate::types::{ Length, Rect };
pub const TOOLTIP_DELAY: std::time::Duration = std::time::Duration::from_millis( 600 );
@@ -31,7 +31,7 @@ impl<A: App> AppData<A>
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;
@@ -132,7 +132,7 @@ impl<A: App> AppData<A>
id,
layer: crate::app::Layer::Overlay,
anchor: crate::app::Anchor::ALL,
size: ( 0, 0 ),
size: ( Length::px( 0.0 ), Length::px( 0.0 ) ),
exclusive_zone: -1,
keyboard_exclusive: false,
input_region: Some( Vec::new() ),

View File

@@ -1,12 +1,15 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! `glScissor`-based clipping + whole-canvas fill / clear for
//! [`GlesCanvas`]. When [`GlesCanvas::set_clip_rects`] receives
//! multiple rects the bounding-box union is used as the scissor —
//! coarse, but the partial-redraw path normally clusters 13 rects
//! so the union is barely larger than the sum. Disjoint regions
//! would want a stencil-buffer path; not implemented today.
//! Clipping + whole-canvas fill / clear for [`GlesCanvas`]. Rect clips
//! ([`GlesCanvas::set_clip_rects`]) use `glScissor`: when given multiple rects
//! the bounding-box union is the scissor — coarse, but the partial-redraw path
//! normally clusters 13 rects so the union is barely larger than the sum
//! (disjoint regions are not clipped tightly). Arbitrary path clips
//! ([`GlesCanvas::set_clip_path`]) are anti-aliased: the clipped draws are
//! captured into an offscreen layer FBO, then composited back onto the canvas
//! multiplied by a CPU-rasterised (tiny-skia) anti-aliased coverage mask, so the
//! clipped edge is smooth rather than the hard edge a 1-bit stencil would give.
use glow::HasContext;
@@ -16,8 +19,16 @@ 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.
self.flush_clip_layer();
// Scissor is global GL state; rebind our FBO first so the scissor
// applies to this canvas and not whatever target was active before.
self.activate_target();
@@ -51,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
@@ -58,6 +71,114 @@ impl GlesCanvas
// to match below.
unsafe { self.gl.disable( glow::SCISSOR_TEST ); }
self.clip_scissor = None;
self.flush_clip_layer();
}
/// Allocate the offscreen clip layer (a full-canvas colour FBO) on first
/// use. Freed and re-allocated at the new size by `resize`.
fn ensure_clip_layer( &mut self )
{
if self.clip_layer.is_some() { return; }
// SAFETY: GL context current (canvas invariant). Allocates an FBO + colour
// texture sized to the canvas and attaches it; the handles are stored so
// `Drop`/`resize` manage their lifetime. Restores the canvas FBO binding.
unsafe
{
let fbo = self.gl.create_framebuffer().expect( "clip-layer FBO" );
let tex = super::helpers::alloc_fbo_tex( &self.gl, self.version, self.width, self.height );
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( fbo ) );
self.gl.framebuffer_texture_2d( glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT0, glow::TEXTURE_2D, Some( tex ), 0 );
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( self.fbo ) );
self.clip_layer = Some( ( fbo, tex ) );
}
}
/// Clip subsequent draws to an arbitrary vector path, with an anti-aliased
/// edge. Begins capturing draws into the offscreen clip layer; the path's
/// anti-aliased coverage is rasterised (tiny-skia) into `clip_mask_tex` and
/// applied when the layer is composited back by `flush_clip_layer` (on the
/// next `clear_clip` / `set_clip_rects`). GLES counterpart of the software
/// backend's `set_clip_path`.
pub fn set_clip_path( &mut self, cmds: &[ crate::types::PathCmd ] )
{
// A new path clip supersedes any layer still open.
self.flush_clip_layer();
let Some( path ) = crate::render::helpers::build_ts_path( cmds ) else { self.clear_clip(); return; };
let b = path.bounds();
let x0 = b.left().floor().max( 0.0 );
let y0 = b.top().floor().max( 0.0 );
let x1 = b.right().ceil().min( self.width as f32 );
let y1 = b.bottom().ceil().min( self.height as f32 );
let pw = ( x1 - x0 ).max( 0.0 ) as u32;
let ph = ( y1 - y0 ).max( 0.0 ) as u32;
if pw == 0 || ph == 0 || pw > 8192 || ph > 8192 { self.clear_clip(); return; }
// Rasterise the path coverage (anti-aliased, opaque inside) into a
// bbox-sized pixmap; the composite shader multiplies the layer by it.
let Some( mut pixmap ) = tiny_skia::Pixmap::new( pw, ph ) else { return; };
let mut paint = tiny_skia::Paint::default();
paint.set_color( tiny_skia::Color::WHITE );
paint.anti_alias = true;
let tf = tiny_skia::Transform::from_translate( -x0, -y0 );
pixmap.fill_path( &path, &paint, tiny_skia::FillRule::Winding, tf, None );
let rgba = pixmap.data().to_vec();
let mask = super::helpers::upload_rgba_texture( &self.gl, self.version, &rgba, pw as i32, ph as i32 );
self.clip_mask_tex = Some( mask );
self.clip_bbox = Rect { x: x0, y: y0, width: pw as f32, height: ph as f32 };
self.ensure_clip_layer();
// Redirect to the layer and clear it transparent so untouched pixels stay
// empty (they contribute nothing to the composite).
self.clip_layer_active = true;
self.clip_scissor = None;
self.activate_target();
// SAFETY: GL context current; `activate_target` bound the layer FBO.
unsafe
{
self.gl.clear_color( 0.0, 0.0, 0.0, 0.0 );
self.gl.clear( glow::COLOR_BUFFER_BIT );
}
}
/// Composite the clip layer back onto the canvas FBO, multiplied by the
/// anti-aliased coverage mask, then end the path clip. No-op when no path
/// clip is open.
fn flush_clip_layer( &mut self )
{
if !self.clip_layer_active { return; }
self.clip_layer_active = false;
let Some( ( _, layer_tex ) ) = self.clip_layer else { return; };
let Some( mask_tex ) = self.clip_mask_tex.take() else { return; };
let bbox = self.clip_bbox;
let mvp = super::helpers::ortho_rect( self.width, self.height, bbox );
// SAFETY: GL context current. Bind the canvas FBO, disable scissor, and
// draw a quad over the bbox sampling the layer (unit 0) and coverage mask
// (unit 1); the premultiplied result is blended SRC_OVER. All handles are
// canvas-owned; the transient mask texture is deleted after the draw.
unsafe
{
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( self.fbo ) );
self.gl.viewport( 0, 0, self.width as i32, self.height as i32 );
self.gl.disable( glow::SCISSOR_TEST );
self.gl.use_program( Some( self.clip_composite_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_clip_mvp ), false, &mvp );
self.gl.uniform_2_f32( Some( &self.u_clip_canvas ), self.width as f32, self.height as f32 );
self.gl.uniform_4_f32( Some( &self.u_clip_bbox ), bbox.x, bbox.y, bbox.width, bbox.height );
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( layer_tex ) );
self.gl.uniform_1_i32( Some( &self.u_clip_layer ), 0 );
self.gl.active_texture( glow::TEXTURE1 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( mask_tex ) );
self.gl.uniform_1_i32( Some( &self.u_clip_mask ), 1 );
self.gl.bind_vertex_array( Some( self.quad_vao ) );
self.gl.draw_arrays( glow::TRIANGLES, 0, 6 );
self.gl.bind_vertex_array( None );
self.gl.bind_texture( glow::TEXTURE_2D, None );
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, None );
self.gl.delete_texture( mask_tex );
}
}
/// Snapshot of the active scissor as a `Vec<Rect>` (empty when no

View File

@@ -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 );
@@ -83,22 +86,32 @@ impl GlesCanvas
/// disable scissor when the canvas has no clip).
pub( super ) fn activate_target( &self )
{
// SAFETY: rebinds canvas-owned FBO + viewport + scissor. All values
// (`self.fbo`, `self.width`, `self.height`, `self.clip_scissor`)
// While a path clip is active, draws are redirected to the offscreen clip
// layer (unclipped); the anti-aliased mask is applied when the layer is
// composited back on clip end. Otherwise draws go to `fbo` with the
// active scissor.
// SAFETY: rebinds a canvas-owned FBO + viewport + scissor. All values
// (`self.fbo`, `self.clip_layer`, `self.width/height`, `self.clip_scissor`)
// live as long as `&self`, and the bind is idempotent.
let target = match ( self.clip_layer_active, self.clip_layer )
{
( true, Some( ( fbo, _ ) ) ) => fbo,
_ => self.fbo,
};
let to_layer = self.clip_layer_active && self.clip_layer.is_some();
unsafe
{
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( self.fbo ) );
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( target ) );
self.gl.viewport( 0, 0, self.width as i32, self.height as i32 );
match self.clip_scissor
{
Some( r ) =>
Some( r ) if !to_layer =>
{
let ( x, y, w, h ) = self.scissor_pixels( r );
self.gl.enable( glow::SCISSOR_TEST );
self.gl.scissor( x, y, w, h );
}
None =>
_ =>
{
self.gl.disable( glow::SCISSOR_TEST );
}

View File

@@ -2,10 +2,10 @@
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! 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<u8>
{
for path in SYSTEM_FONT_CANDIDATES.iter()
{
if std::path::Path::new( path ).exists()
{
if let Ok( bytes ) = std::fs::read( path )
{
return bytes;
}
}
}
crate::theme::fallback::FALLBACK_FONT.to_vec()
}

View File

@@ -68,13 +68,8 @@ impl GlesCanvas
/// level so the cache key is never seeded with a bogus mapping.
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;
}

View File

@@ -9,12 +9,14 @@
//! [`crate::egl_context`] — this module is just the renderer that runs
//! once a context is current.
//!
//! Clipping is implemented with `glScissor`. When
//! Rect clipping is implemented with `glScissor`. When
//! [`GlesCanvas::set_clip_rects`] receives multiple rects the
//! bounding-box union is used as the scissor — coarse, but the
//! partial-redraw path normally clusters the dirty rects of 13
//! widgets so the union is barely larger than the sum. Disjoint
//! regions would want a stencil-buffer path; not implemented today.
//! widgets so the union is barely larger than the sum. Arbitrary
//! path clipping ([`GlesCanvas::set_clip_path`]) captures the clipped
//! draws into an offscreen layer and composites it back through an
//! anti-aliased coverage mask, for a smooth clipped edge.
//!
//! # Submodule layout
//!
@@ -33,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
@@ -133,12 +135,12 @@ pub struct GlesCanvas
{
pub gl: Arc<glow::Context>,
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<Font>,
/// Raw bytes of the default font. Required by rustybuzz for
/// HarfBuzz shaping (see [`crate::text_shaping`]). Kept on the
/// HarfBuzz shaping (see the `text_shaping` private module). Kept on the
/// canvas so the shape pipeline has direct access without a
/// global lookup.
pub font_bytes: Arc<Vec<u8>>,
@@ -354,6 +356,23 @@ pub struct GlesCanvas
/// (GL_SCISSOR_TEST is enabled), `None` when cleared.
clip_scissor: Option<Rect>,
/// Anti-aliased path clip (`set_clip_path`). While `clip_layer_active`,
/// `activate_target` redirects draws to `clip_layer` (a full-canvas
/// offscreen FBO); ending the clip composites that layer back onto `fbo`
/// multiplied by `clip_mask_tex` (an anti-aliased coverage texture covering
/// `clip_bbox`). The layer FBO is allocated lazily on the first path clip.
clip_layer: Option<( glow::Framebuffer, glow::Texture )>,
clip_layer_active: bool,
clip_mask_tex: Option<glow::Texture>,
clip_bbox: Rect,
/// Composite program (layer × coverage mask). Shared with sub-canvases.
clip_composite_program: glow::Program,
u_clip_mvp: glow::UniformLocation,
u_clip_layer: glow::UniformLocation,
u_clip_mask: glow::UniformLocation,
u_clip_canvas: glow::UniformLocation,
u_clip_bbox: glow::UniformLocation,
/// Persistent shadow framebuffer. All draw methods bind this;
/// [`Self::present`](framebuffer) is the only call that switches
/// to the default framebuffer.
@@ -398,6 +417,15 @@ impl Drop for GlesCanvas
{
self.gl.delete_framebuffer( self.fbo );
self.gl.delete_texture( self.fbo_tex );
if let Some( ( fbo, tex ) ) = self.clip_layer.take()
{
self.gl.delete_framebuffer( fbo );
self.gl.delete_texture( tex );
}
if let Some( tex ) = self.clip_mask_tex.take()
{
self.gl.delete_texture( tex );
}
if let Some( ( fbo, tex ) ) = self.aux_a.take()
{
self.gl.delete_framebuffer( fbo );

View File

@@ -27,7 +27,7 @@
use glow::HasContext;
use crate::theme::{ gradient_lut, BlendMode, InsetShadow, LinearGradient, RadialGradient, Shadow };
use crate::types::{ Color, Corners, Rect };
use crate::types::{ Color, Corners, PathCmd, Rect };
use super::helpers::ortho_rect;
use super::GlesCanvas;
@@ -51,6 +51,79 @@ 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 ) );
}
/// CPU fallback for vector paths on the GPU backend: rasterise into a
/// tiny-skia pixmap sized to the path's bounding box, then blit it as a
/// texture via `draw_image_data`. No GPU path shader.
fn rasterise_path( &mut self, cmds: &[ PathCmd ], color: Color, stroke: Option<f32> )
{
let Some( path ) = crate::render::helpers::build_ts_path( cmds ) else { return };
let bounds = path.bounds();
let pad = stroke.map_or( 1.0, |w| w * 0.5 + 1.0 );
let x0 = ( bounds.left() - pad ).floor();
let y0 = ( bounds.top() - pad ).floor();
let x1 = ( bounds.right() + pad ).ceil();
let y1 = ( bounds.bottom() + pad ).ceil();
let pw = ( x1 - x0 ).max( 1.0 ) as u32;
let ph = ( y1 - y0 ).max( 1.0 ) as u32;
if pw > 8192 || ph > 8192 { return; }
let Some( mut pixmap ) = tiny_skia::Pixmap::new( pw, ph ) else { return };
let mut paint = tiny_skia::Paint::default();
paint.set_color( Color::rgba( color.r, color.g, color.b, color.a * self.global_alpha ).to_tiny_skia() );
paint.anti_alias = true;
let tf = tiny_skia::Transform::from_translate( -x0, -y0 );
match stroke
{
Some( w ) =>
{
let mut s = tiny_skia::Stroke::default();
s.width = w;
pixmap.stroke_path( &path, &paint, &s, tf, None );
}
None => { pixmap.fill_path( &path, &paint, tiny_skia::FillRule::Winding, tf, None ); }
}
// tiny-skia stores premultiplied RGBA; the texture shader expects straight.
let mut rgba = pixmap.data().to_vec();
for px in rgba.chunks_exact_mut( 4 )
{
let a = px[ 3 ] as u32;
if a > 0 && a < 255
{
px[ 0 ] = ( px[ 0 ] as u32 * 255 / a ).min( 255 ) as u8;
px[ 1 ] = ( px[ 1 ] as u32 * 255 / a ).min( 255 ) as u8;
px[ 2 ] = ( px[ 2 ] as u32 * 255 / a ).min( 255 ) as u8;
}
}
// Transient texture (upload → draw → delete): a path's pixels change
// every frame for animated vectors, which would make the content-keyed
// image cache in `draw_image_data` grow without bound and exhaust GL.
let dest = Rect { x: x0, y: y0, width: pw as f32, height: ph as f32 };
let tex = super::helpers::upload_rgba_texture( &self.gl, self.version, &rgba, pw as i32, ph as i32 );
self.draw_external_texture( tex, dest, 1.0 );
unsafe { self.gl.delete_texture( tex ); }
}
/// Fill `rect` with a solid colour, with per-corner rounding from `corners`.
/// Coverage (including the rounded corners) comes from an SDF in the rect
/// shader; `color.a` is multiplied by `global_alpha`. Culled early when the
/// rect falls entirely outside the active scissor.
pub fn fill_rect( &mut self, rect: Rect, color: Color, corners: Corners )
{
if self.rect_culled( rect, 1.0 ) { return; }
@@ -61,13 +134,7 @@ impl GlesCanvas
// stay anchored to the original rect — `u_pad` lets the shader
// 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
@@ -158,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);
@@ -206,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
@@ -359,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
@@ -412,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
@@ -487,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

View File

@@ -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,
@@ -28,40 +28,26 @@ use super::shaders::
GLYPH_FRAG_SRC, GLYPH_BATCH_VERT_SRC, GLYPH_BATCH_FRAG_SRC,
LINEAR_GRADIENT_FRAG_SRC, RADIAL_GRADIENT_FRAG_SRC,
RECT_FRAG_SRC,
CLIP_COMPOSITE_FRAG_SRC,
SHADOW_INSET_FRAG_SRC, SHADOW_INSET_OVERLAY_FRAG_SRC, SHADOW_OUTER_FRAG_SRC,
SUB_BLIT_FRAG_SRC,
TEX_FRAG_SRC, VERT_SRC,
};
use super::{ GlesCanvas, GlesVersion };
/// Process-wide cache of the GLES path's default font. Avoids
/// re-reading + re-parsing the small Sora face on every surface
/// bring-up. The fallback chain (Noto Sans / CJK / Devanagari / …)
/// is owned by the crate-private system-fonts module and loaded
/// lazily per codepoint, not per canvas.
static DEFAULT_FONT_GLES: OnceLock<crate::system_fonts::FontHandle> = OnceLock::new();
fn default_handle_gles() -> crate::system_fonts::FontHandle
{
DEFAULT_FONT_GLES.get_or_init( ||
{
let bytes = load_default_font_bytes();
let font = Font::from_bytes( bytes.as_slice(), FontSettings::default() )
.expect( "bad font" );
crate::system_fonts::FontHandle
{
font: Arc::new( font ),
bytes: Arc::new( bytes ),
face: 0,
}
} ).clone()
}
impl GlesCanvas
{
/// Build a GPU canvas of `width × height` physical px on an already-current
/// EGL/GLES context. Compiles every shader program, looks up its uniforms,
/// uploads the shared quad geometry and the glyph atlas (format chosen per ES
/// profile), then allocates the persistent shadow FBO and makes it the active
/// draw target — every draw writes into the FBO, and [`Self::present`] is the
/// only call that blits it to the default framebuffer. `dpi_scale` and
/// `global_alpha` start at 1.0; the default font comes from the process-wide
/// cached handle. Panics if the FBO is incomplete or a program fails to link.
pub fn new( gl: Arc<glow::Context>, version: GlesVersion, width: u32, height: u32 ) -> Self
{
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;
@@ -211,6 +197,16 @@ impl GlesCanvas
gl.get_uniform_location( glyph_batch_program, "u_sampler" ).unwrap(),
)};
let clip_composite_program = compile_program( &gl, VERT_SRC, CLIP_COMPOSITE_FRAG_SRC );
let ( u_clip_mvp, u_clip_layer, u_clip_mask, u_clip_canvas, u_clip_bbox ) = unsafe
{(
gl.get_uniform_location( clip_composite_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( clip_composite_program, "u_layer" ).unwrap(),
gl.get_uniform_location( clip_composite_program, "u_mask" ).unwrap(),
gl.get_uniform_location( clip_composite_program, "u_canvas" ).unwrap(),
gl.get_uniform_location( clip_composite_program, "u_bbox" ).unwrap(),
)};
let ( glyph_batch_vao, glyph_batch_vbo ) = unsafe
{
let vao = gl.create_vertex_array().unwrap();
@@ -464,6 +460,16 @@ impl GlesCanvas
image_cache: HashMap::new(),
gradient_lut_cache: HashMap::new(),
clip_scissor: None,
clip_layer: None,
clip_layer_active: false,
clip_mask_tex: None,
clip_bbox: crate::types::Rect::default(),
clip_composite_program,
u_clip_mvp,
u_clip_layer,
u_clip_mask,
u_clip_canvas,
u_clip_bbox,
fbo,
fbo_tex,
aux_a: None,
@@ -665,6 +671,16 @@ impl GlesCanvas
image_cache: HashMap::new(),
gradient_lut_cache: HashMap::new(),
clip_scissor: None,
clip_layer: None,
clip_layer_active: false,
clip_mask_tex: None,
clip_bbox: crate::types::Rect::default(),
clip_composite_program: self.clip_composite_program,
u_clip_mvp: self.u_clip_mvp,
u_clip_layer: self.u_clip_layer,
u_clip_mask: self.u_clip_mask,
u_clip_canvas: self.u_clip_canvas,
u_clip_bbox: self.u_clip_bbox,
fbo,
fbo_tex,
aux_a: None,
@@ -672,6 +688,7 @@ impl GlesCanvas
}
}
/// `( width, height )` of the FBO in physical px.
pub fn size( &self ) -> ( u32, u32 ) { ( self.width, self.height ) }
/// Discard all cached gradient LUT textures. Call after a theme change
@@ -692,14 +709,19 @@ impl GlesCanvas
}
}
/// DPI scale factor applied to font sizes before rasterisation.
pub fn dpi_scale( &self ) -> f32 { self.dpi_scale }
/// 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
@@ -734,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<LineMetrics>
{
self.font.horizontal_line_metrics( size )
@@ -768,10 +794,22 @@ impl GlesCanvas
glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT0,
glow::TEXTURE_2D, Some( self.fbo_tex ), 0,
);
// The clip layer was sized for the old dimensions — free it so the next
// path clip re-allocates at the new size; any in-flight clip is dropped.
if let Some( ( fbo, tex ) ) = self.clip_layer.take()
{
self.gl.delete_framebuffer( fbo );
self.gl.delete_texture( tex );
}
if let Some( tex ) = self.clip_mask_tex.take()
{
self.gl.delete_texture( tex );
}
self.gl.viewport( 0, 0, width as i32, height as i32 );
self.gl.clear_color( 0.0, 0.0, 0.0, 0.0 );
self.gl.clear( glow::COLOR_BUFFER_BIT );
}
self.clip_layer_active = false;
// Auxiliary textures were sized for the old dimensions — drop them so
// the next effect that needs them re-allocates at the new size.
self.invalidate_aux();

View File

@@ -147,6 +147,33 @@ void main()
}
"##;
// Clip-layer composite shader for `set_clip_path`. Path-clipped content is
// drawn to an offscreen layer; this composites that layer onto the canvas
// multiplied by an anti-aliased coverage mask, giving a smooth clipped edge.
// `u_layer` is the full-canvas layer texture (premultiplied), `u_mask` the
// path coverage rasterised over `u_bbox` (x, y, w, h in canvas pixels). The
// quad is drawn over the bbox; `v_uv` runs 0..1 across it. Both FBO-backed
// textures are sampled y-flipped (GL's lower-left origin).
pub( super ) const CLIP_COMPOSITE_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_layer;
uniform sampler2D u_mask;
uniform vec2 u_canvas;
uniform vec4 u_bbox;
void main()
{
// `v_uv.y` runs bottom-to-top in screen space (FBO origin is lower-left, as
// `ortho_rect` + the texture shader's flip establish), so the screen Y of
// this fragment is `bbox.y + (1 - v_uv.y) * bbox.h`.
vec2 sp = vec2( u_bbox.x + v_uv.x * u_bbox.z, u_bbox.y + ( 1.0 - v_uv.y ) * u_bbox.w );
vec2 luv = vec2( sp.x / u_canvas.x, 1.0 - sp.y / u_canvas.y );
vec4 col = texture2D( u_layer, luv );
float cov = texture2D( u_mask, vec2( v_uv.x, 1.0 - v_uv.y ) ).a;
gl_FragColor = col * cov;
}
"##;
// Fragment shader for single-channel glyph textures with color tint.
//
// Glyphs live in a shared GL_LUMINANCE atlas. `u_uv_scale` and `u_uv_offset`

View File

@@ -34,11 +34,19 @@ fn font_id( font: &Arc<Font> ) -> 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<Font> )
{
self.draw_text_inner( text, x, y, size, color, Some( font ) );
@@ -92,7 +100,9 @@ impl GlesCanvas
if !canvas_handle.bytes.is_empty() { Some( canvas_handle.clone() ) } else { None }
)
};
let shaped = crate::text_shaping::shape_line( text, scaled, resolve );
let shaped = crate::text_shaping::shape_line_cached(
text, scaled, Arc::as_ptr( &prefer_handle.font ) as usize, resolve,
);
if shaped.is_empty() { return; }
// Resolve each glyph's `font_id` to an actual `Arc<Font>` for
@@ -105,7 +115,7 @@ impl GlesCanvas
{
fonts.push( ( font_id( &canvas_handle.font ), Arc::clone( &canvas_handle.font ) ) );
}
for g in &shaped
for g in shaped.iter()
{
if fonts.iter().any( |( id, _ )| *id == g.font_id ) { continue; }
let mut found = None;
@@ -222,7 +232,7 @@ impl GlesCanvas
let surface_h = self.height as f32;
let mut verts: Vec<f32> = Vec::with_capacity( shaped.len() * 24 );
let mut cx = x;
for g in &shaped
for g in shaped.iter()
{
let glyph_id = g.glyph_id as u16;
let key = ( glyph_id, size_key, g.font_id );
@@ -285,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<Font> ) -> f32
{
self.measure_inner( text, size, Some( font ) )
@@ -325,7 +340,9 @@ impl GlesCanvas
if !canvas_handle.bytes.is_empty() { Some( canvas_handle.clone() ) } else { None }
)
};
let shaped = crate::text_shaping::shape_line( text, scaled, resolve );
let shaped = crate::text_shaping::shape_line_cached(
text, scaled, Arc::as_ptr( &prefer_handle.font ) as usize, resolve,
);
if shaped.is_empty()
{
return text.chars().map( |ch|

View File

@@ -44,15 +44,46 @@ impl<A: App> AppData<A>
{
self.surface_mut( focus ).request_redraw();
}
MoveOutcome::OverscrollDown { progress } =>
{
self.app.on_overscroll_down_progress( progress );
// Like a vertical swipe, the dismiss rides an overlay panel
// (subsurface) over a static main, so refresh the overlays but
// leave the main alone — re-rastering it every motion is waste.
self.overlays_dirty = true;
for ss in self.overlays.values_mut()
{
ss.request_redraw();
}
}
MoveOutcome::Swipe { up, down, horizontal } =>
{
if let Some( v ) = up { self.app.on_swipe_progress( v ); }
if let Some( v ) = down { self.app.on_swipe_down_progress( v ); }
if let Some( v ) = horizontal { self.app.on_swipe_horizontal_progress( v ); }
// Swipe-progress callbacks mutate app state outside
// `update`, so the cached view tree is stale.
self.dirty_caches();
self.surface_mut( focus ).request_redraw();
// Swipe-progress callbacks mutate app state outside `update`,
// so the cached trees are stale and the affected surfaces must
// redraw. Only the HORIZONTAL pager moves the main surface,
// though — vertical swipes drive overlay panels and leave the
// main static. Re-rastering the (full-screen) main on every
// motion event of a vertical swipe is pure waste and, on a slow
// GPU, stalls the event loop so the gesture itself feels laggy
// to start. So refresh the overlays always, but the main only
// for a horizontal swipe.
// ...unless the app is sliding the pager on subsurfaces
// (`subsurface_motion_only`): then the main is unchanged and the
// slide is a per-iteration subsurface reposition, so re-rastering
// the full main every motion event is the same waste the vertical
// panels already avoid — and, with child subsurfaces, it flickers.
if horizontal.is_some() && !self.app.subsurface_motion_only()
{
self.view_dirty = true;
if let SurfaceFocus::Main = focus
{
self.main.request_redraw();
}
}
self.overlays_dirty = true;
for ss in self.overlays.values_mut()
{
ss.request_redraw();
@@ -158,6 +189,21 @@ impl<A: App> AppData<A>
ss.frame_pending = false;
}
}
ReleaseEvent::OverscrollDown { committed } =>
{
if let Some( msg ) = self.app.on_overscroll_down( committed )
{
self.pending_msgs.push( msg );
}
self.dirty_caches();
self.main.request_redraw();
self.main.frame_pending = false;
for ss in self.overlays.values_mut()
{
ss.request_redraw();
ss.frame_pending = false;
}
}
ReleaseEvent::HorizontalFellThrough =>
{
// Below threshold: pulse horizontal 0 so a

View File

@@ -23,7 +23,7 @@ impl<A: App> AppData<A>
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
{

View File

@@ -47,6 +47,17 @@ mod tests;
// ─── State ───────────────────────────────────────────────────────────────────
/// Axis a swipe locked onto during its first 8 px of travel. Once set,
/// the perpendicular axis is ignored for the rest of the gesture so a
/// vertical swipe that drifts sideways never also drives the pager (and
/// vice-versa).
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
pub enum SwipeAxis
{
Vertical,
Horizontal,
}
/// Per-surface gesture tracking. Lives on `SurfaceState::gesture`.
///
/// Every field is `pub` because `AppData`'s long-press deadline
@@ -68,6 +79,10 @@ pub struct GestureState<Msg: Clone>
/// Index of the Scroll viewport that owns the current gesture, if
/// the press landed inside one.
pub scrolling_widget: Option<( usize, crate::widget::scroll::ScrollAxis )>,
/// Scroll viewports containing the press, innermost first.
pub scroll_candidates: Vec<( usize, crate::widget::scroll::ScrollAxis )>,
/// `true` once the first 8 px of motion has pinned `scrolling_widget` to a definite axis.
pub scroll_locked: bool,
/// Scroll-viewport drag exceeded the 8 px start tolerance — the
/// release will be consumed as a scroll instead of a tap.
pub scroll_drag_started: bool,
@@ -77,6 +92,10 @@ pub struct GestureState<Msg: Clone>
/// Vertical drag exceeded the 8 px threshold. Release runs the
/// swipe-up / swipe-down commit check instead of the button path.
pub vertical_drag_started: bool,
/// Axis this swipe locked onto on its first 8 px of travel. Pins the
/// gesture to one axis so vertical and horizontal swipes stay
/// mutually exclusive. `None` until the lock fires.
pub swipe_axis: Option<SwipeAxis>,
/// Slider widget being dragged for a live-value update.
pub dragging_slider: Option<usize>,
/// Instant when the long-press window opened. `None` if the hit
@@ -109,6 +128,12 @@ pub struct GestureState<Msg: Clone>
/// `app.on_drop`. Set by the touch deadline when `drag_start_msg`
/// is present, or by the mouse-motion promotion path.
pub long_press_fired: bool,
/// Accumulated downward overscroll (physical px) while a vertical
/// scroll is pinned at its top — built up by [`Self::on_move`] when a
/// pull-down can no longer scroll content, unwound by upward drag, and
/// drained on release into an `OverscrollDown` event. Drives
/// pull-from-top-to-dismiss.
pub overscroll_y: f32,
/// `true` when the press came from a mouse (`wl_pointer`) rather
/// than touch. The 6 px stray-cancel in [`Self::on_move`] is gated
/// on this: touch needs the cancel so a hold that turns into a
@@ -136,9 +161,12 @@ impl<Msg: Clone> GestureState<Msg>
start: None,
pressed_idx: None,
scrolling_widget: None,
scroll_candidates: Vec::new(),
scroll_locked: false,
scroll_drag_started: false,
horizontal_drag_started: false,
vertical_drag_started: false,
swipe_axis: None,
dragging_slider: None,
long_press_start: None,
long_press_origin: None,
@@ -146,6 +174,7 @@ impl<Msg: Clone> GestureState<Msg>
drag_start_msg: None,
long_press_text_idx: None,
long_press_fired: false,
overscroll_y: 0.0,
mouse_press: false,
}
}
@@ -175,12 +204,17 @@ impl<Msg: Clone> GestureState<Msg>
}).unwrap_or( ( None, None ) );
self.start = Some( pos );
self.scrolling_widget = scroll_rects.iter().rev()
.find( |( r, _, _ )| r.contains( pos ) )
.map( |( _, idx, ax )| ( *idx, *ax ) );
self.scroll_candidates = scroll_rects.iter()
.filter( |( r, _, _ )| r.contains( pos ) )
.map( |( _, idx, ax )| ( *idx, *ax ) )
.collect();
self.scrolling_widget = self.scroll_candidates.first().copied();
self.scroll_locked = false;
self.scroll_drag_started = false;
self.overscroll_y = 0.0;
self.horizontal_drag_started = false;
self.vertical_drag_started = false;
self.swipe_axis = None;
self.pressed_idx = hit;
// A fresh press resets any stale long-press / source state
// from a prior gesture that never released cleanly. The
@@ -289,48 +323,100 @@ impl<Msg: Clone> GestureState<Msg>
return MoveOutcome::Idle;
}
// Scroll viewport drag: mutate offset in place and advance the
// gesture origin so the next delta is frame-to-frame, not
// press-to-now (otherwise the first 8 px trip the threshold
// and the entire scroll gets absorbed into one delta). Both
// axes are routed independently; an axis the viewport does not
// allow is just ignored (delta still consumed by the gesture,
// so the swipe handler does not fight the scroll for it).
if let Some( ( scroll_idx, axis ) ) = self.scrolling_widget
if self.scrolling_widget.is_some()
{
if let Some( start ) = self.start
{
let dx = pos.x - start.x;
let dy = pos.y - start.y;
if !self.scroll_locked
{
if dx.abs() <= 8.0 && dy.abs() <= 8.0
{
return MoveOutcome::Idle;
}
let prefer_x = dx.abs() > dy.abs();
if let Some( c ) = self.scroll_candidates.iter()
.find( |( _, ax )| if prefer_x { ax.allows_x() } else { ax.allows_y() } )
.copied()
{
self.scrolling_widget = Some( c );
}
self.scroll_locked = true;
}
let ( scroll_idx, axis ) = self.scrolling_widget.unwrap();
let entry = scroll_offsets.entry( scroll_idx ).or_insert( ( 0.0, 0.0 ) );
if axis.allows_x() { entry.0 = ( entry.0 - dx ).max( 0.0 ); }
if axis.allows_y() { entry.1 = ( entry.1 - dy ).max( 0.0 ); }
if axis.allows_y()
{
// Split the vertical delta between content scroll and top
// overscroll: a downward pull first scrolls toward the top,
// then any excess (the offset can't go below 0) feeds
// `overscroll_y`; an upward pull unwinds that overscroll
// before it resumes scrolling content.
if dy >= 0.0
{
let consume = dy.min( entry.1 );
entry.1 -= consume;
self.overscroll_y += dy - consume;
}
else
{
let up = -dy;
let consume = up.min( self.overscroll_y );
self.overscroll_y -= consume;
entry.1 += up - consume;
}
}
let moved = if axis.allows_x() && axis.allows_y() { dx.hypot( dy ) }
else if axis.allows_x() { dx.abs() }
else { dy.abs() };
if moved > 8.0 { self.scroll_drag_started = true; }
self.start = Some( pos );
// Gate the dismiss on the same 8 px arm as a real scroll drag so
// tap jitter at the top can't engage a follow-the-finger close.
if self.overscroll_y > 0.0 && axis.allows_y() && self.scroll_drag_started
{
let progress = if swipe.surface_height > 0
{
self.overscroll_y / swipe.surface_height as f32
}
else { 0.0 };
return MoveOutcome::OverscrollDown { progress };
}
return MoveOutcome::Scroll;
}
return MoveOutcome::Idle;
}
// Swipe progress — independent on both axes so a vertical-
// dominant gesture that picks up horizontal drift still drives
// the pager.
// Swipe progress — locked to a single axis (see below) so a
// gesture only ever drives one of the vertical panels or the
// horizontal pager, never both.
if let Some( start ) = self.start
{
let dy = start.y - pos.y;
let dx = pos.x - start.x;
let ( up, down ) = if swipe.surface_height > 0
// Lock onto the dominant axis on the first 8 px of travel so
// vertical and horizontal swipes stay mutually exclusive.
if self.swipe_axis.is_none() && dx.abs().max( dy.abs() ) > 8.0
{
self.swipe_axis = Some( if dy.abs() >= dx.abs() { SwipeAxis::Vertical } else { SwipeAxis::Horizontal } );
}
let vertical_allowed = self.swipe_axis != Some( SwipeAxis::Horizontal );
let ( up, down ) = if vertical_allowed && swipe.surface_height > 0
{
let h = swipe.surface_height as f32;
if dy > 0.0
{
let threshold = h * swipe.up_thresh;
if dy.abs() > 8.0 { self.vertical_drag_started = true; }
( Some( ( dy / threshold ).clamp( 0.0, 1.0 ) ), None )
// Unclamped on purpose — lets follow-the-finger
// panels track past the commit threshold.
( Some( ( dy / threshold ).max( 0.0 ) ), None )
}
else if start.y <= h * swipe.down_edge
{
@@ -344,11 +430,18 @@ impl<Msg: Clone> GestureState<Msg>
}
else { ( None, None ) };
let horizontal = if swipe.surface_width > 0
// Only drive the horizontal pager once the gesture has LOCKED onto the
// horizontal axis. Emitting progress while `swipe_axis` is still None
// would activate the pager from the sub-8 px lateral jitter at the
// start of a vertical swipe; `horizontal_drag_started` would then never
// trip, so the release emits no horizontal event and the pager stays
// stuck active (frozen homescreen). Locking to Horizontal implies
// `dx.abs() > 8.0`, so the drag is unambiguously started here.
let horizontal = if self.swipe_axis == Some( SwipeAxis::Horizontal ) && swipe.surface_width > 0
{
let h_thr = swipe.surface_width as f32 * swipe.horizontal_thresh;
let h_progress = if h_thr > 0.0 { dx / h_thr } else { 0.0 };
if dx.abs() > 8.0 { self.horizontal_drag_started = true; }
self.horizontal_drag_started = true;
Some( h_progress )
}
else { None };
@@ -426,6 +519,15 @@ impl<Msg: Clone> GestureState<Msg>
if scroll_consumed
{
self.start = None;
// A pull past the top releases into an overscroll-dismiss event;
// the app decides whether the accumulated pull committed.
let over = self.overscroll_y;
self.overscroll_y = 0.0;
if over > 0.0
{
let threshold = swipe.surface_height as f32 * swipe.overscroll_thresh;
events.push( ReleaseEvent::OverscrollDown { committed: over >= threshold } );
}
return events;
}

View File

@@ -28,6 +28,10 @@ pub enum MoveOutcome<Msg>
Slider { msg: Option<Msg> },
/// Scroll viewport offset updated. Handler just requests a redraw.
Scroll,
/// Vertical scroll pinned at the top while the finger keeps pulling
/// down: `progress` is the accumulated overscroll as a fraction of
/// surface height. Handler fires `app.on_overscroll_down_progress`.
OverscrollDown { progress: f32 },
/// Swipe progress. Each axis is `Some(value)` when it has a valid
/// progress reading for this move (not every motion updates all
/// axes). Handler fires the matching app callback.
@@ -50,6 +54,10 @@ pub enum ReleaseEvent<Msg>
SwipeUp,
/// Vertical swipe committed down.
SwipeDown,
/// Released after a top overscroll pull-down — fire
/// `app.on_overscroll_down(committed)`. `committed` is set when the
/// pull passed `overscroll_thresh` of surface height.
OverscrollDown { committed: bool },
/// Horizontal swipe did not commit — fire
/// `app.on_swipe_horizontal_progress(0.0)` + main redraw.
HorizontalFellThrough,
@@ -79,6 +87,9 @@ pub struct SwipeConfig
pub down_edge: f32,
/// Horizontal commit fraction of surface width.
pub horizontal_thresh: f32,
/// Top-overscroll dismiss commit fraction of surface height — the
/// pull-down past a scroll's top must exceed this to commit.
pub overscroll_thresh: f32,
pub surface_width: u32,
pub surface_height: u32,
}

View File

@@ -56,6 +56,7 @@ fn cfg_full( w: u32, h: u32 ) -> SwipeConfig
down_thresh: 0.30,
down_edge: 0.10,
horizontal_thresh: 0.30,
overscroll_thresh: 0.25,
surface_width: w,
surface_height: h,
}
@@ -269,6 +270,76 @@ fn move_below_eight_pixels_does_not_arm_scroll_drag()
assert!( !g.scroll_drag_started );
}
// ── on_move / on_release: top overscroll dismiss ──────────────────────────
#[ test ]
fn pull_down_at_top_emits_overscroll_progress()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 3, ScrollAxis::Vertical ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls );
let mut offsets = HashMap::new();
// Already at the top; drag down 120 px → all of it is overscroll and the
// content offset stays pinned at 0.
let out = g.on_move( pt( 100.0, 220.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert_eq!( offsets.get( &3 ).copied(), Some( ( 0.0, 0.0 ) ) );
match out
{
MoveOutcome::OverscrollDown { progress } =>
assert!( ( progress - 120.0 / 1200.0 ).abs() < 1e-4 ),
_ => panic!( "expected OverscrollDown" ),
}
}
#[ test ]
fn upward_drag_unwinds_overscroll_before_scrolling_content()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 4, ScrollAxis::Vertical ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls );
let mut offsets = HashMap::new();
let _ = g.on_move( pt( 100.0, 200.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert!( ( g.overscroll_y - 100.0 ).abs() < 1e-4 );
// Drag back up 60 px → unwinds 60 px of overscroll, content stays at top.
let _ = g.on_move( pt( 100.0, 140.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert!( ( g.overscroll_y - 40.0 ).abs() < 1e-4 );
assert_eq!( offsets.get( &4 ).map( |o| o.1 ), Some( 0.0 ) );
// A further 90 px up unwinds the remaining 40 and scrolls the 50 px excess.
let _ = g.on_move( pt( 100.0, 50.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert_eq!( g.overscroll_y, 0.0 );
assert_eq!( offsets.get( &4 ).map( |o| o.1 ), Some( 50.0 ) );
}
#[ test ]
fn release_after_overscroll_past_threshold_commits_dismiss()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 5, ScrollAxis::Vertical ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls );
let mut offsets = HashMap::new();
// cfg_full overscroll_thresh is 0.25 → 300 px of 1200 commits; drag 400 px.
let _ = g.on_move( pt( 100.0, 500.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
let events = g.on_release( pt( 100.0, 500.0 ), &widgets, &cfg_full( 800, 1200 ), false );
assert!( events.iter().any( |e| matches!( e, ReleaseEvent::OverscrollDown { committed: true } ) ) );
}
#[ test ]
fn release_after_short_overscroll_does_not_commit()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 6, ScrollAxis::Vertical ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls );
let mut offsets = HashMap::new();
// 100 px is below the 300 px commit threshold.
let _ = g.on_move( pt( 100.0, 200.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
let events = g.on_release( pt( 100.0, 200.0 ), &widgets, &cfg_full( 800, 1200 ), false );
assert!( events.iter().any( |e| matches!( e, ReleaseEvent::OverscrollDown { committed: false } ) ) );
}
// ── on_move: swipe progress ───────────────────────────────────────────────
#[ test ]
@@ -309,10 +380,58 @@ fn move_horizontal_eleven_pixels_arms_horizontal_drag()
}
#[ test ]
fn move_up_progress_clamps_to_unit_interval()
fn vertical_lock_suppresses_later_horizontal_drift()
{
// A swipe that locks vertical must not arm the horizontal pager even
// if the finger later drifts sideways past the 8 px threshold.
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let mut g = GestureState::<Msg>::new();
g.start = Some( pt( 100.0, 600.0 ) );
let mut offsets = HashMap::new();
let _ = g.on_move( pt( 100.0, 560.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert_eq!( g.swipe_axis, Some( SwipeAxis::Vertical ) );
let out = g.on_move( pt( 140.0, 540.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert!( !g.horizontal_drag_started );
assert!( matches!( out, MoveOutcome::Swipe { horizontal: None, .. } ) );
}
#[ test ]
fn horizontal_lock_suppresses_later_vertical_drift()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let mut g = GestureState::<Msg>::new();
g.start = Some( pt( 100.0, 600.0 ) );
let mut offsets = HashMap::new();
let _ = g.on_move( pt( 140.0, 600.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert_eq!( g.swipe_axis, Some( SwipeAxis::Horizontal ) );
let out = g.on_move( pt( 160.0, 560.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert!( !g.vertical_drag_started );
assert!( matches!( out, MoveOutcome::Swipe { up: None, down: None, .. } ) );
}
#[ test ]
fn pre_lock_lateral_drift_does_not_drive_horizontal_pager()
{
// Regression: a vertical swipe that opens with a few px of lateral drift
// (axis not yet locked) must not emit horizontal progress. It used to, which
// armed the pager without ever tripping `horizontal_drag_started` — so the
// release sent no horizontal event and the pager stayed stuck active,
// freezing the homescreen.
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let mut g = GestureState::<Msg>::new();
g.start = Some( pt( 100.0, 600.0 ) );
let mut offsets = HashMap::new();
let out = g.on_move( pt( 103.0, 595.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert_eq!( g.swipe_axis, None );
assert!( matches!( out, MoveOutcome::Swipe { horizontal: None, .. } ) );
}
#[ test ]
fn move_up_progress_unclamped_past_unit_interval()
{
// Surface 1000 px tall, threshold 30 % = 300 px. A 600 px upward drag
// must clamp progress to 1.0 instead of overshooting to 2.0.
// overshoots to 2.0 — up progress is unclamped so follow-the-finger
// panels can keep tracking past the commit threshold.
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let mut g = GestureState::<Msg>::new();
g.start = Some( pt( 100.0, 800.0 ) );
@@ -321,7 +440,7 @@ fn move_up_progress_clamps_to_unit_interval()
match out
{
MoveOutcome::Swipe { up: Some( v ), .. } =>
assert!( ( v - 1.0 ).abs() < 1e-6, "expected clamp to 1.0, got {v}" ),
assert!( ( v - 2.0 ).abs() < 1e-6, "expected unclamped 2.0, got {v}" ),
_ => panic!( "expected Swipe with up=Some" ),
}
}

View File

@@ -45,6 +45,7 @@ impl<A: App> KeyboardHandler for AppData<A>
)
{
let focus = self.focus_for_surface( surface ).unwrap_or( SurfaceFocus::Main );
let _ = surface;
self.keyboard_focus = focus;
self.surface_mut( focus ).request_redraw();
if let Some( ref mut a ) = self.a11y { a.set_window_focus( true ); }

View File

@@ -21,10 +21,10 @@ impl<A: App> AppData<A>
// 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<A: App> AppData<A>
// 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<A: App> AppData<A>
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
}

View File

@@ -14,7 +14,7 @@ impl<A: App> AppData<A>
{
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<A: App> AppData<A>
{
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<A: App> AppData<A>
{
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<A: App> AppData<A>
{
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<A: App> AppData<A>
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<A: App> AppData<A>
} 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() )
{

View File

@@ -47,33 +47,39 @@ impl<A: App> AppData<A>
// 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
{
self.handle_text_insert( focus, "\n" );
} else {
let target = focused.or( self.surface( focus ).hovered_idx );
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
{
self.pending_msgs.push( m );
handled = true;
}
} else if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed )
}
if !handled
{
if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed )
{
self.pending_msgs.push( msg );
}
}
}
}
pub( super ) fn handle_key_arrow_vertical( &mut self, focus: SurfaceFocus, event: &KeyEvent )
{
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;
@@ -100,7 +106,7 @@ impl<A: App> AppData<A>
{
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 );
@@ -123,7 +129,7 @@ impl<A: App> AppData<A>
{
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 ); }
}
@@ -132,7 +138,7 @@ impl<A: App> AppData<A>
{
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 ); }
}
@@ -142,7 +148,7 @@ impl<A: App> AppData<A>
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
{

View File

@@ -24,6 +24,7 @@ impl<A: App> AppData<A>
down_thresh: self.app.swipe_down_threshold(),
down_edge: self.app.swipe_down_edge(),
horizontal_thresh: self.app.swipe_horizontal_threshold(),
overscroll_thresh: self.app.overscroll_dismiss_threshold(),
surface_width: ss.physical_width(),
surface_height: ss.physical_height(),
}

View File

@@ -33,12 +33,12 @@ impl<A: App> AppData<A>
// 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<A: App> AppData<A>
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<A: App> AppData<A>
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 }
} );

View File

@@ -43,9 +43,9 @@ impl<A: App> AppData<A>
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<A: App> AppData<A>
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<A: App> AppData<A>
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<A: App> AppData<A>
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<A: App> AppData<A>
// 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
{

View File

@@ -33,7 +33,7 @@ impl<A: App> AppData<A>
{
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

View File

@@ -33,7 +33,7 @@ impl<A: App> AppData<A>
let scroll_hit =
{
let ss = self.surface( focus );
ss.scroll_rects.iter().rev()
ss.frame.scroll_rects.iter()
.find( |( r, _, _ )| r.contains( pos ) )
.map( |( _, idx, ax )| ( *idx, *ax ) )
};
@@ -47,7 +47,7 @@ impl<A: App> AppData<A>
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

View File

@@ -51,7 +51,7 @@ impl<A: App> AppData<A>
let token = self.loop_handle.insert_source( timer, move |_, _, data: &mut AppData<A>|
{
let live_msg = crate::tree::find_handlers(
&data.surface( focus ).widget_rects,
&data.surface( focus ).frame.widget_rects,
idx,
)
.and_then( |h| h.press_msg() );

View File

@@ -58,6 +58,14 @@ impl<A: App> TouchHandler for AppData<A>
let pos = self.surface( focus ).to_physical( position.0, position.1 );
self.pointer_pos = pos;
// Compositor re-grab after the origin surface died mid-drag: this
// slot is already our primary and the drag state was migrated here,
// so treat the redundant `down` as a continuation, not a restart.
if self.surface( focus ).primary_touch_id == Some( id )
{
return;
}
// Auxiliary slot path: a second (or third…) finger arriving
// while another finger already drives the primary slot stays
// out of the single-slot gesture machine entirely. The app
@@ -101,7 +109,7 @@ impl<A: App> TouchHandler for AppData<A>
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
};
@@ -114,7 +122,7 @@ impl<A: App> TouchHandler for AppData<A>
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() )
@@ -130,7 +138,7 @@ impl<A: App> TouchHandler for AppData<A>
// 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
{
@@ -200,7 +208,7 @@ impl<A: App> TouchHandler for AppData<A>
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();
@@ -242,7 +250,7 @@ impl<A: App> TouchHandler for AppData<A>
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 );
@@ -250,7 +258,7 @@ impl<A: App> TouchHandler for AppData<A>
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 }
} );
@@ -281,9 +289,30 @@ impl<A: App> TouchHandler for AppData<A>
fn cancel( &mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _touch: &WlTouch )
{
// Snapshot every auxiliary slot's last position so the app
// can be notified with a meaningful release point, then drop
// every per-surface slot in one pass.
self.reset_touch_state();
}
}
impl<A: App> AppData<A>
{
/// Drop every in-flight touch gesture across all surfaces, notifying
/// the app of any auxiliary slots that were still down. Shared by the
/// `wl_touch.cancel` handler and the touch-capability add / remove path
/// (suspend / resume on devices that power the touchscreen down): a
/// yanked capability never delivers the pending `up` / `cancel`, so
/// without this the stale `primary_touch_id` / gesture state strands and
/// the first gesture after resume is misrouted and lost. Returns `true`
/// when it actually had state to clear.
pub( crate ) fn reset_touch_state( &mut self ) -> bool
{
let had_state = self.main.primary_touch_id.is_some()
|| !self.main.touch_slots.is_empty()
|| !self.touch_focus.is_empty()
|| self.overlays.values().any( |ss| ss.primary_touch_id.is_some() || !ss.touch_slots.is_empty() );
// Snapshot every auxiliary slot's last position so the app can be
// notified with a meaningful release point, then drop every
// per-surface slot in one pass.
let mut aux_releases: Vec<( i32, crate::types::Point )> = Vec::new();
let collect = |ss: &mut SurfaceState<A::Message>, out: &mut Vec<( i32, crate::types::Point )>|
{
@@ -302,5 +331,6 @@ impl<A: App> TouchHandler for AppData<A>
self.app.on_touch_up( id as i64, pos.x, pos.y );
}
self.stop_button_repeat();
had_state
}
}

View File

@@ -1,7 +1,7 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::Rect;
use crate::types::{ Length, Rect };
use crate::render::Canvas;
use crate::widget::Element;
@@ -23,15 +23,37 @@ use crate::widget::Element;
/// .into()
/// # }
/// ```
///
/// `padding`, `spacing` and `max_width` all accept any
/// [`crate::Length`], so a responsive layout reads as:
///
/// ```rust,no_run
/// # use ltk::{ button, column, text, Length, Element };
/// # #[ derive( Clone ) ] enum Msg { Ok }
/// # fn _ex() -> Element<Msg> {
/// column()
/// // Padding is 3 % of the viewport's smaller side, clamped to 16..48 px.
/// .padding( Length::vmin( 3.0 ).clamp( 16.0, 48.0 ) )
/// .spacing( Length::vmin( 1.5 ).at_least( 8.0 ) )
/// .max_width( Length::vw( 60.0 ).at_most( 720.0 ) )
/// .push( text( "Responsive" ) )
/// .push( button( "OK" ).on_press( Msg::Ok ) )
/// .into()
/// # }
/// ```
pub struct Column<Msg: Clone>
{
pub children: Vec<Element<Msg>>,
pub spacing: f32,
pub padding: f32,
pub align_center_x: bool,
pub center_y: bool,
pub max_width: Option<f32>,
pub fit_content: bool,
pub( crate ) children: Vec<Element<Msg>>,
/// Vertical gap between children. Stored as [`Length`] so a `Vmin(2.0)`
/// or `Em(0.5)` gap scales with the viewport instead of freezing at a
/// px constant.
pub( crate ) spacing: Length,
/// Padding on all sides. Same [`Length`] semantics as `spacing`.
pub( crate ) padding: Length,
pub( crate ) align_center_x: bool,
pub( crate ) center_y: bool,
pub( crate ) max_width: Option<Length>,
pub( crate ) fit_content: bool,
}
impl<Msg: Clone> Column<Msg>
@@ -41,8 +63,8 @@ impl<Msg: Clone> Column<Msg>
Self
{
children: Vec::new(),
spacing: 8.0,
padding: 16.0,
spacing: Length::px( 8.0 ),
padding: Length::px( 16.0 ),
align_center_x: true,
center_y: false,
max_width: None,
@@ -57,17 +79,20 @@ impl<Msg: Clone> Column<Msg>
self
}
/// Set the vertical gap between children in pixels. Default: `8.0`.
pub fn spacing( mut self, s: f32 ) -> Self
/// Set the vertical gap between children. Default: `8.0` px. Accepts
/// any [`Length`] — pass an `f32` for the px case, or a relative
/// value like `Length::vmin( 2.0 )` to scale with the viewport.
pub fn spacing( mut self, s: impl Into<Length> ) -> Self
{
self.spacing = s;
self.spacing = s.into();
self
}
/// Set the padding (all sides) in pixels. Default: `16.0`.
pub fn padding( mut self, p: f32 ) -> Self
/// Set the padding (all sides). Default: `16.0` px. Accepts any
/// [`Length`].
pub fn padding( mut self, p: impl Into<Length> ) -> Self
{
self.padding = p;
self.padding = p.into();
self
}
@@ -85,14 +110,33 @@ impl<Msg: Clone> Column<Msg>
self
}
/// Limit the content width in pixels. The column still reports `max_width` as
/// its preferred width so the parent allocates the full available rect.
pub fn max_width( mut self, w: f32 ) -> Self
/// Limit the content width. Accepts any [`Length`]. The column still
/// reports the parent's `max_width` as its preferred width so the
/// parent allocates the full available rect.
pub fn max_width( mut self, w: impl Into<Length> ) -> Self
{
self.max_width = Some( w );
self.max_width = Some( w.into() );
self
}
#[ inline ]
fn resolved_spacing( &self, canvas: &Canvas ) -> f32
{
self.spacing.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT )
}
#[ inline ]
fn resolved_padding( &self, canvas: &Canvas ) -> f32
{
self.padding.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT )
}
#[ inline ]
fn resolved_max_width( &self, canvas: &Canvas ) -> Option<f32>
{
self.max_width.map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) )
}
/// Report the intrinsic content width as preferred width instead of filling
/// the available `max_width`. Use this when the column represents a card
/// or widget meant to sit side-by-side with other children inside a
@@ -108,10 +152,10 @@ impl<Msg: Clone> Column<Msg>
self
}
fn inner_w( &self, available: f32 ) -> f32
fn inner_w( &self, available: f32, canvas: &Canvas ) -> f32
{
let w = available - self.padding * 2.0;
self.max_width.map( |m| w.min( m ) ).unwrap_or( w )
let w = available - self.resolved_padding( canvas ) * 2.0;
self.resolved_max_width( canvas ).map( |m| w.min( m ) ).unwrap_or( w )
}
fn content_h( &self, inner_w: f32, canvas: &Canvas ) -> f32
@@ -120,18 +164,19 @@ impl<Msg: Clone> Column<Msg>
self.children.iter()
.map( |c| match c
{
Element::Spacer( s ) => s.fixed_height.unwrap_or( 0.0 ),
Element::Spacer( s ) => s.resolved_height( canvas ).unwrap_or( 0.0 ),
other => other.preferred_size( inner_w, canvas ).1,
} )
.sum::<f32>()
+ self.spacing * (self.children.len().saturating_sub( 1 )) as f32
+ self.resolved_spacing( canvas ) * ( self.children.len().saturating_sub( 1 ) ) as f32
}
/// Return the preferred `(width, height)` given available `max_width`.
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{
let inner_w = self.inner_w( max_width );
let total_h = self.content_h( inner_w, canvas ) + self.padding * 2.0;
let inner_w = self.inner_w( max_width, canvas );
let pad = self.resolved_padding( canvas );
let total_h = self.content_h( inner_w, canvas ) + pad * 2.0;
let w = if self.fit_content
{
@@ -162,7 +207,7 @@ impl<Msg: Clone> Column<Msg>
other => other.preferred_size( inner_w, canvas ).0,
} )
.fold( 0.0_f32, f32::max );
( content_w + self.padding * 2.0 ).min( max_width )
( content_w + pad * 2.0 ).min( max_width )
} else {
max_width
};
@@ -175,15 +220,15 @@ impl<Msg: Clone> Column<Msg>
/// Layout children within rect and return (rect, child_index) pairs.
pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<(Rect, usize)>
{
let inner_w = self.inner_w( rect.width );
let inner_w = self.inner_w( rect.width, canvas );
let pad = self.resolved_padding( canvas );
let spacing = self.resolved_spacing( canvas );
// Flexible spacers and Scroll widgets claim remaining vertical space.
// Fixed-height spacers behave like normal fixed-size children.
let total_weight: u32 = self.children.iter()
.map( |c| match c
{
Element::Spacer( s ) if s.fixed_height.is_none() => s.weight,
Element::Scroll( _ ) => 1,
Element::Spacer( s ) if s.resolved_height( canvas ).is_none() => s.weight,
Element::Scroll( s ) if s.axis.allows_y() => 1,
_ => 0,
} )
.sum();
@@ -191,27 +236,27 @@ impl<Msg: Clone> Column<Msg>
let fixed_h: f32 = self.children.iter()
.map( |c|
{
if matches!( c, Element::Scroll( _ ) )
if matches!( c, Element::Scroll( s ) if s.axis.allows_y() )
{
0.0
} else if let Element::Spacer( s ) = c {
s.fixed_height.unwrap_or( 0.0 )
s.resolved_height( canvas ).unwrap_or( 0.0 )
} else {
c.preferred_size( inner_w, canvas ).1
}
} )
.sum::<f32>()
+ self.spacing * (self.children.len().saturating_sub( 1 )) as f32;
+ spacing * ( self.children.len().saturating_sub( 1 ) ) as f32;
let avail_h = rect.height - self.padding * 2.0;
let avail_h = rect.height - pad * 2.0;
let avail_spare = ( avail_h - fixed_h ).max( 0.0 );
// `center_y` only applies when there are no spacers.
let start_y = if total_weight == 0 && self.center_y
{
rect.y + self.padding + avail_spare / 2.0
rect.y + pad + avail_spare / 2.0
} else {
rect.y + self.padding
rect.y + pad
};
let start_x = rect.x + (rect.width - inner_w) / 2.0;
@@ -224,7 +269,7 @@ impl<Msg: Clone> Column<Msg>
{
Element::Spacer( s ) =>
{
let h = if let Some( fixed ) = s.fixed_height
let h = if let Some( fixed ) = s.resolved_height( canvas )
{
fixed
} else if total_weight > 0
@@ -235,7 +280,7 @@ impl<Msg: Clone> Column<Msg>
};
( inner_w, h )
},
Element::Scroll( _ ) =>
Element::Scroll( s ) if s.axis.allows_y() =>
{
let h = if total_weight > 0
{
@@ -254,7 +299,7 @@ impl<Msg: Clone> Column<Msg>
start_x
};
result.push( ( Rect { x, y, width: w, height: h }, i ) );
y += h + self.spacing;
y += h + spacing;
}
result
}
@@ -330,16 +375,18 @@ mod tests
#[ test ]
fn inner_w_respects_padding_and_max_width()
{
let canvas = make_canvas();
let col = column::<()>().padding( 20.0 ).max_width( 100.0 );
// available = 200, minus padding*2 = 160, capped at max_width = 100
assert_eq!( col.inner_w( 200.0 ), 100.0 );
assert_eq!( col.inner_w( 200.0, &canvas ), 100.0 );
}
#[ test ]
fn inner_w_without_max_width_subtracts_padding()
{
let canvas = make_canvas();
let col = column::<()>().padding( 10.0 );
assert_eq!( col.inner_w( 200.0 ), 180.0 );
assert_eq!( col.inner_w( 200.0, &canvas ), 180.0 );
}
#[ test ]
@@ -356,4 +403,39 @@ mod tests
let ( _, h ) = col.preferred_size( 100.0, &canvas );
assert_eq!( h, 16.0 );
}
#[ test ]
fn vmin_spacing_resolves_against_canvas_viewport()
{
// Canvas is 800x600 → vmin = 600. 5 % of 600 = 30 px per gap.
// Three zero-height spacers → two gaps → 60 px total.
let canvas = make_canvas();
let col = column::<()>()
.padding( 0.0 )
.spacing( Length::vmin( 5.0 ) )
.push( crate::spacer() )
.push( crate::spacer() )
.push( crate::spacer() );
let ( _, h ) = col.preferred_size( 100.0, &canvas );
assert_eq!( h, 60.0 );
}
#[ test ]
fn vmin_padding_doubles_around_content()
{
// 4 % of 600 = 24 px padding on each side → 48 px on an empty column.
let canvas = make_canvas();
let col = column::<()>().padding( Length::vmin( 4.0 ) );
let ( _, h ) = col.preferred_size( 100.0, &canvas );
assert_eq!( h, 48.0 );
}
#[ test ]
fn vmin_max_width_caps_inner_w()
{
// 20 % of 600 = 120 px max-width.
let canvas = make_canvas();
let col = column::<()>().padding( 0.0 ).max_width( Length::vmin( 20.0 ) );
assert_eq!( col.inner_w( 200.0, &canvas ), 120.0 );
}
}

View File

@@ -1,7 +1,7 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::Rect;
use crate::types::{ Length, Rect };
use crate::render::Canvas;
use crate::widget::Element;
@@ -23,19 +23,43 @@ use crate::widget::Element;
/// .into()
/// # }
/// ```
///
/// `spacing` and `padding` accept any [`crate::Length`]:
///
/// ```rust,no_run
/// # use std::sync::Arc;
/// # use ltk::{ icon_button, row, Length, Element };
/// # #[ derive( Clone ) ] enum Msg { A, B }
/// # fn _ex( a_rgba: Arc<Vec<u8>>, b_rgba: Arc<Vec<u8>>, w: u32, h: u32 ) -> Element<Msg> {
/// row()
/// // 2 % of the viewport's smaller side, never below 8 px.
/// .spacing( Length::vmin( 2.0 ).at_least( 8.0 ) )
/// .push( icon_button( a_rgba, w, h ).on_press( Msg::A ) )
/// .push( icon_button( b_rgba, w, h ).on_press( Msg::B ) )
/// .into()
/// # }
/// ```
pub struct Row<Msg: Clone>
{
pub children: Vec<Element<Msg>>,
pub spacing: f32,
pub padding: f32,
pub align_right: bool,
pub( crate ) children: Vec<Element<Msg>>,
/// Horizontal gap between children. [`Length`]; default `8.0` px.
pub( crate ) spacing: Length,
/// Padding on all sides. [`Length`]; default `0.0` px.
pub( crate ) padding: Length,
pub( crate ) align_right: bool,
}
impl<Msg: Clone> Row<Msg>
{
pub fn new() -> Self
{
Self { children: Vec::new(), spacing: 8.0, padding: 0.0, align_right: false }
Self
{
children: Vec::new(),
spacing: Length::px( 8.0 ),
padding: Length::px( 0.0 ),
align_right: false,
}
}
/// Append a child widget or layout.
@@ -45,20 +69,34 @@ impl<Msg: Clone> Row<Msg>
self
}
/// Set the horizontal gap between children in pixels. Default: `8.0`.
pub fn spacing( mut self, s: f32 ) -> Self
/// Set the horizontal gap between children. Default: `8.0` px. Accepts
/// any [`Length`] so the gap can scale with the viewport.
pub fn spacing( mut self, s: impl Into<Length> ) -> Self
{
self.spacing = s;
self.spacing = s.into();
self
}
/// Set the padding (all sides) in pixels. Default: `0.0`.
pub fn padding( mut self, p: f32 ) -> Self
/// Set the padding (all sides). Default: `0.0` px. Accepts any
/// [`Length`].
pub fn padding( mut self, p: impl Into<Length> ) -> Self
{
self.padding = p;
self.padding = p.into();
self
}
#[ inline ]
fn resolved_spacing( &self, canvas: &Canvas ) -> f32
{
self.spacing.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT )
}
#[ inline ]
fn resolved_padding( &self, canvas: &Canvas ) -> f32
{
self.padding.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT )
}
/// Push the content block to the right edge of the available width.
pub fn align_right( mut self ) -> Self
{
@@ -69,16 +107,18 @@ impl<Msg: Clone> Row<Msg>
/// Return the preferred `(width, height)` given available `max_width`.
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{
let pad = self.resolved_padding( canvas );
let spacing = self.resolved_spacing( canvas );
// Width contribution of every fixed (non-flex, non-flex-spacer) child.
// Used to compute the residual width that wrap-style children will
// actually render in, so their reported height matches the layout.
let inner_w = ( max_width - self.padding * 2.0 ).max( 0.0 );
let gaps = self.spacing * self.children.len().saturating_sub( 1 ) as f32;
let inner_w = ( max_width - pad * 2.0 ).max( 0.0 );
let gaps = spacing * self.children.len().saturating_sub( 1 ) as f32;
let fixed_w: f32 = self.children.iter()
.filter( |c| match c
{
Element::Flex( _ ) => false,
Element::Spacer( s ) => s.fixed_width.is_some(),
Element::Spacer( s ) => s.resolved_width( canvas ).is_some(),
_ => true,
} )
.map( |c| c.preferred_size( max_width, canvas ).0 )
@@ -103,7 +143,7 @@ impl<Msg: Clone> Row<Msg>
let has_flex = self.children.iter().any( |c| match c
{
Element::Flex( _ ) => true,
Element::Spacer( s ) => s.fixed_width.is_none(),
Element::Spacer( s ) => s.resolved_width( canvas ).is_none(),
_ => false,
} );
@@ -115,11 +155,11 @@ impl<Msg: Clone> Row<Msg>
.map( |c| c.preferred_size( max_width, canvas ).0 )
.sum::<f32>()
+ gaps
+ self.padding * 2.0;
+ pad * 2.0;
total_w.min( max_width )
};
( w, max_h + self.padding * 2.0 )
( w, max_h + pad * 2.0 )
}
pub fn draw( &self, _canvas: &mut Canvas, _rect: Rect, _focused: bool ) {}
@@ -134,7 +174,9 @@ impl<Msg: Clone> Row<Msg>
/// [`Row::align_right`]).
pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<(Rect, usize)>
{
let inner_h = rect.height - self.padding * 2.0;
let pad = self.resolved_padding( canvas );
let spacing = self.resolved_spacing( canvas );
let inner_h = rect.height - pad * 2.0;
// Spacers and `Flex` wrappers report 0 width here; their real width
// comes from the flex distribution below.
@@ -142,7 +184,7 @@ impl<Msg: Clone> Row<Msg>
.map( |c| c.preferred_size( rect.width, canvas ) )
.collect();
let gaps = self.spacing * self.children.len().saturating_sub( 1 ) as f32;
let gaps = spacing * self.children.len().saturating_sub( 1 ) as f32;
let fixed_w: f32 = self.children.iter().zip( sizes.iter() )
.filter( |( c, _ )| match c
{
@@ -151,7 +193,7 @@ impl<Msg: Clone> Row<Msg>
// `Spacer::width(...)`-pinned spacers, contributes to the
// fixed-width tally.
Element::Flex( _ ) => false,
Element::Spacer( s ) => s.fixed_width.is_some(),
Element::Spacer( s ) => s.resolved_width( canvas ).is_some(),
_ => true,
} )
.map( |( _, ( w, _ ) )| *w )
@@ -159,13 +201,13 @@ impl<Msg: Clone> Row<Msg>
let total_weight: u32 = self.children.iter()
.filter_map( |c| match c {
Element::Spacer( s ) if s.fixed_width.is_none() => Some( s.weight ),
Element::Spacer( s ) if s.resolved_width( canvas ).is_none() => Some( s.weight ),
Element::Flex( f ) => Some( f.weight ),
_ => None,
} )
.sum();
let inner_w = ( rect.width - self.padding * 2.0 ).max( 0.0 );
let inner_w = ( rect.width - pad * 2.0 ).max( 0.0 );
let leftover = ( inner_w - fixed_w - gaps ).max( 0.0 );
let has_spacers = total_weight > 0;
@@ -173,11 +215,11 @@ impl<Msg: Clone> Row<Msg>
{
// Spacers and `Flex` wrappers claim the leftover; the cluster
// sits flush to the left edge of the inner rect.
( rect.x + self.padding, leftover / total_weight as f32 )
( rect.x + pad, leftover / total_weight as f32 )
}
else if self.align_right
{
( rect.x + rect.width - (fixed_w + gaps) - self.padding, 0.0 )
( rect.x + rect.width - ( fixed_w + gaps ) - pad, 0.0 )
}
else
{
@@ -190,7 +232,7 @@ impl<Msg: Clone> Row<Msg>
{
let width = match child
{
Element::Spacer( s ) => match s.fixed_width
Element::Spacer( s ) => match s.resolved_width( canvas )
{
Some( fw ) => fw,
None => flex_unit * s.weight as f32,
@@ -198,9 +240,9 @@ impl<Msg: Clone> Row<Msg>
Element::Flex( f ) => flex_unit * f.weight as f32,
_ => w,
};
let y = rect.y + self.padding + (inner_h - h) / 2.0;
let y = rect.y + pad + ( inner_h - h ) / 2.0;
result.push( ( Rect { x, y, width, height: h }, i ) );
x += width + self.spacing;
x += width + spacing;
}
result
}
@@ -296,4 +338,37 @@ mod tests
let rect = Rect { x: 0., y: 0., width: 400., height: 48. };
assert!( r.layout( rect, &canvas ).is_empty() );
}
#[ test ]
fn vmin_padding_doubles_around_content()
{
// 800x600 canvas → vmin = 600. 4 % = 24 px each side → 48 px height.
let canvas = make_canvas();
let r = row::<()>().padding( Length::vmin( 4.0 ) );
let ( _, h ) = r.preferred_size( 500.0, &canvas );
assert_eq!( h, 48.0 );
}
#[ test ]
fn vmin_spacing_pins_visible_layout_gap()
{
// Two fixed-width spacers separated by a vmin spacing of 5 %.
// Canvas vmin = 600 → 30 px between the inner edges. With both
// spacers 10 px wide, the second one's `x` minus the first one's
// `x + width` must equal the gap regardless of where the row chose
// to anchor the cluster (centered, since there are no flex spacers).
let canvas = make_canvas();
let r = row::<()>()
.padding( 0.0 )
.spacing( Length::vmin( 5.0 ) )
.push( crate::spacer().width( 10.0 ) )
.push( crate::spacer().width( 10.0 ) );
let rect = Rect { x: 0., y: 0., width: 200., height: 48. };
let placed = r.layout( rect, &canvas );
assert_eq!( placed.len(), 2 );
let ( first_rect, _ ) = placed[ 0 ];
let ( second_rect, _ ) = placed[ 1 ];
let gap = second_rect.x - ( first_rect.x + first_rect.width );
assert!( ( gap - 30.0 ).abs() < 1e-3, "expected ~30 px gap, got {gap}" );
}
}

View File

@@ -1,6 +1,8 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::render::Canvas;
use crate::types::Length;
use crate::widget::Element;
/// A flexible, invisible spacer that expands to fill available space.
@@ -49,14 +51,34 @@ use crate::widget::Element;
/// .into()
/// # }
/// ```
///
/// `.height(...)` and `.width(...)` also accept any [`crate::Length`], so the
/// gap can scale with the surface instead of being frozen at a px constant:
///
/// ```rust,no_run
/// # use ltk::{ column, spacer, text, Length, Element };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex() -> Element<Msg> {
/// column()
/// .push( text( "Header" ) )
/// // 6 % of the surface's smaller side, never below 16 px or above 64 px.
/// .push( spacer().height( Length::vmin( 6.0 ).clamp( 16.0, 64.0 ) ) )
/// .push( text( "Content" ) )
/// .into()
/// # }
/// ```
pub struct Spacer
{
/// Relative weight of this spacer (default 1).
pub weight: u32,
/// Fixed height in pixels (overrides flexible behavior in a column).
pub fixed_height: Option<f32>,
/// Fixed width in pixels (overrides flexible behavior in a row).
pub fixed_width: Option<f32>,
pub( crate ) weight: u32,
/// Fixed height (overrides flexible behavior in a column). Accepts any
/// [`Length`] — pass an `f32`/`i32`/`u32` for the px case (kept for
/// backward compatibility with existing call sites), or
/// `Length::vmin( … )` etc. for viewport-relative gaps.
pub( crate ) fixed_height: Option<Length>,
/// Fixed width (overrides flexible behavior in a row). Same length-type
/// semantics as [`Self::fixed_height`].
pub( crate ) fixed_width: Option<Length>,
}
impl Spacer
@@ -68,34 +90,50 @@ impl Spacer
self
}
/// Set a fixed height for this spacer in pixels.
/// When set, the spacer will occupy exactly this much vertical space
/// instead of expanding flexibly.
pub fn height( mut self, h: f32 ) -> Self
/// Set a fixed height for this spacer. Accepts any [`Length`]: a bare
/// `24.0_f32` is treated as `Length::px( 24.0 )` for source-level
/// backwards compatibility; a `Length::vmin( 6.0 )` makes the gap
/// scale with the surface's smaller dimension.
pub fn height( mut self, h: impl Into<Length> ) -> Self
{
self.fixed_height = Some( h );
self.fixed_height = Some( h.into() );
self
}
/// Set a fixed width for this spacer in pixels.
/// When set, the spacer occupies exactly this much horizontal space
/// inside a [`Row`](crate::layout::row::Row) instead of expanding
/// flexibly. Mirrors [`Self::height`] for the horizontal axis — useful
/// to reserve a precise visual margin while a sibling
/// [`Flex`](crate::Flex) claims the remaining width.
pub fn width( mut self, w: f32 ) -> Self
/// Set a fixed width for this spacer. Mirrors [`Self::height`] for the
/// horizontal axis.
pub fn width( mut self, w: impl Into<Length> ) -> Self
{
self.fixed_width = Some( w );
self.fixed_width = Some( w.into() );
self
}
/// Returns `( fixed_width, fixed_height )`, falling back to `0.0` on the
/// axes that were not pinned. The parent layout distributes leftover
/// along its main axis among the still-flexible spacers and `Flex`
/// wrappers, weighted by `weight`.
pub fn preferred_size( &self ) -> (f32, f32)
/// Returns `( fixed_width, fixed_height )` resolved against the
/// current canvas viewport, falling back to `0.0` on axes that were
/// not pinned. The parent layout distributes leftover along its main
/// axis among the still-flexible spacers and `Flex` wrappers,
/// weighted by `weight`.
pub fn preferred_size( &self, canvas: &Canvas ) -> ( f32, f32 )
{
( self.fixed_width.unwrap_or( 0.0 ), self.fixed_height.unwrap_or( 0.0 ) )
let vp = canvas.viewport_layout();
let em = Length::EM_BASE_DEFAULT;
(
self.fixed_width .map( |l| l.resolve( vp, em ) ).unwrap_or( 0.0 ),
self.fixed_height.map( |l| l.resolve( vp, em ) ).unwrap_or( 0.0 ),
)
}
/// Resolved fixed height in logical pixels, or `None` when the
/// spacer is flex. Cheaper than [`Self::preferred_size`] when the
/// layout only needs the main-axis size for one orientation.
pub fn resolved_height( &self, canvas: &Canvas ) -> Option<f32>
{
self.fixed_height.map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) )
}
pub fn resolved_width( &self, canvas: &Canvas ) -> Option<f32>
{
self.fixed_width.map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) )
}
/// No-op — spacers are invisible.
@@ -141,3 +179,66 @@ pub fn spacer() -> Spacer
{
Spacer { weight: 1, fixed_height: None, fixed_width: None }
}
#[ cfg( test ) ]
mod tests
{
use super::*;
use crate::render::Canvas;
fn make_canvas() -> Canvas { Canvas::new( 800, 600 ) }
#[ test ]
fn height_accepts_f32_as_pixels()
{
let s = spacer().height( 24.0 );
let canvas = make_canvas();
assert_eq!( s.resolved_height( &canvas ), Some( 24.0 ) );
}
#[ test ]
fn height_accepts_length_and_resolves_against_viewport()
{
// 10 % of the smaller side (= 600) = 60 px.
let s = spacer().height( Length::vmin( 10.0 ) );
let canvas = make_canvas();
assert_eq!( s.resolved_height( &canvas ), Some( 60.0 ) );
}
#[ test ]
fn width_accepts_length_and_resolves_against_viewport()
{
let s = spacer().width( Length::vw( 25.0 ) );
let canvas = make_canvas();
// 25 % of 800 = 200.
assert_eq!( s.resolved_width( &canvas ), Some( 200.0 ) );
}
#[ test ]
fn flex_spacer_has_no_resolved_dimensions()
{
let s = spacer().weight( 3 );
let canvas = make_canvas();
assert_eq!( s.resolved_height( &canvas ), None );
assert_eq!( s.resolved_width( &canvas ), None );
}
#[ test ]
fn builder_stores_weight_and_fixed_dimensions()
{
let d = spacer();
assert_eq!( d.weight, 1 );
assert!( d.fixed_height.is_none() );
assert!( d.fixed_width.is_none() );
let h = spacer().height( 24.0 );
assert_eq!( h.fixed_height, Some( Length::px( 24.0 ) ) );
assert!( h.fixed_width.is_none() );
let w = spacer().width( 12.0 );
assert_eq!( w.fixed_width, Some( Length::px( 12.0 ) ) );
assert!( w.fixed_height.is_none() );
assert_eq!( spacer().weight( 5 ).weight, 5 );
}
}

View File

@@ -52,8 +52,14 @@ pub enum VAlign
pub struct Stack<Msg: Clone>
{
/// Children with their alignment, margin, and extra `(x, y)` translation
/// applied after alignment. Drawn in order — last child is on top.
pub children: Vec<( Element<Msg>, HAlign, VAlign, f32, f32, f32 )>,
/// applied after alignment. Drawn in order — last child is on top. The 7th
/// `Option<Rect>` overrides alignment/sizing with an exact rect (see
/// [`push_placed`](Self::push_placed)); the 8th clips the child's draw to a
/// rect (Android clipChildren — see [`push_placed_clipped`](Self::push_placed_clipped)).
pub( crate ) children: Vec<( Element<Msg>, HAlign, VAlign, f32, f32, f32, Option<Rect>, Option<Rect> )>,
/// When `true`, [`preferred_size`](Self::preferred_size) reports the max of
/// children's intrinsic widths and heights instead of `(max_width, tallest)`.
pub( crate ) fit_content: bool,
}
impl<Msg: Clone> Stack<Msg>
@@ -61,7 +67,14 @@ impl<Msg: Clone> Stack<Msg>
/// Create an empty stack.
pub fn new() -> Self
{
Self { children: Vec::new() }
Self { children: Vec::new(), fit_content: false }
}
/// Enable [`Self::fit_content`].
pub fn fit_content( mut self ) -> Self
{
self.fit_content = true;
self
}
/// Append a child that fills the entire Stack rect (Android FrameLayout default).
@@ -90,7 +103,7 @@ impl<Msg: Clone> Stack<Msg>
margin: f32,
) -> Self
{
self.children.push( ( e.into(), h_align, v_align, margin, 0.0, 0.0 ) );
self.children.push( ( e.into(), h_align, v_align, margin, 0.0, 0.0, None, None ) );
self
}
@@ -107,15 +120,66 @@ impl<Msg: Clone> Stack<Msg>
offset_y: f32,
) -> Self
{
self.children.push( ( e.into(), h_align, v_align, 0.0, offset_x, offset_y ) );
self.children.push( ( e.into(), h_align, v_align, 0.0, offset_x, offset_y, None, None ) );
self
}
/// Append a child placed at an exact `rect` (in the Stack's coordinate space),
/// bypassing alignment and intrinsic sizing. This is what hosts a view tree
/// whose geometry is computed elsewhere — e.g. Android's measure/layout pass,
/// which yields absolute rects per view.
pub fn push_placed(
mut self,
e: impl Into<Element<Msg>>,
rect: Rect,
) -> Self
{
self.children.push( ( e.into(), HAlign::Start, VAlign::Top, 0.0, 0.0, 0.0, Some( rect ), None ) );
self
}
/// Like [`push_placed`](Self::push_placed) but clips the child's drawing to
/// `clip` (in the Stack's coordinate space). Mirrors Android's clipChildren:
/// content that overflows the clip — e.g. a scrolled list row reaching above
/// the list, or an inner card past a rounded bubble — is not painted.
pub fn push_placed_clipped(
mut self,
e: impl Into<Element<Msg>>,
rect: Rect,
clip: Rect,
) -> Self
{
self.children.push( ( e.into(), HAlign::Start, VAlign::Top, 0.0, 0.0, 0.0, Some( rect ), Some( clip ) ) );
self
}
/// Return the preferred `(width, height)` — the maximum height among children.
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{
if self.fit_content
{
let content_w = self.children.iter()
.map( |( c, .. )| match c
{
Element::Spacer( s ) => s.resolved_width( canvas ).unwrap_or( 0.0 ),
Element::Separator( _ ) => 0.0,
Element::Scroll( _ ) => 0.0,
Element::ProgressBar( _ ) => 0.0,
Element::Slider( _ ) => 0.0,
Element::TextEdit( t ) => if t.fixed_width.is_some()
{
t.preferred_size( max_width, canvas ).0
} else { 0.0 },
other => other.preferred_size( max_width, canvas ).0,
} )
.fold( 0.0_f32, f32::max );
let max_h = self.children.iter()
.map( |( c, _, _, _, _, _ )| c.preferred_size( max_width, canvas ).1 )
.map( |( c, .. )| c.preferred_size( max_width, canvas ).1 )
.fold( 0.0_f32, f32::max );
return ( content_w.min( max_width ), max_h );
}
let max_h = self.children.iter()
.map( |( c, .. )| c.preferred_size( max_width, canvas ).1 )
.fold( 0.0_f32, f32::max );
( max_width, max_h )
}
@@ -123,8 +187,13 @@ impl<Msg: Clone> Stack<Msg>
/// Return `(rect, child_index)` pairs, computing each child's rect from its alignment.
pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<(Rect, usize)>
{
self.children.iter().enumerate().map( |( i, ( child, h_align, v_align, margin, ox, oy ) )|
self.children.iter().enumerate().map( |( i, ( child, h_align, v_align, margin, ox, oy, placed, .. ) )|
{
if let Some( p ) = placed
{
return ( Rect { x: rect.x + p.x, y: rect.y + p.y, width: p.width, height: p.height }, i );
}
let inner_w = ( rect.width - margin * 2.0 ).max( 0.0 );
let inner_h = ( rect.height - margin * 2.0 ).max( 0.0 );
let ( pref_w, pref_h ) = child.preferred_size( inner_w, canvas );
@@ -160,9 +229,10 @@ impl<Msg: Clone> Stack<Msg>
Stack
{
children: self.children.into_iter()
.map( |( child, ha, va, margin, ox, oy )|
( child.map_arc( f ), ha, va, margin, ox, oy ) )
.map( |( child, ha, va, margin, ox, oy, placed, clip )|
( child.map_arc( f ), ha, va, margin, ox, oy, placed, clip ) )
.collect(),
fit_content: self.fit_content,
}
}
}

View File

@@ -2,7 +2,7 @@
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::render::Canvas;
use crate::types::Rect;
use crate::types::{ Length, Rect };
use crate::widget::Element;
/// A grid layout that wraps children into rows of a fixed column count.
@@ -32,15 +32,18 @@ use crate::widget::Element;
pub struct WrapGrid<Msg: Clone>
{
/// Child widgets laid out in row-major order.
pub children: Vec<Element<Msg>>,
pub( crate ) children: Vec<Element<Msg>>,
/// Number of columns per row.
pub columns: usize,
/// Horizontal gap between cells (pixels).
pub spacing_x: f32,
/// Vertical gap between rows (pixels).
pub spacing_y: f32,
/// Padding on all sides (pixels).
pub padding: f32,
pub( crate ) columns: usize,
/// Horizontal gap between cells.
pub( crate ) spacing_x: Length,
/// Vertical gap between rows.
pub( crate ) spacing_y: Length,
/// Padding on all sides.
pub( crate ) padding: Length,
/// When `true`, a partial last row is centred horizontally within
/// the grid's content rect instead of being left-aligned.
pub( crate ) centre_last_row: bool,
}
impl<Msg: Clone> WrapGrid<Msg>
@@ -53,34 +56,54 @@ impl<Msg: Clone> WrapGrid<Msg>
}
/// Set both horizontal and vertical gap between cells (default 8.0).
pub fn spacing( mut self, s: f32 ) -> Self
pub fn spacing( mut self, s: impl Into<Length> ) -> Self
{
let s = s.into();
self.spacing_x = s;
self.spacing_y = s;
self
}
/// Set only the horizontal gap between cells; leaves vertical spacing untouched.
pub fn spacing_x( mut self, s: f32 ) -> Self
pub fn spacing_x( mut self, s: impl Into<Length> ) -> Self
{
self.spacing_x = s;
self.spacing_x = s.into();
self
}
/// Set only the vertical gap between rows; leaves horizontal spacing untouched.
pub fn spacing_y( mut self, s: f32 ) -> Self
pub fn spacing_y( mut self, s: impl Into<Length> ) -> Self
{
self.spacing_y = s;
self.spacing_y = s.into();
self
}
/// Set the padding on all sides (default 0.0).
pub fn padding( mut self, p: f32 ) -> Self
pub fn padding( mut self, p: impl Into<Length> ) -> Self
{
self.padding = p;
self.padding = p.into();
self
}
/// Centre a partial last row horizontally inside the content rect.
/// Default is `false` (left-aligned, like other grids).
pub fn centre_last_row( mut self, yes: bool ) -> Self
{
self.centre_last_row = yes;
self
}
fn resolved( &self, canvas: &Canvas ) -> ( f32, f32, f32 )
{
let vp = canvas.viewport_layout();
let em = Length::EM_BASE_DEFAULT;
(
self.spacing_x.resolve( vp, em ),
self.spacing_y.resolve( vp, em ),
self.padding.resolve( vp, em ),
)
}
/// Compute the preferred size given an available width.
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{
@@ -88,12 +111,13 @@ impl<Msg: Clone> WrapGrid<Msg>
{
return ( max_width, 0.0 );
}
let ( sx, sy, pad ) = self.resolved( canvas );
let cols = self.columns;
let inner_w = (max_width - self.padding * 2.0).max( 0.0 );
let cell_w = (inner_w - self.spacing_x * (cols as f32 - 1.0)).max( 0.0 ) / cols as f32;
let inner_w = (max_width - pad * 2.0).max( 0.0 );
let cell_w = (inner_w - sx * (cols as f32 - 1.0)).max( 0.0 ) / cols as f32;
let row_count = (self.children.len() + cols - 1) / cols;
let mut total_h = self.padding * 2.0;
let mut total_h = pad * 2.0;
for row in 0..row_count
{
let start = row * cols;
@@ -103,7 +127,7 @@ impl<Msg: Clone> WrapGrid<Msg>
.map( |c| c.preferred_size( cell_w, canvas ).1 )
.fold( 0.0_f32, f32::max );
total_h += row_h;
if row + 1 < row_count { total_h += self.spacing_y; }
if row + 1 < row_count { total_h += sy; }
}
( max_width, total_h )
}
@@ -115,11 +139,12 @@ impl<Msg: Clone> WrapGrid<Msg>
{
return Vec::new();
}
let ( sx, sy, pad ) = self.resolved( canvas );
let cols = self.columns;
let inner_w = (rect.width - self.padding * 2.0).max( 0.0 );
let cell_w = (inner_w - self.spacing_x * (cols as f32 - 1.0)).max( 0.0 ) / cols as f32;
let x0 = rect.x + self.padding;
let mut y = rect.y + self.padding;
let inner_w = (rect.width - pad * 2.0).max( 0.0 );
let cell_w = (inner_w - sx * (cols as f32 - 1.0)).max( 0.0 ) / cols as f32;
let x0 = rect.x + pad;
let mut y = rect.y + pad;
let row_count = (self.children.len() + cols - 1) / cols;
let mut out = Vec::with_capacity( self.children.len() );
@@ -133,13 +158,20 @@ impl<Msg: Clone> WrapGrid<Msg>
.map( |c| c.preferred_size( cell_w, canvas ).1 )
.fold( 0.0_f32, f32::max );
for col in 0..(end - start)
let items_in_row = end - start;
let row_offset = if self.centre_last_row && items_in_row < cols
{
let x = x0 + col as f32 * (cell_w + self.spacing_x);
let missing = (cols - items_in_row) as f32;
missing * (cell_w + sx) / 2.0
} else { 0.0 };
for col in 0..items_in_row
{
let x = x0 + row_offset + col as f32 * (cell_w + sx);
let crect = Rect { x, y, width: cell_w, height: row_h };
out.push( ( crect, start + col ) );
}
y += row_h + self.spacing_y;
y += row_h + sy;
}
out
}
@@ -156,6 +188,7 @@ impl<Msg: Clone> WrapGrid<Msg>
spacing_x: self.spacing_x,
spacing_y: self.spacing_y,
padding: self.padding,
centre_last_row: self.centre_last_row,
}
}
}
@@ -320,6 +353,43 @@ mod tests
assert!( (rects[0].0.x - 50.0).abs() < 0.01 );
assert!( (rects[0].0.y - 30.0).abs() < 0.01 );
}
// --- layout: centre_last_row ---
#[test]
fn last_row_centred_when_partial()
{
// 3 children, 2 cols => row 0: 2 items, row 1: 1 item centred.
// cell_w = 200/2 = 100; centred-offset = (2-1)*100/2 = 50.
let g = spacer_grid( 2, 3, 0.0, 0.0 ).centre_last_row( true );
let c = canvas();
let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 400.0 };
let rects = g.layout( rect, &c );
assert!( (rects[2].0.x - 50.0).abs() < 0.01 );
}
#[test]
fn centre_last_row_noop_on_full_row()
{
// 4 children, 2 cols => both rows full; nothing to centre.
let g = spacer_grid( 2, 4, 0.0, 0.0 ).centre_last_row( true );
let c = canvas();
let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 400.0 };
let rects = g.layout( rect, &c );
assert!( rects[2].0.x.abs() < 0.01 );
assert!( (rects[3].0.x - 100.0).abs() < 0.01 );
}
#[test]
fn centre_last_row_off_by_default()
{
// Same case as above but without the flag — last item stays at x=0.
let g = spacer_grid( 2, 3, 0.0, 0.0 );
let c = canvas();
let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 400.0 };
let rects = g.layout( rect, &c );
assert!( rects[2].0.x.abs() < 0.01 );
}
}
/// Create a grid layout with the given number of columns.
@@ -340,8 +410,9 @@ pub fn grid<Msg: Clone>( columns: usize ) -> WrapGrid<Msg>
{
children: Vec::new(),
columns,
spacing_x: 8.0,
spacing_y: 8.0,
padding: 0.0,
spacing_x: Length::px( 8.0 ),
spacing_y: Length::px( 8.0 ),
padding: Length::px( 0.0 ),
centre_last_row: false,
}
}

View File

@@ -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:
@@ -107,9 +111,110 @@
//! Geometry and primitive values that flow through every builder:
//!
//! - [`Color`], [`Rect`], [`Point`], [`Size`], [`Corners`], [`WidgetId`].
//! - [`Length`] — a size/distance that may be absolute pixels
//! ([`LengthBase::Px`]), relative to the surface viewport
//! ([`LengthBase::Vw`] / [`LengthBase::Vh`] / [`LengthBase::Vmin`] /
//! [`LengthBase::Vmax`] / [`LengthBase::Orient`]) or to the root font
//! size ([`LengthBase::Em`]). Every setter that takes a size, padding,
//! spacing or font height now accepts `impl Into<Length>`, so legacy
//! `.size( 24.0 )` keeps working while new code can write
//! `.size( Length::vmin( 4.0 ).clamp( 16.0, 32.0 ) )` for a typeface
//! that scales with the screen. See **Responsive design** below.
//!
//! See [`types`] for the full module with `//!` description.
//!
//! ## Responsive design: one UI from phone to desktop
//!
//! A core goal of ltk is that a **single view** reads coherently across
//! a portrait phone, a tablet and a landscape desktop window — no
//! per-target forks, no media-query soup. The mechanism is **fluid
//! sizing**: express sizes as a fraction of the surface so the whole
//! design breathes with the screen, instead of freezing at one pixel
//! size that only looks right on one device.
//!
//! ### The core rule — fluid, but clamped
//!
//! Default to `Length::vmin( pct ).clamp( lo, hi )` for every font size,
//! padding, spacing and spacer:
//!
//! - the percentage tracks the surface's **smaller** side, so portrait
//! and landscape stay coherent — an element keeps the same fraction of
//! the narrow axis whichever way the device is held;
//! - the px `clamp` bounds the fluid range so the design never collapses
//! on a watch-sized surface nor balloons on a 4K monitor.
//!
//! The clamp is not optional polish: it is what turns *pure* proportional
//! sizing (fragile at the extremes) into *bounded* proportional sizing
//! (robust everywhere). Treat "always clamp a fluid value" as the rule,
//! not the exception.
//!
//! ### Orientation-dependent proportion — [`Length::orient`]
//!
//! Sometimes the right *proportion* differs by orientation, not merely
//! the reference axis. A logo may want 40 % of the width in portrait —
//! there is vertical room to spare — but only 5 % of the height in
//! landscape, where vertical room is scarce.
//! [`Length::orient`]`( portrait, landscape )` expresses exactly that:
//! `portrait` % of the **width** when the surface is portrait,
//! `landscape` % of the **height** when it is landscape (the short side
//! of each orientation, but with its own proportion).
//!
//! For images, pair it with
//! [`Image::short_side`](widget::image::Image::short_side), which sizes
//! the image along the screen's short side and lets the other axis follow
//! the source aspect ratio:
//!
//! ```rust,no_run
//! # use std::sync::Arc;
//! # use ltk::{ img_widget, Length, Element };
//! # #[ derive( Clone ) ] enum Msg {}
//! # fn _ex( rgba: Arc<Vec<u8>>, w: u32, h: u32 ) -> Element<Msg> {
//! // 40 % of the width in portrait, 5 % of the height in landscape.
//! img_widget( rgba, w, h ).short_side( Length::orient( 40.0, 5.0 ) ).into()
//! # }
//! ```
//!
//! ### When constant *physical* size matters instead
//!
//! Fluid units scale with the screen's pixels, not with real-world
//! millimetres — and legibility is a function of physical (angular) size,
//! not of what fraction of the screen a glyph fills. When a size must stay
//! a **constant physical size** across very different displays, use the
//! other mode: [`Length::dp`] (a density-independent pixel — `n ×`
//! [`density`], the mainstream HiDPI unit), or [`LengthBase::Em`] for text
//! relative to the root font size. The pre-calibrated
//! [`theme::typography`] scale
//! ([`theme::typography::h0`]…[`theme::typography::body_xs`]) is built on
//! clamped `vmin`, so it stays fluid while respecting a readable px floor
//! and ceiling — a good default for running text.
//!
//! ### The two modes, and choosing per app
//!
//! ltk offers both strategies as first-class citizens and lets the app
//! pick — per value, or process-wide for every stock widget:
//!
//! - **Fluid** — [`Length::fluid`] and the raw `vmin` / `vmax` / `vw` /
//! `vh` / `orient` units. Surface-proportional; tracks the short side
//! (width in portrait, height in landscape). Best for full-screen system
//! surfaces on known hardware (lock screen, greeter, splash, kiosk,
//! launcher).
//! - **Physical** — [`Length::dp`] plus [`set_density`] / [`density`].
//! Constant real-world size, HiDPI-aware. Best for conventional windowed
//! apps across an open-ended device set.
//!
//! Stock widgets carry one design pixel per dimension and resolve it
//! through the process-wide [`WidgetScaling`] mode
//! ([`set_widget_scaling`]): [`WidgetScaling::Fluid`] (the default) reads
//! it as [`Length::fluid`], [`WidgetScaling::Physical`] as [`Length::dp`].
//! An explicit [`Length`] on a widget always overrides the mode.
//!
//! ### Structural changes — branch in `view()`
//!
//! When the layout *structure* must change (sidebar → bottom tabs, two
//! columns → one) rather than merely resize, branch in `view()` on
//! `surface_width` / `surface_height`. Keep this for genuine
//! restructuring; [`Length`] covers all pure sizing.
//!
//! ## Runtime-free embedding
//!
//! Use [`core::UiSurface`] when you need ltk's layout, drawing and
@@ -132,6 +237,16 @@
//! - **Sora Regular** (`src/theme/fallback/Sora-Regular.otf`) — the
//! embedded font fallback, [SIL OFL 1.1](https://scripts.sil.org/OFL),
//! © The Sora Project Authors, Jonathan Barnbrook, Julián Moncada.
//! - **Pointer cursors** under `themes/default/cursors/` — GNOME's
//! *Adwaita* cursor theme (the cursors GNOME Shell ships), bundled
//! verbatim, © the GNOME Project. Offered upstream under
//! [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) *or*
//! [LGPL 3](https://www.gnu.org/licenses/lgpl-3.0.html), and
//! [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) for
//! the newer assets; any one option satisfies the licence. See
//! `themes/default/cursors/README.md` (what the set is and how it is
//! used) and `themes/default/cursors/LICENSE.md` (attribution).
//! Upstream: <https://gitlab.gnome.org/GNOME/adwaita-icon-theme>.
//!
//! The remaining artwork in the default theme (wallpapers, lockscreens,
//! launcher logo, brand-mark variants, per-application icons) is
@@ -146,6 +261,12 @@
// English entry.
rust_i18n::i18n!( "locales", fallback = "en" );
/// Serialises tests across modules that read or mutate the process-wide
/// density / [`WidgetScaling`] globals, so parallel test threads never
/// observe each other's transient state.
#[ cfg( test ) ]
pub( crate ) static TEST_GLOBALS_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new( () );
pub mod types;
pub( crate ) mod render;
pub( crate ) mod system_fonts;
@@ -156,6 +277,7 @@ pub( crate ) mod layout;
pub( crate ) mod app;
pub mod theme;
pub mod wallpaper;
pub mod chassis;
pub( crate ) mod tree;
pub( crate ) mod draw;
@@ -169,6 +291,7 @@ pub mod core;
pub use app::
{
Anchor, App, ChannelSender, ShellMode, Layer, OverlayId, OverlaySpec,
SubsurfaceId, SubsurfaceSpec, SubsurfaceParent,
InvalidationScope, SurfaceTarget, ToplevelEvent,
};
pub use theme::
@@ -201,9 +324,17 @@ pub use theme::branding_image as theme_branding_image;
pub use theme::{ decode_svg_bytes, icon_path as theme_icon_path, icon_rgba as theme_icon_rgba };
pub use gles_render::{ BorrowedGlesTexture, GlesVersion };
pub use render::is_software_render;
pub use render::Canvas;
pub use wallpaper::{ WallpaperBundle, ImageData };
pub use types::{ Color, Corners, CursorShape, Point, Rect, Size, WidgetId };
pub use chassis::{ set_default_theme, theme_logo_rgba, theme_icon_tinted, branding_bundle_or_solid, wallpaper_bundle_or_solid, backdrop };
pub use types::{ Color, Corners, CursorShape, Length, LengthBase, PathCmd, Point, Rect, Size, WidgetId };
pub use types::{ WidgetScaling, FLUID_MIN, FLUID_MAX };
pub use types::{ fluid_reference, set_fluid_reference };
pub use types::{ density, set_density };
pub use types::{ widget_scaling, set_widget_scaling };
pub use widget::{ Element, button, icon_button, text_edit, image as img_widget, text, container };
pub use text_shaping::measure_text;
pub use widget::rich_text::{ rich_text, RichText, LinkSpan };
pub use widget::button::ButtonVariant;
pub use widget::slider::{ Slider, slider, SliderAxis };
pub use widget::vslider::{ VSlider, vslider };
@@ -244,6 +375,7 @@ pub use layout::stack::{ Stack, stack, HAlign, VAlign };
pub use layout::wrap_grid::{ WrapGrid, grid };
pub use widget::scroll::{ scroll, ScrollAxis };
pub use widget::viewport::{ Viewport, viewport };
pub use widget::carousel::{ Carousel, carousel };
pub use app::run;
pub use app::{ try_run, RunError };
pub use smithay_client_toolkit::seat::keyboard::Keysym;
@@ -419,13 +551,15 @@ pub mod runtime
///
/// **Not part of the stable public API.** Anything in here may change between
/// patch releases without notice. Hidden from generated docs via
/// `#[doc(hidden)]` for the same reason.
/// `#[doc(hidden)]`, and gated behind the `test-support` feature so it is
/// absent from third-party builds entirely (ltk's own `make test` enables it).
#[ cfg( feature = "test-support" ) ]
#[ doc( hidden ) ]
pub mod test_support
{
pub use crate::tree::{ find_widget_at, find_widget, find_handlers, next_focusable_index };
pub use crate::widget::{ LaidOutWidget, WidgetHandlers };
pub use crate::widget::slider::{ value_from_x_in_rect, value_from_pos_in_rect, SliderAxis };
pub use crate::widget::slider::{ value_from_x_in_rect, value_from_pos_in_rect, thumb_design_px, SliderAxis };
pub use crate::widget::vslider::value_from_y_in_rect;
pub use crate::event_loop::diff_overlay_ids;
}

View File

@@ -3,7 +3,9 @@
//! Clip-mask management for [`SoftwareCanvas`]. The partial-redraw
//! path calls `set_clip_rects` before every repaint so only pixels
//! inside the dirty rects are touched.
//! inside the dirty rects are touched. `set_clip_path` installs an
//! arbitrary anti-aliased vector path as the clip (an exact tiny-skia
//! coverage [`Mask`]) for shaped clipping such as a circular avatar.
use tiny_skia::{ FillRule, Mask, PathBuilder, Transform };
@@ -46,6 +48,37 @@ impl SoftwareCanvas
}
}
/// Clip subsequent paints to an arbitrary vector path (surface
/// coordinates), via an anti-aliased tiny-skia coverage mask. The GLES
/// counterpart composites an offscreen layer through an equivalent
/// anti-aliased mask; both give a smooth clipped edge.
pub fn set_clip_path( &mut self, cmds: &[ crate::types::PathCmd ] )
{
let w = self.pixmap.width();
let h = self.pixmap.height();
let Some( path ) = crate::render::helpers::build_ts_path( cmds ) else
{
self.clear_clip();
return;
};
let Some( mut mask ) = Mask::new( w, h ) else
{
self.clip_mask = None;
self.clip_bounds = Vec::new();
return;
};
mask.fill_path( &path, FillRule::Winding, true, Transform::identity() );
let b = path.bounds();
self.clip_mask = Some( mask );
self.clip_bounds = vec![ Rect
{
x: b.left(),
y: b.top(),
width: b.right() - b.left(),
height: b.bottom() - b.top(),
} ];
}
/// Remove the active clip so subsequent paints cover the full canvas.
pub fn clear_clip( &mut self )
{

View File

@@ -1,17 +1,56 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! 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 };
use crate::types::Corners;
use crate::types::{ Corners, PathCmd };
/// Cubic bezier control-point factor for a quarter-circle approximation
/// (`(4/3) * (sqrt(2) - 1) ≈ 0.5523`).
const KAPPA: f32 = 0.5523_f32;
/// Build a tiny-skia path from a list of [`PathCmd`]s. Shared by both backends'
/// `fill_path` / `stroke_path`. Returns None for an empty or degenerate path.
pub ( crate ) fn build_ts_path( cmds: &[ PathCmd ] ) -> Option<Path>
{
let mut pb = PathBuilder::new();
for cmd in cmds
{
match *cmd
{
PathCmd::MoveTo( x, y ) => pb.move_to( x, y ),
PathCmd::LineTo( x, y ) => pb.line_to( x, y ),
PathCmd::QuadTo( x1, y1, x, y ) => pb.quad_to( x1, y1, x, y ),
PathCmd::CubicTo( x1, y1, x2, y2, x, y ) => pb.cubic_to( x1, y1, x2, y2, x, y ),
PathCmd::Close => pb.close(),
}
}
pb.finish()
}
/// Validate the dimensions of an RGBA8 image buffer for `draw_image_data`,
/// shared by both backends. Returns `true` when the buffer is drawable;
/// `false` (after logging a one-line warning tagged with `backend`) when the
/// declared `img_w × img_h × 4` does not match `data.len()` or either extent is
/// zero, so the caller returns without uploading or seeding a cache key.
pub ( crate ) fn validate_rgba_dims( backend: &str, data: &[u8], img_w: u32, img_h: u32 ) -> bool
{
let expected = ( img_w as usize ).saturating_mul( img_h as usize ).saturating_mul( 4 );
if img_w == 0 || img_h == 0 || data.len() != expected
{
eprintln!(
"[ltk] {}::draw_image_data: refusing draw — {}×{} declared, {} bytes provided, expected {}",
backend, img_w, img_h, data.len(), expected,
);
return false;
}
true
}
/// Build a rounded rectangle path with independent per-corner radii
/// using cubic bezier curves. Each corner is clamped against the
/// inscribed-circle limit `min(width, height) / 2` before drawing,
@@ -59,54 +98,3 @@ pub ( super ) fn build_rounded_rect( rect: tiny_skia::Rect, corners: Corners ) -
pb.close();
pb.finish()
}
/// System-font search chain, ordered by preference. Shared by
/// [`find_font`] (which panics when none match) and
/// [`find_font_opt`] (which returns `None` — used by tests that
/// want to skip gracefully on images without the usual fonts
/// installed).
const SYSTEM_FONT_CANDIDATES: &[&str] =
&[
// Debian `fonts-sora` — the canonical path `ltk-theme-default`
// depends on. Listed first so Sora wins as the default font
// whenever the package is installed.
"/usr/share/fonts/opentype/sora/Sora-Regular.otf",
"/usr/share/fonts/truetype/sora/Sora-Regular.ttf",
"/usr/share/fonts/sora/Sora-Regular.ttf",
"/usr/share/fonts/TTF/Sora-Regular.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
];
/// Resolve the first system font available from
/// [`SYSTEM_FONT_CANDIDATES`], or `None` if none exist. Used by
/// tests; runtime code uses [`find_font`].
pub ( crate ) fn find_font_opt() -> Option<String>
{
SYSTEM_FONT_CANDIDATES.iter()
.find( |p| std::path::Path::new( p ).exists() )
.copied()
.map( str::to_string )
}
/// Load the bytes of a default system font. Tries the candidate chain
/// via [`find_font_opt`]; falls back to the embedded
/// [`crate::theme::fallback::FALLBACK_FONT`] (Sora Regular, ~50 KB,
/// OFL 1.1) when nothing matches or the file cannot be read. Always
/// returns usable bytes so canvas construction never panics on a
/// system without the expected fonts.
pub ( super ) fn load_default_font_bytes() -> Vec<u8>
{
if let Some( path ) = find_font_opt()
{
if let Ok( bytes ) = std::fs::read( &path )
{
return bytes;
}
}
crate::theme::fallback::FALLBACK_FONT.to_vec()
}

View File

@@ -23,16 +23,22 @@ impl SoftwareCanvas
{
pub fn draw_image_data( &mut self, rgba_data: &[u8], img_w: u32, img_h: u32, dest: Rect, opacity: f32 )
{
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! {

View File

@@ -19,15 +19,17 @@
//!
//! * [`setup`] — `SoftwareCanvas::{new, sub_canvas, resize, blit,
//! set_font_registry, font_for}` (construction + accessors).
//! * [`clip`] — `SoftwareCanvas::{set_clip_rects, clear_clip,
//! has_clip, strip_intersects_clip, clear_rects_transparent}`.
//! * [`clip`] — `SoftwareCanvas::{set_clip_rects, set_clip_path,
//! clear_clip, has_clip, strip_intersects_clip,
//! clear_rects_transparent}`.
//! * [`primitives`] — `SoftwareCanvas::{clear, fill, fill_rect,
//! stroke_rect, draw_line}`.
//! * [`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;
@@ -37,7 +39,7 @@ use tiny_skia::{ Mask, Pixmap };
use crate::gles_render::{ BorrowedGlesTexture, GlesCanvas, GlesVersion };
use crate::theme::{ FontRegistry, FontStyle, InsetShadow, Paint as ThemePaint, Shadow };
use crate::types::{ Color, Corners, Rect };
use crate::types::{ Color, Corners, Length, Rect, WidgetScaling };
pub( crate ) mod setup;
pub( crate ) mod clip;
@@ -119,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<Font>,
/// Raw bytes of the default font. Kept alongside `font` so the
/// HarfBuzz shaper (rustybuzz) can be invoked without re-reading
@@ -199,6 +201,83 @@ impl Canvas
}
}
/// `(width, height)` of the surface in **logical** pixels (physical
/// size divided by `dpi_scale`). This is the right viewport for
/// resolving [`crate::Length`] values, which are themselves in
/// logical units. Falls back to physical size if `dpi_scale` is
/// 0 or negative so a misconfigured canvas still returns a usable
/// non-zero viewport instead of `NaN`/`inf`.
///
/// ```rust,no_run
/// # use ltk::core::Canvas;
/// let mut c = Canvas::new( 720, 1440 );
/// c.set_dpi_scale( 2.0 );
/// assert_eq!( c.viewport_logical(), ( 360.0, 720.0 ) );
/// ```
pub fn viewport_logical( &self ) -> ( f32, f32 )
{
let ( pw, ph ) = self.size();
let scale = self.dpi_scale();
if scale > 0.0
{
( pw as f32 / scale, ph as f32 / scale )
} else {
( pw as f32, ph as f32 )
}
}
/// `(width, height)` of the surface in **physical** pixels — the same
/// space the layout tree is computed in (the root rect is `pw × ph`).
/// This is the viewport that layout-affecting [`crate::Length`] values
/// (widths, paddings, gaps, widget sizes) must resolve against so a
/// `Vw(100)` fills the surface. Text font sizes are the exception:
/// they resolve against [`Self::viewport_logical`] and are then scaled
/// by `dpi_scale` at raster time, so they must NOT use this.
pub fn viewport_layout( &self ) -> ( f32, f32 )
{
let ( pw, ph ) = self.size();
( pw as f32, ph as f32 )
}
/// Resolve a stock-widget **geometry** design pixel (height, padding,
/// box size, gap…) through the process-wide [`crate::WidgetScaling`]
/// mode, into a concrete physical-pixel value for the layout tree.
/// Widgets call this instead of using a raw `theme::` constant so their
/// intrinsic geometry follows the app's chosen adaptation strategy
/// ([`crate::WidgetScaling::Fluid`] → surface-proportional,
/// [`crate::WidgetScaling::Physical`] → constant physical size). Geometry
/// lives in physical space, so this resolves against
/// [`Self::viewport_layout`].
pub fn geom_px( &self, design_px: f32 ) -> f32
{
Length::widget( design_px ).resolve( self.viewport_layout(), Length::EM_BASE_DEFAULT )
}
/// Resolve a stock-widget **font** design pixel through the process-wide
/// [`crate::WidgetScaling`] mode. Font sizes are handed to the raster
/// path pre-`dpi_scale`, so this bridges the logical/physical split for
/// each mode: in [`crate::WidgetScaling::Fluid`] it resolves the fluid
/// size against [`Self::viewport_logical`] (so `× dpi_scale` at raster
/// yields a surface-proportional physical size); in
/// [`crate::WidgetScaling::Physical`] it divides the density-scaled size
/// by `dpi_scale` (so `× dpi_scale` yields a constant physical size).
pub fn font_px( &self, design_px: f32 ) -> f32
{
match crate::types::widget_scaling()
{
WidgetScaling::Fluid =>
{
Length::fluid( design_px ).resolve( self.viewport_logical(), Length::EM_BASE_DEFAULT )
}
WidgetScaling::Physical =>
{
let scale = self.dpi_scale();
let scale = if scale > 0.0 { scale } else { 1.0 };
design_px * crate::types::density() / scale
}
}
}
/// Borrow the GLES texture backing this canvas, when the canvas
/// is GPU-backed.
pub fn borrowed_gles_texture( &self ) -> Option<BorrowedGlesTexture>
@@ -223,6 +302,45 @@ impl Canvas
}
}
/// Whether this is the CPU (software) backend. Callers that can honour a real
/// path clip on the software backend but only a bounding rect on GLES branch
/// on this.
pub fn is_software( &self ) -> bool
{
matches!( self, Canvas::Software( _ ) )
}
/// Read this canvas into tightly packed straight-alpha RGBA8, top-left row
/// first (`out.len()` must be at least width*height*4). Unlike
/// [`Self::read_gles_rgba_pixels`] this also serves the software backend, by
/// un-premultiplying its pixmap — used to read back an offscreen software
/// canvas (e.g. an Android `Canvas(Bitmap)`) into a straight-alpha buffer.
pub fn read_rgba_pixels( &self, out: &mut [u8] ) -> Result<(), String>
{
match self
{
Canvas::Software( c ) =>
{
let pixels = c.pixmap.pixels();
if out.len() < pixels.len() * 4
{
return Err( "read_rgba_pixels: output buffer too small".to_string() );
}
for ( i, p ) in pixels.iter().enumerate()
{
let d = p.demultiply();
let o = i * 4;
out[ o ] = d.red();
out[ o + 1 ] = d.green();
out[ o + 2 ] = d.blue();
out[ o + 3 ] = d.alpha();
}
Ok( () )
}
Canvas::Gles( c ) => c.read_rgba_pixels( out ),
}
}
/// Composite an externally-owned GL texture into `dest`. No-op on
/// the software backend (no GL state to sample from). Used by
/// widgets that host content rendered by an external producer —
@@ -288,6 +406,9 @@ impl Canvas
/// Install a theme font registry on the active backend.
pub fn set_font_registry( &mut self, registry: Arc<FontRegistry> )
{
// Drop the shaped-line cache: a new registry can swap the faces the
// resolver leads with, so cached glyph runs may no longer match.
crate::text_shaping::clear_shape_cache();
match self
{
Canvas::Software( c ) => c.set_font_registry( registry ),
@@ -376,6 +497,22 @@ impl Canvas
}
}
/// Clip subsequent draws to an arbitrary vector path (in surface
/// coordinates). Anti-aliased per-path clipping: the software backend uses a
/// tiny-skia coverage mask; the GLES backend captures the clipped draws into
/// an offscreen layer and composites it back through an anti-aliased coverage
/// mask. Replaces any active clip; restore the previous clip via
/// [`Self::set_clip_rects`] with
/// a [`Self::clip_bounds`] snapshot taken beforehand, or [`Self::clear_clip`].
pub fn set_clip_path( &mut self, cmds: &[ crate::types::PathCmd ] )
{
match self
{
Canvas::Software( c ) => c.set_clip_path( cmds ),
Canvas::Gles( c ) => c.set_clip_path( cmds ),
}
}
/// Snapshot the currently installed clip bounds (empty when no clip
/// is active). Used by widgets that need to install a tighter clip
/// for a single primitive and then restore whatever the outer
@@ -480,6 +617,29 @@ impl Canvas
}
}
/// Fill an arbitrary vector path (commands in surface coordinates) with a
/// solid colour. The software backend rasterises with tiny-skia directly;
/// the GLES backend rasterises into a tiny-skia pixmap and blits it (the
/// CPU fallback — no path shader on the GPU path).
pub fn fill_path( &mut self, cmds: &[ crate::types::PathCmd ], color: Color )
{
match self
{
Canvas::Software( c ) => c.fill_path( cmds, color ),
Canvas::Gles( c ) => c.fill_path( cmds, color ),
}
}
/// Stroke an arbitrary vector path (commands in surface coordinates).
pub fn stroke_path( &mut self, cmds: &[ crate::types::PathCmd ], color: Color, width: f32 )
{
match self
{
Canvas::Software( c ) => c.stroke_path( cmds, color, width ),
Canvas::Gles( c ) => c.stroke_path( cmds, color, width ),
}
}
/// Paint an outer drop shadow behind the rounded rect `target`.
///
/// On the GPU backend this runs an analytic soft-shadow shader
@@ -642,3 +802,211 @@ impl Canvas
}
}
}
#[ cfg( test ) ]
mod viewport_tests
{
use super::Canvas;
#[ test ]
fn viewport_logical_at_scale_one_matches_physical()
{
let c = Canvas::new( 800, 600 );
assert_eq!( c.viewport_logical(), ( 800.0, 600.0 ) );
}
#[ test ]
fn viewport_logical_divides_by_dpi_scale()
{
let mut c = Canvas::new( 720, 1440 );
c.set_dpi_scale( 2.0 );
assert_eq!( c.viewport_logical(), ( 360.0, 720.0 ) );
}
#[ test ]
fn viewport_logical_falls_back_to_physical_when_scale_is_zero()
{
// Guard the misconfigured-scale path: a divide-by-zero would
// poison every `Length::Vmin`/`Vw`/`Vh` resolution downstream.
let mut c = Canvas::new( 800, 600 );
c.set_dpi_scale( 0.0 );
assert_eq!( c.viewport_logical(), ( 800.0, 600.0 ) );
}
#[ test ]
fn viewport_layout_is_physical_and_ignores_dpi_scale()
{
// Layout-affecting `Length` values resolve against this, so it must
// stay in physical space (where the layout tree is computed) even on
// HiDPI — unlike `viewport_logical`, it does not divide by the scale.
let mut c = Canvas::new( 720, 1440 );
assert_eq!( c.viewport_layout(), ( 720.0, 1440.0 ) );
c.set_dpi_scale( 2.0 );
assert_eq!( c.viewport_layout(), ( 720.0, 1440.0 ) );
}
#[ test ]
fn geom_px_resolves_widget_design_pixel_per_mode()
{
use crate::types::{ set_widget_scaling, set_density, WidgetScaling, Length };
let _g = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() );
// Fluid (default): geom_px == fluid( n ) against the physical viewport.
set_widget_scaling( WidgetScaling::Fluid );
let c = Canvas::new( 412, 900 );
assert_eq!( c.geom_px( 48.0 ), Length::fluid( 48.0 ).resolve( ( 412.0, 900.0 ), Length::EM_BASE_DEFAULT ) );
// Physical: geom_px == n * density, independent of surface size.
set_widget_scaling( WidgetScaling::Physical );
set_density( 2.0 );
assert_eq!( c.geom_px( 48.0 ), 96.0 );
set_density( 1.0 );
set_widget_scaling( WidgetScaling::Fluid );
}
#[ test ]
fn font_px_is_constant_physical_in_physical_mode()
{
use crate::types::{ set_widget_scaling, set_density, WidgetScaling };
let _g = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() );
// Physical mode divides by dpi_scale so `× dpi_scale` at raster yields
// a constant physical size (n * density).
set_widget_scaling( WidgetScaling::Physical );
set_density( 2.0 );
let mut c = Canvas::new( 720, 1440 );
c.set_dpi_scale( 2.0 );
// 16 * 2 (density) / 2 (dpi_scale) = 16 logical → 32 physical after raster.
assert_eq!( c.font_px( 16.0 ), 16.0 );
set_density( 1.0 );
set_widget_scaling( WidgetScaling::Fluid );
}
}
#[ cfg( test ) ]
mod clip_path_tests
{
// Exercised on the software backend (`Canvas::new`); the GLES layer-composite
// path needs a live GL context and is covered by the `clip_path` example.
use super::Canvas;
use crate::types::{ Color, PathCmd, Rect };
fn square( x: f32, y: f32, side: f32 ) -> Vec<PathCmd>
{
vec![
PathCmd::MoveTo( x, y ),
PathCmd::LineTo( x + side, y ),
PathCmd::LineTo( x + side, y + side ),
PathCmd::LineTo( x, y + side ),
PathCmd::Close,
]
}
#[ test ]
fn set_clip_path_bounds_match_the_path_bounding_box()
{
let mut c = Canvas::new( 200, 200 );
c.set_clip_path( &square( 10.0, 20.0, 100.0 ) );
let b = c.clip_bounds();
assert_eq!( b.len(), 1 );
let r = b[ 0 ];
assert!( ( r.x - 10.0 ).abs() < 0.5 && ( r.y - 20.0 ).abs() < 0.5, "origin {r:?}" );
assert!( ( r.width - 100.0 ).abs() < 0.5 && ( r.height - 100.0 ).abs() < 0.5, "size {r:?}" );
}
#[ test ]
fn set_clip_path_with_no_segments_clears_the_clip()
{
let mut c = Canvas::new( 100, 100 );
c.set_clip_path( &[] );
assert!( c.clip_bounds().is_empty() );
}
#[ test ]
fn set_clip_path_masks_a_fill_to_the_path_shape_not_its_bbox()
{
let mut c = Canvas::new( 100, 100 );
// Downward triangle: apex top-centre, base along the bottom.
let tri = vec![
PathCmd::MoveTo( 50.0, 5.0 ),
PathCmd::LineTo( 95.0, 95.0 ),
PathCmd::LineTo( 5.0, 95.0 ),
PathCmd::Close,
];
c.set_clip_path( &tri );
c.fill_rect( Rect { x: 0.0, y: 0.0, width: 100.0, height: 100.0 }, Color::rgba( 1.0, 0.0, 0.0, 1.0 ), 0.0 );
let Canvas::Software( sc ) = &c else { panic!( "Canvas::new builds a software canvas" ) };
// A point well inside the triangle is painted; a top corner — inside the
// bounding box but outside the triangle — stays clear, proving the clip
// follows the path silhouette rather than its bounding rect.
let inside = sc.pixmap.pixel( 50, 70 ).expect( "in-bounds pixel" );
let corner = sc.pixmap.pixel( 8, 8 ).expect( "in-bounds pixel" );
assert!( inside.red() > 200 && inside.alpha() > 200, "inside triangle must be filled" );
assert_eq!( corner.alpha(), 0, "bbox corner outside the triangle must stay clear" );
}
}
#[ cfg( test ) ]
mod pixel_snap_tests
{
// The GLES backend rounds glyph pen positions and image destinations to
// integer pixels for crisp 1:1 sampling; these check the software backend
// now matches that snapping so both backends place content identically.
use super::Canvas;
use crate::types::{ Color, Rect };
fn red_4x4() -> Vec<u8>
{
[ 255u8, 0, 0, 255 ].repeat( 16 )
}
#[ test ]
fn image_dest_below_half_snaps_to_the_same_pixels_as_integer_dest()
{
let red = red_4x4();
let mut a = Canvas::new( 32, 32 );
a.draw_image_data( &red, 4, 4, Rect { x: 5.0, y: 5.0, width: 4.0, height: 4.0 }, 1.0 );
let mut b = Canvas::new( 32, 32 );
b.draw_image_data( &red, 4, 4, Rect { x: 5.4, y: 5.4, width: 4.0, height: 4.0 }, 1.0 );
let Canvas::Software( sa ) = &a else { panic!( "software canvas" ) };
let Canvas::Software( sb ) = &b else { panic!( "software canvas" ) };
assert_eq!( sa.pixmap.data(), sb.pixmap.data(), "a sub-half fractional dest snaps to the integer origin" );
assert!( sa.pixmap.pixel( 5, 5 ).unwrap().red() > 200, "the block lands on pixel (5,5)" );
assert_eq!( sa.pixmap.pixel( 4, 4 ).unwrap().alpha(), 0, "pixel (4,4) is outside the snapped block" );
}
#[ test ]
fn image_dest_at_or_above_half_rounds_to_the_next_pixel()
{
let red = red_4x4();
let mut c = Canvas::new( 32, 32 );
c.draw_image_data( &red, 4, 4, Rect { x: 5.6, y: 5.6, width: 4.0, height: 4.0 }, 1.0 );
let Canvas::Software( sc ) = &c else { panic!( "software canvas" ) };
assert!( sc.pixmap.pixel( 6, 6 ).unwrap().red() > 200, "round(5.6)=6 moves the block one pixel" );
assert_eq!( sc.pixmap.pixel( 5, 5 ).unwrap().alpha(), 0, "pixel (5,5) is now clear" );
}
#[ test ]
fn text_pen_position_is_rounded_not_truncated()
{
let draw = |x: f32| -> Vec<u8>
{
let mut c = Canvas::new( 64, 64 );
c.draw_text( "l", x, 40.0, 32.0, Color::rgba( 1.0, 1.0, 1.0, 1.0 ) );
let Canvas::Software( sc ) = &c else { panic!( "software canvas" ) };
sc.pixmap.data().to_vec()
};
let at_int = draw( 20.0 );
let at_low = draw( 20.4 );
let at_high = draw( 20.6 );
assert!( at_int.iter().any( |&b| b != 0 ), "the glyph must paint some pixels" );
assert_eq!( at_int, at_low, "x and x+0.4 round to the same pixel column" );
assert_ne!( at_int, at_high, "x+0.6 rounds up to the next column" );
}
}

View File

@@ -9,9 +9,9 @@
use tiny_skia::{ Paint, PathBuilder, Stroke, Transform };
use crate::types::{ Color, Corners, Rect };
use crate::types::{ Color, Corners, PathCmd, Rect };
use super::helpers::build_rounded_rect;
use super::helpers::{ build_rounded_rect, build_ts_path };
use super::SoftwareCanvas;
impl SoftwareCanvas
@@ -100,4 +100,24 @@ impl SoftwareCanvas
stroke.width = width;
self.pixmap.stroke_path( &path, &paint, &stroke, Transform::identity(), self.clip_mask.as_ref() );
}
pub fn fill_path( &mut self, cmds: &[ PathCmd ], color: Color )
{
let Some( path ) = build_ts_path( cmds ) else { return };
let mut paint = Paint::default();
paint.set_color( Color::rgba( color.r, color.g, color.b, color.a * self.global_alpha ).to_tiny_skia() );
paint.anti_alias = true;
self.pixmap.fill_path( &path, &paint, tiny_skia::FillRule::Winding, Transform::identity(), self.clip_mask.as_ref() );
}
pub fn stroke_path( &mut self, cmds: &[ PathCmd ], color: Color, width: f32 )
{
let Some( path ) = build_ts_path( cmds ) else { return };
let mut paint = Paint::default();
paint.set_color( Color::rgba( color.r, color.g, color.b, color.a * self.global_alpha ).to_tiny_skia() );
paint.anti_alias = true;
let mut stroke = Stroke::default();
stroke.width = width;
self.pixmap.stroke_path( &path, &paint, &stroke, Transform::identity(), self.clip_mask.as_ref() );
}
}

View File

@@ -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<Vec<u8>>`) alongside the fontdue `Arc<Font>` so
/// the HarfBuzz shaper can be invoked without re-reading the file.
/// Sora is small (~50 KB) so the cost was minor in absolute terms —
/// but a layer shell that brings up a launcher overlay, a QS panel,
/// a calendar popup and a handful of toast surfaces would still pay
/// the parse cost a dozen times in a single session, all of which
/// is wasted work.
static DEFAULT_FONT: OnceLock<FontHandle> = OnceLock::new();
fn default_handle() -> FontHandle
{
DEFAULT_FONT.get_or_init( ||
{
let bytes = load_default_font_bytes();
let font = Font::from_bytes( bytes.as_slice(), FontSettings::default() )
.expect( "bad font" );
FontHandle
{
font: Arc::new( font ),
bytes: Arc::new( bytes ),
face: 0,
}
} ).clone()
}
impl SoftwareCanvas
{
/// Create a canvas of the given pixel dimensions, loading a system font.

View File

@@ -115,7 +115,9 @@ impl SoftwareCanvas
// refused the font, missing bytes for the preferred face)
// falls through to no-op — we'd rather not paint than risk
// a corrupted line.
let shaped = crate::text_shaping::shape_line( text, scaled, resolve );
let shaped = crate::text_shaping::shape_line_cached(
text, scaled, Arc::as_ptr( &canvas_handle.font ) as usize, resolve,
);
if shaped.is_empty() { return; }
// Resolve every glyph's font into an `Arc<Font>` for
@@ -128,7 +130,7 @@ impl SoftwareCanvas
{
fonts.push( ( primary_id, Arc::clone( &canvas_handle.font ) ) );
}
for g in &shaped
for g in shaped.iter()
{
if fonts.iter().any( |( id, _ )| *id == g.font_id ) { continue; }
// Walk the fallback chain to find an Arc<Font> with this
@@ -155,7 +157,7 @@ impl SoftwareCanvas
let mut layout: Vec<( GlyphKey, f32, f32 )> = Vec::with_capacity( shaped.len() );
{
let mut cursor_x = x;
for g in &shaped
for g in shaped.iter()
{
let Some( ( _, font_arc ) ) = fonts.iter().find( |( id, _ )| *id == g.font_id ) else
{
@@ -187,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
{
@@ -251,7 +257,9 @@ impl SoftwareCanvas
if !canvas_handle.bytes.is_empty() { Some( canvas_handle.clone() ) } else { None }
)
};
let shaped = crate::text_shaping::shape_line( text, scaled, resolve );
let shaped = crate::text_shaping::shape_line_cached(
text, scaled, Arc::as_ptr( &canvas_handle.font ) as usize, resolve,
);
if shaped.is_empty()
{
// Fallback: rustybuzz could not shape (no bytes for the

View File

@@ -1,7 +1,9 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Lazy per-glyph fallback font resolution.
//! Process-wide font resolution for both backends: the primary UI font
//! ([`default_handle`] / [`primary_handle`], loaded once from the
//! [`SYSTEM_FONT_CANDIDATES`] chain) and lazy per-glyph fallback.
//!
//! The default font ([`crate::theme::fallback::FALLBACK_FONT`], Sora)
//! covers Latin and a portion of extended Latin; everything outside
@@ -147,6 +149,76 @@ pub fn lookup( ch: char ) -> Option<Arc<Font>>
lookup_handle( ch ).map( |h| h.font )
}
/// System-font search chain for the primary UI font, ordered by preference.
/// Shared by [`find_font_opt`] and [`load_default_font_bytes`].
const SYSTEM_FONT_CANDIDATES: &[&str] =
&[
// Debian `fonts-sora` — the canonical path `ltk-theme-default`
// depends on. Listed first so Sora wins as the default font
// whenever the package is installed.
"/usr/share/fonts/opentype/sora/Sora-Regular.otf",
"/usr/share/fonts/truetype/sora/Sora-Regular.ttf",
"/usr/share/fonts/sora/Sora-Regular.ttf",
"/usr/share/fonts/TTF/Sora-Regular.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
];
/// Resolve the first system font available from [`SYSTEM_FONT_CANDIDATES`], or
/// `None` if none exist. Used by the font registry and by tests that skip
/// gracefully on images without the usual fonts installed.
pub ( crate ) fn find_font_opt() -> Option<String>
{
SYSTEM_FONT_CANDIDATES.iter()
.find( |p| std::path::Path::new( p ).exists() )
.copied()
.map( str::to_string )
}
/// Load the bytes of a default system font. Tries the candidate chain via
/// [`find_font_opt`]; falls back to the embedded
/// [`crate::theme::fallback::FALLBACK_FONT`] (Sora Regular, ~50 KB, OFL 1.1)
/// when nothing matches or the file cannot be read. Always returns usable bytes
/// so canvas construction never panics on a system without the expected fonts.
pub ( crate ) fn load_default_font_bytes() -> Vec<u8>
{
if let Some( path ) = find_font_opt()
{
if let Ok( bytes ) = std::fs::read( &path )
{
return bytes;
}
}
crate::theme::fallback::FALLBACK_FONT.to_vec()
}
/// The process-wide primary UI font handle — the single default every canvas
/// loads. Cached so a layer shell bringing up several surfaces parses the small
/// Sora face once rather than per surface. The per-glyph fallback chain (Noto
/// Sans / CJK / Devanagari / …) is loaded lazily through [`lookup`], not here.
pub ( crate ) fn default_handle() -> FontHandle
{
static PRIMARY: OnceLock<FontHandle> = OnceLock::new();
PRIMARY.get_or_init( ||
{
let bytes = load_default_font_bytes();
let font = Font::from_bytes( bytes.as_slice(), FontSettings::default() ).expect( "primary font parses" );
FontHandle { font: Arc::new( font ), bytes: Arc::new( bytes ), face: 0 }
} ).clone()
}
/// The process-wide primary UI font — the same default a canvas loads — for
/// standalone text measurement (e.g. an embedded measure pass with no live
/// canvas). Falls back to the bundled font when no system font is found.
pub fn primary_handle() -> FontHandle
{
default_handle()
}
/// Bytes-aware variant of [`lookup`]. Returns the full
/// [`FontHandle`] (fontdue handle + raw bytes + face index) so
/// callers that need to invoke a HarfBuzz-style shaper can do so

View File

@@ -126,6 +126,84 @@ where
out
}
thread_local!
{
/// Shaped-line cache. [`shape_line`] re-parses the font face
/// (`rustybuzz::Face::from_slice`) and re-runs HarfBuzz on every call;
/// during an animation the same labels are shaped every frame, at both
/// measure and draw time. Caching the visual-order glyph run by
/// `(font context, pixel size, text)` makes those repeats free. Keyed by
/// the resolver's leading font (its `Arc` pointer) since the output
/// depends on which face leads; the system fallback chain is
/// process-global and stable, so it need not be in the key.
static SHAPE_CACHE: std::cell::RefCell<
std::collections::HashMap<( usize, u32, String ), std::sync::Arc<Vec<PositionedGlyph>>>
> = std::cell::RefCell::new( std::collections::HashMap::new() );
}
/// Soft cap on cached shaped lines; cleared wholesale on overflow (the live
/// set of on-screen labels is far smaller than this).
const SHAPE_CACHE_CAP: usize = 8192;
/// Measure a single line of `text` at `size` px with the default UI font (and
/// the system fallback chain), returning `(width, line_height)` in pixels. For
/// laying text out without a live [`crate::Canvas`] — e.g. an embedded Android
/// measure pass that needs the same metrics the renderer will use.
pub fn measure_text( text: &str, size: f32 ) -> ( f32, f32 )
{
let primary = crate::system_fonts::primary_handle();
let line_h = primary.font.horizontal_line_metrics( size ).map( |m| m.new_line_size ).unwrap_or( size * 1.3 );
if text.is_empty()
{
return ( 0.0, line_h );
}
let resolver = primary.clone();
let glyphs = shape_line( text, size, move | ch |
{
if resolver.font.lookup_glyph_index( ch ) != 0
{
Some( resolver.clone() )
}
else
{
crate::system_fonts::lookup_handle( ch ).or_else( || Some( resolver.clone() ) )
}
} );
let width: f32 = glyphs.iter().map( | g | g.x_advance ).sum();
( width, line_h )
}
/// Cached wrapper over [`shape_line`]. `font_ctx` identifies the resolver's
/// leading font so two fonts shaping the same text don't collide — pass
/// `std::sync::Arc::as_ptr( &handle.font ) as usize`. `resolve_font` runs only
/// on a cache miss.
pub fn shape_line_cached<F>( text: &str, px: f32, font_ctx: usize, resolve_font: F ) -> std::sync::Arc<Vec<PositionedGlyph>>
where
F: FnMut( char ) -> Option<crate::system_fonts::FontHandle>,
{
if text.is_empty() { return std::sync::Arc::new( Vec::new() ); }
let key = ( font_ctx, px.to_bits(), text.to_owned() );
if let Some( hit ) = SHAPE_CACHE.with( |c| c.borrow().get( &key ).cloned() )
{
return hit;
}
let shaped = std::sync::Arc::new( shape_line( text, px, resolve_font ) );
SHAPE_CACHE.with( |c|
{
let mut c = c.borrow_mut();
if c.len() >= SHAPE_CACHE_CAP { c.clear(); }
c.insert( key, std::sync::Arc::clone( &shaped ) );
} );
shaped
}
/// Drop every cached shaped line. Called when the font registry changes so a
/// new face set can't be served stale glyph runs keyed by a reused pointer.
pub fn clear_shape_cache()
{
SHAPE_CACHE.with( |c| c.borrow_mut().clear() );
}
/// Shape a text run using rustybuzz (HarfBuzz-compatible shaping) and
/// return the glyph sequence with ink positions.
///

View File

@@ -126,7 +126,7 @@ pub fn active_mode() -> ThemeMode
}
/// The currently loaded theme document. Use this for slot-typed lookups
/// when the per-slot helpers ([`crate::theme::color`], [`crate::theme::surface`], …) are not
/// when the per-slot helpers ([`crate::theme::color`], [`crate::theme::surface()`], …) are not
/// expressive enough — e.g. iterating `mode.slots.entries`.
pub fn active_document() -> Arc<ThemeDocument>
{

View File

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

View File

@@ -262,7 +262,7 @@ mod tests
/// without the usual system fonts).
fn system_font() -> Option<Arc<Font>>
{
let path = crate::render::helpers::find_font_opt()?;
let path = crate::system_fonts::find_font_opt()?;
let bytes = std::fs::read( path ).ok()?;
let font = Font::from_bytes( bytes.as_slice(), FontSettings::default() ).ok()?;
Some( Arc::new( font ) )

View File

@@ -32,7 +32,7 @@
//! state. Use [`set_active_document`] / [`set_active_mode`] to change it,
//! and [`active_document`] / [`active_mode`] / [`active_theme_id`] to read
//! it back. Per-slot shorthand accessors ([`color`], [`paint()`], [`surface()`],
//! [`palette`], …) cover the common patterns without going through the
//! [`palette()`], …) cover the common patterns without going through the
//! full document.
//!
//! There is **no in-code fallback**: if `ensure_active` cannot locate the

View File

@@ -51,7 +51,7 @@ impl Palette
/// (`bg-page`, `surface`, `surface-alt`, `text-primary`,
/// `text-secondary`, `accent`, `divider`, `icon`). Missing slots
/// fall back to a documented sensible default so downstream widgets
/// never see uninitialised colours. Used by [`crate::theme::palette`] and
/// never see uninitialised colours. Used by [`crate::theme::palette()`] and
/// [`crate::theme::window_controls`].
pub fn from_slots( slots: &SlotStore ) -> Self
{

View File

@@ -6,15 +6,105 @@
//! Designed around the **Sora** typeface (Google Fonts). If Sora is not
//! installed on the system, ltk falls back to Liberation Sans / DejaVu Sans;
//! glyph metrics will differ slightly but the scale still reads correctly.
//!
//! Two scales coexist: the historic **px** constants (`H0` through
//! `BODY_XS`) for code that wants a frozen pixel size, and the new
//! **responsive** scale ([`h0`], [`h1`], …, [`body_xs`]) that returns
//! viewport-relative [`crate::Length`] values clamped to the same px
//! range that used to be the constant. Call sites can mix freely:
//! `.size( typography::H2 )` still resolves to `Length::px( 24.0 )` via
//! `From<f32>`, while `.size( typography::h2() )` scales with the
//! surface's smaller dimension.
use crate::types::Length;
/// Frozen px sizes for the typographic scale, largest (`H0`, a display
/// heading) to smallest (`BODY_XS`, fine print). `H0`…`H3` are the heading
/// ramp; `BODY` is running text, with `BODY_S` / `BODY_XS` for secondary and
/// caption text. Each is the px size; for sizes that scale with the surface
/// use the responsive [`h0`]…[`body_xs`] functions instead.
pub const H0: f32 = 50.0;
/// 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
/// `size * LINE_HEIGHT` when laying out multi-line text blocks.
pub const LINE_HEIGHT: f32 = 1.5;
// ─── Responsive scale ────────────────────────────────────────────────────────
//
// The percentages are calibrated so the px clamps match the legacy constants
// at a 1000 px logical smaller side (a typical landscape tablet). On a Librem 5
// portrait (~720 px) headings round down sensibly; on a 4K desktop the upper
// clamp kicks in before display titles get absurd.
/// Responsive counterparts of the px constants: each returns a [`Length`]
/// that scales with the surface's smaller dimension (`vmin`) and is clamped
/// to a sensible px range, so the same level reads correctly from a portrait
/// phone to a 4K desktop. Same hierarchy as the constants — `h0` the largest
/// display heading down to `body_xs` the smallest caption. Largest display
/// heading: scales as 5% of `vmin`, clamped to `32..=80` px.
pub fn h0() -> Length { Length::vmin( 5.0 ).clamp( 32.0, 80.0 ) }
/// See [`h0`]. Second heading level: 3.4% of `vmin`, clamped to `24..=56` px.
pub fn h1() -> Length { Length::vmin( 3.4 ).clamp( 24.0, 56.0 ) }
/// See [`h0`]. Third heading level: 2.4% of `vmin`, clamped to `18..=40` px.
pub fn h2() -> Length { Length::vmin( 2.4 ).clamp( 18.0, 40.0 ) }
/// See [`h0`]. Fourth heading level: 2.0% of `vmin`, clamped to `16..=32` px.
pub fn h3() -> Length { Length::vmin( 2.0 ).clamp( 16.0, 32.0 ) }
/// See [`h0`]. Running body text: 1.6% of `vmin`, clamped to `14..=22` px.
pub fn body() -> Length { Length::vmin( 1.6 ).clamp( 14.0, 22.0 ) }
/// See [`h0`]. Small body text: 1.4% of `vmin`, clamped to `12..=18` px.
pub fn body_s() -> Length { Length::vmin( 1.4 ).clamp( 12.0, 18.0 ) }
/// See [`h0`]. Smallest caption text: 1.2% of `vmin`, clamped to `11..=15` px.
pub fn body_xs() -> Length { Length::vmin( 1.2 ).clamp( 11.0, 15.0 ) }
#[ cfg( test ) ]
mod tests
{
use super::*;
/// 360-px-wide portrait phone — Vmin ratio under the clamp's lower bound,
/// so every scale snaps to its `min_px`.
#[ test ]
fn responsive_scale_pins_to_min_on_narrow_phones()
{
let vp = ( 360.0, 720.0 );
let em = Length::EM_BASE_DEFAULT;
assert_eq!( h0().resolve( vp, em ), 32.0 );
assert_eq!( body().resolve( vp, em ), 14.0 );
assert_eq!( body_xs().resolve( vp, em ), 11.0 );
}
/// 1000-px smaller side — the calibration point. Numbers should be
/// "around" the legacy px constants without exceeding the upper clamp.
#[ test ]
fn responsive_scale_centers_around_legacy_px_constants()
{
let vp = ( 1000.0, 1000.0 );
let em = Length::EM_BASE_DEFAULT;
assert_eq!( h0().resolve( vp, em ), 50.0 );
assert_eq!( h2().resolve( vp, em ), 24.0 );
assert_eq!( body().resolve( vp, em ), 16.0 );
}
/// 4K-class smaller side — every scale should saturate to its upper
/// clamp instead of growing absurdly.
#[ test ]
fn responsive_scale_pins_to_max_on_large_displays()
{
let vp = ( 2160.0, 3840.0 );
let em = Length::EM_BASE_DEFAULT;
assert_eq!( h0().resolve( vp, em ), 80.0 );
assert_eq!( body().resolve( vp, em ), 22.0 );
}
}

View File

@@ -24,6 +24,8 @@
//! `ltk::Rect`, …) so application code rarely needs the `ltk::types::`
//! prefix.
use std::sync::atomic::{ AtomicU8, AtomicU32, Ordering };
/// An RGBA color with floating-point channels in the range `[0.0, 1.0]`.
#[ derive( Debug, Clone, Copy, PartialEq ) ]
pub struct Color
@@ -271,6 +273,19 @@ impl From<( f32, f32, f32, f32 )> for Corners
}
}
/// One command of a vector path, in canvas (surface) coordinates. Fed to
/// [`Canvas::fill_path`](crate::Canvas::fill_path) / `stroke_path` to render
/// arbitrary shapes (e.g. an Android `Path` / a Lottie frame).
#[ derive( Clone, Copy, Debug, PartialEq ) ]
pub enum PathCmd
{
MoveTo( f32, f32 ),
LineTo( f32, f32 ),
QuadTo( f32, f32, f32, f32 ),
CubicTo( f32, f32, f32, f32, f32, f32 ),
Close,
}
/// A stable widget identifier used for focus management.
///
/// Assign an id to a widget with `.id( WidgetId("my_widget") )`, then request
@@ -430,3 +445,456 @@ mod tests
assert_eq!( r, e );
}
}
// ─── Length ──────────────────────────────────────────────────────────────────
/// One of the pure relative-or-absolute modes a [`Length`] can carry.
/// Split out so [`Length`] itself can stay `Copy` while still supporting
/// optional clamp bounds — the recursive `Clamp` variant of the original
/// sketch would have forced a `Box` allocation, which on a widget tree
/// that builds these values per frame is the wrong trade.
#[ derive( Debug, Clone, Copy, PartialEq ) ]
pub enum LengthBase
{
/// Absolute, in logical pixels.
Px( f32 ),
/// Percentage of the viewport's width (`Vw(10.0)` == 10 % of width).
Vw( f32 ),
/// Percentage of the viewport's height.
Vh( f32 ),
/// Percentage of the viewport's **smaller** dimension. The right
/// default for typography and gutters that must survive a
/// portrait/landscape rotation without growing absurd.
Vmin( f32 ),
/// Percentage of the viewport's **larger** dimension.
Vmax( f32 ),
/// Orientation-dependent percentage of the viewport's **short** side,
/// with a different proportion per orientation. In portrait (width ≤
/// height) it resolves to `portrait` % of the **width**; in landscape
/// (width > height) to `landscape` % of the **height**. Both axes are
/// the short side of their orientation, but the design proportion
/// differs — e.g. a logo that wants 40 % of the width when there is
/// vertical room to spare, but only 5 % of the (scarce) height when
/// laid out landscape.
Orient { portrait: f32, landscape: f32 },
/// Multiple of the root font size (typographic hierarchy: a heading
/// of `Em(2.0)` is twice the body size, regardless of viewport).
Em( f32 ),
}
impl LengthBase
{
fn resolve( &self, viewport: ( f32, f32 ), em_base: f32 ) -> f32
{
let ( vw, vh ) = viewport;
match self
{
LengthBase::Px( v ) => *v,
LengthBase::Vw( pct ) => vw * pct / 100.0,
LengthBase::Vh( pct ) => vh * pct / 100.0,
LengthBase::Vmin( pct ) => vw.min( vh ) * pct / 100.0,
LengthBase::Vmax( pct ) => vw.max( vh ) * pct / 100.0,
LengthBase::Orient { portrait, landscape } =>
{
if vw <= vh
{
vw * portrait / 100.0
} else {
vh * landscape / 100.0
}
}
LengthBase::Em( mul ) => em_base * mul,
}
}
}
/// A size or distance value that may be expressed in absolute pixels or
/// relative to the rendering surface. Every widget API that used to take
/// `f32` for a size, padding, spacing or font height now takes
/// `impl Into<Length>`, so existing call sites keep compiling unchanged
/// while new code can switch to viewport-relative units for layouts that
/// must scale across screen sizes (portrait phone, landscape tablet,
/// 4K desktop) without per-target tweaks.
///
/// Resolution requires a viewport — passed in as `(width, height)` in
/// **logical** pixels — and an `em_base` (the body-text font size that
/// `Em` is a multiple of). All resolution funnels through
/// [`Length::resolve`], so widgets can stay backend-agnostic.
///
/// Construct directly via the [`LengthBase`] variants
/// (`Length::vmin( 18.0 )`, `Length::px( 24.0 )`, …) or implicitly from
/// `f32`/`i32`/`u32` for the px case so legacy `.size( 24.0 )` style
/// keeps compiling unchanged. Optionally chain `.clamp( min_px, max_px )`
/// to bound a relative value into a safe range.
#[ derive( Debug, Clone, Copy, PartialEq ) ]
pub struct Length
{
pub base: LengthBase,
/// Lower bound in absolute logical px. `None` means unbounded.
pub min_px: Option<f32>,
/// Upper bound in absolute logical px. `None` means unbounded.
pub max_px: Option<f32>,
}
impl Length
{
/// Default font-size that [`LengthBase::Em`] is a multiple of. Matches
/// the `typography::BODY` constant of the default theme.
pub const EM_BASE_DEFAULT: f32 = 16.0;
pub const fn from_base( base: LengthBase ) -> Self
{
Self { base, min_px: None, max_px: None }
}
/// Shorthand constructors. `Length::vmin( 18.0 )` reads better than
/// `Length::from_base( LengthBase::Vmin( 18.0 ) )` at every call site
/// and the brevity matters when these appear in tight view code.
pub const fn px( v: f32 ) -> Self { Self::from_base( LengthBase::Px( v ) ) }
pub const fn vw( v: f32 ) -> Self { Self::from_base( LengthBase::Vw( v ) ) }
pub const fn vh( v: f32 ) -> Self { Self::from_base( LengthBase::Vh( v ) ) }
pub const fn vmin( v: f32 ) -> Self { Self::from_base( LengthBase::Vmin( v ) ) }
pub const fn vmax( v: f32 ) -> Self { Self::from_base( LengthBase::Vmax( v ) ) }
pub const fn em( v: f32 ) -> Self { Self::from_base( LengthBase::Em( v ) ) }
/// Orientation-aware size: `portrait` % of the **width** when the
/// viewport is portrait, `landscape` % of the **height** when it is
/// landscape. See [`LengthBase::Orient`]. Chain `.clamp( lo, hi )` to
/// bound the result in px as with any relative length.
pub const fn orient( portrait: f32, landscape: f32 ) -> Self
{
Self::from_base( LengthBase::Orient { portrait, landscape } )
}
/// **Fluid** design pixel (the [`WidgetScaling::Fluid`] mode). `px` is
/// the size at the reference surface set via [`set_fluid_reference`]
/// (defaults to 412 px — the eydos mobile reference width); the value
/// then scales as a fraction of the surface's **short** side (width in
/// portrait, height in landscape) and is auto-clamped to
/// `[px * `[`FLUID_MIN`]`, px * `[`FLUID_MAX`]`]` so it neither
/// collapses on a tiny surface nor balloons on a 4K one. A single
/// design number therefore yields a surface-proportional size with no
/// per-call percentages — this is how stock widgets stay fluid by
/// default. For explicit control use [`Length::vmin`] /
/// [`Length::orient`] with your own [`Length::clamp`].
pub fn fluid( px: f32 ) -> Self
{
let r = fluid_reference();
Length::vmin( px / r * 100.0 ).clamp( px * FLUID_MIN, px * FLUID_MAX )
}
/// **Density-independent** pixel (the [`WidgetScaling::Physical`] mode).
/// `px` is multiplied by the process [`density`] (derived from the
/// output's DPI, or set with [`set_density`]) to yield a **constant
/// physical size** across displays — the mainstream `dp` of Android /
/// Flutter / CSS. Unlike [`Length::fluid`] it does **not** scale with
/// the surface size, only with pixel density. Density defaults to
/// `1.0`, so `dp( n )` == `n` px until a density is set.
pub fn dp( px: f32 ) -> Self
{
Length::px( px * density() )
}
/// Resolve a stock-widget design pixel through the process-wide
/// [`widget_scaling`] mode: [`Length::fluid`] in [`WidgetScaling::Fluid`]
/// (the default), [`Length::dp`] in [`WidgetScaling::Physical`]. Widgets
/// route their intrinsic geometry / font constants through this (see
/// [`crate::Canvas::geom_px`] / [`crate::Canvas::font_px`]) so a single
/// process-level switch picks the adaptation strategy for every stock
/// widget at once, while explicit [`Length`] overrides still win.
pub fn widget( px: f32 ) -> Self
{
match widget_scaling()
{
WidgetScaling::Fluid => Length::fluid( px ),
WidgetScaling::Physical => Length::dp( px ),
}
}
/// Resolve to a concrete logical-pixel value given a viewport and an
/// `em_base` (the root font size that `Em` is a fraction of).
pub fn resolve( &self, viewport: ( f32, f32 ), em_base: f32 ) -> f32
{
let raw = self.base.resolve( viewport, em_base );
let lo = self.min_px;
let hi = self.max_px;
// If both bounds present, normalise their order so swapped args
// don't produce NaN out of f32::clamp.
let ( lo, hi ) = match ( lo, hi )
{
( Some( a ), Some( b ) ) if a > b => ( Some( b ), Some( a ) ),
other => other,
};
let v = match lo { Some( a ) => raw.max( a ), None => raw };
match hi { Some( b ) => v.min( b ), None => v }
}
/// Cap the resolved value to `[min_px, max_px]`. Bounds are
/// absolute px because the typical use is "this Vmin should never
/// shrink past readable nor balloon past comfortable"; bounding
/// a relative value with another relative value is rare enough to
/// not justify boxing the type. If you swap min/max the resolver
/// tolerates it instead of panicking.
pub fn clamp( mut self, min_px: f32, max_px: f32 ) -> Length
{
self.min_px = Some( min_px );
self.max_px = Some( max_px );
self
}
/// One-sided bound: never resolve below `min_px`. Named `at_least`
/// (rather than `min`) to avoid clashing visually with `f32::min`,
/// which has the opposite semantics ("return the smaller of two").
pub fn at_least( mut self, min_px: f32 ) -> Length
{
self.min_px = Some( min_px );
self
}
/// One-sided bound: never resolve above `max_px`. Counterpart to
/// [`Self::at_least`].
pub fn at_most( mut self, max_px: f32 ) -> Length
{
self.max_px = Some( max_px );
self
}
}
/// Lower auto-clamp factor of [`Length::fluid`]: a fluid value never
/// resolves below `px * FLUID_MIN`, so it stays usable on a tiny surface.
pub const FLUID_MIN: f32 = 0.7;
/// Upper auto-clamp factor of [`Length::fluid`]: a fluid value never
/// resolves above `px * FLUID_MAX`, so it stays tasteful on a huge surface.
pub const FLUID_MAX: f32 = 1.5;
static FLUID_REFERENCE_BITS: AtomicU32 = AtomicU32::new( 412.0_f32.to_bits() );
/// Set the reference surface (short-side px) that [`Length::fluid`]
/// interprets its design pixels against. Call once at startup (e.g. before
/// [`crate::run`]) to align the fluid scale to the surface mock-up the app
/// was designed for. Default: 412 px.
pub fn set_fluid_reference( reference_vmin: f32 )
{
FLUID_REFERENCE_BITS.store( reference_vmin.to_bits(), Ordering::Relaxed );
}
/// Current reference used by [`Length::fluid`] — the short-side px at which
/// `fluid( n )` resolves to `n` px before clamping.
pub fn fluid_reference() -> f32
{
f32::from_bits( FLUID_REFERENCE_BITS.load( Ordering::Relaxed ) )
}
static DENSITY_BITS: AtomicU32 = AtomicU32::new( 1.0_f32.to_bits() );
/// Set the process-wide pixel density used by [`Length::dp`] (the
/// [`WidgetScaling::Physical`] mode). Typically derived from the output's
/// physical DPI so `dp` sizes stay physically constant across displays.
/// Default: `1.0`.
pub fn set_density( d: f32 )
{
DENSITY_BITS.store( d.max( 0.0 ).to_bits(), Ordering::Relaxed );
}
/// Current pixel density — the factor [`Length::dp`] multiplies its design
/// pixels by. `1.0` until [`set_density`] is called.
pub fn density() -> f32
{
f32::from_bits( DENSITY_BITS.load( Ordering::Relaxed ) )
}
/// How a stock widget adapts its intrinsic geometry to the display when the
/// app does not override it. The two modes ltk offers, chosen per process
/// with [`set_widget_scaling`]:
///
/// - [`WidgetScaling::Fluid`] — sizes scale as a fraction of the surface
/// (via [`Length::fluid`]): the design breathes with the screen, and a
/// size tracks the **short** side (width in portrait, height in
/// landscape). The default.
/// - [`WidgetScaling::Physical`] — sizes stay a constant physical size
/// (via [`Length::dp`] and [`density`]), the mainstream HiDPI model.
///
/// Both leave explicit [`Length`] overrides (`vmin` / `orient` / `dp` / …)
/// on individual widgets untouched — the mode only picks the meaning of the
/// theme's default design pixels.
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
pub enum WidgetScaling
{
/// Surface-proportional defaults. See [`Length::fluid`].
Fluid,
/// Constant-physical-size defaults. See [`Length::dp`].
Physical,
}
static WIDGET_SCALING_BITS: AtomicU8 = AtomicU8::new( 0 );
/// Set the process-wide [`WidgetScaling`] mode for stock-widget defaults.
/// Call once at startup. Default: [`WidgetScaling::Fluid`].
pub fn set_widget_scaling( mode: WidgetScaling )
{
let v = match mode { WidgetScaling::Fluid => 0, WidgetScaling::Physical => 1 };
WIDGET_SCALING_BITS.store( v, Ordering::Relaxed );
}
/// Current [`WidgetScaling`] mode. [`WidgetScaling::Fluid`] until
/// [`set_widget_scaling`] changes it.
pub fn widget_scaling() -> WidgetScaling
{
match WIDGET_SCALING_BITS.load( Ordering::Relaxed )
{
1 => WidgetScaling::Physical,
_ => WidgetScaling::Fluid,
}
}
impl From<f32> for Length
{
fn from( v: f32 ) -> Self { Length::px( v ) }
}
impl From<i32> for Length
{
fn from( v: i32 ) -> Self { Length::px( v as f32 ) }
}
impl From<u32> for Length
{
fn from( v: u32 ) -> Self { Length::px( v as f32 ) }
}
impl From<LengthBase> for Length
{
fn from( base: LengthBase ) -> Self { Length::from_base( base ) }
}
#[ cfg( test ) ]
mod length_tests
{
use super::Length;
use crate::TEST_GLOBALS_LOCK as GLOBALS_LOCK;
#[ test ]
fn px_is_passthrough()
{
assert_eq!( Length::px( 42.0 ).resolve( ( 800.0, 600.0 ), 16.0 ), 42.0 );
}
#[ test ]
fn vw_vh_are_percent_of_viewport()
{
assert_eq!( Length::vw( 50.0 ).resolve( ( 800.0, 600.0 ), 16.0 ), 400.0 );
assert_eq!( Length::vh( 25.0 ).resolve( ( 800.0, 600.0 ), 16.0 ), 150.0 );
}
#[ test ]
fn vmin_picks_smaller_side()
{
assert_eq!( Length::vmin( 10.0 ).resolve( ( 800.0, 600.0 ), 16.0 ), 60.0 );
assert_eq!( Length::vmin( 10.0 ).resolve( ( 600.0, 800.0 ), 16.0 ), 60.0 );
}
#[ test ]
fn vmax_picks_larger_side()
{
assert_eq!( Length::vmax( 10.0 ).resolve( ( 800.0, 600.0 ), 16.0 ), 80.0 );
}
#[ test ]
fn orient_uses_width_pct_in_portrait_and_height_pct_in_landscape()
{
// Portrait 1080×2400: 40 % of the width.
assert_eq!( Length::orient( 40.0, 5.0 ).resolve( ( 1080.0, 2400.0 ), 16.0 ), 432.0 );
// Landscape 2400×1080: 5 % of the height.
assert_eq!( Length::orient( 40.0, 5.0 ).resolve( ( 2400.0, 1080.0 ), 16.0 ), 54.0 );
// Square viewport counts as portrait (width ≤ height).
assert_eq!( Length::orient( 10.0, 20.0 ).resolve( ( 500.0, 500.0 ), 16.0 ), 50.0 );
}
#[ test ]
fn em_uses_em_base()
{
assert_eq!( Length::em( 2.0 ).resolve( ( 800.0, 600.0 ), 18.0 ), 36.0 );
}
#[ test ]
fn clamp_bounds_relative_value()
{
// 50 % of the smaller side (= 300) capped to [100, 200] → 200.
let l = Length::vmin( 50.0 ).clamp( 100.0, 200.0 );
assert_eq!( l.resolve( ( 800.0, 600.0 ), 16.0 ), 200.0 );
// 1 % of the smaller side (= 6) lifted to the min of 50.
let l2 = Length::vmin( 1.0 ).clamp( 50.0, 200.0 );
assert_eq!( l2.resolve( ( 800.0, 600.0 ), 16.0 ), 50.0 );
// Caller swapped min/max — resolver tolerates without panic.
let l3 = Length::vmin( 50.0 ).clamp( 200.0, 100.0 );
assert_eq!( l3.resolve( ( 800.0, 600.0 ), 16.0 ), 200.0 );
}
#[ test ]
fn f32_converts_to_px()
{
let l: Length = 24.0_f32.into();
assert_eq!( l.base, super::LengthBase::Px( 24.0 ) );
}
#[ test ]
fn fluid_equals_design_px_at_reference_surface()
{
// At a surface whose short side is the 412 px reference, fluid( n ) == n.
assert_eq!( Length::fluid( 48.0 ).resolve( ( 412.0, 900.0 ), 16.0 ), 48.0 );
}
#[ test ]
fn fluid_scales_with_surface_and_auto_clamps()
{
// Twice the reference short side → would double, but the +50 % cap
// (48 * FLUID_MAX = 72) holds it.
assert_eq!( Length::fluid( 48.0 ).resolve( ( 824.0, 1600.0 ), 16.0 ), 72.0 );
// A tiny surface → the -30 % floor (48 * FLUID_MIN = 33.6) holds it.
assert_eq!( Length::fluid( 48.0 ).resolve( ( 200.0, 400.0 ), 16.0 ), 33.6 );
// Fluid tracks the short side: same result portrait or landscape.
let p = Length::fluid( 48.0 ).resolve( ( 412.0, 1000.0 ), 16.0 );
let l = Length::fluid( 48.0 ).resolve( ( 1000.0, 412.0 ), 16.0 );
assert_eq!( p, l );
}
#[ test ]
fn dp_is_identity_at_default_density()
{
let _g = GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() );
// Density defaults to 1.0, so dp( n ) resolves to n regardless of viewport.
assert_eq!( super::density(), 1.0 );
assert_eq!( Length::dp( 48.0 ).resolve( ( 412.0, 900.0 ), 16.0 ), 48.0 );
assert_eq!( Length::dp( 48.0 ).resolve( ( 3840.0, 2160.0 ), 16.0 ), 48.0 );
}
// Serialised: this is the only test that mutates the process-wide density
// and widget-scaling globals, so it owns them start-to-finish and restores
// the defaults, keeping the other (read-only-default) tests deterministic.
#[ test ]
fn density_and_widget_scaling_modes()
{
use super::{ density, set_density, widget_scaling, set_widget_scaling, WidgetScaling };
let _g = GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() );
// Defaults.
assert_eq!( density(), 1.0 );
assert_eq!( widget_scaling(), WidgetScaling::Fluid );
assert_eq!( Length::widget( 48.0 ), Length::fluid( 48.0 ) );
// Density scales dp.
set_density( 3.0 );
assert_eq!( Length::dp( 48.0 ).resolve( ( 412.0, 900.0 ), 16.0 ), 144.0 );
// Physical mode routes widget() through dp.
set_widget_scaling( WidgetScaling::Physical );
assert_eq!( Length::widget( 48.0 ), Length::dp( 48.0 ) );
// Restore defaults for the rest of the suite.
set_density( 1.0 );
set_widget_scaling( WidgetScaling::Fluid );
}
}

View File

@@ -32,12 +32,12 @@ mod tests;
pub struct AnchoredOverlay<Msg: Clone>
{
/// The element to draw at the anchored position.
pub child: Box<Element<Msg>>,
pub( crate ) child: Box<Element<Msg>>,
/// Stable identifier of the widget whose rect provides the anchor.
pub anchor_id: WidgetId,
pub( crate ) anchor_id: WidgetId,
/// Vertical pixel gap between the bottom of the anchor and the top
/// of the child.
pub gap: f32,
pub( crate ) gap: f32,
}
impl<Msg: Clone> AnchoredOverlay<Msg>

View File

@@ -2,7 +2,7 @@
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::sync::Arc;
use crate::types::{ Color, Rect, WidgetId };
use crate::types::{ Color, Length, Rect, WidgetId };
use crate::render::Canvas;
use super::Element;
@@ -47,34 +47,45 @@ pub enum ButtonContent
pub struct Button<Msg: Clone>
{
/// The visual content of this button.
pub content: ButtonContent,
pub( crate ) content: ButtonContent,
/// Message emitted when the button is pressed, or `None` if disabled.
pub on_press: Option<Msg>,
pub( crate ) on_press: Option<Msg>,
/// Message emitted when the user holds the button for
/// [`App::long_press_duration`](crate::app::App::long_press_duration)
/// without moving past the tolerance, OR when the user right-clicks
/// with the mouse. `None` leaves the button without a context-menu
/// equivalent. The fire does NOT by itself put the gesture into
/// drag mode — that is governed by [`Self::on_drag_start`].
pub on_long_press: Option<Msg>,
pub( crate ) on_long_press: Option<Msg>,
/// Drag-arm message. Fires when the press transitions into a drag:
/// touch on hold-timer expiry (in addition to `on_long_press`),
/// mouse on motion past the drag-promotion threshold (without
/// firing the menu). Independent of `on_long_press` so a button
/// can open a menu without becoming draggable, or be draggable
/// without showing a menu.
pub on_drag_start: Option<Msg>,
pub( crate ) on_drag_start: Option<Msg>,
/// Visual variant controlling colors and borders.
pub variant: ButtonVariant,
/// Width and height in pixels for icon buttons. Defaults to `48.0`.
pub icon_size: f32,
pub( crate ) variant: ButtonVariant,
/// Width and height in pixels for icon buttons. The `0.0` default
/// follows the process [`crate::WidgetScaling`] mode (via
/// [`crate::Canvas::geom_px`]); any positive value pins an explicit size.
pub( crate ) icon_size: f32,
/// Optional label font size for text buttons. `None` uses the theme's
/// default (`theme::FONT_SIZE`); a [`Length`] scales the label with the
/// surface (e.g. `Length::vmin( 2.2 ).clamp( 14.0, 22.0 )`).
pub( crate ) font_size: Option<Length>,
/// Optional height for text buttons. `None` uses the theme's default
/// (`theme::HEIGHT`); a [`Length`] scales the button box with the
/// surface so it does not stay frozen while the rest of a fluid layout
/// grows. Resolved in physical layout space, like all geometry.
pub( crate ) height: Option<Length>,
/// Optional stable identifier for focus management.
pub id: Option<WidgetId>,
pub( crate ) id: Option<WidgetId>,
/// Whether this button participates in keyboard focus (Tab). Default: `true`.
pub focusable: bool,
pub( crate ) focusable: bool,
/// Override the pointer cursor shape on hover. `None` falls back
/// to the `Pointer` (hand) default for clickable widgets.
pub cursor: Option<crate::types::CursorShape>,
pub( crate ) cursor: Option<crate::types::CursorShape>,
/// When `true`, holding the button down auto-fires the
/// `on_press` message: one immediate fire on press, then an
/// initial delay (≈ 500 ms — same as the keyboard) followed by
@@ -83,8 +94,8 @@ pub struct Button<Msg: Clone>
/// target). The runtime cancels the timer on release, on touch
/// cancel, and on long-press promotion. Default `false` — most
/// buttons fire on tap only.
pub repeating: bool,
pub tooltip: Option<String>,
pub( crate ) repeating: bool,
pub( crate ) tooltip: Option<String>,
}
impl<Msg: Clone> Button<Msg>
@@ -99,7 +110,9 @@ impl<Msg: Clone> Button<Msg>
on_long_press: None,
on_drag_start: None,
variant: ButtonVariant::Primary,
icon_size: theme::HEIGHT,
icon_size: 0.0,
font_size: None,
height: None,
id: None,
focusable: true,
cursor: None,
@@ -134,7 +147,9 @@ impl<Msg: Clone> Button<Msg>
on_long_press: None,
on_drag_start: None,
variant: ButtonVariant::Tertiary,
icon_size: theme::HEIGHT,
icon_size: 0.0,
font_size: None,
height: None,
id: None,
focusable: true,
cursor: None,
@@ -222,6 +237,52 @@ impl<Msg: Clone> Button<Msg>
self
}
/// Set the label font size for text buttons. Accepts logical `f32`
/// pixels or any [`Length`] (e.g. `Length::vmin( 2.2 ).clamp( 14.0,
/// 22.0 )` to scale with the surface). No-op for icon buttons.
pub fn font_size( mut self, size: impl Into<Length> ) -> Self
{
self.font_size = Some( size.into() );
self
}
/// Set the button height for text buttons. Accepts logical `f32` pixels
/// or any [`Length`] (e.g. `Length::vmin( 7.0 ).clamp( 40.0, 64.0 )` to
/// scale the box with the surface). No-op for icon buttons, which are
/// sized by [`Self::icon_size`].
pub fn height( mut self, h: impl Into<Length> ) -> Self
{
self.height = Some( h.into() );
self
}
/// Resolve the label font size against the canvas viewport, matching how
/// [`text`](crate::text) sizes its glyphs. An explicit override bypasses
/// the mode; the default follows the process [`crate::WidgetScaling`].
fn label_font_size( &self, canvas: &Canvas ) -> f32
{
self.font_size
.map( |l| l.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) )
.unwrap_or_else( || canvas.font_px( theme::FONT_SIZE ) )
}
/// Resolve the button height against the physical layout viewport (like
/// all geometry). An explicit override bypasses the mode; the default
/// follows the process [`crate::WidgetScaling`].
fn resolved_height( &self, canvas: &Canvas ) -> f32
{
self.height
.map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) )
.unwrap_or_else( || canvas.geom_px( theme::HEIGHT ) )
}
/// Resolve the icon-button size: a positive [`Self::icon_size`] pins it,
/// the `0.0` sentinel follows the widget-scaling mode.
fn resolved_icon_size( &self, canvas: &Canvas ) -> f32
{
if self.icon_size > 0.0 { self.icon_size } else { canvas.geom_px( theme::HEIGHT ) }
}
/// Assign a stable identifier for focus management.
pub fn id( mut self, id: WidgetId ) -> Self
{
@@ -268,13 +329,13 @@ impl<Msg: Clone> Button<Msg>
{
ButtonContent::Text( label ) =>
{
let text_w = canvas.measure_text( label, theme::FONT_SIZE );
let w = (text_w + theme::PAD_H * 2.0).min( max_width );
( w, theme::HEIGHT )
let text_w = canvas.measure_text( label, self.label_font_size( canvas ) );
let w = (text_w + canvas.geom_px( theme::PAD_H ) * 2.0).min( max_width );
( w, self.resolved_height( canvas ) )
}
ButtonContent::Icon { .. } =>
{
let s = self.icon_size.min( max_width );
let s = self.resolved_icon_size( canvas ).min( max_width );
( s, s )
}
}
@@ -302,7 +363,8 @@ impl<Msg: Clone> Button<Msg>
fn draw_text_button( &self, canvas: &mut Canvas, rect: Rect, focused: bool, label: &str )
{
let is_disabled = self.on_press.is_none();
let text_y = rect.y + (rect.height + theme::FONT_SIZE) / 2.0 - 2.0;
let fs = self.label_font_size( canvas );
let text_y = rect.y + (rect.height + fs) / 2.0 - 2.0;
match self.variant
{
@@ -326,12 +388,12 @@ impl<Msg: Clone> Button<Msg>
theme::RADIUS + theme::FOCUS_W + 2.0,
);
}
let text_w = canvas.measure_text( label, theme::FONT_SIZE );
let text_w = canvas.measure_text( label, fs );
canvas.draw_text(
label,
rect.x + (rect.width - text_w) / 2.0,
text_y,
theme::FONT_SIZE,
fs,
text_c,
);
}
@@ -352,12 +414,12 @@ impl<Msg: Clone> Button<Msg>
theme::RADIUS + theme::FOCUS_W + 2.0,
);
}
let text_w = canvas.measure_text( label, theme::FONT_SIZE );
let text_w = canvas.measure_text( label, fs );
canvas.draw_text(
label,
rect.x + (rect.width - text_w) / 2.0,
text_y,
theme::FONT_SIZE,
fs,
text_c,
);
}
@@ -369,12 +431,12 @@ impl<Msg: Clone> Button<Msg>
let ring = rect.expand( 2.0 );
canvas.stroke_rect( ring, theme::focus_color(), theme::FOCUS_W, theme::RADIUS );
}
let text_w = canvas.measure_text( label, theme::FONT_SIZE );
let text_w = canvas.measure_text( label, fs );
canvas.draw_text(
label,
rect.x + (rect.width - text_w) / 2.0,
text_y,
theme::FONT_SIZE,
fs,
text_c,
);
}
@@ -446,6 +508,8 @@ impl<Msg: Clone> Button<Msg>
on_drag_start: self.on_drag_start.map( |m| ( *f )( m ) ),
variant: self.variant,
icon_size: self.icon_size,
font_size: self.font_size,
height: self.height,
id: self.id,
focusable: self.focusable,
cursor: self.cursor,

View File

@@ -99,3 +99,54 @@ fn repeating_builder_sets_flag()
let b = b.repeating( false );
assert!( !b.repeating );
}
#[ test ]
fn font_size_none_by_default_follows_widget_scaling()
{
let _g = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() );
let b = Button::<()>::new( "ok".into() );
let canvas = Canvas::new( 400, 800 );
assert!( b.font_size.is_none() );
// The default routes through the widget-scaling mode, not a raw constant.
assert_eq!( b.label_font_size( &canvas ), canvas.font_px( theme::FONT_SIZE ) );
}
#[ test ]
fn font_size_builder_resolves_length_against_viewport()
{
// vmin on a 400×800 surface → 10 % of the smaller side (400) = 40 px.
let b = Button::<()>::new( "ok".into() ).font_size( Length::vmin( 10.0 ) );
let canvas = Canvas::new( 400, 800 );
assert_eq!( b.label_font_size( &canvas ), 40.0 );
}
#[ test ]
fn font_size_survives_map_msg()
{
let f: super::super::MapFn<i32, ()> = std::sync::Arc::new( |_| () );
let b: Button<()> = Button::<i32>::new( "ok".into() )
.font_size( Length::px( 21.0 ) )
.map_msg( &f );
assert_eq!( b.font_size, Some( Length::px( 21.0 ) ) );
}
#[ test ]
fn height_none_by_default_follows_widget_scaling()
{
let _g = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() );
let b = Button::<()>::new( "ok".into() );
let canvas = Canvas::new( 400, 800 );
assert!( b.height.is_none() );
// The default routes through the widget-scaling mode, not a raw constant.
assert_eq!( b.preferred_size( 1000.0, &canvas ).1, canvas.geom_px( theme::HEIGHT ) );
}
#[ test ]
fn height_builder_scales_button_box_with_surface()
{
// vmin height on a 400×800 surface → 10 % of the smaller side (400) = 40 px,
// resolved in physical layout space (not divided by dpi_scale).
let b = Button::<()>::new( "ok".into() ).height( Length::vmin( 10.0 ) );
let canvas = Canvas::new( 400, 800 );
assert_eq!( b.preferred_size( 1000.0, &canvas ).1, 40.0 );
}

166
src/widget/carousel/mod.rs Normal file
View File

@@ -0,0 +1,166 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Horizontal carousel: each child occupies `focused_width_frac` of the
//! viewport width, neighbours peek out to the sides. The carousel itself
//! is a pure layout primitive — the caller owns the scroll offset and
//! drives drag / inertia / snap externally. This keeps the widget side
//! stateless and lets the host compositor reuse its existing touch
//! pipeline.
//!
//! ```rust,no_run
//! # use ltk::{ carousel, container, spacer, Element };
//! # #[ derive( Clone ) ] enum Msg { Select( usize ) }
//! # fn _ex( offset: f32 ) -> Element<Msg> {
//! carousel()
//! .focused_width_frac( 0.8 )
//! .gap( 16.0 )
//! .offset( offset )
//! .push( container( spacer() ) )
//! .push( container( spacer() ) )
//! .into()
//! # }
//! ```
use crate::render::Canvas;
use crate::types::{ Rect, WidgetId };
use crate::widget::Element;
#[cfg(test)]
mod tests;
pub struct Carousel<Msg: Clone>
{
/// Child widgets in display order, left-to-right.
pub( crate ) children: Vec<Element<Msg>>,
/// Optional stable identifier — used by the host as the key for its
/// drag / inertia / snap state.
pub( crate ) id: Option<WidgetId>,
/// Each child's width as a fraction of the viewport width. 0.8 leaves
/// 10% on each side for the neighbours to peek out.
pub( crate ) focused_width_frac: f32,
/// Horizontal gap between adjacent children, in logical pixels.
pub( crate ) gap: f32,
/// Logical-pixel offset applied to every child. Positive values shift
/// children to the right (revealing later tiles). The host clamps,
/// snaps and animates this value.
pub( crate ) offset: f32,
}
impl<Msg: Clone> Carousel<Msg>
{
pub fn push( mut self, child: impl Into<Element<Msg>> ) -> Self
{
self.children.push( child.into() );
self
}
pub fn id( mut self, id: WidgetId ) -> Self
{
self.id = Some( id );
self
}
pub fn focused_width_frac( mut self, f: f32 ) -> Self
{
self.focused_width_frac = f.clamp( 0.05, 1.0 );
self
}
pub fn gap( mut self, g: f32 ) -> Self
{
self.gap = g.max( 0.0 );
self
}
pub fn offset( mut self, o: f32 ) -> Self
{
self.offset = o;
self
}
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 )
{
if self.children.is_empty() { return ( max_width, 0.0 ); }
let child_w = ( max_width * self.focused_width_frac ).max( 1.0 );
let max_h = self.children.iter()
.map( |c| c.preferred_size( child_w, canvas ).1 )
.fold( 0.0_f32, f32::max );
( max_width, max_h )
}
/// Snap-target offset that centres `idx` in the viewport. Positive
/// values shift the carousel content right — i.e. the negation of
/// the natural "scroll x" so callers can pass it straight back into
/// [`offset()`](Self::offset).
pub fn snap_offset( &self, viewport_w: f32, idx: usize ) -> f32
{
if self.children.is_empty() { return 0.0; }
let child_w = ( viewport_w * self.focused_width_frac ).max( 1.0 );
let stride = child_w + self.gap;
-( idx as f32 ) * stride
}
/// Index of the child whose centre is closest to the viewport centre,
/// given the current `offset`. Used by the host to decide which tile a
/// release should snap to and to compute the keyboard-navigation target.
pub fn focused_index( &self, viewport_w: f32 ) -> usize
{
if self.children.is_empty() { return 0; }
let child_w = ( viewport_w * self.focused_width_frac ).max( 1.0 );
let stride = child_w + self.gap;
let raw = -self.offset / stride;
raw.round().clamp( 0.0, ( self.children.len() - 1 ) as f32 ) as usize
}
pub fn layout( &self, rect: Rect, _canvas: &Canvas ) -> Vec<( Rect, usize )>
{
if self.children.is_empty() { return Vec::new(); }
let child_w = ( rect.width * self.focused_width_frac ).max( 1.0 );
let base_x = rect.x + ( rect.width - child_w ) / 2.0 + self.offset;
let stride = child_w + self.gap;
self.children.iter().enumerate().map( |( i, _ )|
{
let x = base_x + ( i as f32 ) * stride;
( Rect { x, y: rect.y, width: child_w, height: rect.height }, i )
}).collect()
}
pub fn draw( &self ) {}
pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> Carousel<U>
where
U: Clone + 'static,
Msg: 'static,
{
Carousel
{
children: self.children.into_iter().map( |c| c.map_arc( f ) ).collect(),
id: self.id,
focused_width_frac: self.focused_width_frac,
gap: self.gap,
offset: self.offset,
}
}
}
impl<Msg: Clone + 'static> From<Carousel<Msg>> for Element<Msg>
{
fn from( c: Carousel<Msg> ) -> Self
{
Element::Carousel( c )
}
}
pub fn carousel<Msg: Clone>() -> Carousel<Msg>
{
Carousel
{
children: Vec::new(),
id: None,
focused_width_frac: 0.8,
gap: 16.0,
offset: 0.0,
}
}

View File

@@ -0,0 +1,112 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use super::*;
use crate::layout::spacer::spacer;
use crate::render::Canvas;
fn canvas() -> Canvas { Canvas::new( 1, 1 ) }
fn three_spacer_carousel() -> Carousel<()>
{
carousel()
.focused_width_frac( 0.8 )
.gap( 0.0 )
.push( spacer() )
.push( spacer() )
.push( spacer() )
}
#[test]
fn empty_layout_is_empty()
{
let c: Carousel<()> = carousel();
let rect = Rect { x: 0.0, y: 0.0, width: 100.0, height: 100.0 };
assert!( c.layout( rect, &canvas() ).is_empty() );
}
#[test]
fn first_child_centred_at_offset_zero()
{
let c = three_spacer_carousel();
let rect = Rect { x: 0.0, y: 0.0, width: 100.0, height: 200.0 };
let rects = c.layout( rect, &canvas() );
// child_w = 80, base_x = (100 - 80)/2 = 10
assert!( ( rects[0].0.x - 10.0 ).abs() < 0.01 );
assert!( ( rects[0].0.width - 80.0 ).abs() < 0.01 );
}
#[test]
fn children_strided_by_child_width_plus_gap()
{
let c = carousel::<()>()
.focused_width_frac( 0.5 )
.gap( 8.0 )
.push( spacer() )
.push( spacer() );
let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 100.0 };
let rects = c.layout( rect, &canvas() );
// child_w = 100; stride = 108; base_x = (200 - 100)/2 = 50
assert!( ( rects[1].0.x - ( rects[0].0.x + 108.0 ) ).abs() < 0.01 );
}
#[test]
fn snap_offset_centres_target_index()
{
let c = three_spacer_carousel();
// child_w = 80, stride = 80; index 2 → offset -160 to land it centred.
assert!( ( c.snap_offset( 100.0, 2 ) - ( -160.0 ) ).abs() < 0.01 );
}
#[test]
fn focused_index_rounds_to_nearest_tile()
{
let mut c = three_spacer_carousel();
c.offset = -120.0; // halfway between index 1 (-80) and index 2 (-160) → rounds away from 0
assert_eq!( c.focused_index( 100.0 ), 2 );
c.offset = -100.0; // closer to index 1
assert_eq!( c.focused_index( 100.0 ), 1 );
}
#[test]
fn focused_index_clamps_to_valid_range()
{
let mut c = three_spacer_carousel();
c.offset = 500.0;
assert_eq!( c.focused_index( 100.0 ), 0 );
c.offset = -10_000.0;
assert_eq!( c.focused_index( 100.0 ), 2 );
}
#[test]
fn offset_shifts_all_children_horizontally()
{
let c = three_spacer_carousel().offset( -25.0 );
let rect = Rect { x: 0.0, y: 0.0, width: 100.0, height: 200.0 };
let rects = c.layout( rect, &canvas() );
// child_w=80, base_x_at_offset_0 = 10. With offset = -25, base_x = -15.
assert!( ( rects[0].0.x - ( -15.0 ) ).abs() < 0.01 );
// Stride = 80, so each subsequent child is +80 of the previous.
assert!( ( rects[1].0.x - rects[0].0.x - 80.0 ).abs() < 0.01 );
}
#[test]
fn focused_width_frac_is_clamped_to_unit_range()
{
let c: Carousel<()> = carousel().focused_width_frac( 5.0 ).push( spacer() );
assert!( c.focused_width_frac <= 1.0 );
let c2: Carousel<()> = carousel().focused_width_frac( -3.0 ).push( spacer() );
assert!( c2.focused_width_frac > 0.0 );
}
#[test]
fn children_use_full_rect_height()
{
let c = three_spacer_carousel();
let rect = Rect { x: 0.0, y: 0.0, width: 100.0, height: 200.0 };
let rects = c.layout( rect, &canvas() );
for ( r, _ ) in &rects {
assert!( ( r.height - 200.0 ).abs() < 0.01 );
assert!( r.y.abs() < 0.01 );
}
}

View File

@@ -37,13 +37,13 @@ pub struct Checkbox<Msg: Clone>
{
/// Current checked state. Drawn from this field every frame; the
/// runtime never mutates it.
pub checked: bool,
pub( crate ) checked: bool,
/// Message emitted on activation. `None` leaves the checkbox inert.
pub on_toggle: Option<Msg>,
pub( crate ) on_toggle: Option<Msg>,
/// Optional label drawn to the right of the box.
pub label: Option<String>,
pub( crate ) label: Option<String>,
/// Optional stable identifier for focus management.
pub id: Option<WidgetId>,
pub( crate ) id: Option<WidgetId>,
}
impl<Msg: Clone> Checkbox<Msg>
@@ -83,14 +83,15 @@ impl<Msg: Clone> Checkbox<Msg>
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{
let box_size = canvas.geom_px( theme::BOX_SIZE );
let w = if let Some( ref label ) = self.label
{
let text_w = canvas.measure_text( label, theme::FONT_SIZE );
( theme::BOX_SIZE + theme::GAP + text_w ).min( max_width )
let text_w = canvas.measure_text( label, canvas.font_px( theme::FONT_SIZE ) );
( box_size + canvas.geom_px( theme::GAP ) + text_w ).min( max_width )
} else {
theme::BOX_SIZE.min( max_width )
box_size.min( max_width )
};
( w, theme::HEIGHT )
( w, canvas.geom_px( theme::HEIGHT ) )
}
/// Focus ring on the box extends `FOCUS_W + 2 + FOCUS_W/2 ≈ 6.5 px` beyond
@@ -102,21 +103,22 @@ impl<Msg: Clone> Checkbox<Msg>
pub fn draw( &self, canvas: &mut Canvas, rect: Rect, focused: bool )
{
let box_y = rect.y + ( rect.height - theme::BOX_SIZE ) / 2.0;
let box_size = canvas.geom_px( theme::BOX_SIZE );
let box_y = rect.y + ( rect.height - box_size ) / 2.0;
let box_rect = Rect
{
x: rect.x,
y: box_y,
width: theme::BOX_SIZE,
height: theme::BOX_SIZE,
width: box_size,
height: box_size,
};
if self.checked
{
canvas.fill_rect( box_rect, theme::box_checked(), theme::RADIUS );
let cx = rect.x + theme::BOX_SIZE / 2.0;
let cy = box_y + theme::BOX_SIZE / 2.0;
let s = theme::BOX_SIZE * 0.3;
let cx = rect.x + box_size / 2.0;
let cy = box_y + box_size / 2.0;
let s = box_size * 0.3;
canvas.draw_line( cx - s, cy, cx - s * 0.3, cy + s * 0.7, theme::check_color(), theme::CHECK_W );
canvas.draw_line( cx - s * 0.3, cy + s * 0.7, cx + s, cy - s * 0.5, theme::check_color(), theme::CHECK_W );
} else {
@@ -131,9 +133,10 @@ impl<Msg: Clone> Checkbox<Msg>
if let Some( ref label ) = self.label
{
let text_x = rect.x + theme::BOX_SIZE + theme::GAP;
let text_y = rect.y + ( rect.height + theme::FONT_SIZE ) / 2.0 - 2.0;
canvas.draw_text( label, text_x, text_y, theme::FONT_SIZE, theme::label_color() );
let fs = canvas.font_px( theme::FONT_SIZE );
let text_x = rect.x + box_size + canvas.geom_px( theme::GAP );
let text_y = rect.y + ( rect.height + fs ) / 2.0 - 2.0;
canvas.draw_text( label, text_x, text_y, fs, theme::label_color() );
}
}

View File

@@ -24,7 +24,7 @@
use std::sync::Arc;
use crate::types::Color;
use crate::types::{ Color, Length };
use crate::layout::column::column;
use crate::layout::row::row;
use crate::layout::spacer::spacer;
@@ -114,12 +114,12 @@ pub fn parse_hex( s: &str ) -> Option<Color>
/// a continuous hue strip.
pub struct ColorPicker<Msg: Clone>
{
pub value: Color,
pub on_change: Option<Arc<dyn Fn( Color ) -> Msg>>,
pub( crate ) value: Color,
pub( crate ) on_change: Option<Arc<dyn Fn( Color ) -> Msg>>,
/// When `true` the alpha slider is shown and the hex input
/// accepts `#RRGGBBAA`. Default: `false` — most "pick a theme
/// colour" flows are opaque.
pub show_alpha: bool,
pub( crate ) show_alpha: bool,
}
impl<Msg: Clone + 'static> ColorPicker<Msg>
@@ -163,7 +163,7 @@ impl<Msg: Clone + 'static> ColorPicker<Msg>
.radius( 12.0 )
.padding( 0.0 )
.into();
let mut preview_row = row::<Msg>().spacing( theme::SPACING ).push(
let mut preview_row = row::<Msg>().spacing( Length::widget( theme::SPACING ) ).push(
container::<Msg>( swatch )
.padding( 0.0 )
.radius( 12.0 ),
@@ -208,7 +208,7 @@ impl<Msg: Clone + 'static> ColorPicker<Msg>
} );
}
column::<Msg>().spacing( 4.0 )
.push( text( label ).size( theme::LABEL_FS ).color( theme::text_muted() ) )
.push( text( label ).size( Length::widget( theme::LABEL_FS ) ).color( theme::text_muted() ) )
.push( s )
.into()
};
@@ -218,7 +218,7 @@ impl<Msg: Clone + 'static> ColorPicker<Msg>
let b_build: Arc<dyn Fn( f32 ) -> Color> = Arc::new( move |v| Color::rgba( value.r, value.g, v, value.a ) );
let a_build: Arc<dyn Fn( f32 ) -> Color> = Arc::new( move |v| Color::rgba( value.r, value.g, value.b, v ) );
let mut sliders = column::<Msg>().spacing( theme::SPACING )
let mut sliders = column::<Msg>().spacing( Length::widget( theme::SPACING ) )
.push( chan_slider( "R", value.r, r_build ) )
.push( chan_slider( "G", value.g, g_build ) )
.push( chan_slider( "B", value.b, b_build ) );
@@ -267,18 +267,18 @@ impl<Msg: Clone + 'static> ColorPicker<Msg>
} );
}
let hue_row: Element<Msg> = column::<Msg>().spacing( 4.0 )
.push( text( "Hue" ).size( theme::LABEL_FS ).color( theme::text_muted() ) )
.push( text( "Hue" ).size( Length::widget( theme::LABEL_FS ) ).color( theme::text_muted() ) )
.push( hue_slider )
.into();
let body = column::<Msg>().spacing( theme::SPACING * 2.0 )
let body = column::<Msg>().spacing( Length::widget( theme::SPACING * 2.0 ) )
.push( preview_row )
.push( sliders )
.push( hue_row );
container::<Msg>( body )
.background( theme::surface_alt() )
.padding( theme::PADDING )
.padding( Length::widget( theme::PADDING ) )
.radius( theme::RADIUS )
.into()
}

Some files were not shown because too many files have changed in this diff Show More