First commit. Version 0.1.0

This commit is contained in:
2026-05-10 09:58:23 +02:00
parent af105b7f7d
commit bbab5e238d
635 changed files with 53627 additions and 175 deletions

737
src/app.rs Normal file
View File

@@ -0,0 +1,737 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
pub use smithay_client_toolkit::seat::keyboard::Keysym;
pub use calloop::channel::Sender as ChannelSender;
use crate::widget::Element;
/// Wayland shell mode for the application surface.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShellMode
{
/// Normal application window using xdg-shell protocol.
/// This is the default for regular applications.
Window,
/// Layer-shell surface at the specified layer.
/// Used for shell components like panels, backgrounds, overlays.
Layer( Layer ),
}
/// Layer-shell layer position.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Layer
{
/// Below normal windows (wallpapers, desktop backgrounds).
Background,
/// Below normal windows but above background.
Bottom,
/// Above normal windows (panels, docks).
Top,
/// Above everything (notifications, on-screen displays).
Overlay,
}
impl Layer
{
/// Convert to smithay's wlr-layer Layer type.
pub( crate ) fn to_wlr_layer( self ) -> smithay_client_toolkit::shell::wlr_layer::Layer
{
use smithay_client_toolkit::shell::wlr_layer::Layer as WlrLayer;
match self
{
Layer::Background => WlrLayer::Background,
Layer::Bottom => WlrLayer::Bottom,
Layer::Top => WlrLayer::Top,
Layer::Overlay => WlrLayer::Overlay,
}
}
}
/// Layer-shell anchor edges.
/// Determines which screen edges the surface is attached to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Anchor
{
pub top: bool,
pub bottom: bool,
pub left: bool,
pub right: bool,
}
impl Anchor
{
/// Anchor to all edges (fill screen).
pub const ALL: Self = Self { top: true, bottom: true, left: true, right: true };
/// Anchor to top edge only (horizontal bar at top).
pub const TOP: Self = Self { top: true, bottom: false, left: true, right: true };
/// Anchor to bottom edge only (horizontal bar at bottom).
pub const BOTTOM: Self = Self { top: false, bottom: true, left: true, right: true };
/// Anchor to left edge only (vertical bar at left).
pub const LEFT: Self = Self { top: true, bottom: true, left: true, right: false };
/// Anchor to right edge only (vertical bar at right).
pub const RIGHT: Self = Self { top: true, bottom: true, left: false, right: true };
/// Convert to smithay's Anchor bitflags.
pub( crate ) fn to_wlr_anchor( self ) -> smithay_client_toolkit::shell::wlr_layer::Anchor
{
use smithay_client_toolkit::shell::wlr_layer::Anchor as WlrAnchor;
let mut anchor = WlrAnchor::empty();
if self.top { anchor |= WlrAnchor::TOP; }
if self.bottom { anchor |= WlrAnchor::BOTTOM; }
if self.left { anchor |= WlrAnchor::LEFT; }
if self.right { anchor |= WlrAnchor::RIGHT; }
anchor
}
}
/// Stable identifier for an overlay surface. The runtime uses it to diff the
/// overlay list between frames: if the same `OverlayId` is returned from
/// [`App::overlays`] on consecutive frames the underlying Wayland surface
/// and its internal state are kept; if it disappears the surface is destroyed.
#[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord )]
pub struct OverlayId( pub u32 );
/// 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 )]
pub enum SurfaceTarget
{
/// The application's main surface (the one returned by [`App::view`]).
Main,
/// The overlay with the given stable [`OverlayId`].
Overlay( OverlayId ),
}
/// Which surfaces a given [`App::update`] mutation can affect.
///
/// Returned by [`App::invalidate_after`] (default [`InvalidationScope::All`])
/// to let the runtime skip redraws on surfaces whose contents could not
/// possibly have changed by the message in question. On a shell with many
/// overlays most messages only touch one of them, so the savings are large.
#[derive( Debug, Clone )]
pub enum InvalidationScope
{
/// Treat every surface as potentially affected (safe default).
All,
/// Only the listed surfaces may have changed; others can be skipped this
/// iteration. An empty list means "nothing visible to redraw" — useful
/// for messages that only touch internal bookkeeping.
Only( Vec<SurfaceTarget> ),
}
impl InvalidationScope
{
/// Combine two scopes. `All` absorbs any other scope; two `Only` scopes
/// merge into a deduped list of targets. Used by the run loop to fold the
/// per-message scopes from a batch of pending messages into a single
/// decision before applying it.
pub fn union( self, other: Self ) -> Self
{
match ( self, other )
{
( Self::All, _ ) | ( _, Self::All ) => Self::All,
( Self::Only( mut a ), Self::Only( b ) ) =>
{
for t in b
{
if !a.contains( &t ) { a.push( t ); }
}
Self::Only( a )
}
}
}
}
/// Description of an additional layer-shell surface rendered on top of the
/// main application surface.
///
/// Apps return a list of these from [`App::overlays`] each frame. The runtime
/// creates one Wayland layer-shell surface per active overlay, renders its
/// [`view`](Self::view) to its own canvas, and dispatches input to it
/// independently of the main surface. Overlays share the same message type as
/// the main app — messages emitted by widgets inside an overlay are delivered
/// to [`App::update`] exactly like messages from the main view.
///
/// Overlays are useful for building shells whose main surface sits behind
/// regular app windows (e.g. a homescreen on [`Layer::Background`]) while
/// still exposing on-demand panels above everything (launcher, quick
/// settings, power menu…).
pub struct OverlaySpec<Message: Clone>
{
/// Stable identifier, used to diff overlays between frames.
pub id: OverlayId,
/// Wayland layer for this overlay. Typically [`Layer::Overlay`] or
/// [`Layer::Top`].
pub layer: Layer,
/// 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 ),
/// Exclusive zone in pixels reserved from the anchored edge.
/// `-1` requests focus without reserving space, `0` is the default for
/// transient overlays that should not push other surfaces around.
pub exclusive_zone: i32,
/// When `true`, the compositor sends keyboard events to this overlay
/// without requiring a click first.
pub keyboard_exclusive: bool,
/// Interactive input region as a list of rects (logical pixels). Only
/// these areas receive pointer/touch input; the rest passes through.
/// `None` means the full surface receives input.
pub input_region: Option<Vec<crate::types::Rect>>,
/// Widget tree for this overlay.
pub view: Element<Message>,
/// Message sent when the overlay should be dismissed. The runtime
/// fires it in three situations:
///
/// 1. The compositor sends `xdg_popup.popup_done` (xdg-popup mode)
/// — typically when the user clicks outside the popup with the
/// grab fully active.
/// 2. The user presses pointer / touch on the main surface while
/// the overlay is still mapped, and the press does not fall on
/// the trigger rect identified by [`Self::anchor_widget_id`].
/// This covers compositors (notably Mutter) that route the
/// button to the parent surface instead of breaking the popup
/// grab when the cursor was already over the parent.
/// 3. The user presses Escape while at least one xdg-popup overlay
/// is open.
///
/// The application is expected to flip its `is_open` flag (or
/// equivalent) to `false` in `update()` so the next frame stops
/// returning the spec from [`App::overlays`]. The runtime is
/// idempotent if the message arrives more than once for the same
/// open / close cycle.
pub on_dismiss: Option<Message>,
/// When set, the overlay is rendered as a Wayland **xdg-popup**
/// anchored to the rect of the widget tagged with this
/// [`crate::types::WidgetId`] in the previous frame's layout — the
/// standard mechanism for combo / context-menu / tooltip popups in
/// `xdg-shell` applications. The compositor positions the popup
/// adjacent to the anchor and is allowed to flip it (drop-up
/// instead of drop-down) when there is not enough room.
///
/// When this field is `None`, the overlay is rendered as a
/// [`wlr-layer-shell`](Layer) surface — the path used by shells,
/// panels, lock screens and overlays that need full-surface
/// coverage. In that mode `layer` / `anchor` / `exclusive_zone` /
/// `keyboard_exclusive` carry the placement; for the popup mode
/// they are ignored.
pub anchor_widget_id: Option<crate::types::WidgetId>,
}
/// Trait that application types must implement to integrate with ltk.
pub trait App: 'static
{
/// The message type produced by this application.
type Message: Clone + 'static;
/// Build the widget tree for the current frame.
fn view( &self ) -> Element<Self::Message>;
/// Apply a message to the application state.
fn update( &mut self, msg: Self::Message );
/// Tell the runtime which surfaces *could* have changed visibly as a
/// result of [`update`](Self::update) being called with this message.
///
/// Default is [`InvalidationScope::All`] — every surface is redrawn.
/// A shell that knows, for example, that a `SetVolume` message only
/// affects its quick-settings overlay can return
/// `InvalidationScope::Only( vec![ SurfaceTarget::Overlay( quick_id ) ] )`
/// to skip pointless work on other surfaces.
///
/// Called *before* [`update`](Self::update) (so it sees the message but
/// not the post-update state). Side-effect free: must not mutate `self`.
fn invalidate_after( &self, _msg: &Self::Message ) -> InvalidationScope
{
InvalidationScope::All
}
/// Describe the auxiliary overlay surfaces that should be active this
/// frame. Each entry becomes its own Wayland layer-shell surface
/// independent from the main surface returned by [`view`](Self::view).
///
/// Return an empty `Vec` (the default) to disable multi-surface support —
/// the app then renders only onto its main surface.
///
/// The runtime diffs the list across frames using [`OverlaySpec::id`]:
/// stable IDs are kept alive, new IDs cause a surface to be created, and
/// IDs that disappear cause the surface to be destroyed.
fn overlays( &self ) -> Vec<OverlaySpec<Self::Message>> { Vec::new() }
/// Return any pending messages from external sources (timers, async, etc.).
fn poll_external( &mut self ) -> Vec<Self::Message> { vec![] }
/// 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.
///
/// Default: `true` (always close).
fn on_close_requested( &mut self ) -> bool { true }
/// Called when tapping/clicking outside any widget.
fn on_tap( &mut self ) -> Option<Self::Message> { None }
/// Called on key press when no text input is focused.
fn on_key( &mut self, _keysym: Keysym ) -> Option<Self::Message> { None }
/// Called on key press with modifier state. Override this instead of
/// `on_key` when you need Ctrl/Shift/Alt awareness.
fn on_key_with_modifiers( &mut self, keysym: Keysym, _ctrl: bool, _shift: bool ) -> Option<Self::Message>
{
self.on_key( keysym )
}
/// Called when a sufficient upward swipe gesture is detected.
///
/// Return a message to handle the swipe (e.g., opening an overview).
/// The gesture is recognized when the user drags upward by at least
/// `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.
///
/// 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.
fn on_swipe_progress( &mut self, _progress: f32 ) {}
/// Called when a sufficient downward swipe gesture is detected.
///
/// Return a message to handle the swipe (e.g., toggling quick settings).
/// The gesture is recognized when the user drags downward by at least
/// `swipe_down_threshold()` × screen height and then releases.
fn on_swipe_down( &mut self ) -> Option<Self::Message> { None }
/// Called during a downward 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 downward, reaching
/// 1.0 when the drag distance equals `swipe_down_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 down 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_down_progress( &mut self, _progress: f32 ) {}
/// Fraction of screen height required to trigger [`on_swipe_up`](Self::on_swipe_up).
///
/// Default: `0.6` (60% of screen height). Lower values make the gesture easier
/// to trigger; higher values require more vertical travel.
fn swipe_threshold( &self ) -> f32 { 0.6 }
/// Fraction of screen height required to trigger [`on_swipe_down`](Self::on_swipe_down).
///
/// Default: `0.15` (15% of screen height). Typically lower than `swipe_threshold()`
/// because downward swipes are often initiated from a small UI element like a top bar.
fn swipe_down_threshold( &self ) -> f32 { 0.15 }
/// Top-edge band where a downward swipe may originate, as a fraction of
/// screen height. Presses below this band never fire
/// [`on_swipe_down`](Self::on_swipe_down) nor
/// [`on_swipe_down_progress`](Self::on_swipe_down_progress).
///
/// Default: `1.0` (entire screen — any starting point is accepted).
/// Set to a small value like `0.05` to restrict the gesture to the top
/// edge, matching the usual system-panel pull-down UX.
fn swipe_down_edge( &self ) -> f32 { 1.0 }
/// Called when a sufficient leftward swipe gesture is detected.
///
/// Return a message to handle the swipe (e.g., paging to the next
/// homescreen). Fires on release when the user dragged left by at least
/// `swipe_horizontal_threshold()` × screen width. Unlike vertical swipes,
/// horizontal swipes survive starting the press on an interactive widget:
/// once the drag distance crosses an 8 px threshold the press is promoted
/// to a horizontal drag and no tap will fire at release.
fn on_swipe_left( &mut self ) -> Option<Self::Message> { None }
/// Called when a sufficient rightward swipe gesture is detected.
/// Mirror of [`on_swipe_left`](Self::on_swipe_left) for the other direction.
fn on_swipe_right( &mut self ) -> Option<Self::Message> { None }
/// Called during a horizontal swipe with signed progress. Negative values
/// mean the finger has moved left, positive values mean right. The value
/// is `dx / (threshold * width)`, so ±1.0 marks the commit threshold;
/// it is **not** clamped so follow-the-finger panels can keep tracking
/// past the threshold. When the user releases without completing the
/// gesture this method is called once more with `0.0` to signal
/// cancellation.
fn on_swipe_horizontal_progress( &mut self, _progress: f32 ) {}
/// Fraction of screen width required to commit [`App::on_swipe_left`] /
/// [`App::on_swipe_right`]. Default: `0.5` (half the screen). A shell that
/// wants easier paging can lower this; one that wants to avoid
/// accidental pages can raise it.
fn swipe_horizontal_threshold( &self ) -> f32 { 0.5 }
/// Duration a press must remain stationary (within tolerance) before the
/// widget's long-press message fires and the gesture transitions into
/// drag mode. Default: `500 ms`.
fn long_press_duration( &self ) -> std::time::Duration
{
std::time::Duration::from_millis( 500 )
}
/// Called on every pointer motion once a long-press has fired and the
/// gesture has entered drag mode. `x`, `y` are in physical pixels of the
/// surface that owned the original press.
fn on_drag_move( &mut self, _x: f32, _y: f32 ) {}
/// Called on release once a long-press has fired. Return a message to
/// handle the drop (e.g., commit the drop target). `x`, `y` are in
/// physical pixels of the surface that owned the original press. The
/// regular tap / swipe handling is suppressed for this gesture.
fn on_drop( &mut self, _x: f32, _y: f32 ) -> Option<Self::Message> { None }
/// Called when a text-input widget gains or loses focus.
fn on_text_input_focused( &mut self, _active: bool ) {}
/// Return `Some(id)` once to focus the widget with that [`WidgetId`](crate::types::WidgetId).
/// The app must return `None` after the first call.
fn take_focus_request( &mut self ) -> Option<crate::types::WidgetId> { None }
/// Called on every pointer motion (mouse move, button press, button
/// release). `x`, `y` are in physical pixels of the main surface,
/// the same coordinate space as widget rects. Default: no-op.
///
/// Useful for embedding components that need to know the live
/// pointer position without going through a full
/// [`crate::widget::external::ExternalSource`] / `WidgetHandlers`
/// dispatch path — for example forwarding clicks into an embedded
/// WPE WebView.
fn on_pointer_move( &mut self, _x: f32, _y: f32 ) {}
/// Called on a wheel / touchpad scroll event whose position does
/// not fall inside any LTK [`scroll`](crate::scroll) viewport. `x`,
/// `y` are in physical pixels (same coordinate space as
/// [`Self::on_pointer_move`]); `dx`, `dy` are scroll deltas in the
/// raw axis units the compositor delivered (`AxisSource::Wheel`
/// gives ~10 px ticks, touchpads give continuous values). Default:
/// no-op.
///
/// Useful for embeddings that take over scrolling for their own
/// content — for example forwarding the event to a WPE view that
/// owns a scrollable web page.
fn on_pointer_axis( &mut self, _x: f32, _y: f32, _dx: f32, _dy: f32 ) {}
/// Called on every left-button mouse press / release before the
/// regular gesture machine fires. `pressed = true` for press,
/// `false` for release. `(x, y)` are in physical pixels (same
/// coordinate space as [`Self::on_pointer_move`]). Default: no-op.
///
/// Lets embeddings see the raw button transitions without going
/// through LTK's tap/long-press/drag classification — needed for
/// forwarding clicks AND drags to an embedded view, since the tap
/// classifier collapses press+release into a single event.
fn on_pointer_button( &mut self, _x: f32, _y: f32, _pressed: bool ) {}
/// Called on every keyboard event (press *and* release) before the
/// regular focus-aware dispatch ([`Self::on_key`] /
/// [`Self::on_key_with_modifiers`]) runs. Default: no-op.
///
/// `keycode` is the raw hardware scancode straight from the
/// compositor (`wl_keyboard.key`'s `key` argument); `keysym` is
/// the xkb-translated keysym the user effectively pressed. Most
/// consumers only care about `keysym`, but embeddings that
/// forward into an inner window system (a WPE web view, an X11
/// emulator, …) typically need both — the inner system does its
/// own keycode → keysym translation for layout-aware shortcuts.
///
/// This bypass exists for those embeddings: it fires *in addition*
/// to the normal callbacks; consume or ignore at will.
fn on_raw_key( &mut self, _keysym: Keysym, _keycode: u32, _pressed: bool, _ctrl: bool, _shift: bool ) {}
/// Called when the surface is (re-)configured with a new size.
///
/// `width` and `height` are in **physical pixels** (the Wayland surface
/// size multiplied by the current buffer scale). They therefore match the
/// coordinate space used for layout and the pointer/touch callbacks
/// (`on_drag_move`, `on_drop`, widget hit-testing). Also fired when the
/// buffer scale changes, so apps can refresh any state keyed off the old
/// physical dimensions.
fn on_resize( &mut self, _width: u32, _height: 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.
fn set_channel_sender( &mut self, _sender: ChannelSender<Self::Message> ) {}
/// How often the event loop should call [`poll_external`](Self::poll_external) for
/// periodic work such as clock ticks. Return `None` (the default) to disable the
/// timer — `poll_external` will still be called after every Wayland event.
fn poll_interval( &self ) -> Option<std::time::Duration> { None }
/// Delay before the runtime starts repeating a held-down key.
///
/// `Some(d)` overrides whatever the compositor advertises through
/// `wl_keyboard.repeat_info`; `None` (the default) means "use the
/// compositor's setting, or fall back to 500 ms when the compositor
/// did not provide one".
///
/// Returning `Some(Duration::ZERO)` effectively disables the wait
/// before the first repeat — useful only for diagnostic builds.
fn key_repeat_delay( &self ) -> Option<std::time::Duration> { None }
/// Interval between successive synthetic key events while a key is
/// held down past the initial [`Self::key_repeat_delay`].
///
/// `Some(d)` overrides the compositor's `wl_keyboard.repeat_info`
/// rate; `None` (the default) means "use the compositor's setting,
/// or fall back to 33 ms (~30 Hz) when the compositor did not
/// provide one". Pass `Some(Duration::ZERO)` to disable repeats
/// entirely (or override the per-key gate via
/// [`Self::key_repeats`]).
fn key_repeat_interval( &self ) -> Option<std::time::Duration> { None }
/// Override the pointer cursor shape globally. When `Some(shape)`,
/// the runtime sends that shape to the compositor regardless of
/// which widget the pointer is over — useful for "the app is busy"
/// states (return [`crate::CursorShape::Wait`]) or while the app
/// is doing a long synchronous operation. Returning `None` (the
/// default) lets per-widget defaults take effect.
///
/// The runtime calls this every frame, so flipping a `loading`
/// boolean in [`Self::update`] propagates to the cursor on the
/// next iteration without any extra wiring.
fn cursor_override( &self ) -> Option<crate::types::CursorShape> { None }
/// Decide whether a given key participates in held-key repeat.
///
/// Default: every non-modifier key repeats *except* `Escape`, `Tab`
/// and `ISO_Left_Tab` — those drive one-shot UI semantics
/// (dismiss / focus cycle) where a held key would multiply the
/// effect in surprising ways. Override to widen or narrow the gate
/// (a chess clock app, for example, might want even Tab to
/// repeat).
fn key_repeats( &self, keysym: Keysym ) -> bool
{
!matches!( keysym,
Keysym::Escape | Keysym::Tab | Keysym::ISO_Left_Tab,
)
}
/// Return `true` while a frame-by-frame animation is running.
/// 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
/// Background color for the canvas. Override to make the surface
/// transparent or to deviate from the theme. Default: the active
/// theme's `bg` token, so the window matches the rest of the
/// shell out of the box.
fn background_color( &self ) -> crate::types::Color
{
crate::theme::palette().bg
}
/// Return the interactive input region as a list of rects (logical pixels).
/// Only these areas receive pointer/touch input; the rest passes through.
/// Return `None` (default) to receive input everywhere.
fn input_region( &self ) -> Option<Vec<crate::types::Rect>> { None }
/// Return `Some(( title, app_id ))` to force an XDG toplevel window instead of
/// layer-shell overlay. The compositor will display the title in the title bar
/// and use the app_id for taskbar/icon matching. Return `None` (default) to
/// use layer-shell when available.
///
/// **Deprecated**: Use [`shell_mode`](Self::shell_mode) instead.
fn window_config( &self ) -> Option<( &str, &str )> { None }
/// Specify the Wayland shell mode for this application.
///
/// - [`ShellMode::Window`]: Normal application window (xdg-shell). **Default.**
/// - [`ShellMode::Layer`]: System component at a specific layer (layer-shell).
///
/// For regular applications, use the default `Window` mode.
/// For shell components (panels, backgrounds, overlays), use `Layer`.
fn shell_mode( &self ) -> ShellMode { ShellMode::Window }
/// Suggest an initial size for an xdg-shell window in logical pixels.
///
/// Returning `Some(( w, h ))` makes ltk call both
/// `xdg_toplevel.set_min_size( w, h )` and
/// `xdg_toplevel.set_max_size( w, h )` before the first commit, which
/// most compositors honour as an exact size for the configure they
/// send back. The window is still resizable from the application's
/// point of view — if the compositor sends a different configure, the
/// runtime adopts that size on the next frame.
///
/// Only applies to [`ShellMode::Window`] surfaces and to the
/// xdg-shell fallback path used when `wlr-layer-shell` is missing.
/// Ignored for layer-shell surfaces, which use [`Self::layer_size`]
/// instead.
///
/// **Default**: `None` (let the compositor pick).
fn window_size_hint( &self ) -> Option<( u32, u32 )> { None }
/// Request fullscreen on the toplevel before its first commit.
///
/// When `true`, ltk calls `xdg_toplevel.set_fullscreen( None )` so
/// the compositor picks an output and maps the surface fullscreen
/// from the start (no flicker through a windowed configure first).
/// The compositor's own decorations are suppressed for fullscreen
/// surfaces; the built-in titlebar from [`Self::window_config`] is
/// still painted by ltk on top of the surface unless the app opts
/// out.
///
/// Only applies to [`ShellMode::Window`] surfaces and to the
/// xdg-shell fallback path used when `wlr-layer-shell` is missing.
///
/// **Default**: `false`.
fn start_fullscreen( &self ) -> bool { false }
/// Specify the exclusive zone for layer-shell surfaces.
///
/// The exclusive zone reserves screen space for this surface.
/// For example, a top panel with height 50 should return `50` to prevent
/// other windows from overlapping it.
///
/// - `> 0`: Reserve this many pixels from the anchored edge
/// - `0`: No exclusive zone (default for overlays)
/// - `-1`: Don't reserve space but request focus
///
/// Only applies to [`ShellMode::Layer`] surfaces. Ignored for windows.
fn exclusive_zone( &self ) -> i32 { -1 }
/// Specify which screen edges the layer-shell surface is anchored to.
///
/// Anchoring determines the position and size of the surface:
/// - [`Anchor::TOP`]: Top bar (anchored to top, left, right)
/// - [`Anchor::BOTTOM`]: Bottom bar (anchored to bottom, left, right)
/// - [`Anchor::LEFT`]: Left sidebar (anchored to left, top, bottom)
/// - [`Anchor::RIGHT`]: Right sidebar (anchored to right, top, bottom)
/// - [`Anchor::ALL`]: Full screen (anchored to all edges)
///
/// **Default**: `Anchor::ALL` (full screen)
///
/// Only applies to [`ShellMode::Layer`] surfaces. Ignored for windows.
fn layer_anchor( &self ) -> Anchor { Anchor::ALL }
/// Specify the desired size for layer-shell surfaces.
///
/// Returns `(width, height)` where:
/// - `0` means "fill available space in that dimension"
/// - `> 0` means "use this exact size in pixels"
///
/// **Examples:**
/// - Top bar: `(0, 50)` - full width, 50px height
/// - Bottom bar: `(0, 40)` - full width, 40px height
/// - Side panel: `(300, 0)` - 300px width, full height
/// - Full screen: `(0, 0)` - fill everything (default)
///
/// **Default**: `(0, 0)` (fill all available space)
///
/// Only applies to [`ShellMode::Layer`] surfaces. Ignored for windows.
fn layer_size( &self ) -> ( u32, u32 ) { ( 0, 0 ) }
/// Request exclusive keyboard focus for this layer-shell surface.
///
/// When `true`, the compositor sends keyboard events to this surface
/// without requiring a pointer click first. Useful for greeters,
/// lock screens, and other surfaces that must capture input immediately.
///
/// **Default**: `false` (on-demand keyboard interactivity)
///
/// Only applies to [`ShellMode::Layer`] surfaces. Ignored for windows.
fn keyboard_exclusive( &self ) -> bool { false }
}
/// Run the application. Blocks until the window is closed.
///
/// Panics on init failure (no Wayland compositor, missing protocol, etc.).
/// Embedders that need to recover gracefully — e.g. fall back to a
/// software TTY UI when no compositor is reachable — should call
/// [`try_run`] instead and match on the returned [`RunError`].
pub fn run<A: App>( app: A )
{
crate::event_loop::run( app );
}
/// Run the application, returning a typed error on init failure.
///
/// Same as [`run`] but recoverable: every fatal init step (Wayland
/// connection, registry, calloop event loop, `wl_compositor` / `wl_shm`
/// / `xdg_wm_base` bindings) is converted into a [`RunError`] variant
/// the caller can match on. Once init succeeds the function blocks
/// until the surface is closed and returns `Ok(())`. Errors from the
/// dispatch loop itself (already on screen) still panic — they are
/// non-recoverable since the surface state machine cannot be unwound
/// cleanly from this entry point.
///
/// Use this when:
///
/// * the application needs a graceful fallback path on systems without
/// a Wayland session (CI runners, minimal containers, X11-only
/// environments where ltk has no backend),
/// * an embedder wants to log the specific protocol that's missing
/// instead of panicking with a generic stack trace,
/// * the caller wants to retry / wait for a compositor to come up
/// instead of aborting.
///
/// The standard `run( app )` remains the simpler entry point for
/// applications that always run on a known-good Wayland session.
///
/// ```rust,no_run
/// # use ltk::{ button, App, Element, RunError };
/// # #[ derive( Clone ) ] enum Msg {}
/// # struct MyApp;
/// # impl App for MyApp {
/// # type Message = Msg;
/// # fn view( &self ) -> Element<Msg> { button( "x" ).into() }
/// # fn update( &mut self, _: Msg ) {}
/// # }
/// match ltk::try_run( MyApp )
/// {
/// Ok( () ) => {}
/// Err( RunError::NoWaylandConnection( _ ) ) =>
/// {
/// eprintln!( "no compositor — falling back to stdio" );
/// // run a CLI fallback…
/// }
/// Err( RunError::MissingProtocol { name, .. } ) =>
/// {
/// eprintln!( "compositor lacks `{name}` — refusing to start" );
/// std::process::exit( 1 );
/// }
/// Err( e ) =>
/// {
/// eprintln!( "ltk init failed: {e}" );
/// std::process::exit( 1 );
/// }
/// }
/// ```
pub fn try_run<A: App>( app: A ) -> Result<(), RunError>
{
crate::event_loop::try_run( app )
}
pub use crate::event_loop::RunError;

485
src/core.rs Normal file
View File

@@ -0,0 +1,485 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Runtime-free UI surface primitives.
//!
//! This module exposes the part of ltk that is useful outside `ltk::run`:
//! widget tree layout, drawing into a `Canvas`, hit-test rect snapshots, and
//! damage tracking. It deliberately contains no Wayland client event loop,
//! layer-shell, xdg-shell, SHM pool, or frame-callback logic.
use std::collections::HashMap;
use std::ffi::c_void;
use std::sync::Arc;
pub use crate::gles_render::{ BorrowedGlesTexture, GlesVersion };
pub use crate::render::Canvas;
pub use crate::widget::{ LaidOutWidget, WidgetHandlers };
use crate::draw::{ self, DrawCtx };
use crate::egl_context::EglOffscreenContext;
use crate::types::{ Color, Point, Rect };
use crate::widget::Element;
/// Options for one runtime-free render pass.
#[ derive( Debug, Clone, Copy ) ]
pub struct RenderOptions
{
/// Physical-pixel bounds to lay out the tree into.
pub bounds: Rect,
/// Background fill. Use [`Color::TRANSPARENT`] for a transparent target.
pub background: Color,
/// Draw red debug rectangles around laid-out interactive widgets.
pub debug_layout: bool,
}
impl RenderOptions
{
/// Render into the full `width × height` canvas with a transparent
/// background and debug layout disabled.
pub fn full_canvas( width: u32, height: u32 ) -> Self
{
Self
{
bounds: Rect { x: 0.0, y: 0.0, width: width as f32, height: height as f32 },
background: Color::TRANSPARENT,
debug_layout: false,
}
}
/// Set the background fill for the render pass.
pub fn background( mut self, color: Color ) -> Self
{
self.background = color;
self
}
/// Enable or disable debug layout rectangles.
pub fn debug_layout( mut self, yes: bool ) -> Self
{
self.debug_layout = yes;
self
}
}
/// Result of rendering a tree into a [`UiSurface`].
#[ derive( Debug, Clone ) ]
pub struct RenderOutput
{
/// Damage rects in physical pixels. Empty means "damage the full bounds".
pub damage_rects: Vec<Rect>,
/// True when the caller should treat the whole render bounds as dirty.
pub full_redraw: bool,
}
/// A retained rendering target for code that wants ltk widgets without
/// `ltk::run`.
///
/// A compositor can keep one `UiSurface` per decoration or panel, mutate
/// focus/hover/pressed state from its own input routing, then call
/// [`Self::render`] whenever it decides a repaint is needed.
// Field declaration order is load-bearing: Rust drops fields top-to-bottom,
// so `canvas` is dropped before `egl_context`. That matters because
// `Canvas::Gles::Drop` releases textures / FBOs / shader programs through
// glow, which requires the matching GL context to be current — which is what
// `Drop for UiSurface` ensures by calling `make_owned_gles_current()` first.
// If `egl_context` were declared above `canvas`, the EGL context would be
// torn down first and `canvas`'s GL releases would silently leak / corrupt
// state. Do not reorder these two fields without preserving that property.
pub struct UiSurface<Msg: Clone>
{
canvas: Canvas,
egl_context: Option<EglOffscreenContext>,
focused_idx: Option<usize>,
hovered_idx: Option<usize>,
pressed_idx: Option<usize>,
prev_focused: Option<usize>,
prev_hovered: Option<usize>,
prev_pressed: Option<usize>,
widget_rects: Vec<LaidOutWidget<Msg>>,
cursor_state: HashMap<usize, usize>,
selection_anchor: HashMap<usize, usize>,
scroll_offsets: HashMap<usize, f32>,
scroll_canvases: HashMap<usize, Canvas>,
scroll_navigable_items: HashMap<usize, Vec<( usize, f32, f32 )>>,
content_dirty: bool,
}
impl<Msg: Clone> UiSurface<Msg>
{
/// Create a software-backed surface of the given physical size.
///
/// This is intentionally conservative for compositor integrations: an
/// embedder typically already owns an EGL/GLES context, so `new` must
/// not allocate a hidden offscreen context per decoration. Use
/// [`Self::from_gles_context`] when the caller can provide an
/// already-current GL context.
pub fn new( width: u32, height: u32 ) -> Self
{
Self::new_software( width, height )
}
/// Create a software-backed surface explicitly.
pub fn new_software( width: u32, height: u32 ) -> Self
{
Self::from_canvas( Canvas::new( width, height ) )
}
/// Create a GLES-backed surface with an ltk-owned offscreen EGL context.
///
/// The context is made current before render/resize operations. This is
/// useful for runtime-free rendering; compositors that already own a GL
/// context can instead build a `Canvas::new_gles(...)` and pass it to
/// [`Self::from_canvas`].
pub fn try_new_gles( width: u32, height: u32 ) -> Result<Self, String>
{
let egl_context = EglOffscreenContext::new()?;
egl_context.make_current()?;
let canvas = Canvas::new_gles(
Arc::clone( egl_context.gl() ),
egl_context.version(),
width,
height,
);
Ok( Self::from_canvas_with_egl_context( canvas, Some( egl_context ) ) )
}
/// Create a GLES-backed surface from a caller-owned, already-current GL
/// context.
///
/// This is the path intended for compositor embedders: no EGL display,
/// EGL context, or pbuffer is allocated by ltk. The caller must keep
/// the underlying GL context alive and current whenever this surface
/// is created, rendered, resized, or dropped.
pub fn from_gles_context(
gl: Arc<glow::Context>,
version: GlesVersion,
width: u32,
height: u32,
) -> Self
{
Self::from_canvas( Canvas::new_gles( gl, version, width, height ) )
}
/// Create a GLES-backed surface from the GL function loader of the current
/// context.
///
/// This is useful for renderers such as Smithay's `GlesRenderer`, which
/// keep their own EGL context and expose custom GL access through a
/// callback. This constructor does not allocate an EGL context; it only
/// builds ltk's `glow` dispatch table and GPU canvas resources in the
/// context that is current while this function runs.
///
/// # Safety
///
/// `loader` must resolve symbols for the GL context that is current on this
/// thread. That same context must remain alive, and must be made current
/// before any call that touches the returned surface — **including the
/// implicit destructor**: dropping the `UiSurface` releases GPU resources
/// (textures, FBOs, programs) through the caller-owned context and will
/// leak / corrupt state if that context is not current at drop time.
pub unsafe fn from_current_gles_loader<F>(
mut loader: F,
version: GlesVersion,
width: u32,
height: u32,
) -> Self
where
F: FnMut( &str ) -> *const c_void,
{
// SAFETY: forwarded from `from_current_gles_loader`'s own `# Safety`
// contract — `loader` resolves symbols for a context current on this
// thread, so `glGetString( GL_VERSION )` (called inside
// `from_loader_function`) is well-defined.
let gl = Arc::new( unsafe { glow::Context::from_loader_function( move |name| loader( name ) ) } );
Self::from_gles_context( gl, version, width, height )
}
/// Wrap an existing canvas. This is the hook for compositor-owned GPU
/// targets once the caller provides an already-current GL context. If
/// `canvas` is `Canvas::Gles`, the caller remains responsible for making the
/// matching GL context current before calling methods that touch the
/// canvas — **including the implicit destructor**: dropping the
/// `UiSurface` releases GPU resources (textures, FBOs, programs) through
/// the caller-owned context and will leak / corrupt state if that context
/// is not current at drop time.
pub fn from_canvas( canvas: Canvas ) -> Self
{
Self::from_canvas_with_egl_context( canvas, None )
}
fn from_canvas_with_egl_context(
canvas: Canvas,
egl_context: Option<EglOffscreenContext>,
) -> Self
{
Self
{
canvas,
egl_context,
focused_idx: None,
hovered_idx: None,
pressed_idx: None,
prev_focused: None,
prev_hovered: None,
prev_pressed: None,
widget_rects: Vec::new(),
cursor_state: HashMap::new(),
selection_anchor: HashMap::new(),
scroll_offsets: HashMap::new(),
scroll_canvases: HashMap::new(),
scroll_navigable_items: HashMap::new(),
content_dirty: true,
}
}
/// Access the backing canvas after a render pass.
pub fn canvas( &self ) -> &Canvas
{
&self.canvas
}
/// Mutable access to the backing canvas for compositor-specific upload or
/// presentation code. Mark content dirty afterwards if external drawing
/// changes what ltk should preserve across partial redraws.
pub fn canvas_mut( &mut self ) -> &mut Canvas
{
self.make_owned_gles_current();
&mut self.canvas
}
/// Current canvas size in physical pixels.
pub fn size( &self ) -> ( u32, u32 )
{
self.canvas.size()
}
/// Resize the backing canvas and force the next render to redraw fully.
pub fn resize( &mut self, width: u32, height: u32 )
{
self.make_owned_gles_current();
self.canvas.resize( width, height );
self.mark_content_dirty();
}
/// Set the DPI scale used for text and font metrics.
pub fn set_dpi_scale( &mut self, scale: f32 )
{
self.make_owned_gles_current();
self.canvas.set_dpi_scale( scale );
self.mark_content_dirty();
}
/// Mark the next render as content-changing, forcing a full repaint.
pub fn mark_content_dirty( &mut self )
{
self.content_dirty = true;
}
/// Current laid-out interactive widgets from the last render.
pub fn widget_rects( &self ) -> &[ LaidOutWidget<Msg> ]
{
&self.widget_rects
}
/// Hit-test a physical point against the last rendered widget rects.
pub fn hit_test( &self, pos: Point ) -> Option<usize>
{
crate::tree::find_widget_at( &self.widget_rects, pos )
}
/// Lookup a laid-out widget by flat index.
pub fn widget( &self, flat_idx: usize ) -> Option<&LaidOutWidget<Msg>>
{
crate::tree::find_widget( &self.widget_rects, flat_idx )
}
/// Lookup the handler snapshot for a laid-out widget.
pub fn handlers( &self, flat_idx: usize ) -> Option<&WidgetHandlers<Msg>>
{
crate::tree::find_handlers( &self.widget_rects, flat_idx )
}
/// Update keyboard focus state. This is interaction-only, so the next
/// render can use partial damage when layout/content did not change.
pub fn set_focused( &mut self, idx: Option<usize> )
{
self.focused_idx = idx;
}
/// Update hover state. This is interaction-only.
pub fn set_hovered( &mut self, idx: Option<usize> )
{
self.hovered_idx = idx;
}
/// Update pressed state. This is interaction-only.
pub fn set_pressed( &mut self, idx: Option<usize> )
{
self.pressed_idx = idx;
}
pub fn focused( &self ) -> Option<usize> { self.focused_idx }
pub fn hovered( &self ) -> Option<usize> { self.hovered_idx }
pub fn pressed( &self ) -> Option<usize> { self.pressed_idx }
/// Render `element` into the backing canvas.
///
/// This does not commit, swap buffers, request frame callbacks, or talk to
/// Wayland. The caller owns presentation and frame pacing.
pub fn render( &mut self, element: &Element<Msg>, options: RenderOptions ) -> RenderOutput
{
self.make_owned_gles_current();
let ( width, height ) = self.canvas.size();
let damage_rects = if self.content_dirty || self.widget_rects.is_empty()
{
Vec::new()
}
else
{
draw::compute_interaction_dirty_rects(
&self.widget_rects,
self.prev_focused, self.prev_hovered, self.prev_pressed,
self.focused_idx, self.hovered_idx, self.pressed_idx,
width,
height,
)
};
let use_partial = !self.content_dirty && !damage_rects.is_empty();
// Only `compute_damage` (called below in the !use_partial branch)
// needs the previous frame's rects, and it consumes them by reference.
// The clone is therefore deferred to that branch — the partial-damage
// path (typical for hover / focus / press transitions) skips it
// entirely, saving one Vec<LaidOutWidget> clone per frame.
let old_rects: Vec<LaidOutWidget<Msg>> = if use_partial
{
Vec::new()
}
else
{
self.widget_rects.clone()
};
if use_partial
{
self.canvas.set_clip_rects( &damage_rects );
if options.background.a > 0.0
{
self.canvas.fill( options.background );
}
else
{
self.canvas.clear_rects_transparent( &damage_rects );
}
}
else
{
self.canvas.clear_clip();
if options.background.a > 0.0
{
self.canvas.fill( options.background );
}
else
{
self.canvas.clear();
}
}
let mut ctx: DrawCtx<Msg> = DrawCtx
{
focused_idx: self.focused_idx,
hovered_idx: self.hovered_idx,
pressed_idx: self.pressed_idx,
cursor_state: std::mem::take( &mut self.cursor_state ),
selection_anchor: std::mem::take( &mut self.selection_anchor ),
widget_rects: Vec::new(),
debug_layout: options.debug_layout && !use_partial,
scroll_offsets: std::mem::take( &mut self.scroll_offsets ),
scroll_rects: Vec::new(),
scroll_canvases: std::mem::take( &mut self.scroll_canvases ),
scroll_navigable_items: std::mem::take( &mut self.scroll_navigable_items ),
previous_widget_rects: self.widget_rects.clone(),
};
draw::layout_and_draw( element, &mut self.canvas, options.bounds, &mut ctx, 0 );
if ctx.debug_layout && !use_partial
{
for w in &ctx.widget_rects
{
self.canvas.stroke_rect( w.rect, Color::rgb( 1.0, 0.0, 0.0 ), 1.5, 0.0 );
}
}
self.canvas.clear_clip();
let output_damage = if use_partial
{
damage_rects
}
else
{
draw::compute_damage(
&old_rects,
&ctx.widget_rects,
self.prev_focused, self.prev_hovered, self.prev_pressed,
self.focused_idx, self.hovered_idx, self.pressed_idx,
width,
height,
)
};
self.prev_focused = self.focused_idx;
self.prev_hovered = self.hovered_idx;
self.prev_pressed = self.pressed_idx;
self.widget_rects = ctx.widget_rects;
self.cursor_state = ctx.cursor_state;
self.selection_anchor = ctx.selection_anchor;
self.scroll_offsets = ctx.scroll_offsets;
// `ctx.scroll_rects` is intentionally dropped here. The `Scroll` widget
// pushes its hit-test rects into the DrawCtx during the draw pass, but
// `UiSurface` exposes no wheel-event routing API, so persisting them
// across frames serves no consumer. If a future embedder needs to
// dispatch wheel events through `UiSurface`, add a `scroll_rects()`
// getter together with a `scroll_by( flat_idx, dy )` mutator and start
// keeping the field again.
self.scroll_canvases = ctx.scroll_canvases;
self.scroll_navigable_items = ctx.scroll_navigable_items;
self.content_dirty = false;
RenderOutput
{
full_redraw: output_damage.is_empty(),
damage_rects: output_damage,
}
}
fn make_owned_gles_current( &self )
{
if let Some( egl_context ) = &self.egl_context
{
if let Err( e ) = egl_context.make_current()
{
log_make_current_failure_once( &e );
}
}
}
}
fn log_make_current_failure_once( reason: &str )
{
use std::sync::Once;
static ONCE: Once = Once::new();
ONCE.call_once( ||
{
eprintln!( "[ltk] core: eglMakeCurrent failed: {reason} (subsequent GL ops will silently no-op or render garbage)" );
} );
}
impl<Msg: Clone> Drop for UiSurface<Msg>
{
fn drop( &mut self )
{
self.make_owned_gles_current();
}
}

102
src/draw/chrome.rs Normal file
View File

@@ -0,0 +1,102 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Surface chrome: the client-side title bar and the Wayland input
//! region. Both are backend-agnostic — the software and GPU draw
//! paths call them with a `&mut Canvas` / a `&WlSurface` and get back
//! the geometry they need for hit testing + commit.
use smithay_client_toolkit::compositor::{ CompositorState, Region };
use smithay_client_toolkit::reexports::client::protocol::wl_surface::WlSurface;
use crate::render::Canvas;
use crate::types::{ Color, Rect };
/// Paint the client-side title bar (close button, divider, title text).
///
/// Returns the close button rect in physical pixels so the caller can
/// store it for hit testing. Returns `Rect::default()` when `tb_h <=
/// 0` — overlays / layer-shell surfaces have no title bar.
pub( crate ) fn draw_titlebar( canvas: &mut Canvas, title: &str, pw: u32, tb_h: f32, sf: f32 ) -> Rect
{
if tb_h <= 0.0 { return Rect::default(); }
let tb_rect = Rect { x: 0.0, y: 0.0, width: pw as f32, height: tb_h };
canvas.fill_rect( tb_rect, Color::rgb( 0.15, 0.15, 0.18 ), 0.0 );
let title_size = 15.0;
// `Canvas::draw_text` interprets `y` as the baseline. Match the
// vertical-centring formula used by `Button::draw_text_button` so
// chrome and content text lines line up at the same optical height.
let title_y = ( tb_h + title_size * sf ) / 2.0 - 2.0;
let title_w = canvas.measure_text( title, title_size );
let title_x = ( pw as f32 - title_w ) / 2.0;
canvas.draw_text( title, title_x, title_y, title_size, Color::WHITE );
let btn_size = tb_h - 8.0 * sf;
let btn_x = pw as f32 - btn_size - 8.0 * sf;
let btn_y = 4.0 * sf;
let close_rect_phys = Rect { x: btn_x, y: btn_y, width: btn_size, height: btn_size };
canvas.fill_rect( close_rect_phys, Color::rgba( 1.0, 1.0, 1.0, 0.1 ), 4.0 * sf );
let cx = btn_x + btn_size / 2.0;
let cy = btn_y + btn_size / 2.0;
let arm = btn_size * 0.25;
canvas.draw_line( cx - arm, cy - arm, cx + arm, cy + arm, Color::WHITE, 2.0 * sf );
canvas.draw_line( cx + arm, cy - arm, cx - arm, cy + arm, Color::WHITE, 2.0 * sf );
canvas.draw_line( 0.0, tb_h - 0.5, pw as f32, tb_h - 0.5, Color::rgba( 1.0, 1.0, 1.0, 0.15 ), 1.0 );
close_rect_phys
}
/// Stamp a red warning banner at the top of the surface when the
/// active theme came from the in-memory B/W fallback (i.e.
/// [`crate::theme::is_fallback_active`] returned `true`). Gives the
/// user a clear visual cue that `ltk-theme-default` is missing.
///
/// Paints a thin red strip across the top of the surface (24 px
/// logical) with the install-instruction text. When the fallback is
/// not active this is a no-op.
pub( crate ) fn draw_fallback_banner(
canvas: &mut Canvas,
pw: u32,
sf: f32,
)
{
if !crate::theme::is_fallback_active() { return; }
let banner_h = 24.0 * sf;
let rect = Rect { x: 0.0, y: 0.0, width: pw as f32, height: banner_h };
canvas.fill_rect( rect, Color::rgb( 0.75, 0.10, 0.10 ), 0.0 );
let msg = "ltk: fallback theme — install `ltk-theme-default`";
let size = 12.0;
let msg_w = canvas.measure_text( msg, size );
let x = ( ( pw as f32 - msg_w ) / 2.0 ).max( 6.0 * sf );
let y = ( banner_h - size * sf ) / 2.0;
canvas.draw_text( msg, x, y, size, Color::WHITE );
}
/// Apply or clear the surface's input region (set by
/// `App::input_region`). `None` restores the default of "receive
/// input everywhere"; `Some(&[])` makes the surface pointer-through.
pub( crate ) fn apply_input_region(
wl_surface: &WlSurface,
compositor: &CompositorState,
input_region: Option<&[Rect]>,
)
{
if let Some( regions ) = input_region
{
if let Ok( region ) = Region::new( compositor )
{
for r in regions
{
region.add( r.x as i32, r.y as i32, r.width as i32, r.height as i32 );
}
wl_surface.set_input_region( Some( region.wl_region() ) );
}
} else {
wl_surface.set_input_region( None );
}
}

511
src/draw/damage.rs Normal file
View File

@@ -0,0 +1,511 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Damage tracking: what rects need to be repainted this frame, and
//! what rects need to be declared to Wayland.
//!
//! Two entry points with different jobs:
//!
//! * [`compute_interaction_dirty_rects`] — called on the partial-redraw
//! path BEFORE painting. Takes the previous and current interaction
//! snapshots (focus / hover / pressed) and emits the union of the
//! `paint_rect`s of the widgets whose state transitioned. The caller
//! uses these to install a clip mask before the partial repaint and
//! to stamp `wl_surface.damage_buffer` afterwards.
//! * [`compute_damage`] — called on the full-redraw path AFTER painting.
//! Compares the previous-frame widget rects with the just-produced
//! ones; if layout moved or changed, returns an empty vec (meaning
//! "damage the whole surface"), otherwise returns a tight list of
//! rects for the widgets whose interaction state changed.
//!
//! The 50%-of-screen heuristic is shared by both: if the accumulated
//! damage covers more than half the surface, the single full-surface
//! damage rect is cheaper than the per-region list.
use crate::types::Rect;
use crate::widget::LaidOutWidget;
/// Intersect `r` with `bounds`, returning a rect that is entirely
/// inside `bounds`. Returns a zero-size rect if they do not overlap.
pub( super ) fn clamp_rect_to( r: Rect, bounds: Rect ) -> Rect
{
let x0 = r.x.max( bounds.x );
let y0 = r.y.max( bounds.y );
let x1 = ( r.x + r.width ).min( bounds.x + bounds.width );
let y1 = ( r.y + r.height ).min( bounds.y + bounds.height );
if x1 <= x0 || y1 <= y0
{
Rect { x: x0, y: y0, width: 0.0, height: 0.0 }
} else {
Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 }
}
}
/// Build the dirty-rect list for an interaction-only frame: union of
/// the `paint_rect`s of the widgets whose focus / hover / pressed
/// transitioned. Each widget's `paint_rect` already encloses its hover
/// halo, focus ring, and any other overdraw, so no extra padding is
/// needed here.
pub( crate ) fn compute_interaction_dirty_rects<Msg: Clone>(
widget_rects: &[ LaidOutWidget<Msg> ],
prev_focused: Option<usize>, prev_hovered: Option<usize>, prev_pressed: Option<usize>,
new_focused: Option<usize>, new_hovered: Option<usize>, new_pressed: Option<usize>,
pw: u32,
ph: u32,
) -> Vec<Rect>
{
let mut rects: Vec<Rect> = Vec::new();
let visit = |idx_opt: Option<usize>, sink: &mut Vec<Rect>|
{
if let Some( idx ) = idx_opt
{
if let Some( w ) = widget_rects.iter().find( |w| w.flat_idx == idx )
{
if !w.handlers.is_slider()
{
sink.push( w.paint_rect );
}
}
}
};
if prev_focused != new_focused
{
visit( prev_focused, &mut rects );
visit( new_focused, &mut rects );
}
if prev_hovered != new_hovered
{
visit( prev_hovered, &mut rects );
visit( new_hovered, &mut rects );
}
if prev_pressed != new_pressed
{
visit( prev_pressed, &mut rects );
visit( new_pressed, &mut rects );
}
// Snap to integer pixel boundaries before clamping. The clip mask is
// rasterized with anti_alias=false (binary, sampled at pixel centers), and
// `Canvas::clear_rects_transparent` uses `as i32` floor on the min and
// `.ceil() as i32` on the max. If `paint_rect` carries fractional coords
// (e.g. from `expand( 14.5 )` on icon-button hover halos), those two paths
// can disagree by 1 px at the edge — leaving a pixel cleared to transparent
// black but not repainted on the next frame. Floor min / ceil max here so
// both paths see the same whole-pixel rect.
let sw = pw as f32;
let sh = ph as f32;
for r in &mut rects
{
let x0 = r.x.floor().max( 0.0 );
let y0 = r.y.floor().max( 0.0 );
let x1 = ( r.x + r.width ).ceil().min( sw );
let y1 = ( r.y + r.height ).ceil().min( sh );
r.x = x0;
r.y = y0;
r.width = ( x1 - x0 ).max( 0.0 );
r.height = ( y1 - y0 ).max( 0.0 );
}
rects.retain( |r| r.width > 0.0 && r.height > 0.0 );
rects
}
/// Compare previous and current widget rects + interaction state to
/// find damaged regions. Returns a list of damage rects, or empty vec
/// if everything changed (full redraw).
pub( crate ) fn compute_damage<Msg: Clone>(
old_rects: &[ LaidOutWidget<Msg> ],
new_rects: &[ LaidOutWidget<Msg> ],
old_focused: Option<usize>,
old_hovered: Option<usize>,
old_pressed: Option<usize>,
new_focused: Option<usize>,
new_hovered: Option<usize>,
new_pressed: Option<usize>,
screen_w: u32,
screen_h: u32,
) -> Vec<Rect>
{
// Widget tree structure changed: full redraw.
if old_rects.len() != new_rects.len()
{
return Vec::new();
}
let mut damage = Vec::new();
let changed_indices: Vec<usize> = [
old_focused, new_focused,
old_hovered, new_hovered,
old_pressed, new_pressed,
].iter().filter_map( |&idx| idx ).collect();
for &idx in &changed_indices
{
// Each widget's declared `paint_rect` already encloses its overdraw.
if let Some( w ) = old_rects.iter().find( |w| w.flat_idx == idx )
{
damage.push( w.paint_rect );
}
if let Some( w ) = new_rects.iter().find( |w| w.flat_idx == idx )
{
damage.push( w.paint_rect );
}
}
// Layout shift: any rect moved or resized => full redraw.
for ( i, old_w ) in old_rects.iter().enumerate()
{
if let Some( new_w ) = new_rects.get( i )
{
let old_r = old_w.rect;
let new_r = new_w.rect;
if ( old_r.x - new_r.x ).abs() > 0.5
|| ( old_r.y - new_r.y ).abs() > 0.5
|| ( old_r.width - new_r.width ).abs() > 0.5
|| ( old_r.height - new_r.height ).abs() > 0.5
{
return Vec::new();
}
}
}
// No interaction changes but a redraw was requested => content changed
// (e.g. clock tick) and a full redraw is the right answer.
if damage.is_empty()
{
return Vec::new();
}
// Dilate every damage rect by 1 px on each side. The GPU rect/gradient
// shaders draw their quads expanded by 1 px so the outer half of the
// SDF antialiasing band has fragments to cover; under a partial
// redraw the scissor would otherwise clip that band at the widget's
// `paint_rect` boundary, re-introducing the aliased step on the
// straight edges of pills / rounded rects. The cost is negligible
// (one extra pixel of clear + repaint per damage rect).
for r in &mut damage
{
r.x -= 1.0;
r.y -= 1.0;
r.width += 2.0;
r.height += 2.0;
}
let sw = screen_w as f32;
let sh = screen_h as f32;
for r in &mut damage
{
r.x = r.x.max( 0.0 );
r.y = r.y.max( 0.0 );
r.width = r.width.min( sw - r.x );
r.height = r.height.min( sh - r.y );
}
// Total damage > 50% of screen: full redraw is no slower and emits a
// single damage rect instead of many.
let total_damage: f32 = damage.iter().map( |r| r.width * r.height ).sum();
if total_damage > sw * sh * 0.5
{
return Vec::new();
}
damage
}
#[ cfg( test ) ]
mod tests
{
use super::*;
use crate::widget::WidgetHandlers;
fn r( x: f32, y: f32, w: f32, h: f32 ) -> Rect
{
Rect { x, y, width: w, height: h }
}
fn lw( idx: usize, rect: Rect, paint: Rect ) -> LaidOutWidget<()>
{
LaidOutWidget
{
rect,
flat_idx: idx,
id: None,
paint_rect: paint,
handlers: WidgetHandlers::None,
keyboard_focusable: true,
cursor: crate::types::CursorShape::Default,
}
}
// ── clamp_rect_to ─────────────────────────────────────────────────────────
#[ test ]
fn clamp_rect_to_returns_intersection()
{
let bounds = r( 0.0, 0.0, 100.0, 100.0 );
let inside = r( 10.0, 10.0, 20.0, 20.0 );
assert_eq!( clamp_rect_to( inside, bounds ), inside );
}
#[ test ]
fn clamp_rect_to_clips_to_bounds()
{
let bounds = r( 0.0, 0.0, 100.0, 100.0 );
let bleed = r( 80.0, 80.0, 50.0, 50.0 );
assert_eq!( clamp_rect_to( bleed, bounds ), r( 80.0, 80.0, 20.0, 20.0 ) );
}
#[ test ]
fn clamp_rect_to_disjoint_returns_zero_size()
{
let bounds = r( 0.0, 0.0, 100.0, 100.0 );
let off = r( 200.0, 200.0, 50.0, 50.0 );
let out = clamp_rect_to( off, bounds );
assert_eq!( ( out.width, out.height ), ( 0.0, 0.0 ) );
}
// ── compute_interaction_dirty_rects ───────────────────────────────────────
#[ test ]
fn no_state_change_yields_empty_dirty_rects()
{
let widgets = vec![ lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ) ];
let rects = compute_interaction_dirty_rects(
&widgets,
Some( 1 ), None, None,
Some( 1 ), None, None,
800, 600,
);
assert!( rects.is_empty() );
}
#[ test ]
fn focus_change_emits_both_old_and_new_paint_rects()
{
let widgets = vec![
lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ),
lw( 2, r( 100.0, 0.0, 50.0, 50.0 ), r( 100.0, 0.0, 50.0, 50.0 ) ),
];
let rects = compute_interaction_dirty_rects(
&widgets,
Some( 1 ), None, None,
Some( 2 ), None, None,
800, 600,
);
assert_eq!( rects.len(), 2 );
}
#[ test ]
fn hover_change_emits_paint_rects_independent_of_focus()
{
let widgets = vec![
lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ),
lw( 2, r( 100.0, 0.0, 50.0, 50.0 ), r( 100.0, 0.0, 50.0, 50.0 ) ),
];
let rects = compute_interaction_dirty_rects(
&widgets,
Some( 1 ), Some( 1 ), None,
Some( 1 ), Some( 2 ), None,
800, 600,
);
assert_eq!( rects.len(), 2 );
}
#[ test ]
fn dirty_rects_are_snapped_and_clamped_to_screen()
{
let widgets = vec![
lw( 1, r( -10.0, -10.0, 30.5, 40.5 ), r( -10.0, -10.0, 30.5, 40.5 ) ),
];
let rects = compute_interaction_dirty_rects(
&widgets,
None, None, None,
Some( 1 ), None, None,
100, 100,
);
assert_eq!( rects.len(), 1 );
// Floor of -10 is -10 → max(0) = 0; ceil of -10+30.5=20.5 → 21.
assert_eq!( rects[ 0 ].x, 0.0 );
assert_eq!( rects[ 0 ].y, 0.0 );
assert_eq!( rects[ 0 ].width, 21.0 );
assert_eq!( rects[ 0 ].height, 31.0 );
}
#[ test ]
fn dirty_rects_outside_screen_are_dropped()
{
let widgets = vec![
lw( 1, r( 1000.0, 1000.0, 50.0, 50.0 ), r( 1000.0, 1000.0, 50.0, 50.0 ) ),
];
let rects = compute_interaction_dirty_rects(
&widgets,
None, None, None,
Some( 1 ), None, None,
100, 100,
);
assert!( rects.is_empty() );
}
#[ test ]
fn missing_widget_idx_silently_skipped()
{
let widgets = vec![ lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ) ];
// Old focus references an idx that no longer exists in widget_rects —
// this happens during a layout where a widget disappeared between
// frames. The function must not panic; it just emits whatever new
// state's rect it can find.
let rects = compute_interaction_dirty_rects(
&widgets,
Some( 99 ), None, None,
Some( 1 ), None, None,
800, 600,
);
assert_eq!( rects.len(), 1 );
}
// ── compute_damage ────────────────────────────────────────────────────────
#[ test ]
fn tree_size_change_returns_full_redraw()
{
let old = vec![ lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ) ];
let new = vec![
lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ),
lw( 2, r( 0.0, 60.0, 50.0, 50.0 ), r( 0.0, 60.0, 50.0, 50.0 ) ),
];
let damage = compute_damage(
&old, &new,
None, None, None,
None, None, None,
800, 600,
);
assert!( damage.is_empty(), "tree size change must signal full redraw via empty vec" );
}
#[ test ]
fn layout_shift_returns_full_redraw()
{
let old = vec![ lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ) ];
let new = vec![ lw( 1, r( 0.0, 60.0, 50.0, 50.0 ), r( 0.0, 60.0, 50.0, 50.0 ) ) ];
let damage = compute_damage(
&old, &new,
Some( 1 ), None, None,
Some( 1 ), None, None,
800, 600,
);
assert!( damage.is_empty() );
}
#[ test ]
fn focus_change_emits_partial_damage()
{
let widgets = vec![
lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ),
lw( 2, r( 100.0, 0.0, 50.0, 50.0 ), r( 100.0, 0.0, 50.0, 50.0 ) ),
];
let damage = compute_damage(
&widgets, &widgets,
Some( 1 ), None, None,
Some( 2 ), None, None,
800, 600,
);
assert!( !damage.is_empty(), "focus change must produce non-empty damage list" );
assert!(
damage.len() >= 2,
"both old and new focused widgets contribute their paint_rects"
);
}
#[ test ]
fn no_change_with_redraw_request_returns_full_redraw()
{
// `damage.is_empty()` after the changed_indices loop means nothing
// interaction-level changed but the caller still asked to redraw —
// content tick (e.g. clock). The function returns the empty vec to
// signal "redraw everything" rather than nothing.
let widgets = vec![ lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ) ];
let damage = compute_damage(
&widgets, &widgets,
None, None, None,
None, None, None,
800, 600,
);
assert!( damage.is_empty() );
}
#[ test ]
fn damage_rects_are_dilated_by_one_pixel_each_side()
{
// Every damage rect grows by 1 px on each side to cover the SDF
// antialiasing band; the rect is then clamped to the surface.
let widgets = vec![ lw( 1, r( 100.0, 100.0, 50.0, 50.0 ), r( 100.0, 100.0, 50.0, 50.0 ) ) ];
let damage = compute_damage(
&widgets, &widgets,
None, None, None,
Some( 1 ), None, None,
800, 600,
);
assert_eq!( damage.len(), 2 );
for d in &damage
{
assert_eq!( d.x, 99.0 );
assert_eq!( d.y, 99.0 );
assert_eq!( d.width, 52.0 );
assert_eq!( d.height, 52.0 );
}
}
#[ test ]
fn damage_above_fifty_percent_collapses_to_full_redraw()
{
// Widget covers 60 % of a 100×100 surface. The dilated rect easily
// exceeds the 50 % threshold and the function falls back to full
// redraw.
let widgets = vec![
lw( 1, r( 0.0, 0.0, 80.0, 80.0 ), r( 0.0, 0.0, 80.0, 80.0 ) ),
];
let damage = compute_damage(
&widgets, &widgets,
None, None, None,
Some( 1 ), None, None,
100, 100,
);
assert!( damage.is_empty(), "damage > 50 % of surface must signal full redraw" );
}
#[ test ]
fn damage_below_fifty_percent_returns_partial()
{
// 10×10 widget on a 100×100 surface → ~1 % per rect, well under 50 %.
let widgets = vec![
lw( 1, r( 5.0, 5.0, 10.0, 10.0 ), r( 5.0, 5.0, 10.0, 10.0 ) ),
];
let damage = compute_damage(
&widgets, &widgets,
None, None, None,
Some( 1 ), None, None,
100, 100,
);
assert!( !damage.is_empty() );
}
#[ test ]
fn damage_clamped_to_surface_extents()
{
// Widget paint_rect at the right edge — dilation can push it past the
// surface; the clamp must keep width/height within the surface.
let widgets = vec![
lw( 1, r( 90.0, 90.0, 5.0, 5.0 ), r( 90.0, 90.0, 5.0, 5.0 ) ),
];
let damage = compute_damage(
&widgets, &widgets,
None, None, None,
Some( 1 ), None, None,
100, 100,
);
for d in &damage
{
assert!( d.x + d.width <= 100.0 + f32::EPSILON );
assert!( d.y + d.height <= 100.0 + f32::EPSILON );
}
}
}

253
src/draw/gles.rs Normal file
View File

@@ -0,0 +1,253 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! GLES (EGL + FBO) draw paths.
//!
//! The GPU counterparts to [`super::software::draw_surface_full`] and
//! [`super::software::draw_surface_partial`]. Same DrawCtx flow; the
//! differences are:
//!
//! * Canvas is `Canvas::new_gles` backed by a persistent FBO rather
//! than a CPU pixmap → FBO pixels survive across frames naturally,
//! so the partial path only needs a scissor.
//! * Presentation goes through `egl_ctx.swap_buffers_with_damage`
//! which attaches + damages + commits atomically. The partial path
//! translates the top-left dirty rects into EGL's bottom-left
//! convention before passing them in.
//! * No `wl_shm` pool; no `pick_shm_format`; no byte-order swap.
//!
//! The Y-flip for the swap-with-damage call lives here (only the GPU
//! path needs it — SHM buffers are already top-down).
use std::sync::Arc;
use smithay_client_toolkit::compositor::CompositorState;
use smithay_client_toolkit::reexports::client::protocol::wl_surface::WlSurface;
use crate::egl_context::EglContext;
use crate::event_loop::SurfaceState;
use crate::render::Canvas;
use crate::types::{ Color, Rect };
use crate::widget::Element;
use super::{ DrawCtx, layout_and_draw };
use super::chrome::{ apply_input_region, draw_fallback_banner, draw_titlebar };
/// GPU full-redraw path. Mirrors [`super::software::draw_surface_full`]
/// but writes into the EGL surface's persistent FBO instead of an SHM
/// buffer:
///
/// 1. `eglMakeCurrent` so subsequent GL calls hit this surface.
/// 2. (Lazily) build the [`Canvas::Gles`], or resize its FBO if the
/// surface dimensions changed.
/// 3. Clear, run layout+draw, blit the FBO to the default framebuffer.
/// 4. Set buffer_scale + input_region (state attached to the implicit
/// commit done by `eglSwapBuffers`).
/// 5. `eglSwapBuffers` — performs the wl attach/damage/commit atomically.
pub( crate ) fn draw_surface_full_gpu<Msg: Clone>(
ss: &mut SurfaceState<Msg>,
compositor: &CompositorState,
egl_ctx: &Arc<EglContext>,
view: &Element<Msg>,
bg: Color,
input_region: Option<&[Rect]>,
debug_layout: bool,
pw: u32,
ph: u32,
scale: u32,
request_frame: &dyn Fn( &WlSurface ),
)
{
let Some( es ) = ss.egl_surface.as_ref() else { return };
if egl_ctx.make_current( es ).is_err() { return; }
let canvas = ss.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();
let sf = scale as f32;
let tb_h = ss.titlebar_height * sf;
let screen_rect = Rect { x: 0.0, y: tb_h, width: pw as f32, height: ( ph as f32 - tb_h ).max( 0.0 ) };
if bg.a > 0.0 { canvas.fill( bg ); } else { canvas.clear(); }
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(),
};
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
if ctx.debug_layout
{
for w in &ctx.widget_rects
{
canvas.stroke_rect( w.rect, Color::rgb( 1.0, 0.0, 0.0 ), 1.5, 0.0 );
}
}
if let Some( ref menu ) = ss.context_menu
{
super::draw_context_menu( canvas, menu );
}
// Fallback-theme warning. Painted last so it sits on top of every
// widget; no-op when the real theme is loaded.
draw_fallback_banner( canvas, pw, sf );
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.scroll_navigable_items = ctx.scroll_navigable_items;
ss.content_dirty = false;
let wl_surface = ss.surface.wl_surface();
apply_input_region( wl_surface, compositor, input_region );
// 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.
request_frame( wl_surface );
// Full-surface damage. (0,0,w,h) is identical in EGL bottom-left and
// top-left coords, so no Y-flip needed.
let _ = egl_ctx.swap_buffers_with_damage( es, &[ ( 0, 0, pw as i32, ph as i32 ) ] );
ss.frame_pending = true;
}
/// GPU partial-redraw path. Same flow as
/// [`super::software::draw_surface_partial`] (clip mask → repaint
/// background + widgets → present), but the clip is implemented with
/// `glScissor` (bbox union) inside the canvas, and presentation goes
/// through `eglSwapBuffers`. The FBO is preserved between frames so
/// the unclipped pixels naturally carry over.
pub( crate ) fn draw_surface_partial_gpu<Msg: Clone>(
ss: &mut SurfaceState<Msg>,
compositor: &CompositorState,
egl_ctx: &Arc<EglContext>,
view: &Element<Msg>,
bg: Color,
input_region: Option<&[Rect]>,
dirty_rects: Vec<Rect>,
pw: u32,
ph: u32,
scale: u32,
request_frame: &dyn Fn( &WlSurface ),
)
{
let Some( es ) = ss.egl_surface.as_ref() else { return };
if egl_ctx.make_current( es ).is_err() { return; }
let canvas = ss.canvas.as_mut().expect( "partial path requires existing canvas" );
if canvas.size() != ( pw, ph )
{
canvas.resize( pw, ph );
canvas.set_dpi_scale( scale as f32 );
}
canvas.set_clip_rects( &dirty_rects );
let sf = scale as f32;
let tb_h = ss.titlebar_height * sf;
let screen_rect = Rect { x: 0.0, y: tb_h, width: pw as f32, height: ( ph as f32 - tb_h ).max( 0.0 ) };
if bg.a > 0.0 { canvas.fill( bg ); }
else
{
canvas.clear_rects_transparent( &dirty_rects );
}
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(),
};
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
if let Some( ref menu ) = ss.context_menu
{
super::draw_context_menu( canvas, menu );
}
// Fallback-theme warning. Painted last so it sits on top of every
// widget; no-op when the real theme is loaded.
draw_fallback_banner( canvas, pw, sf );
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.scroll_navigable_items = ctx.scroll_navigable_items;
ss.content_dirty = false;
let wl_surface = ss.surface.wl_surface();
apply_input_region( wl_surface, compositor, input_region );
request_frame( wl_surface );
// Convert top-left dirty rects to EGL bottom-left coords for the
// swap-with-damage call.
let damage: Vec<( i32, i32, i32, i32 )> = dirty_rects.iter().map( |r|
{
let x = r.x.floor() as i32;
let w = r.width.ceil() as i32;
let h = r.height.ceil() as i32;
let y_top = r.y.floor() as i32;
let y_bottom = ph as i32 - y_top - h;
( x, y_bottom.max( 0 ), w.max( 0 ), h.max( 0 ) )
} ).collect();
let _ = egl_ctx.swap_buffers_with_damage( es, &damage );
ss.frame_pending = true;
}

379
src/draw/layout.rs Normal file
View File

@@ -0,0 +1,379 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Element-tree recursive walker.
//!
//! [`layout_and_draw`] is the single bottom of the draw pipeline:
//! given an [`Element`] + its allocated rect, it lays out children,
//! paints leaves, threads scroll sub-canvases, and records the
//! [`LaidOutWidget`] entries the input layer will hit-test against.
//! Both software and GPU paths funnel every widget through this one
//! function.
use crate::render::Canvas;
use crate::types::Rect;
use crate::widget::{ Element, LaidOutWidget, WidgetHandlers };
use super::DrawCtx;
use super::damage::clamp_rect_to;
pub( crate ) fn layout_and_draw<Msg: Clone>(
element: &Element<Msg>,
canvas: &mut Canvas,
rect: Rect,
ctx: &mut DrawCtx<Msg>,
flat_idx: usize,
) -> usize
{
match element
{
Element::Column( col ) =>
{
let child_rects = col.layout( rect, canvas );
let mut idx = flat_idx;
for ( child_rect, child_i ) in child_rects
{
idx = layout_and_draw::<Msg>( &col.children[child_i], canvas, child_rect, ctx, idx );
}
idx
}
Element::Row( r ) =>
{
let child_rects = r.layout( rect, canvas );
let mut idx = flat_idx;
for ( child_rect, child_i ) in child_rects
{
idx = layout_and_draw::<Msg>( &r.children[child_i], canvas, child_rect, ctx, idx );
}
idx
}
Element::Stack( s ) =>
{
let child_rects = s.layout( rect, canvas );
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 );
}
idx
}
Element::WrapGrid( g ) =>
{
let child_rects = g.layout( rect, canvas );
let mut idx = flat_idx;
for ( child_rect, child_i ) in child_rects
{
idx = layout_and_draw::<Msg>( &g.children[child_i], canvas, child_rect, ctx, idx );
}
idx
}
Element::Flex( f ) =>
{
// `Flex` is invisible chrome: it claimed leftover width up at
// `Row::layout`, here we just unwrap it and draw the child
// inside the allocated rect. No flat-index of its own.
layout_and_draw::<Msg>( f.child.as_ref(), canvas, rect, ctx, flat_idx )
}
Element::AnchoredOverlay( a ) =>
{
// Look up the anchor's rect from the previous frame's
// `widget_rects` snapshot. If found, place the child flush
// below the anchor at the child's intrinsic size; if not,
// fall back to the parent-supplied rect so the child still
// renders (modal-style, typically a one-frame artefact on
// the first frame after open).
let anchor_rect = ctx.previous_widget_rects.iter()
.find( |w| w.id == Some( a.anchor_id ) )
.map( |w| w.rect );
let target = match anchor_rect
{
Some( anchor ) =>
{
// Use the child's intrinsic preferred size so the
// popup keeps its design width / height regardless
// of how wide the trigger pill happens to be.
let ( w, h ) = a.child.preferred_size( rect.width, canvas );
Rect
{
x: anchor.x,
y: anchor.y + anchor.height + a.gap,
width: w,
height: h,
}
}
None => rect,
};
layout_and_draw::<Msg>( a.child.as_ref(), canvas, target, ctx, flat_idx )
}
Element::Pressable( p ) =>
{
// Push the wrapper's hit rect *before* recursing so that any
// interactive child pushed during recursion sits later in
// `widget_rects` and wins under `iter().rev()` hit testing.
let my_idx = flat_idx;
if p.has_handler()
{
ctx.widget_rects.push( LaidOutWidget
{
rect,
flat_idx: my_idx,
id: p.id,
paint_rect: rect,
handlers: WidgetHandlers::Button
{
on_press: p.on_press.clone(),
on_long_press: p.on_long_press.clone(),
on_drag_start: p.on_drag_start.clone(),
on_escape: p.on_escape.clone(),
repeating: false,
},
keyboard_focusable: false,
cursor: p.cursor.unwrap_or( crate::types::CursorShape::Pointer ),
} );
}
layout_and_draw::<Msg>( p.child.as_ref(), canvas, rect, ctx, my_idx + 1 )
}
Element::Container( c ) =>
{
let saved_alpha = canvas.global_alpha();
canvas.set_global_alpha( saved_alpha * c.opacity );
// Surface slot takes precedence over flat background; falls
// through to `c.background` when the slot is absent (third-
// party theme without the named surface — content still
// renders, just without the themed chrome).
let painted = match c.surface.as_deref()
{
Some( slot ) => match crate::theme::resolve_surface( slot )
{
Some( ( surf, outer ) ) =>
{
canvas.fill_surface
(
rect,
&surf.fill,
&outer,
&surf.inset_shadows,
c.corners,
);
true
}
None => false,
},
None => false,
};
if !painted
{
if let Some( bg ) = c.background
{
canvas.fill_rect( rect, bg, c.corners );
}
}
if let Some( ( color, width ) ) = c.border
{
canvas.stroke_rect( rect, color, width, c.corners );
}
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 ),
};
let result = layout_and_draw::<Msg>( c.child.as_ref(), canvas, inner, ctx, flat_idx );
canvas.set_global_alpha( saved_alpha );
result
}
Element::Scroll( s ) =>
{
let my_idx = flat_idx;
let offset_raw = ctx.scroll_offsets.get( &my_idx ).copied().unwrap_or( 0.0 );
let child_h = s.child.preferred_size( rect.width, canvas ).1;
let offset = crate::widget::scroll::clamp_offset( offset_raw, child_h, rect.height );
// Write the clamped offset back so input handlers (wheel /
// drag) cannot accumulate past the content extents. Without
// this, repeated wheel ticks past the bottom keep growing
// `offset_raw` and the user has to "undo" that excess before
// the content scrolls back up. The clamp is idempotent for
// in-range values, so the only effect is collapsing
// out-of-range entries to the legitimate maximum.
if offset != offset_raw
{
ctx.scroll_offsets.insert( my_idx, offset );
}
// Reuse the sub-canvas from the previous frame if its size matches;
// only reallocate when the viewport is resized.
let sw = (rect.width.ceil() as u32).max( 1 );
let sh = (rect.height.ceil() as u32).max( 1 );
let mut sub = ctx.scroll_canvases.remove( &my_idx )
.filter( |c| c.size() == ( sw, sh ) )
.unwrap_or_else( || canvas.sub_canvas( sw, sh ) );
sub.clear();
// Child content fills at least the full viewport height so that
// children with VAlign::Bottom are positioned correctly when the
// content is shorter than the viewport.
let effective_h = child_h.max( rect.height );
// Shift child up by offset so scrolled content appears at y=0 in
// the sub-canvas.
let child_rect = Rect { x: 0.0, y: -offset, width: rect.width, height: effective_h };
let rects_before = ctx.widget_rects.len();
let scroll_rects_before = ctx.scroll_rects.len();
let next_idx = layout_and_draw::<Msg>( s.child.as_ref(), &mut sub, child_rect, ctx, my_idx + 1 );
// Translate widget_rects from sub-canvas space to global space; drop
// clipped ones. Also clamp `paint_rect` to the scroll viewport so a
// widget painting outside the sub-canvas does not invalidate pixels
// it cannot actually reach (the sub-canvas is blitted as a whole,
// so nothing outside the viewport appears on screen anyway).
let new_rects: Vec<LaidOutWidget<Msg>> = ctx.widget_rects.drain( rects_before.. ).collect();
// Capture every interactive item the child laid out — including
// items that the visibility filter below will discard — so the
// keyboard handler can step `hovered_idx` item-by-item without
// caring about which items are currently scrolled into view.
// The recorded Y is in pre-translation, pre-offset coordinates
// (i.e. `child_y` relative to the start of the scroll content),
// recovered by undoing the `-offset` shift the child layout pass
// applied: `content_y = w.rect.y - child_rect.y = w.rect.y + offset`.
let navigable: Vec<( usize, f32, f32 )> = new_rects.iter()
.filter( |w| w.handlers.is_navigable_list_item() )
.map( |w| ( w.flat_idx, w.rect.y + offset, w.rect.height ) )
.collect();
if !navigable.is_empty()
{
ctx.scroll_navigable_items.insert( my_idx, navigable );
}
for mut w in new_rects
{
// Sub-canvas origin (0,0) maps to global (rect.x, rect.y).
w.rect.x += rect.x;
w.rect.y += rect.y;
w.paint_rect.x += rect.x;
w.paint_rect.y += rect.y;
w.paint_rect = clamp_rect_to( w.paint_rect, rect );
// Only keep widgets at least partially visible inside the viewport.
if w.rect.y + w.rect.height > rect.y && w.rect.y < rect.y + rect.height
{
ctx.widget_rects.push( w );
}
}
// Same translation for scroll_rects pushed by any nested
// scroll widgets — without this, the wheel handler hit-tests
// in surface coords against rects in sub-canvas coords and
// only matches when the sub-canvas happens to start at
// (0, 0) of the surface. Clamp to the outer scroll rect so
// the inner scroll only captures wheel events inside its
// visible region.
let new_scroll_rects: Vec<( Rect, usize )> =
ctx.scroll_rects.drain( scroll_rects_before.. ).collect();
for ( mut r, idx ) in new_scroll_rects
{
r.x += rect.x;
r.y += rect.y;
let clamped = clamp_rect_to( r, rect );
if clamped.width > 0.0 && clamped.height > 0.0
{
ctx.scroll_rects.push( ( clamped, idx ) );
}
}
ctx.scroll_rects.push( ( rect, my_idx ) );
canvas.blit( &sub, rect.x as i32, rect.y as i32 );
ctx.scroll_canvases.insert( my_idx, sub );
next_idx
}
Element::Viewport( v ) =>
{
let child_h = v.child.preferred_size( rect.width, canvas ).1;
let effective_h = child_h.max( rect.height );
let vw = ( rect.width.ceil() as u32 ).max( 1 );
let vh = ( rect.height.ceil() as u32 ).max( 1 );
let mut sub = canvas.sub_canvas( vw, vh );
sub.clear();
let child_rect = Rect { x: 0.0, y: 0.0, width: rect.width, height: effective_h };
let rects_before = ctx.widget_rects.len();
let scroll_rects_before = ctx.scroll_rects.len();
let next_idx = layout_and_draw::<Msg>( v.child.as_ref(), &mut sub, child_rect, ctx, flat_idx );
let new_rects: Vec<LaidOutWidget<Msg>> = ctx.widget_rects.drain( rects_before.. ).collect();
for mut w in new_rects
{
w.rect.x += rect.x;
w.rect.y += rect.y;
w.paint_rect.x += rect.x;
w.paint_rect.y += rect.y;
w.paint_rect = clamp_rect_to( w.paint_rect, rect );
if w.rect.y + w.rect.height > rect.y && w.rect.y < rect.y + rect.height
{
ctx.widget_rects.push( w );
}
}
// Translate scroll_rects pushed by nested scroll widgets
// from sub-canvas to surface space. Same reasoning as the
// scroll-inside-scroll case above.
let new_scroll_rects: Vec<( Rect, usize )> =
ctx.scroll_rects.drain( scroll_rects_before.. ).collect();
for ( mut r, idx ) in new_scroll_rects
{
r.x += rect.x;
r.y += rect.y;
let clamped = clamp_rect_to( r, rect );
if clamped.width > 0.0 && clamped.height > 0.0
{
ctx.scroll_rects.push( ( clamped, idx ) );
}
}
canvas.blit_fade_bottom( &sub, rect.x as i32, rect.y as i32, v.fade_bottom );
next_idx
}
other =>
{
// Gate the focus ring by `is_focusable`: a widget that opts out
// of keyboard focus (e.g. `Button::focusable(false)`) should not
// paint a ring even if it ends up in `focused_idx` after a tap.
let is_focused = ctx.focused_idx == Some( flat_idx ) && other.is_focusable();
let is_hovered = ctx.hovered_idx == Some( flat_idx );
let is_pressed = ctx.pressed_idx == Some( flat_idx );
let cursor_pos = ctx.cursor_state.get( &flat_idx ).copied().unwrap_or( 0 );
let sel_anchor = ctx.selection_anchor.get( &flat_idx ).copied().unwrap_or( cursor_pos );
let widget_id = match other
{
Element::Button( b ) => b.id,
Element::TextEdit( t ) => t.id,
Element::Toggle( t ) => t.id,
Element::Checkbox( c ) => c.id,
Element::Radio( r ) => r.id,
Element::ListItem( l ) => l.id,
Element::WindowButton( b ) => b.id,
_ => None,
};
other.draw( canvas, rect, is_focused, is_hovered, is_pressed, cursor_pos, sel_anchor );
if other.is_interactive()
{
ctx.widget_rects.push( LaidOutWidget
{
rect,
flat_idx,
id: widget_id,
paint_rect: other.paint_bounds( rect ),
handlers: other.handlers(),
keyboard_focusable: other.is_focusable(),
cursor: other.cursor_shape(),
} );
}
flat_idx + 1
}
}
}

327
src/draw/mod.rs Normal file
View File

@@ -0,0 +1,327 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Per-frame drawing pipeline.
//!
//! The run loop hands each configured surface to [`draw_frame`] once per
//! vblank; this module walks it through a decision tree:
//!
//! * **Skip** — no content changed and no interaction state moved, so the
//! previously committed buffer is still correct.
//! * **Partial** — only focus / hover / pressed changed. Install a clip
//! mask covering the paint rects of the affected widgets, repaint only
//! under the clip, damage Wayland with exactly those rects.
//! * **Full** — something substantive changed (app message, animation
//! tick, configure, text edit, scroll, slider drag). Clear + redraw
//! the entire view, let damage tracking tighten the commit.
//!
//! Each of those paths has a software variant (CPU + SHM pool) and a
//! GLES variant (FBO + EGL swap). The four resulting functions live in
//! [`software`] and [`gles`]; this file is just the router plus the
//! small shared setup ([`DrawCtx`], [`pick_shm_format`]).
//!
//! # Submodule layout
//!
//! * [`software`] — `draw_surface_full` / `draw_surface_partial`
//! * [`gles`] — `draw_surface_full_gpu` / `draw_surface_partial_gpu`
//! * [`damage`] — `compute_interaction_dirty_rects`, `compute_damage`,
//! `clamp_rect_to`
//! * [`chrome`] — `draw_titlebar`, `apply_input_region`
//! * [`layout`] — `layout_and_draw` (the recursive element walker)
use std::collections::HashMap;
use std::sync::Arc;
use smithay_client_toolkit::reexports::client::protocol::{ wl_shm, wl_surface::WlSurface };
use smithay_client_toolkit::shm::Shm;
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus, SurfaceState };
use crate::render::Canvas;
use crate::types::{ Color, Rect };
use crate::widget::{ Element, LaidOutWidget };
pub( crate ) mod software;
pub( crate ) mod gles;
pub( crate ) mod damage;
pub( crate ) mod chrome;
pub( crate ) mod layout;
pub( crate ) use damage::{ compute_damage, compute_interaction_dirty_rects };
pub( crate ) use layout::layout_and_draw;
/// Pick the best wl_shm format for our RGBA-premultiplied pixmap.
///
/// Abgr8888 matches tiny-skia's byteorder on little-endian systems, so we can
/// copy with a plain memcpy. If the compositor doesn't advertise it, fall back
/// to Argb8888 (mandatory per wl_shm) which requires a per-channel swap.
///
/// Returns `(format, swap_rb)`.
pub( crate ) fn pick_shm_format( shm: &Shm ) -> ( wl_shm::Format, bool )
{
if shm.formats().contains( &wl_shm::Format::Abgr8888 )
{
( wl_shm::Format::Abgr8888, false )
} else {
( wl_shm::Format::Argb8888, true )
}
}
/// Per-frame draw state threaded through [`layout_and_draw`]. Captures
/// the interaction snapshot (focus / hover / pressed), scratch space
/// for the widget-rect list the frame will produce, and the scroll
/// offsets / sub-canvases carried across frames.
///
/// `scroll_canvases` arrives populated (the previous frame's
/// sub-canvases) so `layout_and_draw` can re-use them for Scroll
/// viewports whose size did not change. `scroll_rects` and
/// `widget_rects` start empty and get filled as the element tree is
/// walked.
pub( crate ) struct DrawCtx<Msg: Clone>
{
pub focused_idx: Option<usize>,
pub hovered_idx: Option<usize>,
pub pressed_idx: Option<usize>,
pub cursor_state: HashMap<usize, usize>,
pub selection_anchor: HashMap<usize, usize>,
pub widget_rects: Vec<LaidOutWidget<Msg>>,
pub debug_layout: bool,
pub scroll_offsets: HashMap<usize, f32>,
pub scroll_rects: Vec<(Rect, usize)>,
pub scroll_canvases: HashMap<usize, Canvas>,
/// Per-scroll navigation map: list of `(flat_idx, content_y, height)`
/// for every interactive item the scroll's child laid out, in
/// document order, **including items currently scrolled off-screen**.
/// Keyboard arrow handlers read this to step the runtime's
/// `hovered_idx` item-by-item without needing to know how the popup
/// content was composed. The Y is in pre-translation, pre-offset
/// coordinates (i.e. relative to the start of the scroll's child
/// content) so the keyboard auto-scroll can compute the offset
/// needed to bring an item into view without depending on the
/// current scroll position.
pub scroll_navigable_items: HashMap<usize, Vec<( usize, f32, f32 )>>,
/// Snapshot of the previous frame's `widget_rects`. Read by
/// [`crate::widget::anchored_overlay::AnchoredOverlay`] at draw time
/// to look up the rect of an anchor widget by [`crate::WidgetId`] and
/// re-position itself relative to that rect. Drivers populate this
/// before invoking the recursive layout / draw walk.
pub previous_widget_rects: Vec<LaidOutWidget<Msg>>,
}
/// 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
/// the widget tree painted, matching the convention every other
/// toolkit follows for runtime-internal popups.
pub( crate ) fn draw_context_menu(
canvas: &mut crate::render::Canvas,
menu: &crate::event_loop::app_data::ContextMenu,
)
{
let palette = crate::theme::palette();
let bg = palette.surface;
let border = palette.divider;
let text = palette.text_primary;
let muted = palette.text_secondary;
let hi = palette.surface_alt;
let r = menu.rect;
canvas.fill_rect( r, bg, 8.0 );
canvas.stroke_rect( r, border, 1.0, 8.0 );
let ( ys, row_h ) = menu.row_ys();
// Row order: Copy / Cut / Paste / Delete. Labels go through
// `rust_i18n::t!()` so the menu picks up the active locale; the
// `enabled` flag mirrors the gating in `handle_context_menu_press`.
let labels: [ ( String, bool ); 4 ] =
[
( rust_i18n::t!( "context_menu.copy" ).to_string(), menu.has_selection ),
( rust_i18n::t!( "context_menu.cut" ).to_string(), menu.has_selection ),
( rust_i18n::t!( "context_menu.paste" ).to_string(), menu.can_paste ),
( rust_i18n::t!( "context_menu.delete" ).to_string(), menu.has_selection ),
];
// Subtle accent band on the row matching the *primary* action so
// the menu reads as "Paste is the default" when there is no
// selection (the common case for a paste-into-empty-field click)
// and "Copy is the default" when a selection is active. Just a
// hint, not a binding — every row still works on its own click.
let primary_idx = if menu.has_selection { 0 } else { 2 };
let primary_band = crate::types::Rect
{
x: r.x + 4.0, y: ys[ primary_idx ] + 2.0,
width: r.width - 8.0, height: row_h - 4.0,
};
canvas.fill_rect( primary_band, hi, 6.0 );
for ( i, ( label, enabled ) ) in labels.iter().enumerate()
{
let color = if *enabled { text } else { muted };
canvas.draw_text(
label,
r.x + 16.0,
ys[ i ] + row_h * 0.5 + 5.0,
14.0,
color,
);
}
// Thin separator between every pair of rows.
let sep_color = palette.divider;
for y in ys.iter().skip( 1 )
{
canvas.draw_line( r.x + 8.0, *y, r.x + r.width - 8.0, *y, sep_color, 1.0 );
}
}
pub( crate ) fn draw_frame<A: App>( data: &mut AppData<A> )
{
// Caches were refreshed by the run loop just before calling us; pull them
// by reference instead of re-invoking `App::view` / `App::overlays` each
// frame. The two `expect`s document the run loop's contract.
let main_view = data.cached_view.as_ref().expect( "view cache populated" );
let overlays = data.cached_overlays.as_ref().expect( "overlays cache populated" );
let main_bg = data.app.background_color();
let main_region = data.app.input_region();
let debug_layout = data.debug_layout;
let ( format, swap_rb ) = pick_shm_format( &data.shm );
let egl_ctx = data.egl_context.as_ref();
let qh = &data.qh;
if data.main.configured && data.main.needs_redraw && !data.main.frame_pending
{
let req_frame = | wl: &WlSurface | { let _ = wl.frame( qh, SurfaceFocus::Main ); };
draw_surface::<A::Message>(
&mut data.main,
&data.compositor_state,
egl_ctx,
main_view,
main_bg,
main_region.as_deref(),
debug_layout,
format,
swap_rb,
&req_frame,
);
data.main.needs_redraw = false;
data.main.last_draw = std::time::Instant::now();
}
for spec in overlays
{
if let Some( ss ) = data.overlays.get_mut( &spec.id )
{
if !ss.configured || !ss.needs_redraw || ss.frame_pending { continue; }
let focus = SurfaceFocus::Overlay( spec.id );
let req_frame = | wl: &WlSurface | { let _ = wl.frame( qh, focus ); };
let bg = Color::rgba( 0.0, 0.0, 0.0, 0.0 );
draw_surface::<A::Message>(
ss,
&data.compositor_state,
egl_ctx,
&spec.view,
bg,
spec.input_region.as_deref(),
debug_layout,
format,
swap_rb,
&req_frame,
);
ss.needs_redraw = false;
ss.last_draw = std::time::Instant::now();
}
}
}
/// Render one surface's current view. Picks between software and GLES,
/// and within each picks between full and partial redraw based on what
/// changed since the last committed frame.
///
/// The decision:
/// * **Skip** — `content_dirty` is false and no interaction state
/// changed. Previously committed buffer stays on screen.
/// * **Partial** — `content_dirty` is false but focus / hover / pressed
/// transitioned. Canvas is preserved across frames, so clip to the
/// dirty widgets and repaint only under the clip.
/// * **Full** — `content_dirty` is true. Clear + redraw + damage.
///
/// Partial eligibility also bails out when the total dirty area >50%
/// of the surface: at that ratio per-region clipping is no faster than
/// a plain full redraw, so the code prefers one big damage rect over
/// several small ones.
fn draw_surface<Msg: Clone>(
ss: &mut SurfaceState<Msg>,
compositor: &smithay_client_toolkit::compositor::CompositorState,
egl_ctx: Option<&Arc<crate::egl_context::EglContext>>,
view: &Element<Msg>,
bg: Color,
input_region: Option<&[Rect]>,
debug_layout: bool,
shm_format: wl_shm::Format,
swap_rb: bool,
request_frame: &dyn Fn( &WlSurface ),
)
{
let scale = ss.scale_factor.max( 1 ) as u32;
let w = ss.width;
let h = ss.height;
if w == 0 || h == 0 { return; }
let pw = w * scale;
let ph = h * scale;
// Decide partial-redraw eligibility BEFORE allocating a buffer. If we end
// up skipping the frame, we want to avoid touching the SHM pool at all.
let canvas_ready = ss.canvas.as_ref()
.map( |c| c.size() == ( pw, ph ) )
.unwrap_or( false );
let partial_eligible = !ss.content_dirty
&& canvas_ready
&& !ss.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.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx,
pw, ph,
);
if dirty_rects.is_empty()
{
// Nothing visible changed — keep the previously committed buffer.
return;
}
// Total dirty area > 50% of screen: a full redraw is no slower than
// per-region clipping but emits a single damage rect.
let total: f32 = dirty_rects.iter().map( |r| r.width * r.height ).sum();
if total < pw as f32 * ph as f32 * 0.5
{
if let ( Some( ctx ), true ) = ( egl_ctx, ss.egl_surface.is_some() )
{
gles::draw_surface_partial_gpu(
ss, compositor, ctx, view, bg, input_region,
dirty_rects, pw, ph, scale, request_frame,
);
} else {
software::draw_surface_partial(
ss, compositor, view, bg, input_region,
shm_format, swap_rb, dirty_rects, pw, ph, scale, request_frame,
);
}
return;
}
}
if let ( Some( ctx ), true ) = ( egl_ctx, ss.egl_surface.is_some() )
{
gles::draw_surface_full_gpu(
ss, compositor, ctx, view, bg, input_region, debug_layout,
pw, ph, scale, request_frame,
);
} else {
software::draw_surface_full(
ss, compositor, view, bg, input_region, debug_layout,
shm_format, swap_rb, pw, ph, scale, request_frame,
);
}
}

283
src/draw/software.rs Normal file
View File

@@ -0,0 +1,283 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Software (CPU + `wl_shm` pool) draw paths.
//!
//! Two functions, both symmetric with their GPU counterparts in
//! [`super::gles`]:
//!
//! * [`draw_surface_full`] — full redraw. Allocate a buffer, clear the
//! canvas, run layout+draw, write pixels to the SHM buffer, damage
//! the whole surface (or fine-grained rects from [`compute_damage`]),
//! commit.
//! * [`draw_surface_partial`] — clip-masked repaint. The canvas
//! pixmap from the previous frame is still valid; install a clip
//! covering only the dirty rects, repaint the background + title bar
//! + widget tree under the clip, and damage Wayland with exactly
//! those rects.
//!
//! Both paths share the DrawCtx / `layout_and_draw` / `draw_titlebar`
//! / `apply_input_region` helpers with the GPU path.
use std::sync::Arc;
use smithay_client_toolkit::compositor::CompositorState;
use smithay_client_toolkit::reexports::client::protocol::{ wl_shm, wl_surface::WlSurface };
use crate::event_loop::SurfaceState;
use crate::render::Canvas;
use crate::types::{ Color, Rect };
use crate::widget::Element;
use super::{ DrawCtx, 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
/// emit either tight per-widget damage or a single full-surface damage rect
/// depending on what [`compute_damage`] derives.
pub( crate ) fn draw_surface_full<Msg: Clone>(
ss: &mut SurfaceState<Msg>,
compositor: &CompositorState,
view: &Element<Msg>,
bg: Color,
input_region: Option<&[Rect]>,
debug_layout: bool,
shm_format: wl_shm::Format,
swap_rb: bool,
pw: u32,
ph: u32,
scale: u32,
request_frame: &dyn Fn( &WlSurface ),
)
{
let Some( pool ) = ss.pool.as_mut() else { return };
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 = ss.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();
let sf = scale as f32;
let tb_h = ss.titlebar_height * sf;
let screen_rect = Rect { x: 0.0, y: tb_h, width: pw as f32, height: (ph as f32 - tb_h).max( 0.0 ) };
if bg.a > 0.0 { canvas.fill( bg ); } else { canvas.clear(); }
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(),
};
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
if ctx.debug_layout
{
for w in &ctx.widget_rects
{
canvas.stroke_rect( w.rect, Color::rgb( 1.0, 0.0, 0.0 ), 1.5, 0.0 );
}
}
if let Some( ref menu ) = ss.context_menu
{
super::draw_context_menu( canvas, menu );
}
// Fallback-theme warning. Painted last so it sits on top of every
// widget; no-op when the real theme is loaded.
draw_fallback_banner( canvas, pw, sf );
let damage_rects = if ss.content_dirty
{
Vec::new()
} else {
compute_damage(
&ss.widget_rects,
&ctx.widget_rects,
ss.prev_focused,
ss.prev_hovered,
ss.prev_pressed,
ss.focused_idx,
ss.hovered_idx,
ss.gesture.pressed_idx,
pw, ph,
)
};
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.scroll_navigable_items = ctx.scroll_navigable_items;
ss.content_dirty = false;
canvas.write_to_wayland_buf( canvas_buf, swap_rb );
let wl_surface = ss.surface.wl_surface();
buffer.attach_to( wl_surface ).expect( "attach" );
if damage_rects.is_empty()
{
wl_surface.damage_buffer( 0, 0, pw as i32, ph as i32 );
} else {
for r in &damage_rects
{
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 );
// 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.
request_frame( wl_surface );
wl_surface.commit();
ss.frame_pending = true;
}
/// Partial redraw: install a clip mask covering only `dirty_rects`, repaint
/// the background + title bar + widget tree under that mask (so unchanged
/// pixels from the previous frame stay), and damage Wayland with exactly
/// those rects.
pub( crate ) fn draw_surface_partial<Msg: Clone>(
ss: &mut SurfaceState<Msg>,
compositor: &CompositorState,
view: &Element<Msg>,
bg: Color,
input_region: Option<&[Rect]>,
shm_format: wl_shm::Format,
swap_rb: bool,
dirty_rects: Vec<Rect>,
pw: u32,
ph: u32,
scale: u32,
request_frame: &dyn Fn( &WlSurface ),
)
{
let Some( pool ) = ss.pool.as_mut() else { return };
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,
};
// Canvas pixels from the previous frame are still valid for the unclipped
// region and serve as our background — do not call clear()/fill() here.
let canvas = ss.canvas.as_mut().expect( "partial path requires existing canvas" );
canvas.set_clip_rects( &dirty_rects );
let sf = scale as f32;
let tb_h = ss.titlebar_height * sf;
let screen_rect = Rect { x: 0.0, y: tb_h, width: pw as f32, height: (ph as f32 - tb_h).max( 0.0 ) };
// Repaint surface background under the clip so the dirty rects start from
// a known state instead of leaking the previous widget's pixels.
if bg.a > 0.0 { canvas.fill( bg ); }
else
{
canvas.clear_rects_transparent( &dirty_rects );
}
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(),
};
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
if let Some( ref menu ) = ss.context_menu
{
super::draw_context_menu( canvas, menu );
}
// Fallback-theme warning. Painted last so it sits on top of every
// widget; no-op when the real theme is loaded. If the banner's row
// happens to fall outside the partial-redraw clip, the previous
// frame's banner is still on the canvas (pixmap preserved), which
// is the correct behaviour.
draw_fallback_banner( canvas, pw, sf );
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.scroll_navigable_items = ctx.scroll_navigable_items;
ss.content_dirty = false;
canvas.write_to_wayland_buf( canvas_buf, swap_rb );
let wl_surface = ss.surface.wl_surface();
buffer.attach_to( wl_surface ).expect( "attach" );
for r in &dirty_rects
{
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 );
request_frame( wl_surface );
wl_surface.commit();
ss.frame_pending = true;
}

515
src/egl_context.rs Normal file
View File

@@ -0,0 +1,515 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! EGL bootstrap for the GPU rendering path.
//!
//! Initialises an `EGLDisplay` from the Wayland connection, picks an
//! `EGLConfig`, and creates an `EGLContext`. Tries GLES 3 first and falls
//! back to GLES 2 if the driver does not advertise it. On success returns an
//! [`EglContext`] holding a `glow::Context` already pointed at the resolved
//! GL functions; on failure returns `Err( reason )` so the caller can fall
//! back to the software `wl_shm` path.
//!
//! Per-surface, [`EglSurface`] wraps a `wl_egl_window` plus an `EGLSurface`
//! pinned to the wayland surface. Resizing the wayland surface must call
//! [`EglSurface::resize`] so the underlying buffer follows.
//!
//! The bootstrap honours `LTK_FORCE_SOFTWARE=1` by failing fast with a
//! descriptive error. Backend selection is announced exactly once per
//! process via `eprintln!( "[ltk] render backend: {GLES3|GLES2|SOFTWARE (...)}" )`
//! — the GPU branch logs from [`EglContext::new`], the SOFTWARE branch from
//! [`log_software_fallback`] (called by the integration site in `draw.rs`).
use khronos_egl as egl;
use std::ffi::c_void;
use std::sync::{ Arc, Once, OnceLock };
use crate::gles_render::GlesVersion;
use smithay_client_toolkit::reexports::client::
{
Connection,
Proxy,
protocol::wl_surface::WlSurface,
};
type EglInstance = egl::DynamicInstance<egl::EGL1_4>;
/// `eglSwapBuffersWithDamageKHR` / `EXT` signature. Loaded at runtime via
/// `eglGetProcAddress` when the corresponding extension is advertised.
///
/// Why we need this: Mesa's plain `eglSwapBuffers` emits
/// `wl_surface.damage(0, 0, INT32_MAX, INT32_MAX)` as a "damage everything"
/// sentinel. Some compositor renderers (sway/wlroots GLES backend at the
/// time of writing) consume that value literally, calling
/// `glTexSubImage2D(0, 0, INT32_MAX, INT32_MAX)` to upload the wl_buffer to
/// their internal cache texture. That fails with `GL_INVALID_VALUE` and the
/// compositor keeps showing the previously-cached frame (the first frame
/// goes through `glTexImage2D` which doesn't have this bug, so the *initial*
/// render is fine — but every subsequent eglSwapBuffers is silently dropped).
///
/// Using `eglSwapBuffersWithDamage` makes Mesa emit
/// `wl_surface.damage_buffer(x, y, w, h)` with the actual rect we pass,
/// which the compositor then uploads correctly.
type SwapBuffersWithDamageFn = unsafe extern "system" fn(
display: egl::EGLDisplay,
surface: egl::EGLSurface,
rects: *const egl::Int,
n_rects: egl::Int,
) -> egl::Boolean;
/// Process-wide EGL display + GLES context. Cheap to clone because the heavy
/// state (`Arc<EglInstance>`, `Arc<glow::Context>`) is reference-counted; the
/// raw EGL handles are POD.
pub struct EglContext
{
pub egl: Arc<EglInstance>,
pub display: egl::Display,
pub config: egl::Config,
pub context: egl::Context,
pub version: GlesVersion,
// Initialised lazily on the first `make_current`. Glow's constructor
// eagerly calls `glGetString( GL_VERSION )`, which requires a current
// context — and at `EglContext::new` time there is no surface yet.
gl: OnceLock<Arc<glow::Context>>,
/// Resolved `eglSwapBuffersWithDamageKHR` / `EXT` pointer when either
/// extension is advertised, `None` otherwise. The draw path uses it in
/// place of `eglSwapBuffers` to avoid Mesa's `INT32_MAX` damage sentinel
/// (see [`SwapBuffersWithDamageFn`] docs).
swap_with_damage: Option<SwapBuffersWithDamageFn>,
}
/// Per-Wayland-surface EGL window. `egl_window` owns the `wl_egl_window`; it
/// must outlive `surface` because EGL keeps a raw pointer into it. `egl` and
/// `display` are kept so that `Drop` can call `eglDestroySurface` without
/// requiring a `&EglContext` at the call site.
pub struct EglSurface
{
pub egl_window: wayland_egl::WlEglSurface,
pub surface: egl::Surface,
egl: Arc<EglInstance>,
display: egl::Display,
}
/// Runtime-free EGL target for code that wants a GPU [`crate::render::Canvas`]
/// without going through `ltk::run`.
///
/// This owns an EGL display, GLES context, and tiny pbuffer surface. The ltk
/// GLES canvas still renders into its own FBO; the pbuffer exists only to make
/// a valid EGL context current for GL calls. Compositors that already own a GL
/// context should normally create `Canvas::new_gles(...)` themselves and pass it
/// to `UiSurface::from_canvas` instead.
pub struct EglOffscreenContext
{
egl: Arc<EglInstance>,
display: egl::Display,
config: egl::Config,
context: egl::Context,
surface: egl::Surface,
version: GlesVersion,
gl: Arc<glow::Context>,
}
impl Drop for EglSurface
{
fn drop( &mut self )
{
// Best-effort: errors here can't be acted on, the surface is going away.
let _ = self.egl.destroy_surface( self.display, self.surface );
}
}
impl Drop for EglOffscreenContext
{
fn drop( &mut self )
{
let _ = self.egl.make_current( self.display, None, None, None );
let _ = self.egl.destroy_surface( self.display, self.surface );
let _ = self.egl.destroy_context( self.display, self.context );
let _ = self.egl.terminate( self.display );
}
}
impl EglContext
{
/// Initialise EGL on `conn`. Returns `Err( reason )` if EGL cannot be
/// used (forced software, library missing, no compatible config, ES2/3
/// context creation failed). The caller falls back to SHM and is
/// expected to log via [`log_software_fallback`].
pub fn new( conn: &Connection ) -> Result<Self, String>
{
if std::env::var( "LTK_FORCE_SOFTWARE" ).map( |v| v != "0" ).unwrap_or( false )
{
return Err( "LTK_FORCE_SOFTWARE=1".to_string() );
}
// SAFETY: `EglInstance::load_required` performs a `dlopen` on the
// system `libEGL.so` and is unsafe because the loaded library has
// arbitrary side effects on global process state. We tolerate that:
// libEGL is an established system component and ltk has no
// alternative path to GPU rendering.
let egl: Arc<EglInstance> = Arc::new(
unsafe { EglInstance::load_required() }
.map_err( |e| format!( "load libEGL: {e:?}" ) )?,
);
let wl_display_ptr = conn.backend().display_ptr() as *mut c_void;
// SAFETY: `wl_display_ptr` comes from `Connection::backend().display_ptr()`,
// which guarantees a valid `wl_display *` for as long as `conn` lives.
// `conn` is a `&Connection` argument so the pointer is valid for the
// duration of this call. EGL retains the display reference internally
// and we keep `egl` alive for the process lifetime.
let display = unsafe { egl.get_display( wl_display_ptr ) }
.ok_or_else( || "eglGetDisplay returned NULL".to_string() )?;
egl.initialize( display )
.map_err( |e| format!( "eglInitialize: {e:?}" ) )?;
// Multiple client APIs can coexist on EGL 1.4; we want OpenGL ES.
egl.bind_api( egl::OPENGL_ES_API )
.map_err( |e| format!( "eglBindAPI: {e:?}" ) )?;
let config_attribs = [
egl::SURFACE_TYPE, egl::WINDOW_BIT,
egl::RED_SIZE, 8,
egl::GREEN_SIZE, 8,
egl::BLUE_SIZE, 8,
egl::ALPHA_SIZE, 8,
egl::RENDERABLE_TYPE, egl::OPENGL_ES2_BIT,
egl::NONE,
];
let config = egl.choose_first_config( display, &config_attribs )
.map_err( |e| format!( "eglChooseConfig: {e:?}" ) )?
.ok_or_else( || "no compatible EGL config".to_string() )?;
// Try ES3 first, fall back to ES2. CONTEXT_CLIENT_VERSION applies to
// both ES2 and ES3 contexts when the value is the major version.
let ( context, version ) = match try_create_context( &egl, display, config, 3 )
{
Ok( ctx ) => ( ctx, GlesVersion::V3 ),
Err( _ ) => match try_create_context( &egl, display, config, 2 )
{
Ok( ctx ) => ( ctx, GlesVersion::V2 ),
Err( e ) => return Err( format!( "eglCreateContext (ES2/ES3): {e:?}" ) ),
},
};
log_backend_once( match version
{
GlesVersion::V3 => "GLES3",
GlesVersion::V2 => "GLES2",
} );
// Try to resolve eglSwapBuffersWithDamage{KHR,EXT}. The KHR variant is
// preferred (newer, identical signature). Falling back to plain
// eglSwapBuffers when neither is available means we keep the latent
// INT32_MAX-damage bug, but at least we don't silently fail to start.
let extensions = egl.query_string( Some( display ), egl::EXTENSIONS )
.ok()
.and_then( |s| s.to_str().ok() )
.unwrap_or( "" );
let proc_name = if extensions.contains( "EGL_KHR_swap_buffers_with_damage" )
{
Some( "eglSwapBuffersWithDamageKHR" )
} else if extensions.contains( "EGL_EXT_swap_buffers_with_damage" ) {
Some( "eglSwapBuffersWithDamageEXT" )
} else {
None
};
// SAFETY: `proc_name` is one of the literals
// `"eglSwapBuffersWithDamageKHR"` / `"eglSwapBuffersWithDamageEXT"`,
// gated on the matching extension being advertised by `eglQueryString`.
// When EGL returns a non-null pointer for that name, the symbol is
// guaranteed by the EGL extension spec to have the
// `SwapBuffersWithDamageFn` signature. The pointer is stored as
// `Option<fn>` and only invoked through that typed slot.
let swap_with_damage: Option<SwapBuffersWithDamageFn> = proc_name
.and_then( |n| egl.get_proc_address( n ) )
.map( |p| unsafe { std::mem::transmute::<_, SwapBuffersWithDamageFn>( p ) } );
Ok( Self { egl, display, config, context, version, gl: OnceLock::new(), swap_with_damage } )
}
/// Access the lazily-constructed `glow::Context`. Must be called only after
/// the first successful `make_current`; panics otherwise.
pub fn gl( &self ) -> &Arc<glow::Context>
{
self.gl.get().expect( "EglContext::gl() called before make_current" )
}
/// Create an `EGLSurface` pinned to `wl_surface` at the given pixel size.
pub fn create_surface(
&self, wl_surface: &WlSurface, width: i32, height: i32,
) -> Result<EglSurface, String>
{
let id = wl_surface.id();
let egl_window = wayland_egl::WlEglSurface::new( id, width.max( 1 ), height.max( 1 ) )
.map_err( |e| format!( "wl_egl_window::new: {e:?}" ) )?;
// SAFETY: `egl_window` is a freshly-built `WlEglSurface` whose `ptr()`
// returns the live `wl_egl_window *`. The returned `EglSurface`
// embeds `egl_window` so the pointer outlives the EGL surface (EGL
// retains its own internal reference). `display` and `config` are
// the values we used at context creation, valid for the lifetime
// of `self`.
let surface = unsafe
{
self.egl.create_window_surface(
self.display,
self.config,
egl_window.ptr() as egl::NativeWindowType,
None,
)
}.map_err( |e| format!( "eglCreateWindowSurface: {e:?}" ) )?;
Ok( EglSurface
{
egl_window,
surface,
egl: Arc::clone( &self.egl ),
display: self.display,
} )
}
/// Make `surface` current for subsequent GL calls. The first successful
/// call lazily constructs the shared `glow::Context` (glow needs a live
/// current context to read `GL_VERSION` during initialisation).
pub fn make_current( &self, surface: &EglSurface ) -> Result<(), String>
{
self.egl.make_current(
self.display,
Some( surface.surface ),
Some( surface.surface ),
Some( self.context ),
).map_err( |e| format!( "eglMakeCurrent: {e:?}" ) )?;
if self.gl.get().is_none()
{
let egl_for_loader = Arc::clone( &self.egl );
// SAFETY: `from_loader_function` is unsafe because it calls
// `glGetString( GL_VERSION )` during construction, which requires
// a current context — established by the `make_current` call
// immediately above. The closure resolves symbols through the
// retained `egl_for_loader` (refcounted clone) so the loader
// stays valid for the lifetime of the returned `glow::Context`.
let gl = Arc::new( unsafe
{
glow::Context::from_loader_function( move |name|
{
egl_for_loader.get_proc_address( name )
.map( |p| p as *const _ )
.unwrap_or( std::ptr::null() )
} )
} );
let _ = self.gl.set( gl );
}
Ok( () )
}
pub fn swap_buffers( &self, surface: &EglSurface ) -> Result<(), String>
{
self.egl.swap_buffers( self.display, surface.surface )
.map_err( |e| format!( "eglSwapBuffers: {e:?}" ) )
}
/// Like [`Self::swap_buffers`] but submits explicit damage rects so Mesa
/// emits proper `wl_surface.damage_buffer` requests instead of its
/// `INT32_MAX`-everywhere sentinel. Falls back to plain `swap_buffers`
/// when the extension is unavailable — that path still works on
/// cooperative compositors but trips over the sentinel on others (see the
/// crate-private `SwapBuffersWithDamageFn` type alias above for the
/// underlying rationale).
///
/// Each rect is `(x, y, width, height)` in **EGL window coordinates**
/// (origin at the bottom-left, in physical pixels). Callers that work in
/// top-left screen coords must flip Y before passing them in. For
/// full-surface damage, `(0, 0, w, h)` is correct in either convention.
pub fn swap_buffers_with_damage(
&self, surface: &EglSurface, rects: &[ ( i32, i32, i32, i32 ) ],
) -> Result<(), String>
{
let Some( func ) = self.swap_with_damage else
{
return self.swap_buffers( surface );
};
// Pack rects into a contiguous i32 array as required by the extension.
let mut packed: Vec<egl::Int> = Vec::with_capacity( rects.len() * 4 );
for &( x, y, w, h ) in rects
{
packed.extend_from_slice( &[ x, y, w, h ] );
}
// SAFETY: `func` was resolved in `EglContext::new` via the typed
// `Option<SwapBuffersWithDamageFn>` slot, so its signature is
// known. `display` / `surface.surface` belong to the live `&self`
// / `&surface` borrows. `packed.as_ptr()` is valid for `rects.len()
// * 4` `egl::Int` reads — we just built it with that exact length.
let ok = unsafe
{
func(
self.display.as_ptr(),
surface.surface.as_ptr(),
packed.as_ptr(),
rects.len() as egl::Int,
)
};
if ok == egl::TRUE
{
Ok( () )
} else {
Err( "eglSwapBuffersWithDamage failed".to_string() )
}
}
}
impl EglSurface
{
pub fn resize( &self, width: i32, height: i32 )
{
self.egl_window.resize( width.max( 1 ), height.max( 1 ), 0, 0 );
}
}
impl EglOffscreenContext
{
/// Create a runtime-free EGL context suitable for `Canvas::new_gles`.
pub fn new() -> Result<Self, String>
{
if std::env::var( "LTK_FORCE_SOFTWARE" ).map( |v| v != "0" ).unwrap_or( false )
{
return Err( "LTK_FORCE_SOFTWARE=1".to_string() );
}
// SAFETY: same as `EglContext::new` — `dlopen` of the system
// `libEGL.so`. Same tradeoff and rationale apply.
let egl: Arc<EglInstance> = Arc::new(
unsafe { EglInstance::load_required() }
.map_err( |e| format!( "load libEGL: {e:?}" ) )?,
);
let display = offscreen_display( &egl )?;
egl.initialize( display )
.map_err( |e| format!( "eglInitialize: {e:?}" ) )?;
egl.bind_api( egl::OPENGL_ES_API )
.map_err( |e| format!( "eglBindAPI: {e:?}" ) )?;
let config_attribs = [
egl::SURFACE_TYPE, egl::PBUFFER_BIT,
egl::RED_SIZE, 8,
egl::GREEN_SIZE, 8,
egl::BLUE_SIZE, 8,
egl::ALPHA_SIZE, 8,
egl::RENDERABLE_TYPE, egl::OPENGL_ES2_BIT,
egl::NONE,
];
let config = egl.choose_first_config( display, &config_attribs )
.map_err( |e| format!( "eglChooseConfig: {e:?}" ) )?
.ok_or_else( || "no compatible offscreen EGL config".to_string() )?;
let ( context, version ) = match try_create_context( &egl, display, config, 3 )
{
Ok( ctx ) => ( ctx, GlesVersion::V3 ),
Err( _ ) => match try_create_context( &egl, display, config, 2 )
{
Ok( ctx ) => ( ctx, GlesVersion::V2 ),
Err( e ) => return Err( format!( "eglCreateContext (ES2/ES3): {e:?}" ) ),
},
};
let pbuffer_attribs = [
egl::WIDTH, 1,
egl::HEIGHT, 1,
egl::NONE,
];
let surface = match egl.create_pbuffer_surface( display, config, &pbuffer_attribs )
{
Ok( surface ) => surface,
Err( e ) =>
{
let _ = egl.destroy_context( display, context );
let _ = egl.terminate( display );
return Err( format!( "eglCreatePbufferSurface: {e:?}" ) );
},
};
if let Err( e ) = egl.make_current( display, Some( surface ), Some( surface ), Some( context ) )
{
let _ = egl.destroy_surface( display, surface );
let _ = egl.destroy_context( display, context );
let _ = egl.terminate( display );
return Err( format!( "eglMakeCurrent: {e:?}" ) );
}
let egl_for_loader = Arc::clone( &egl );
// SAFETY: as in `EglContext::make_current`. The `make_current`
// call above has already established the EGL context as current
// on this thread, so `glGetString( GL_VERSION )` (called inside
// `from_loader_function`) is well-defined.
let gl = Arc::new( unsafe
{
glow::Context::from_loader_function( move |name|
{
egl_for_loader.get_proc_address( name )
.map( |p| p as *const _ )
.unwrap_or( std::ptr::null() )
} )
} );
log_backend_once( match version
{
GlesVersion::V3 => "GLES3",
GlesVersion::V2 => "GLES2",
} );
Ok( Self { egl, display, config, context, surface, version, gl } )
}
/// Make this context current on the calling thread.
pub fn make_current( &self ) -> Result<(), String>
{
self.egl.make_current(
self.display,
Some( self.surface ),
Some( self.surface ),
Some( self.context ),
).map_err( |e| format!( "eglMakeCurrent: {e:?}" ) )
}
pub fn gl( &self ) -> &Arc<glow::Context> { &self.gl }
pub fn version( &self ) -> GlesVersion { self.version }
pub fn config( &self ) -> egl::Config { self.config }
}
fn try_create_context(
egl: &EglInstance, display: egl::Display, config: egl::Config, major: i32,
) -> Result<egl::Context, egl::Error>
{
let attribs = [
egl::CONTEXT_CLIENT_VERSION, major,
egl::NONE,
];
egl.create_context( display, config, None, &attribs )
}
fn offscreen_display( egl: &EglInstance ) -> Result<egl::Display, String>
{
// SAFETY: `DEFAULT_DISPLAY` is the EGL sentinel for "the default display
// for the current platform" and is always a valid argument to
// `eglGetDisplay` — the spec guarantees it returns either a valid
// display handle or `EGL_NO_DISPLAY`. No raw pointer is dereferenced
// in this crate.
unsafe { egl.get_display( egl::DEFAULT_DISPLAY ) }
.ok_or_else( || "eglGetDisplay(DEFAULT_DISPLAY) returned NULL".to_string() )
}
/// Log the SOFTWARE fallback once, with the reason. The GPU branch logs
/// from [`EglContext::new`].
pub fn log_software_fallback( reason: &str )
{
log_backend_once( &format!( "SOFTWARE ({reason})" ) );
}
fn log_backend_once( label: &str )
{
static ONCE: Once = Once::new();
ONCE.call_once( ||
{
eprintln!( "[ltk] render backend: {label}" );
} );
}

2037
src/event_loop/app_data.rs Normal file

File diff suppressed because it is too large Load Diff

495
src/event_loop/handlers.rs Normal file
View File

@@ -0,0 +1,495 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use smithay_client_toolkit::
{
compositor::CompositorHandler,
delegate_compositor, 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,
output::{ OutputHandler, OutputState },
registry::{ ProvidesRegistryState, RegistryState },
registry_handlers,
seat::{ Capability, SeatHandler, SeatState },
shell::
{
WaylandSurface,
wlr_layer::{ LayerShellHandler, LayerSurface, LayerSurfaceConfigure },
xdg::popup::{ Popup, PopupConfigure, PopupHandler },
xdg::window::{ Window, WindowConfigure, WindowHandler },
},
shm::{ Shm, ShmHandler },
};
use smithay_client_toolkit::reexports::client::
{
protocol::
{
wl_callback::{ self, WlCallback },
wl_output::{ self, WlOutput },
wl_surface::WlSurface,
wl_seat::WlSeat,
},
Connection, Dispatch, QueueHandle,
};
use wayland_protocols::wp::text_input::zv3::client::
{
zwp_text_input_manager_v3::ZwpTextInputManagerV3,
zwp_text_input_v3::{ self, ZwpTextInputV3 },
};
use crate::app::App;
use super::app_data::AppData;
impl<A: App> CompositorHandler for AppData<A>
{
fn scale_factor_changed( &mut self, _: &Connection, _: &QueueHandle<Self>, surface: &WlSurface, new_factor: i32 )
{
if new_factor <= 0 { return; }
let Some( focus ) = self.focus_for_surface( surface ) else { return };
let ( pw, ph ) = {
let shm = &self.shm;
let ss = match focus
{
super::SurfaceFocus::Main => &mut self.main,
super::SurfaceFocus::Overlay( id ) => match self.overlays.get_mut( &id )
{
Some( s ) => s,
None => return,
},
};
if new_factor == ss.scale_factor { return; }
ss.scale_factor = new_factor;
surface.set_buffer_scale( new_factor );
if let Some( ref mut canvas ) = ss.canvas
{
canvas.set_dpi_scale( new_factor as f32 );
}
let pw = ss.width * new_factor as u32;
let ph = ss.height * new_factor as u32;
// Resize whichever rendering target is active. The two are mutually
// exclusive so only one branch runs.
if let Some( ref es ) = ss.egl_surface
{
es.resize( pw as i32, ph as i32 );
// Canvas FBO reallocation is deferred to the draw path: it needs
// `eglMakeCurrent` first.
} else {
ss.pool = Some(
smithay_client_toolkit::shm::slot::SlotPool::new(
( pw * ph * 4 ) as usize, shm,
).expect( "pool" ),
);
if let Some( ref mut canvas ) = ss.canvas
{
canvas.resize( pw, ph );
}
}
ss.request_redraw();
( pw, ph )
};
// Notify the app of the new physical dimensions. The previous
// `on_resize` it received was scaled with the OLD factor, so any
// app-side state keyed off those pixels is now stale. Only fire
// for the main surface — overlay resizes don't go through
// `App::on_resize`.
if matches!( focus, super::SurfaceFocus::Main )
{
self.app.on_resize( pw, ph );
self.dirty_caches();
}
}
fn transform_changed( &mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlSurface, _: wl_output::Transform ) {}
fn frame(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_surface: &WlSurface,
_time: u32,
)
{
// Do NOT set needs_redraw here — that would create an infinite 60 fps loop
// (commit → frame callback → redraw → commit → ...). Redraws are driven
// exclusively by input events, poll_external messages, and configure events.
}
fn surface_enter( &mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlSurface, _: &WlOutput ) {}
fn surface_leave( &mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlSurface, _: &WlOutput ) {}
}
impl<A: App> LayerShellHandler for AppData<A>
{
fn closed(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
layer: &LayerSurface,
)
{
match self.focus_for_surface( layer.wl_surface() )
{
Some( super::SurfaceFocus::Main ) | None =>
{
if self.app.on_close_requested()
{
self.exit_requested = true;
}
}
Some( super::SurfaceFocus::Overlay( id ) ) =>
{
// Compositor asked us to destroy this overlay. Remove it;
// the next reconcile will not recreate it unless the app
// still returns its id from `App::overlays()`.
self.overlays.remove( &id );
}
}
}
fn configure(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
layer: &LayerSurface,
configure: LayerSurfaceConfigure,
_serial: u32,
)
{
let ( w, h ) = configure.new_size;
let ( w, h ) = ( w.max( 1 ), h.max( 1 ) );
match self.focus_for_surface( layer.wl_surface() )
{
Some( super::SurfaceFocus::Main ) | None =>
{
self.on_configure( w, h );
self.app.on_resize( w, h );
}
Some( super::SurfaceFocus::Overlay( id ) ) =>
{
if let Some( ss ) = self.overlays.get_mut( &id )
{
ss.on_configure( &self.shm, self.egl_context.as_ref(), w, h );
}
}
}
}
}
impl<A: App> WindowHandler for AppData<A>
{
fn request_close(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_window: &Window,
)
{
if self.app.on_close_requested()
{
self.exit_requested = true;
}
}
fn configure(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
window: &Window,
configure: WindowConfigure,
_serial: u32,
)
{
// Mutter ignores set_fullscreen sent before the surface is
// mapped, so reapply on the first configure.
if self.pending_fullscreen
{
window.set_fullscreen( None );
self.pending_fullscreen = false;
}
let w = configure.new_size.0.map( |v| v.get() ).unwrap_or( 800 );
let h = configure.new_size.1.map( |v| v.get() ).unwrap_or( 600 );
self.on_configure( w, h );
self.app.on_resize( w, h );
}
}
impl<A: App> ShmHandler for AppData<A>
{
fn shm_state( &mut self ) -> &mut Shm
{
&mut self.shm
}
}
impl<A: App> OutputHandler for AppData<A>
{
fn output_state( &mut self ) -> &mut OutputState
{
&mut self.output_state
}
fn new_output( &mut self, _: &Connection, qh: &QueueHandle<Self>, output: WlOutput )
{
// If we were waiting for an output to assign the layer surface to, create it now.
// Same for any overlays that were created before an output existed.
if let Some( ref layer_shell ) = self.layer_shell
{
self.main.surface.materialize( &self.compositor_state, layer_shell, qh, &output );
for ss in self.overlays.values_mut()
{
ss.surface.materialize( &self.compositor_state, layer_shell, qh, &output );
}
}
}
fn update_output( &mut self, _: &Connection, _: &QueueHandle<Self>, _: WlOutput ) {}
fn output_destroyed( &mut self, _: &Connection, _: &QueueHandle<Self>, _: WlOutput ) {}
}
impl<A: App> SeatHandler for AppData<A>
{
fn seat_state( &mut self ) -> &mut SeatState
{
&mut self.seat_state
}
fn new_seat(
&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _seat: WlSeat,
) {}
fn new_capability(
&mut self,
_conn: &Connection,
qh: &QueueHandle<Self>,
seat: WlSeat,
capability: Capability,
)
{
match capability
{
Capability::Keyboard if self.keyboard.is_none() =>
{
self.keyboard = Some(
self.seat_state
.get_keyboard( qh, &seat, None )
.expect( "keyboard" ),
);
}
Capability::Pointer if self.pointer.is_none() =>
{
let pointer = self.seat_state
.get_pointer( qh, &seat )
.expect( "pointer" );
// Create a per-pointer cursor-shape device when the
// compositor advertises wp_cursor_shape_v1. The device
// outlives the WlPointer we just created and is what
// `set_shape(serial, shape)` is called on.
if let Some( ref mgr ) = self.cursor_shape_manager
{
self.cursor_shape_device = Some( mgr.get_shape_device( &pointer, qh ) );
}
self.pointer = Some( pointer );
}
Capability::Touch if self.touch.is_none() =>
{
self.touch = Some(
self.seat_state
.get_touch( qh, &seat )
.expect( "touch" ),
);
}
_ => {}
}
}
fn remove_capability(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_seat: WlSeat,
capability: Capability,
)
{
match capability
{
Capability::Keyboard => { self.keyboard = None; }
Capability::Pointer => { self.pointer = None; }
Capability::Touch => { self.touch = None; }
_ => {}
}
}
fn remove_seat(
&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _seat: WlSeat,
) {}
}
impl<A: App> PopupHandler for AppData<A>
{
fn configure(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
popup: &Popup,
config: PopupConfigure,
)
{
let ( w, h ) = ( config.width.max( 1 ) as u32, config.height.max( 1 ) as u32 );
if let Some( super::SurfaceFocus::Overlay( id ) ) = self.focus_for_surface( popup.wl_surface() )
{
if let Some( ss ) = self.overlays.get_mut( &id )
{
ss.on_configure( &self.shm, self.egl_context.as_ref(), w, h );
}
}
}
fn done(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
popup: &Popup,
)
{
if let Some( super::SurfaceFocus::Overlay( id ) ) = self.focus_for_surface( popup.wl_surface() )
{
if let Some( msg ) = self.overlay_dismiss_msg( id )
{
self.pending_msgs.push( msg );
}
self.overlays.remove( &id );
}
}
}
// --- Delegate macros ---
delegate_compositor!( @<A: App> AppData<A> );
delegate_output!( @<A: App> AppData<A> );
delegate_shm!( @<A: App> AppData<A> );
delegate_seat!( @<A: App> AppData<A> );
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_xdg_shell!( @<A: App> AppData<A> );
delegate_xdg_window!( @<A: App> AppData<A> );
delegate_xdg_popup!( @<A: App> AppData<A> );
delegate_registry!( @<A: App> AppData<A> );
impl<A: App> ProvidesRegistryState for AppData<A>
{
fn registry( &mut self ) -> &mut RegistryState
{
&mut self.registry_state
}
registry_handlers![ OutputState, SeatState ];
}
// --- Dispatch impls for zwp_text_input_v3 ---
impl<A: App> Dispatch<ZwpTextInputManagerV3, ()> for AppData<A>
{
fn event(
_state: &mut Self,
_proxy: &ZwpTextInputManagerV3,
_event: <ZwpTextInputManagerV3 as smithay_client_toolkit::reexports::client::Proxy>::Event,
_data: &(),
_conn: &Connection,
_qh: &QueueHandle<Self>,
)
{
// no events from manager
}
}
// Frame-callback routing.
//
// Each `draw_*` path requests a `wl_surface.frame` with `SurfaceFocus` user-
// data identifying which `SurfaceState` it belongs to. The compositor fires
// the callback when the surface is ready for its next commit (display refresh,
// VRR cadence, "screen is on", …). Clearing `frame_pending` unblocks the run
// loop so the next iteration is allowed to draw that surface again.
//
// While the app is animating we re-arm the surface for redraw inline so the
// loop keeps ticking at the compositor's pace without needing a fixed-period
// timer.
impl<A: App> Dispatch<WlCallback, super::SurfaceFocus> for AppData<A>
{
fn event(
state: &mut Self,
_proxy: &WlCallback,
_event: wl_callback::Event,
focus: &super::SurfaceFocus,
_conn: &Connection,
_qh: &QueueHandle<Self>,
)
{
let is_animating = state.app.is_animating();
match *focus
{
super::SurfaceFocus::Main =>
{
state.main.frame_pending = false;
if is_animating
{
state.view_dirty = true;
state.main.request_redraw();
}
}
super::SurfaceFocus::Overlay( id ) =>
{
if let Some( ss ) = state.overlays.get_mut( &id )
{
ss.frame_pending = false;
if is_animating
{
state.overlays_dirty = true;
ss.request_redraw();
}
}
}
}
}
}
impl<A: App> Dispatch<ZwpTextInputV3, ()> for AppData<A>
{
fn event(
state: &mut Self,
_proxy: &ZwpTextInputV3,
event: zwp_text_input_v3::Event,
_data: &(),
_conn: &Connection,
_qh: &QueueHandle<Self>,
)
{
let focus = state.keyboard_focus;
match event
{
zwp_text_input_v3::Event::CommitString { text } =>
{
if let Some( text ) = text
{
if !text.is_empty()
{
state.handle_text_insert( focus, &text );
}
}
}
zwp_text_input_v3::Event::DeleteSurroundingText { before_length, .. } =>
{
for _ in 0..before_length
{
state.handle_backspace( focus );
}
}
zwp_text_input_v3::Event::Done { .. } =>
{
state.surface_mut( focus ).request_redraw();
}
_ => {}
}
}
}

814
src/event_loop/mod.rs Normal file
View File

@@ -0,0 +1,814 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
pub( crate ) mod app_data;
mod handlers;
pub( crate ) use app_data::{ AppData, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState };
use smithay_client_toolkit::
{
compositor::{ CompositorState, Surface },
output::OutputState,
registry::RegistryState,
seat::SeatState,
shell::
{
WaylandSurface,
wlr_layer::LayerShell,
xdg::
{
XdgPositioner, XdgShell, XdgSurface,
popup::Popup,
window::WindowDecorations,
},
},
shm::Shm,
};
use smithay_client_toolkit::reexports::client::Connection;
use smithay_client_toolkit::reexports::calloop::EventLoop;
use smithay_client_toolkit::reexports::calloop_wayland_source::WaylandSource;
use calloop::timer::{ Timer, TimeoutAction };
use wayland_protocols::wp::text_input::zv3::client::zwp_text_input_manager_v3::ZwpTextInputManagerV3;
use wayland_protocols::xdg::shell::client::xdg_positioner::
{
Anchor as PositionerAnchor,
ConstraintAdjustment,
Gravity,
};
use crate::app::{ App, InvalidationScope, SurfaceTarget };
use crate::draw::draw_frame;
use crate::types::{ Point, Rect };
use std::collections::HashSet;
/// Reasons [`crate::try_run`] (and therefore [`crate::run`]) can fail
/// to bring up the event loop.
///
/// Every variant maps to a fatal failure during init: the Wayland
/// connection, the calloop event loop, or one of the protocol bindings
/// the runtime cannot operate without (`wl_compositor`, `wl_shm`,
/// `xdg_wm_base`). Once init succeeds, runtime errors during the dispatch
/// loop still panic — they are non-recoverable from the caller's point
/// of view since the surface is already on screen.
///
/// Embedders that want a software-rendered fallback or that need to
/// degrade gracefully should call [`crate::try_run`] and match on the
/// variants instead of letting [`crate::run`] panic.
#[ derive( Debug ) ]
pub enum RunError
{
/// `WAYLAND_DISPLAY` is unset, the socket is missing, or the
/// compositor refused the handshake. Includes the detailed reason
/// from the underlying `wayland-client` error.
NoWaylandConnection( String ),
/// The Wayland registry could not be enumerated. Almost always a
/// compositor / driver bug — the registry is the first thing every
/// Wayland client touches.
RegistryInit( String ),
/// `calloop`'s `EventLoop::try_new` or its Wayland source insertion
/// failed (typically an `io` error talking to the kernel).
EventLoop( String ),
/// A required Wayland protocol is missing from the compositor.
/// `name` is the wire-format protocol name; `detail` is the
/// underlying bind error.
MissingProtocol
{
name: &'static str,
detail: String,
},
}
impl std::fmt::Display for RunError
{
fn fmt( &self, f: &mut std::fmt::Formatter<'_> ) -> std::fmt::Result
{
match self
{
Self::NoWaylandConnection( d ) =>
write!( f, "Wayland connection failed: {d}" ),
Self::RegistryInit( d ) =>
write!( f, "Wayland registry init failed: {d}" ),
Self::EventLoop( d ) =>
write!( f, "event-loop setup failed: {d}" ),
Self::MissingProtocol { name, detail } =>
write!( f, "Wayland protocol `{name}` unavailable: {detail}" ),
}
}
}
impl std::error::Error for RunError {}
/// Run the application, panicking on init failure. Thin wrapper over
/// [`try_run`] kept for backwards-compatibility — embedders that need
/// to recover from a missing compositor or a stripped-down driver
/// should call [`try_run`] instead.
pub( crate ) fn run<A: App>( app: A )
{
if let Err( e ) = try_run( app )
{
panic!( "ltk::run failed during init: {e}" );
}
}
/// Run the application, returning a typed error on init failure.
/// The dispatch loop's runtime errors still panic — they are non-
/// recoverable once the surface is on screen, and the surface state
/// machine cannot be unwound cleanly from this entry point.
pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
{
let conn = Connection::connect_to_env()
.map_err( |e| RunError::NoWaylandConnection( format!( "{e}" ) ) )?;
let ( globals, queue ) =
smithay_client_toolkit::reexports::client::globals::registry_queue_init( &conn )
.map_err( |e| RunError::RegistryInit( format!( "{e:?}" ) ) )?;
let qh = queue.handle();
let mut event_loop: EventLoop<AppData<A>> = EventLoop::try_new()
.map_err( |e| RunError::EventLoop( format!( "EventLoop::try_new: {e}" ) ) )?;
WaylandSource::new( conn.clone(), queue )
.insert( event_loop.handle() )
.map_err( |e| RunError::EventLoop( format!( "WaylandSource::insert: {e:?}" ) ) )?;
let compositor = CompositorState::bind( &globals, &qh )
.map_err( |e| RunError::MissingProtocol { name: "wl_compositor", detail: format!( "{e:?}" ) } )?;
let shm = Shm::bind( &globals, &qh )
.map_err( |e| RunError::MissingProtocol { name: "wl_shm", detail: format!( "{e:?}" ) } )?;
// 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.
let egl_context = match crate::egl_context::EglContext::new( &conn )
{
Ok( ctx ) => Some( std::sync::Arc::new( ctx ) ),
Err( reason ) =>
{
crate::egl_context::log_software_fallback( &reason );
None
}
};
crate::render::set_software_render( egl_context.is_none() );
// Bind layer-shell up front. Both the main surface (when requested via
// ShellMode::Layer) and every overlay returned by App::overlays() share
// this single binding.
let layer_shell_opt: Option<LayerShell> = LayerShell::bind( &globals, &qh ).ok();
// Backwards compatibility: window_config() overrides shell_mode()
let force_window = app.window_config()
.map( |( t, id )| ( t.to_string(), id.to_string() ) );
let bind_xdg = |globals: &smithay_client_toolkit::reexports::client::globals::GlobalList, qh: &smithay_client_toolkit::reexports::client::QueueHandle<AppData<A>>|
-> Result<XdgShell, RunError>
{
XdgShell::bind( globals, qh )
.map_err( |e| RunError::MissingProtocol { name: "xdg_wm_base", detail: format!( "{e:?}" ) } )
};
// Pinning min == max is the standard idiom on xdg-shell for "I
// want this exact size". Skipped when going fullscreen — Mutter
// rejects the fullscreen request if a min_size constrains it.
let apply_size_hint = |window: &smithay_client_toolkit::shell::xdg::window::Window|
{
if app.start_fullscreen() { return; }
match app.window_size_hint()
{
Some( ( w, h ) ) =>
{
// `set_min_size` doubles as the suggested initial size:
// most compositors send the first configure with this
// value, after which the surface remains user-resizable
// (the runtime adopts whatever size the compositor
// supplies on subsequent configures). `set_max_size` is
// deliberately *not* called — pinning min == max would
// lock the toplevel and refuse user-initiated resize.
window.set_min_size( Some( ( w, h ) ) );
}
None =>
{
window.set_min_size( Some( ( 360, 480 ) ) );
}
}
};
let apply_fullscreen = |window: &smithay_client_toolkit::shell::xdg::window::Window|
{
if app.start_fullscreen()
{
window.set_fullscreen( None );
}
};
let ( surface_kind, xdg_shell ) = if let Some( ( ref title, ref app_id ) ) = force_window
{
let xdg = bind_xdg( &globals, &qh )?;
let surface = compositor.create_surface( &qh );
let window = xdg.create_window( surface, WindowDecorations::RequestServer, &qh );
window.set_title( title.as_str() );
window.set_app_id( app_id.as_str() );
apply_size_hint( &window );
apply_fullscreen( &window );
window.commit();
( SurfaceKind::Window( window ), Some( xdg ) )
} else {
// Use shell_mode() to determine surface type
use crate::app::ShellMode;
match app.shell_mode()
{
ShellMode::Window => {
let xdg = bind_xdg( &globals, &qh )?;
let surface = compositor.create_surface( &qh );
let window = xdg.create_window( surface, WindowDecorations::RequestServer, &qh );
window.set_title( "ltk" );
window.set_app_id( "ltk" );
apply_size_hint( &window );
window.commit();
( SurfaceKind::Window( window ), Some( xdg ) )
}
ShellMode::Layer( layer ) => {
if layer_shell_opt.is_some()
{
// Defer surface creation until new_output fires: if we create the layer
// surface before the compositor has any output ready (e.g. sway startup),
// the compositor cannot assign it and logs an error.
let cfg = LayerConfig {
layer: layer.to_wlr_layer(),
exclusive_zone: app.exclusive_zone(),
anchor: app.layer_anchor(),
size: app.layer_size(),
keyboard_exclusive: app.keyboard_exclusive(),
namespace: "ltk-sctk",
};
( SurfaceKind::Pending( cfg ), None )
} else {
eprintln!( "ltk: wlr-layer-shell not available, falling back to xdg window" );
let xdg = bind_xdg( &globals, &qh )?;
let surface = compositor.create_surface( &qh );
let window = xdg.create_window( surface, WindowDecorations::RequestServer, &qh );
window.set_title( "ltk" );
window.set_app_id( "ltk" );
apply_size_hint( &window );
window.commit();
( SurfaceKind::Window( window ), Some( xdg ) )
}
}
}
};
let text_input_manager: Option<ZwpTextInputManagerV3> = globals.bind( &qh, 1..=1, () ).ok();
let debug_layout = std::env::var( "LTK_DEBUG_LAYOUT" ).is_ok();
let titlebar_height = if force_window.is_some() { 36.0 } else { 0.0 };
let titlebar_title = force_window.map( |( t, _ )| t ).unwrap_or_default();
let pending_fullscreen = app.start_fullscreen();
let mut data = AppData
{
app,
registry_state: RegistryState::new( &globals ),
seat_state: SeatState::new( &globals, &qh ),
output_state: OutputState::new( &globals, &qh ),
compositor_state: compositor,
shm,
egl_context,
xdg_shell,
layer_shell: layer_shell_opt,
keyboard: None,
pointer: None,
touch: None,
pointer_pos: Point::default(),
cursor_shape_manager:
smithay_client_toolkit::seat::pointer::cursor_shape::CursorShapeManager::bind( &globals, &qh ).ok(),
cursor_shape_device: None,
last_pointer_enter_serial: 0,
current_cursor_shape: None,
text_input_manager,
text_input: None,
shift_pressed: false,
ctrl_pressed: false,
loop_handle: event_loop.handle(),
compositor_repeat_rate: 0,
compositor_repeat_delay: 0,
key_repeat: None,
button_repeat: None,
clipboard: String::new(),
last_press_time: None,
last_press_pos: None,
debug_layout,
pending_msgs: Vec::new(),
pending_drag_inits: Vec::new(),
qh: qh.clone(),
last_pointer_serial: 0,
last_input_serial: 0,
exit_requested: false,
pending_fullscreen,
main: SurfaceState::<A::Message>::new( surface_kind, titlebar_height, titlebar_title ),
overlays: std::collections::HashMap::new(),
pointer_focus: SurfaceFocus::Main,
keyboard_focus: SurfaceFocus::Main,
touch_focus: std::collections::HashMap::new(),
cached_view: None,
cached_overlays: None,
view_dirty: true,
overlays_dirty: true,
};
// Register a calloop channel so the app can send messages from any thread.
// Messages sent through the Sender wake the event loop immediately.
{
let ( sender, channel ) = calloop::channel::channel::<A::Message>();
event_loop.handle()
.insert_source(
channel,
|event, _, data: &mut AppData<A>|
{
if let calloop::channel::Event::Msg( msg ) = event
{
// Just queue the message — the run loop will run
// `App::invalidate_after` and apply the resulting
// scope, which is what decides which surfaces (if
// any) actually need to redraw.
data.pending_msgs.push( msg );
}
},
)
.map_err( |e| RunError::EventLoop( format!( "channel insert_source: {e:?}" ) ) )?;
data.app.set_channel_sender( sender );
}
// Register a periodic timer if the app wants one (e.g. clock tick every second).
// The timer fires independently of Wayland events, waking the event loop on schedule.
if let Some( dur ) = data.app.poll_interval()
{
event_loop.handle()
.insert_source(
Timer::from_duration( dur ),
|_, _, data: &mut AppData<A>|
{
let msgs = data.app.poll_external();
data.pending_msgs.extend( msgs );
let next = data.app.poll_interval()
.unwrap_or( std::time::Duration::from_secs( 1 ) );
TimeoutAction::ToDuration( next )
},
)
.map_err( |e| RunError::EventLoop( format!( "poll timer insert_source: {e:?}" ) ) )?;
}
while !data.exit_requested
{
// Sleep until something interesting fires:
// * Wayland event (input, configure, frame callback, …)
// * calloop timer (poll_external)
// * calloop channel (App::set_channel_sender)
// Pacing is driven by `wl_surface.frame` callbacks, so an idle /
// off-screen / VRR display blocks indefinitely instead of polling
// at a fixed rate. The one exception is a pending long-press:
// cap the wait at its deadline so a perfectly still press still
// fires on time.
let timeout = data.next_long_press_wakeup();
event_loop.dispatch( timeout, &mut data ).expect( "dispatch" );
// Any surface whose press has now crossed `long_press_duration`
// emits its stored message and flips into drag mode for the rest
// of the gesture.
data.check_long_press_deadlines();
// Poll external messages (immediate async results, e.g. PAM auth channel)
let ext: Vec<_> = data.app.poll_external();
data.pending_msgs.extend( ext );
// Process pending messages, folding their per-message invalidation
// scopes into a single decision before applying it.
let msgs: Vec<_> = data.pending_msgs.drain( .. ).collect();
let had_msgs = !msgs.is_empty();
if had_msgs
{
let mut scope = InvalidationScope::Only( Vec::new() );
for msg in msgs
{
scope = scope.union( data.app.invalidate_after( &msg ) );
data.app.update( msg );
}
apply_invalidation( &mut data, scope );
// `update()` may have flipped the busy / loading flag
// the app reads from inside `cursor_override`. Re-sync
// the pointer cursor so the change propagates without
// waiting for the next motion event.
let pf = data.pointer_focus;
data.dispatch_cursor_shape( pf );
}
// 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
// coords would otherwise hit a dragging_app=None shell and be lost.
let drag_inits: Vec<_> = data.pending_drag_inits.drain( .. ).collect();
if !drag_inits.is_empty()
{
for origin in drag_inits
{
data.app.on_drag_move( origin.x, origin.y );
}
data.view_dirty = true;
data.overlays_dirty = true;
data.main.request_redraw();
}
// After update() the app state is the source of truth — discard any
// pending text values so that the next keystroke reads the fresh state
// instead of a stale pre-update buffer (e.g. password cleared on auth failure).
if !data.main.pending_text_values.is_empty()
{
data.main.pending_text_values.clear();
}
for ss in data.overlays.values_mut()
{
ss.pending_text_values.clear();
}
// Reconcile the overlay set against the app's current specs: drop any
// overlays whose id disappeared, create new ones for ids that just
// appeared. Specs are re-queried next frame for drawing.
reconcile_overlays( &mut data );
// Draw any surface that's configured, dirty, and not already waiting
// on a frame callback. The compositor decides our cadence — when no
// surface qualifies we just loop back to `dispatch(None)` and sleep.
let any_drawable = ( data.main.configured && data.main.needs_redraw && !data.main.frame_pending )
|| data.overlays.values().any( |ss| ss.configured && ss.needs_redraw && !ss.frame_pending );
if any_drawable
{
// Rebuild while motion is in progress and on the first frame
// after it ends, so the settle frame paints at full quality
// instead of freezing one step short of the target.
// `wants_low_quality_paint` is the source of truth so
// finger-tracked drags get the same treatment. Slider /
// scroll drags are owned by the runtime gesture machine,
// not by `App::update`, so OR with the gesture state to
// pick up that motion signal too.
let runtime_slider_motion =
data.main.gesture.dragging_slider.is_some()
|| data.overlays.values().any( |ss| ss.gesture.dragging_slider.is_some() );
if runtime_slider_motion
{
data.view_dirty = true;
data.overlays_dirty = true;
}
if data.view_dirty
{
data.cached_view = Some( data.app.view() );
data.view_dirty = false;
}
if data.overlays_dirty
{
data.cached_overlays = Some( data.app.overlays() );
data.overlays_dirty = false;
}
draw_frame( &mut data );
}
// Focus the widget with the requested WidgetId if the app requests it
if let Some( id ) = data.app.take_focus_request()
{
let found = data.main.widget_rects.iter()
.find( |w| w.id == Some( id ) )
.map( |w| w.flat_idx );
if let Some( flat_idx ) = found
{
let qh = data.qh.clone();
data.set_focus( SurfaceFocus::Main, Some( flat_idx ), &qh );
data.main.request_redraw();
}
}
}
Ok( () )
}
/// Apply a folded [`InvalidationScope`] from one message-batch iteration:
/// dirty the relevant cache(s) so the next draw rebuilds the view via
/// [`App::view`] / [`App::overlays`], and call `request_redraw` on each
/// affected surface so the run loop's "anything to draw" check picks it up.
///
/// `InvalidationScope::All` (the default returned by `App::invalidate_after`)
/// matches the pre-hook behaviour of broadcasting to every surface.
fn apply_invalidation<A: App>( data: &mut AppData<A>, scope: InvalidationScope )
{
match scope
{
InvalidationScope::All =>
{
data.view_dirty = true;
data.overlays_dirty = true;
data.main.request_redraw();
for ss in data.overlays.values_mut()
{
ss.request_redraw();
}
}
InvalidationScope::Only( targets ) =>
{
for t in targets
{
match t
{
SurfaceTarget::Main =>
{
data.view_dirty = true;
data.main.request_redraw();
}
SurfaceTarget::Overlay( id ) =>
{
// `overlays()` returns a single Vec, so any
// per-overlay change forces the whole list to be
// re-queried. Coarser than per-overlay caching but
// matches the existing API shape.
data.overlays_dirty = true;
if let Some( ss ) = data.overlays.get_mut( &id )
{
ss.request_redraw();
}
}
}
}
}
}
}
/// Pure overlay-id diff. Given the current set of live overlay ids and the
/// list the app just returned from [`crate::app::App::overlays`], compute
/// `( added, removed )`.
///
/// `added` preserves the order of `next` (so creation order is deterministic);
/// `removed` is unordered (driven by HashMap iteration in the caller).
pub fn diff_overlay_ids(
current: impl IntoIterator<Item = crate::app::OverlayId>,
next: &[ crate::app::OverlayId ],
) -> ( Vec<crate::app::OverlayId>, Vec<crate::app::OverlayId> )
{
let current_set: HashSet<crate::app::OverlayId> = current.into_iter().collect();
let next_set: HashSet<crate::app::OverlayId> = next.iter().copied().collect();
let added: Vec<_> = next.iter().copied().filter( |id| !current_set.contains( id ) ).collect();
let removed: Vec<_> = current_set.into_iter().filter( |id| !next_set.contains( id ) ).collect();
( added, removed )
}
/// Sync `data.overlays` with the app's current `App::overlays()` spec list:
/// destroy any overlay whose id disappeared and create a fresh `SurfaceState`
/// for every id that just appeared. Created surfaces are materialized
/// immediately if an output is already available; otherwise they stay
/// [`SurfaceKind::Pending`] and the `new_output` handler will bring them up.
fn reconcile_overlays<A: App>( data: &mut AppData<A> )
{
let specs = data.app.overlays();
let next_ids: Vec<_> = specs.iter().map( |s| s.id ).collect();
let wanted: HashSet<crate::app::OverlayId> = next_ids.iter().copied().collect();
// Drop overlays that disappeared from the spec list. If the overlay had
// an in-flight drag (long-press fired), migrate that state to `main` so
// the motion / release that follows still routes through the app's drag
// handlers — apps typically hide the overlay *because* of the long-press,
// and we must not lose the drag state with the surface.
let removed: Vec<_> = data.overlays.keys()
.filter( |id| !wanted.contains( id ) )
.copied()
.collect();
for id in removed
{
if let Some( ss ) = data.overlays.remove( &id )
{
if ss.gesture.long_press_fired
{
data.main.gesture.long_press_fired = true;
data.main.gesture.long_press_origin = ss.gesture.long_press_origin;
}
}
}
// Clear any stale per-device focus pointing at a destroyed overlay.
if let SurfaceFocus::Overlay( id ) = data.pointer_focus
{
if !data.overlays.contains_key( &id ) { data.pointer_focus = SurfaceFocus::Main; }
}
if let SurfaceFocus::Overlay( id ) = data.keyboard_focus
{
if !data.overlays.contains_key( &id ) { data.keyboard_focus = SurfaceFocus::Main; }
}
// Rewrite touch_focus entries pointing at a destroyed overlay to Main
// rather than dropping them. Dropping would make subsequent motion/up
// events for the same touch id default to Main *without* the long-press
// drag state, turning a release into a stray tap.
for f in data.touch_focus.values_mut()
{
if let SurfaceFocus::Overlay( id ) = *f
{
if !data.overlays.contains_key( &id ) { *f = SurfaceFocus::Main; }
}
}
// Create overlays that just appeared. Uses field-level borrow splitting so
// the shared borrows of `layer_shell` / `xdg_shell` / `compositor_state`
// / `output_state` / `qh` can coexist with the mutable borrow of
// `overlays` inside the loop.
let layer_shell_opt = data.layer_shell.as_ref();
let xdg_shell_opt = data.xdg_shell.as_ref();
let output_opt = data.output_state.outputs().next();
let cs = &data.compositor_state;
let qh = &data.qh;
let grab_seat = data.seat_state.seats().next();
let grab_serial = data.last_input_serial;
// Snapshot the parent xdg_surface (only an xdg toplevel can host
// xdg-popups; layer-shell parents would need a different code path
// via `LayerSurface::get_popup`, which we do not currently support).
let parent_xdg = match data.main.surface
{
SurfaceKind::Window( ref w ) => Some( w.xdg_surface().clone() ),
_ => None,
};
let parent_scale = data.main.scale_factor.max( 1 ) as f32;
// 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 overlays_m = &mut data.overlays;
for spec in &specs
{
if let Some( ss ) = overlays_m.get_mut( &spec.id )
{
// Already-live overlay: propagate size changes to the layer
// surface so apps can animate an overlay's dimensions (e.g. a
// slide-down panel whose height grows each frame). The very
// first size was applied at `materialize` time and recorded in
// `last_requested_size`, so we only commit when it actually
// differs. Commit is needed after `set_size` so the compositor
// 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 let SurfaceKind::Layer( ref layer_surface ) = ss.surface
{
layer_surface.set_size( spec.size.0, spec.size.1 );
layer_surface.commit();
ss.last_requested_size = spec.size;
}
}
// Compare the anchor at integer logical-pixel resolution:
// floating-point jitter would otherwise call `reposition`
// every frame, which several compositors react to by
// dropping the popup grab.
if let SurfaceKind::Popup( ref popup ) = ss.surface
{
if let ( Some( anchor_id ), Some( xdg_shell ) ) = ( spec.anchor_widget_id, xdg_shell_opt )
{
if let Some( anchor_rect ) = main_widget_rects.iter()
.find( |w| w.id == Some( anchor_id ) )
.map( |w| w.rect )
{
let to_logical = | r: Rect |
{
(
( r.x / parent_scale ).round() as i32,
( r.y / parent_scale ).round() as i32,
( r.width / parent_scale ).round().max( 1.0 ) as i32,
( r.height / parent_scale ).round().max( 1.0 ) as i32,
)
};
let new_q = to_logical( anchor_rect );
let moved = ss.last_popup_anchor.map( |r| to_logical( r ) != new_q ).unwrap_or( true );
if moved
{
if let Ok( positioner ) = XdgPositioner::new( xdg_shell )
{
let ( ax, ay, aw, ah ) = new_q;
let ( spec_w, spec_h ) = spec.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 );
positioner.set_anchor_rect( ax, ay, aw, ah );
positioner.set_anchor( PositionerAnchor::Bottom );
positioner.set_gravity( Gravity::Bottom );
positioner.set_constraint_adjustment(
ConstraintAdjustment::FlipY | ConstraintAdjustment::SlideX,
);
ss.popup_reposition_token = ss.popup_reposition_token.wrapping_add( 1 );
popup.reposition( &positioner, ss.popup_reposition_token );
ss.last_popup_anchor = Some( anchor_rect );
}
}
}
}
}
continue;
}
// `anchor_widget_id = Some(_)` → xdg-popup path; `None` →
// wlr-layer-shell path.
if let Some( anchor_id ) = spec.anchor_widget_id
{
let Some( xdg_shell ) = xdg_shell_opt else
{
eprintln!( "ltk: ignoring popup overlay {:?} — main surface is not an xdg-shell window", spec.id );
continue;
};
let Some( ref parent ) = parent_xdg else
{
eprintln!( "ltk: ignoring popup overlay {:?} — no xdg parent surface", spec.id );
continue;
};
let Some( anchor_rect ) = main_widget_rects.iter()
.find( |w| w.id == Some( anchor_id ) )
.map( |w| w.rect )
else
{
eprintln!( "ltk: popup overlay {:?} could not find anchor widget id {:?}", spec.id, anchor_id );
continue;
};
let positioner = match XdgPositioner::new( xdg_shell )
{
Ok( p ) => p,
Err( e ) =>
{
eprintln!( "ltk: XdgPositioner::new failed for popup {:?}: {e}", spec.id );
continue;
}
};
// Convert the requested popup size and the anchor rect from
// physical pixels (the layout coordinate space) to logical
// pixels — the positioner expresses everything in window
// geometry, which is logical. `size.0 == 0` is the
// "match anchor width" convention (paralleling the
// layer-shell `0 = fill` semantic): the popup is sized to
// the trigger pill so combos / selects render flush with
// the field they belong to.
let ax = ( anchor_rect.x / parent_scale ).round() as i32;
let ay = ( anchor_rect.y / parent_scale ).round() as i32;
let aw = ( anchor_rect.width / parent_scale ).round().max( 1.0 ) as i32;
let ah = ( anchor_rect.height / parent_scale ).round().max( 1.0 ) as i32;
let ( spec_w, spec_h ) = spec.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 );
positioner.set_anchor_rect( ax, ay, aw, ah );
positioner.set_anchor( PositionerAnchor::Bottom );
positioner.set_gravity( Gravity::Bottom );
positioner.set_constraint_adjustment(
ConstraintAdjustment::FlipY | ConstraintAdjustment::SlideX,
);
// xdg_popup.grab must be issued before the first commit
// (error 0 `invalid_grab` otherwise). `Popup::new` commits
// internally, so the lower-level `from_surface` path is
// the only one that lets the grab land in time.
let surface = match Surface::new( cs, qh )
{
Ok( s ) => s,
Err( e ) =>
{
eprintln!( "ltk: Surface::new failed for popup overlay {:?}: {e}", spec.id );
continue;
}
};
let popup = match Popup::from_surface( Some( parent ), &positioner, qh, surface, xdg_shell )
{
Ok( p ) => p,
Err( e ) =>
{
eprintln!( "ltk: Popup::from_surface failed for overlay {:?}: {e}", spec.id );
continue;
}
};
if let Some( ref seat ) = grab_seat
{
popup.xdg_popup().grab( seat, grab_serial );
}
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_popup_anchor = Some( anchor_rect );
overlays_m.insert( spec.id, ss );
continue;
}
// wlr-layer-shell path.
let Some( layer_shell ) = layer_shell_opt else
{
eprintln!( "ltk: ignoring layer-shell overlay {:?} — wlr-layer-shell not available", spec.id );
continue;
};
let cfg = LayerConfig
{
layer: spec.layer.to_wlr_layer(),
exclusive_zone: spec.exclusive_zone,
anchor: spec.anchor,
size: spec.size,
keyboard_exclusive: spec.keyboard_exclusive,
namespace: "ltk-overlay",
};
let mut surface = SurfaceKind::Pending( cfg );
if let Some( ref output ) = output_opt
{
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;
overlays_m.insert( spec.id, ss );
}
}

158
src/gles_render/clip.rs Normal file
View File

@@ -0,0 +1,158 @@
// 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.
use glow::HasContext;
use crate::types::{ Color, Rect };
use super::GlesCanvas;
impl GlesCanvas
{
pub fn set_clip_rects( &mut self, rects: &[Rect] )
{
// 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();
if rects.is_empty()
{
self.clear_clip();
return;
}
let mut x0 = f32::INFINITY;
let mut y0 = f32::INFINITY;
let mut x1 = -f32::INFINITY;
let mut y1 = -f32::INFINITY;
for r in rects
{
x0 = x0.min( r.x );
y0 = y0.min( r.y );
x1 = x1.max( r.x + r.width );
y1 = y1.max( r.y + r.height );
}
x0 = x0.max( 0.0 );
y0 = y0.max( 0.0 );
x1 = x1.min( self.width as f32 );
y1 = y1.min( self.height as f32 );
if x1 <= x0 || y1 <= y0
{
// Empty union — install a zero-area scissor so subsequent draws
// are no-ops without disabling the test.
self.set_scissor( Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 } );
return;
}
self.set_scissor( Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } );
}
pub fn clear_clip( &mut self )
{
// SAFETY: see `primitives.rs` module doc. `disable( SCISSOR_TEST )` is
// pure global-state mutation; the cached `clip_scissor` is updated
// to match below.
unsafe { self.gl.disable( glow::SCISSOR_TEST ); }
self.clip_scissor = None;
}
/// Snapshot of the active scissor as a `Vec<Rect>` (empty when no
/// scissor is set).
pub fn clip_bounds_snapshot( &self ) -> Vec<Rect>
{
self.clip_scissor.map_or_else( Vec::new, |r| vec![ r ] )
}
/// Apply `rect` as the current scissor (top-left coords, GL bottom-left).
fn set_scissor( &mut self, rect: Rect )
{
let ( x, y, w, h ) = self.scissor_pixels( rect );
// SAFETY: `scissor_pixels` clamps to non-negative integers; GL accepts
// arbitrary scissor rects (regions outside the framebuffer simply
// cull all fragments). State change is mirrored in `clip_scissor`.
unsafe
{
self.gl.enable( glow::SCISSOR_TEST );
self.gl.scissor( x, y, w, h );
}
self.clip_scissor = Some( rect );
}
/// Convert a top-left rect to the bottom-left integer pixel scissor that
/// GL expects.
pub( super ) fn scissor_pixels( &self, rect: Rect ) -> ( i32, i32, i32, i32 )
{
let x = rect.x.floor() as i32;
let w = rect.width.ceil() as i32;
let h = rect.height.ceil() as i32;
// GL origin is bottom-left, our coords are top-left.
let y_top = rect.y.floor() as i32;
let y_bottom = self.height as i32 - y_top - h;
( x, y_bottom.max( 0 ), w.max( 0 ), h.max( 0 ) )
}
/// Clear to a solid color. Honours the active scissor — if a clip is set,
/// only the clipped region is filled.
pub fn fill( &mut self, color: Color )
{
self.activate_target();
// SAFETY: `clear` writes the configured `clear_color` into every
// fragment that survives the scissor test (which `activate_target`
// has already configured to match `clip_scissor`).
unsafe
{
self.gl.clear_color( color.r, color.g, color.b, color.a );
self.gl.clear( glow::COLOR_BUFFER_BIT );
}
}
/// Clear to fully transparent. Honours the active scissor.
pub fn clear( &mut self )
{
self.activate_target();
// SAFETY: same as `fill`, with a transparent clear colour.
unsafe
{
self.gl.clear_color( 0.0, 0.0, 0.0, 0.0 );
self.gl.clear( glow::COLOR_BUFFER_BIT );
}
}
/// Zero the pixels inside each rect (alpha+RGB → 0).
pub fn clear_rects_transparent( &mut self, rects: &[Rect] )
{
self.activate_target();
let saved = self.clip_scissor;
// SAFETY: enable scissor + set transparent clear colour once for
// the whole loop; per-rect we rewrite `scissor` and clear. After
// the loop we restore `saved` via `set_scissor` / `clear_clip`
// so the cached `clip_scissor` matches the actual GL state again.
unsafe
{
self.gl.enable( glow::SCISSOR_TEST );
self.gl.clear_color( 0.0, 0.0, 0.0, 0.0 );
}
for r in rects
{
let ( x, y, w, h ) = self.scissor_pixels( *r );
if w <= 0 || h <= 0 { continue; }
// SAFETY: per-rect scissor + clear; same invariants as above.
unsafe
{
self.gl.scissor( x, y, w, h );
self.gl.clear( glow::COLOR_BUFFER_BIT );
}
}
// Restore the previous scissor (or disable if none was active).
match saved
{
Some( r ) => self.set_scissor( r ),
None => self.clear_clip(),
}
}
}

View File

@@ -0,0 +1,342 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! FBO / framebuffer management for [`GlesCanvas`]: sub-canvas blit,
//! main-FBO ⇄ default-framebuffer present, lazy auxiliary FBO for
//! snapshot-based effects (Overlay blend inset shadow), and the
//! externally-exposed borrowed-texture view.
//!
//! See `primitives.rs` module doc for the canvas-wide `unsafe` contract
//! shared by every block in this file. Per-block notes below only call
//! out what is specific to the operation.
use glow::HasContext;
use crate::types::Rect;
use super::helpers::{ alloc_fbo_tex, native_framebuffer_id, native_texture_id, ortho_rect };
use super::raii::{ FboBinding, ProgramBinding };
use super::{ BorrowedGlesTexture, GlesCanvas };
impl GlesCanvas
{
pub fn blit( &mut self, src: &GlesCanvas, dest_x: i32, dest_y: i32 )
{
self.blit_fade_bottom( src, dest_x, dest_y, 0.0 );
}
/// Blit `src` into this canvas at `( dest_x, dest_y )`, optionally feathering
/// the last `fade_bottom_px` source rows so the bottom edge dissolves into
/// transparency instead of cutting off cleanly. Used by viewports whose
/// bottom edge is the leading edge of a slide-down animation, where a hard
/// cut against the underlying layer reads as a knife. With `fade_bottom_px
/// == 0.0` this matches [`Self::blit`] exactly.
pub fn blit_fade_bottom( &mut self, src: &GlesCanvas, dest_x: i32, dest_y: i32, fade_bottom_px: f32 )
{
self.activate_target();
let dest = Rect
{
x: dest_x as f32,
y: dest_y as f32,
width: src.width as f32,
height: src.height as f32,
};
let mvp = ortho_rect( self.width, self.height, dest );
let alpha = self.global_alpha;
let height_px = src.height as f32;
let fade_clamp = fade_bottom_px.max( 0.0 ).min( height_px );
// SAFETY: `src.fbo_tex` is owned by `src` (a `&GlesCanvas` argument)
// and outlives the call. `src` and `self` share the same `Arc<glow::Context>`
// — verified by construction (sub-canvases are built via `sub_canvas`,
// which clones `Arc::clone(&self.gl)`) — so sampling `src`'s texture
// from `self`'s FBO is well-defined.
unsafe
{
// Both the main canvas and the sub-canvas FBO hold premultiplied
// colour, and the global blend is `(ONE, ONE_MINUS_SRC_ALPHA)` —
// the premul over-composite formula this blit needs. No temporary
// blend switch necessary.
self.gl.use_program( Some( self.sub_blit_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_subblit_mvp ), false, &mvp );
self.gl.uniform_1_f32( Some( &self.u_subblit_opacity ), alpha );
self.gl.uniform_1_f32( Some( &self.u_subblit_fade_bottom ), fade_clamp );
self.gl.uniform_1_f32( Some( &self.u_subblit_height_px ), height_px );
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( src.fbo_tex ) );
self.gl.uniform_1_i32( Some( &self.u_subblit_sampler ), 0 );
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 );
}
}
/// Re-bind our FBO + viewport + scissor as the active GL state. Cheap and
/// idempotent, called at the top of every draw / clear / clip method so
/// that switching between canvases (e.g. main → sub-canvas → main) leaves
/// each one's state correct without explicit "make active" calls.
///
/// Why this exists: GL state (FBO binding, scissor box, viewport) is
/// global — there is no implicit per-canvas state. When rendering
/// switches between targets, every method on the active canvas must
/// reassert its own FBO + viewport, plus re-enable its own scissor (or
/// 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`)
// live as long as `&self`, and the bind is idempotent.
unsafe
{
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( self.fbo ) );
self.gl.viewport( 0, 0, self.width as i32, self.height as i32 );
match self.clip_scissor
{
Some( r ) =>
{
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 );
}
}
}
}
/// Return a borrowed descriptor for the FBO color texture
/// containing the latest rendered pixels.
///
/// `y_inverted` is `true`: the FBO uses GL's native lower-left
/// origin, so row 0 in texture memory is the bottom of the
/// rendered image. Consumers that follow the same convention flip
/// during sampling when this flag is set, producing a correctly-
/// oriented result. The CPU-side counterpart
/// [`Self::read_rgba_pixels`] does the same flip inline so the
/// byte buffer is top-down.
pub fn borrowed_texture( &self ) -> BorrowedGlesTexture
{
BorrowedGlesTexture
{
texture_id: native_texture_id( self.fbo_tex ),
framebuffer_id: native_framebuffer_id( self.fbo ),
texture: self.fbo_tex,
framebuffer: self.fbo,
width: self.width,
height: self.height,
premultiplied: true,
y_inverted: true,
}
}
/// Read the FBO color attachment into `out` as tightly packed RGBA8,
/// top-left row first.
///
/// This is a compatibility escape hatch. It forces a GPU→CPU sync and
/// should not be used in steady-state hot paths.
pub fn read_rgba_pixels( &self, out: &mut [u8] ) -> Result<(), String>
{
let needed = self.width as usize * self.height as usize * 4;
if out.len() < needed
{
return Err( format!(
"read_rgba_pixels needs {needed} bytes, got {}",
out.len(),
) );
}
let mut raw = vec![ 0_u8; needed ];
// SAFETY: `raw.len() == needed == width * height * 4` and PACK_ALIGNMENT
// is set to 1, so `read_pixels` writes exactly `needed` bytes into a
// buffer of exactly that size. `RGBA + UNSIGNED_BYTE` is the only
// guaranteed-readable format on every GLES2/3 driver.
unsafe
{
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( self.fbo ) );
self.gl.pixel_store_i32( glow::PACK_ALIGNMENT, 1 );
self.gl.read_pixels(
0,
0,
self.width as i32,
self.height as i32,
glow::RGBA,
glow::UNSIGNED_BYTE,
glow::PixelPackData::Slice( Some( &mut raw ) ),
);
}
let stride = self.width as usize * 4;
for y in 0..self.height as usize
{
let src = ( self.height as usize - 1 - y ) * stride;
let dst = y * stride;
out[ dst..dst + stride ].copy_from_slice( &raw[ src..src + stride ] );
}
Ok( () )
}
/// Lazily allocate the auxiliary FBO+texture pair used as a snapshot of
/// `fbo` for framebuffer-fetch-style effects (Overlay blend,
/// backdrop-blur source). The pair is sized to match the canvas so
/// `gl_FragCoord.xy / canvas_size` samples the right texel.
///
/// Returns the texture handle of `aux_a`. The FBO is only needed for
/// the backdrop blur passes that write into `aux_b`; Overlay only
/// reads from `aux_a`, so this helper keeps the blur-only `aux_b`
/// allocation deferred until it is actually needed.
fn ensure_aux_a( &mut self ) -> glow::Texture
{
if self.aux_a.is_none()
{
// SAFETY: `alloc_fbo_tex` is `unsafe fn`; its requirement (current
// GL context) holds. Same FBO build / completeness assertion as
// `setup.rs::new`. We deliberately leave `aux_a`'s FBO as the live
// binding — the next draw goes through `activate_target` which
// rebinds `self.fbo`.
unsafe
{
let fbo = self.gl.create_framebuffer().expect( "aux_a FBO" );
let tex = 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,
);
let status = self.gl.check_framebuffer_status( glow::FRAMEBUFFER );
assert_eq!( status, glow::FRAMEBUFFER_COMPLETE, "aux_a FBO incomplete: 0x{status:x}" );
self.aux_a = Some( ( fbo, tex ) );
}
}
self.aux_a.expect( "just allocated" ).1
}
/// Snapshot variant that additionally clamps `region` to the active
/// scissor. Safe only for shaders that sample the snapshot at
/// exactly one point per fragment.
pub( super ) fn snapshot_fbo_region_tight( &mut self, region: Rect )
{
self.snapshot_fbo_region_impl( region, true )
}
fn snapshot_fbo_region_impl( &mut self, region: Rect, intersect_scissor: bool )
{
let aux_tex = self.ensure_aux_a();
// Clamp `region` to canvas bounds. `copy_tex_sub_image_2d` would
// generate `INVALID_VALUE` (or undefined behaviour on some
// drivers) if the source rect extends outside the framebuffer.
let cw = self.width as f32;
let ch = self.height as f32;
let mut x0 = region.x.max( 0.0 );
let mut y0_top = region.y.max( 0.0 );
let mut x1 = ( region.x + region.width ).min( cw );
let mut y1_top = ( region.y + region.height ).min( ch );
if intersect_scissor
{
if let Some( clip ) = self.clip_scissor
{
x0 = x0.max( clip.x );
y0_top = y0_top.max( clip.y );
x1 = x1.min( clip.x + clip.width );
y1_top = y1_top.min( clip.y + clip.height );
}
}
let w = ( x1 - x0 ).floor() as i32;
let h = ( y1_top - y0_top ).floor() as i32;
if w <= 0 || h <= 0 { return; }
// GL framebuffer origin is bottom-left; our rect is top-left.
let src_x = x0.floor() as i32;
let src_y = self.height as i32 - y0_top.floor() as i32 - h;
// SAFETY: the four bounds checks above guarantee `(src_x, src_y, w, h)`
// lies fully inside `self.fbo`'s colour attachment, so
// `copy_tex_sub_image_2d` will not raise INVALID_VALUE. `aux_tex` was
// allocated through `ensure_aux_a` to canvas dimensions, so the
// destination region is also in-bounds.
unsafe
{
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( self.fbo ) );
self.gl.bind_texture( glow::TEXTURE_2D, Some( aux_tex ) );
self.gl.copy_tex_sub_image_2d
(
glow::TEXTURE_2D, 0,
src_x, src_y,
src_x, src_y,
w, h,
);
self.gl.bind_texture( glow::TEXTURE_2D, None );
}
}
/// Drop both auxiliary FBO+texture pairs if allocated. Called from
/// [`Self::resize`] so the next effect re-allocates at the new size.
pub( super ) fn invalidate_aux( &mut self )
{
// SAFETY: each (fbo, tex) pair was created through `self.gl` in
// `ensure_aux_a` / `ensure_aux_b`, so deleting through the same
// context is well-defined. `take()` ensures we never double-delete.
unsafe
{
if let Some( ( fbo, tex ) ) = self.aux_a.take()
{
self.gl.delete_framebuffer( fbo );
self.gl.delete_texture( tex );
}
if let Some( ( fbo, tex ) ) = self.aux_b.take()
{
self.gl.delete_framebuffer( fbo );
self.gl.delete_texture( tex );
}
}
}
/// Blit the FBO color attachment onto the default framebuffer (the EGL
/// window). Caller is responsible for the `eglSwapBuffers` that
/// publishes the result. After present, the FBO is rebound so the next
/// frame's draws keep accumulating into the shadow canvas.
///
/// The blit always covers the full surface — partial-redraw still saves
/// work upstream (only changed widget pixels are repainted into the
/// FBO), but the FBO→FB0 transfer itself is a single cheap fullscreen
/// op.
pub fn present( &mut self )
{
// Scoped guards: bind the default framebuffer (id 0) and the
// blit program for the duration of this fn. On Drop they restore
// `self.fbo` and "no program", so any future early-return / panic
// in the blit body cannot leave the canvas pointing at the wrong
// FBO or program. The viewport, blend and scissor are restored
// inline below — they are non-resource state that does not need
// the guard treatment because `activate_target` rewrites them on
// every subsequent draw.
//
// SAFETY: GL context is current (canvas invariant). `self.fbo` is
// the canvas-owned FBO from `setup.rs::new`. `self.blit_program`
// was linked in `setup.rs::new`. Restoring "no program active"
// (`None`) is always sound.
let _fbo = unsafe { FboBinding::scoped( &self.gl, None, Some( self.fbo ) ) };
let _prog = unsafe { ProgramBinding::scoped( &self.gl, Some( self.blit_program ), None ) };
// SAFETY: see above. The block sets up the blit pipeline state,
// draws a fullscreen quad sampling `fbo_tex`, then restores blend
// and viewport so the next frame's draws inherit the canvas-wide
// defaults. FBO + program are restored by the guards on scope exit.
unsafe
{
self.gl.viewport( 0, 0, self.width as i32, self.height as i32 );
self.gl.disable( glow::SCISSOR_TEST );
self.gl.disable( glow::BLEND );
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( self.fbo_tex ) );
self.gl.uniform_1_i32( Some( &self.u_blit_sampler ), 0 );
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.enable( glow::BLEND );
self.gl.viewport( 0, 0, self.width as i32, self.height as i32 );
}
// Scissor was disabled above; reflect that in our cached state.
self.clip_scissor = None;
// Guards drop here: FBO → self.fbo, program → None.
}
}

293
src/gles_render/helpers.rs Normal file
View File

@@ -0,0 +1,293 @@
// SPDX-License-Identifier: LGPL-2.1-only
// 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.
use glow::HasContext;
use crate::types::Rect;
use super::GlesVersion;
pub( super ) fn ortho_rect( vp_w: u32, vp_h: u32, rect: Rect ) -> [f32; 16]
{
let w = vp_w as f32;
let h = vp_h as f32;
let sx = rect.width * 2.0 / w;
let sy = rect.height * 2.0 / h;
let tx = rect.x * 2.0 / w - 1.0;
let ty = 1.0 - rect.y * 2.0 / h - sy;
[
sx, 0.0, 0.0, 0.0,
0.0, sy, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
tx, ty, 0.0, 1.0,
]
}
/// Upload `data` as a 2D RGBA texture, premultiplying alpha into the
/// upload buffer.
///
/// `data` is straight-alpha (the format CPU PNG / JPG decoders
/// produce). GL_LINEAR sampling interpolates RGB and A independently
/// across texels: at the antialiased edge of an icon — say a fully
/// opaque black texel next to a fully transparent white texel —
/// straight-alpha interpolation midway gives `( 0.5, 0.5, 0.5, 0.5 )`
/// which composes onto the destination as a 50 % gray halo, while
/// premultiplied interpolation gives `( 0, 0, 0, 0.5 )` and composes
/// as transparent black. Premultiplying once at upload eliminates
/// the halo on every later draw.
///
/// Defensive: when the declared `w × h × 4` size does not match
/// `data.len()`, the function refuses the upload, logs once via
/// stderr, and substitutes a 1×1 transparent placeholder. The GL
/// driver would otherwise read past the slice end inside
/// `tex_image_2d`, since it trusts the dimensions over the slice
/// length.
pub( super ) fn upload_rgba_texture( gl: &glow::Context, version: GlesVersion, data: &[u8], w: i32, h: i32 ) -> glow::Texture
{
let expected = ( w as i64 ).saturating_mul( h as i64 ).saturating_mul( 4 );
let valid = w > 0 && h > 0 && expected >= 0 && expected as usize == data.len();
let placeholder: [u8; 4] = [ 0, 0, 0, 0 ];
let ( safe_w, safe_h, safe_data ): ( i32, i32, &[u8] ) = if valid
{
( w, h, data )
}
else
{
eprintln!(
"[ltk] upload_rgba_texture: refusing malformed upload — \
{w}×{h} declared, {} bytes provided, expected {}",
data.len(),
expected.max( 0 ),
);
( 1, 1, &placeholder[..] )
};
let mut premul = Vec::with_capacity( safe_data.len() );
for px in safe_data.chunks_exact( 4 )
{
let a = px[3] as u32;
// `(c * a + 127) / 255` — round-to-nearest integer scale.
// Plain `c * a / 255` truncates, leaving fully-opaque pixels
// with rgb < their straight value (visibly darker icons).
premul.push( ( ( px[0] as u32 * a + 127 ) / 255 ) as u8 );
premul.push( ( ( px[1] as u32 * a + 127 ) / 255 ) as u8 );
premul.push( ( ( px[2] as u32 * a + 127 ) / 255 ) as u8 );
premul.push( px[3] );
}
// `RGBA8` (sized) on GLES 3 forces 8-bit-per-channel storage; the
// unsized `RGBA` token leaves the format up to the driver and some
// mobile GPUs pick a 4-bits-per-channel or 565+A4 layout for it,
// which shows up as banded / colour-quantised icons. ES 2 has no
// `RGBA8` constant, so we fall back to the unsized form there
// (matching `alloc_fbo_tex`).
let internal_format = match version
{
GlesVersion::V3 => glow::RGBA8 as i32,
GlesVersion::V2 => glow::RGBA as i32,
};
// SAFETY: caller's GL context is current. The most important
// invariant is on the upload size: the validity check above
// guarantees `safe_w * safe_h * 4 == safe_data.len() == premul.len()`,
// or replaces the upload with a 1×1 transparent placeholder when the
// caller provided malformed dimensions. Without this guard the GLES
// driver trusts the dimensions and reads past the slice end inside
// `tex_image_2d` (the original UB this defensive code prevents).
// `RGBA + UNSIGNED_BYTE` is universally supported. We unbind on
// exit to avoid stranding TEXTURE_2D bound to the new texture.
unsafe
{
let tex = gl.create_texture().unwrap();
gl.bind_texture( glow::TEXTURE_2D, Some( tex ) );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::LINEAR as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::LINEAR as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32 );
gl.tex_image_2d(
glow::TEXTURE_2D, 0, internal_format,
safe_w, safe_h, 0, glow::RGBA, glow::UNSIGNED_BYTE,
glow::PixelUnpackData::Slice( Some( &premul ) ),
);
gl.bind_texture( glow::TEXTURE_2D, None );
tex
}
}
/// Allocate a fresh color texture sized for the FBO. Internal format is sized
/// (`GL_RGBA8`) on ES3 and unsized (`GL_RGBA`) on ES2 — the unsized form is
/// required for color-renderable textures on ES2 drivers.
pub( super ) unsafe fn alloc_fbo_tex( gl: &glow::Context, version: GlesVersion, w: u32, h: u32 ) -> glow::Texture
{
// SAFETY: caller guarantees the GL context bound to `gl` is current
// on this thread. `tex_image_2d` with a `None` data slice allocates
// uninitialised storage of size `w*h*4` bytes (RGBA8 / unsized RGBA);
// the size and format combination is valid on every GLES2 / GLES3
// driver. The bind / unbind pair leaves TEXTURE_2D unbound on exit
// so we don't strand a binding the caller might rely on.
unsafe
{
let tex = gl.create_texture().expect( "create_texture" );
gl.bind_texture( glow::TEXTURE_2D, Some( tex ) );
let internal_format = match version
{
GlesVersion::V3 => glow::RGBA8 as i32,
GlesVersion::V2 => glow::RGBA as i32,
};
gl.tex_image_2d(
glow::TEXTURE_2D, 0, internal_format,
w as i32, h as i32, 0, glow::RGBA, glow::UNSIGNED_BYTE,
glow::PixelUnpackData::Slice( None ),
);
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::NEAREST as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::NEAREST as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32 );
gl.bind_texture( glow::TEXTURE_2D, None );
tex
}
}
pub( super ) fn upload_alpha_texture( gl: &glow::Context, data: &[u8], w: i32, h: i32 ) -> glow::Texture
{
// SAFETY: caller's GL context is current. Caller is responsible for the
// `data.len() == w * h` invariant — `upload_alpha_texture` is only called
// from the glyph atlas path on bitmaps fontdue produced at the same
// dimensions. UNPACK_ALIGNMENT is set to 1 for the upload (1 byte/pixel)
// and restored to the GL default of 4 immediately after, so subsequent
// uploads are not affected.
unsafe
{
let tex = gl.create_texture().unwrap();
gl.bind_texture( glow::TEXTURE_2D, Some( tex ) );
// NEAREST, not LINEAR. Glyph atlases are drawn 1:1 with their bitmap
// (dest size = texture size, integer-aligned position). Mathematically
// LINEAR at an exact texel center collapses to the texel value, but
// mediump precision in the fragment shader (and the `1 - v_uv.y` flip)
// can drift the sample point a fraction of a texel off-center, and the
// LINEAR filter then blends with neighbours — visible as soft, washed
// stems, especially at small sizes. NEAREST snaps to the correct texel
// every time, matching the software path's pixel-perfect bitmap copy.
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::NEAREST as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::NEAREST as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32 );
// GL_LUMINANCE is 1 byte/pixel; default UNPACK_ALIGNMENT (4) misreads
// any row whose width is not a multiple of 4, scrambling those glyphs.
gl.pixel_store_i32( glow::UNPACK_ALIGNMENT, 1 );
gl.tex_image_2d(
glow::TEXTURE_2D, 0, glow::LUMINANCE as i32,
w, h, 0, glow::LUMINANCE, glow::UNSIGNED_BYTE,
glow::PixelUnpackData::Slice( Some( data ) ),
);
gl.pixel_store_i32( glow::UNPACK_ALIGNMENT, 4 );
gl.bind_texture( glow::TEXTURE_2D, None );
tex
}
}
pub( super ) fn compile_program( gl: &glow::Context, vert_src: &str, frag_src: &str ) -> glow::Program
{
// SAFETY: caller's GL context is current. The shaders are compiled and
// linked inside this block; on success `program` is a fresh, fully linked
// GL program object. Vertex / fragment shaders are released with
// `delete_shader` after attach + link — they are flagged for deletion and
// freed when the program is deleted, which is the canonical pattern.
// Compile / link asserts panic with the driver's info log instead of
// returning a half-broken program (callers cannot recover anyway).
unsafe
{
let program = gl.create_program().unwrap();
let vs = gl.create_shader( glow::VERTEX_SHADER ).unwrap();
gl.shader_source( vs, vert_src );
gl.compile_shader( vs );
assert!( gl.get_shader_compile_status( vs ), "VS: {}", gl.get_shader_info_log( vs ) );
let fs = gl.create_shader( glow::FRAGMENT_SHADER ).unwrap();
gl.shader_source( fs, frag_src );
gl.compile_shader( fs );
assert!( gl.get_shader_compile_status( fs ), "FS: {}", gl.get_shader_info_log( fs ) );
gl.attach_shader( program, vs );
gl.attach_shader( program, fs );
gl.bind_attrib_location( program, 0, "a_pos" );
gl.link_program( program );
assert!( gl.get_program_link_status( program ), "Link: {}", gl.get_program_info_log( program ) );
gl.delete_shader( vs );
gl.delete_shader( fs );
program
}
}
pub( super ) fn bytemuck_cast_slice( floats: &[f32] ) -> &[u8]
{
// SAFETY: `f32` has the same allocation provenance / validity as
// `[u8; 4]` for any bit pattern (every bit pattern is a valid byte;
// `f32` admits NaN payloads but those are valid `u8` reads). The
// returned slice's lifetime is tied to the input slice's lifetime
// through the function signature so the read window cannot outlive
// the underlying storage. `len * 4` cannot overflow `usize` because
// it would require an `f32` slice exceeding `usize::MAX / 4` bytes
// — physically impossible on any addressable target.
unsafe
{
std::slice::from_raw_parts(
floats.as_ptr() as *const u8,
floats.len() * 4,
)
}
}
pub( super ) fn native_texture_id( texture: glow::Texture ) -> u32
{
texture.0.get()
}
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()
}

168
src/gles_render/image.rs Normal file
View File

@@ -0,0 +1,168 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Raster-image draw path for [`GlesCanvas`]. Uploads the RGBA
//! bytes as a premultiplied-alpha texture (cached by content
//! fingerprint so repeated draws of the same buffer do not
//! re-upload) and composites it through the texture shader,
//! honouring the canvas' `global_alpha` via the opacity uniform.
//!
//! The cache is keyed by `(size, fingerprint)` where the fingerprint
//! is a 64-bit hash sampled from the RGBA bytes. This avoids the
//! address-reuse trap of a pointer-based key — when an `Arc<Vec<u8>>`
//! gets dropped and the allocator hands the same heap address to a
//! *different* buffer on the next frame, a pointer-keyed cache would
//! serve the stale texture for the new content. Content-keying makes
//! that impossible: identical bytes → identical key, regardless of
//! where they live in memory.
use std::collections::hash_map::DefaultHasher;
use std::hash::{ Hash, Hasher };
use glow::HasContext;
use crate::types::Rect;
use super::helpers::{ ortho_rect, upload_rgba_texture };
use super::GlesCanvas;
/// Compute a 64-bit fingerprint of an RGBA buffer for the texture
/// cache. Hashes the full byte slice for small buffers (icons,
/// thumbnails — below 16 KB ≈ 64×64 RGBA), and falls back to a
/// strided 8 × 512-byte sample for anything larger so a wallpaper
/// blit does not pay an 8 MB hash on every frame. Both modes
/// distinguish the chevron-icon-style cases that motivated the move
/// to content-keying — the SVG-rasterised buffers differ across
/// most of their interior bytes, not just at the corners.
fn fingerprint_rgba( bytes: &[u8] ) -> u64
{
const FULL_HASH_THRESHOLD: usize = 16 * 1024;
const SAMPLE_CHUNKS: usize = 8;
const SAMPLE_CHUNK_BYTES: usize = 512;
let mut h = DefaultHasher::new();
let n = bytes.len();
n.hash( &mut h );
if n <= FULL_HASH_THRESHOLD
{
bytes.hash( &mut h );
} else {
let stride = n / SAMPLE_CHUNKS;
for i in 0..SAMPLE_CHUNKS
{
let pos = ( i * stride ).min( n - SAMPLE_CHUNK_BYTES );
bytes[ pos..pos + SAMPLE_CHUNK_BYTES ].hash( &mut h );
}
}
h.finish()
}
impl GlesCanvas
{
/// Blit RGBA image data scaled to dest rect with opacity.
///
/// Defensive: rejects buffers whose declared `img_w × img_h × 4` does not
/// match `rgba_data.len()`. The mismatch path logs a one-line warning
/// and returns without uploading or drawing — the same boundary that
/// the internal `upload_rgba_texture` helper enforces, raised one
/// 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
{
eprintln!(
"[ltk] GlesCanvas::draw_image_data: refusing draw — {}×{} declared, {} bytes provided, expected {}",
img_w, img_h, rgba_data.len(), expected,
);
return;
}
self.activate_target();
// Content-fingerprint key — see the module doc for the
// rationale. The (w, h) prefix means a pathological pair of
// buffers with identical bytes but different declared sizes
// stays distinct (cannot happen for valid input, defence in
// depth).
let cache_key = ( img_w, img_h, fingerprint_rgba( rgba_data ) );
if !self.image_cache.contains_key( &cache_key )
{
let tex = upload_rgba_texture( &self.gl, self.version, rgba_data, img_w as i32, img_h as i32 );
self.image_cache.insert( cache_key, ( tex, img_w, img_h ) );
}
// Snap to integer pixels. With GL_LINEAR sampling, a
// fractional `dest.x` / `dest.y` makes every fragment sample
// at sub-texel offset — bilinear blends adjacent texels and
// the result reads as ~1 px softer than the source. At
// integer offset every fragment center maps to a texel
// centre and the bilinear collapses to identity, so a 1:1
// sampled icon renders crisp.
let dest = Rect
{
x: dest.x.round(),
y: dest.y.round(),
width: dest.width.round(),
height: dest.height.round(),
};
if let Some( ( tex, _, _ ) ) = self.image_cache.get( &cache_key )
{
let mvp = ortho_rect( self.width, self.height, dest );
let alpha = opacity * self.global_alpha;
// SAFETY: see `primitives.rs` module doc. `*tex` is owned by
// `self.image_cache` so it outlives the call. The image cache
// stays valid as long as `&mut self` is held — no eviction
// path runs concurrently with the draw.
unsafe
{
self.gl.use_program( Some( self.tex_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_tex_mvp ), false, &mvp );
self.gl.uniform_1_f32( Some( &self.u_tex_opacity ), alpha );
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( *tex ) );
self.gl.uniform_1_i32( Some( &self.u_tex_sampler ), 0 );
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 );
}
}
}
/// Draw an externally-owned GL texture into `dest`.
///
/// The caller owns the texture and is responsible for keeping it valid
/// for the duration of this call. No upload, no caching — used to
/// composite content rendered by another GL producer (web engine,
/// video decoder, …) into the LTK widget tree.
pub fn draw_external_texture( &mut self, texture: glow::Texture, dest: Rect, opacity: f32 )
{
self.activate_target();
let dest = Rect
{
x: dest.x.round(),
y: dest.y.round(),
width: dest.width.round(),
height: dest.height.round(),
};
let mvp = ortho_rect( self.width, self.height, dest );
let alpha = opacity * self.global_alpha;
// SAFETY: caller-owned texture must outlive this call. We only
// sample it; we never delete or reassign the GL name.
unsafe
{
self.gl.use_program( Some( self.tex_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_tex_mvp ), false, &mvp );
self.gl.uniform_1_f32( Some( &self.u_tex_opacity ), alpha );
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( texture ) );
self.gl.uniform_1_i32( Some( &self.u_tex_sampler ), 0 );
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 );
}
}
}

387
src/gles_render/mod.rs Normal file
View File

@@ -0,0 +1,387 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! GPU-accelerated rendering backend using EGL + GLES2 / GLES3.
//!
//! Mirrors the public surface of the software backend so that
//! [`crate::core::Canvas`] can route widget draw calls to either backend
//! by `match self`. The EGL context bootstrap lives in
//! [`crate::egl_context`] — this module is just the renderer that runs
//! once a context is current.
//!
//! 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.
//!
//! # Submodule layout
//!
//! * `setup` — `GlesCanvas::{new, sub_canvas, resize, set_font_registry,
//! font_for, dpi/alpha accessors}`.
//! * `framebuffer` — `GlesCanvas::{blit, present, activate_target,
//! borrowed_texture, read_rgba_pixels, ensure_aux_*, snapshot_fbo_region,
//! fill_backdrop, invalidate_aux}` — everything that manipulates the
//! FBO or auxiliary snapshot textures.
//! * `clip` — `GlesCanvas::{set_clip_rects, clear_clip, set_scissor,
//! scissor_pixels, fill, clear, clear_rects_transparent}`.
//! * `primitives` — `GlesCanvas::{fill_rect, fill_linear_gradient_rect,
//! fill_radial_gradient_rect, fill_shadow_outer, fill_shadow_inset,
//! stroke_rect, draw_line}`.
//! * `text` — `GlesCanvas::{draw_text, measure_text, draw_glyph_texture}`.
//! * `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`.
//! * `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
//! (presently only `present`). The renderer otherwise relies on
//! `activate_target`'s lazy re-bind at the entry of each draw
//! method — see the module's own doc for when to use the guards
//! and when not to.
use std::collections::HashMap;
use std::sync::Arc;
use fontdue::Font;
use glow::HasContext;
use crate::theme::FontRegistry;
use crate::types::Rect;
pub( crate ) mod shaders;
pub( crate ) mod helpers;
pub( crate ) mod setup;
pub( crate ) mod framebuffer;
pub( crate ) mod clip;
pub( crate ) mod primitives;
pub( crate ) mod text;
pub( crate ) mod image;
mod raii;
// ─── Public types ────────────────────────────────────────────────────────────
/// Which GLES profile the active context is. Stored so per-frame
/// fast-paths can be selected without re-querying GL.
#[ derive( Clone, Copy, PartialEq, Eq, Debug ) ]
pub enum GlesVersion
{
V2,
V3,
}
/// Borrowed view of the texture backing a [`GlesCanvas`].
///
/// The texture and framebuffer remain owned by ltk. Consumers may
/// sample the texture while the canvas is alive, but must not delete
/// or take ownership of the GL names. A resize can replace both
/// names, so callers should query this after rendering/resizing, not
/// cache it indefinitely.
#[ derive( Clone, Copy, Debug ) ]
pub struct BorrowedGlesTexture
{
/// Native GL texture name for the canvas color attachment.
pub texture_id: u32,
/// Native GL framebuffer name that owns `texture_id` as color attachment 0.
pub framebuffer_id: u32,
/// Glow texture handle for callers already using glow.
pub texture: glow::Texture,
/// Glow framebuffer handle for callers already using glow.
pub framebuffer: glow::Framebuffer,
/// Texture width in physical pixels.
pub width: u32,
/// Texture height in physical pixels.
pub height: u32,
/// The texture contains RGBA pixels with premultiplied alpha.
pub premultiplied: bool,
/// Whether consumers should treat the texture as vertically inverted.
pub y_inverted: bool,
}
/// Cached glyph: pre-rasterized bitmap uploaded as a GL texture.
pub ( super ) struct GlyphEntry
{
pub ( super ) texture: glow::Texture,
pub ( super ) metrics: fontdue::Metrics,
pub ( super ) tex_w: i32,
pub ( super ) tex_h: i32,
}
// ─── GlesCanvas ──────────────────────────────────────────────────────────────
/// GPU-accelerated canvas using EGL + GLES2/3.
///
/// Renders into a persistent FBO (the "shadow canvas") so widget
/// pixels survive across frames — this mirrors the software pixmap
/// model and is what enables the partial-redraw path on the GPU side.
/// `Self::present` blits the FBO onto the default framebuffer; the
/// caller is responsible for the `eglSwapBuffers` that follows.
pub struct GlesCanvas
{
pub gl: Arc<glow::Context>,
pub version: GlesVersion,
/// Default font loaded from the system via `helpers::find_font`.
/// Kept as a fallback for callers that do not route through the
/// theme registry.
pub font: Arc<Font>,
/// Optional theme font registry. Populated by the runtime after
/// theme load; until then it is `None` and [`Self::font_for`]
/// falls back to [`Self::font`].
pub font_registry: Option<Arc<FontRegistry>>,
pub dpi_scale: f32,
pub global_alpha: f32,
pub width: u32,
pub height: u32,
// Shader programs. `Program` is a Copy handle; sub-canvases share
// these with their parent (no reference counting needed since they
// outlive the process).
rect_program: glow::Program,
tex_program: glow::Program,
glyph_program: glow::Program,
blit_program: glow::Program,
sub_blit_program: glow::Program,
linear_gradient_program: glow::Program,
radial_gradient_program: glow::Program,
shadow_outer_program: glow::Program,
shadow_inset_program: glow::Program,
// Shared geometry (a unit quad as two triangles)
quad_vao: glow::VertexArray,
_quad_vbo: glow::Buffer,
// Uniform locations for rect shader
u_rect_mvp: glow::UniformLocation,
u_rect_color: glow::UniformLocation,
u_rect_size: glow::UniformLocation,
u_rect_radii: glow::UniformLocation,
u_rect_stroke: glow::UniformLocation,
u_rect_pad: glow::UniformLocation,
// Uniform locations for texture shader
u_tex_mvp: glow::UniformLocation,
u_tex_opacity: glow::UniformLocation,
u_tex_sampler: glow::UniformLocation,
// Uniform locations for glyph shader
u_glyph_mvp: glow::UniformLocation,
u_glyph_color: glow::UniformLocation,
u_glyph_opacity: glow::UniformLocation,
u_glyph_sampler: glow::UniformLocation,
// Uniform location for blit shader
u_blit_sampler: glow::UniformLocation,
// Uniform locations for sub-canvas blit shader
u_subblit_mvp: glow::UniformLocation,
u_subblit_sampler: glow::UniformLocation,
u_subblit_opacity: glow::UniformLocation,
u_subblit_fade_bottom: glow::UniformLocation,
u_subblit_height_px: glow::UniformLocation,
// Uniform locations for the linear gradient shader
u_lingrad_mvp: glow::UniformLocation,
u_lingrad_lut: glow::UniformLocation,
u_lingrad_dir: glow::UniformLocation,
u_lingrad_size: glow::UniformLocation,
u_lingrad_line_length: glow::UniformLocation,
u_lingrad_radii: glow::UniformLocation,
u_lingrad_pad: glow::UniformLocation,
u_lingrad_lut_domain_min: glow::UniformLocation,
u_lingrad_lut_domain_span: glow::UniformLocation,
// Uniform locations for the radial gradient shader
u_radgrad_mvp: glow::UniformLocation,
u_radgrad_lut: glow::UniformLocation,
u_radgrad_center: glow::UniformLocation,
u_radgrad_radius_frac: glow::UniformLocation,
u_radgrad_size: glow::UniformLocation,
u_radgrad_radii: glow::UniformLocation,
u_radgrad_pad: glow::UniformLocation,
u_radgrad_lut_domain_min: glow::UniformLocation,
u_radgrad_lut_domain_span: glow::UniformLocation,
// Uniform locations for the outer shadow shader
u_shadow_mvp: glow::UniformLocation,
u_shadow_size: glow::UniformLocation,
u_shadow_padding: glow::UniformLocation,
u_shadow_radii: glow::UniformLocation,
u_shadow_spread: glow::UniformLocation,
u_shadow_sigma: glow::UniformLocation,
u_shadow_color: glow::UniformLocation,
// Uniform locations for the inner (inset) shadow shader
u_inset_mvp: glow::UniformLocation,
u_inset_size: glow::UniformLocation,
u_inset_padding: glow::UniformLocation,
u_inset_radii: glow::UniformLocation,
u_inset_spread: glow::UniformLocation,
u_inset_sigma: glow::UniformLocation,
u_inset_offset: glow::UniformLocation,
u_inset_color: glow::UniformLocation,
/// Inset-shadow shader variant for `BlendMode::Overlay`. Distinct
/// from [`Self::shadow_inset_program`] because CSS Overlay cannot
/// be expressed with fixed-function blend — this shader samples
/// the just-snapshotted FBO content (via `aux_a`) and
/// computes the per-channel Overlay formula in-shader, then
/// outputs premultiplied.
shadow_inset_overlay_program: glow::Program,
u_inset_ov_mvp: glow::UniformLocation,
u_inset_ov_size: glow::UniformLocation,
u_inset_ov_padding: glow::UniformLocation,
u_inset_ov_radii: glow::UniformLocation,
u_inset_ov_spread: glow::UniformLocation,
u_inset_ov_sigma: glow::UniformLocation,
u_inset_ov_offset: glow::UniformLocation,
u_inset_ov_color: glow::UniformLocation,
u_inset_ov_snapshot: glow::UniformLocation,
u_inset_ov_canvas_size: glow::UniformLocation,
/// Horizontal pass of the separable Gaussian used by
/// [`Self::fill_backdrop`](framebuffer). Samples
/// `aux_a` (snapshot of the main FBO) and writes the
/// horizontally-blurred result into `aux_b`.
backdrop_blur_h_program: glow::Program,
u_bd_h_source: glow::UniformLocation,
u_bd_h_texel: glow::UniformLocation,
u_bd_h_canvas_size: glow::UniformLocation,
u_bd_h_sigma: glow::UniformLocation,
/// Vertical pass of the separable Gaussian combined with the SDF
/// clip to the surface shape and optional tint. Reads
/// `aux_b` (H-blurred) and writes to the main FBO at the
/// surface rect.
backdrop_composite_program: glow::Program,
u_bd_c_mvp: glow::UniformLocation,
u_bd_c_source: glow::UniformLocation,
u_bd_c_canvas_size: glow::UniformLocation,
u_bd_c_texel: glow::UniformLocation,
u_bd_c_sigma: glow::UniformLocation,
u_bd_c_size: glow::UniformLocation,
u_bd_c_padding: glow::UniformLocation,
u_bd_c_radii: glow::UniformLocation,
u_bd_c_tint: glow::UniformLocation,
/// Fast (low-quality) horizontal Gaussian. Same role as
/// `backdrop_blur_h_program` but with a 9-tap kernel
/// (`RADIUS = 4`) instead of 41 taps. Used during animations /
/// drags via [`crate::render::low_quality_paint`].
backdrop_fast_blur_h_program: glow::Program,
u_bd_fh_source: glow::UniformLocation,
u_bd_fh_texel: glow::UniformLocation,
u_bd_fh_canvas_size: glow::UniformLocation,
u_bd_fh_sigma: glow::UniformLocation,
/// Fast (low-quality) vertical + SDF + tint composite. 9-tap
/// kernel counterpart to `backdrop_composite_program`.
backdrop_fast_composite_program: glow::Program,
u_bd_fc_mvp: glow::UniformLocation,
u_bd_fc_source: glow::UniformLocation,
u_bd_fc_canvas_size: glow::UniformLocation,
u_bd_fc_texel: glow::UniformLocation,
u_bd_fc_sigma: glow::UniformLocation,
u_bd_fc_size: glow::UniformLocation,
u_bd_fc_padding: glow::UniformLocation,
u_bd_fc_radii: glow::UniformLocation,
u_bd_fc_tint: glow::UniformLocation,
// Glyph cache: (char, size_key, font_id) → GlyphEntry. The
// `font_id` is the address of the `Arc<Font>` used for the
// rasterisation, so distinct weights / families of the same
// (char, size) keep separate atlas entries.
glyph_cache: HashMap<(char, u32, usize), GlyphEntry>,
// Reusable texture cache for images. Keyed by
// `(width, height, content fingerprint)` rather than the source
// buffer's heap address — pointer-keying produced ghosting when
// short-lived `Arc<Vec<u8>>` buffers got dropped and the
// allocator handed the same address to a different buffer next
// frame (the cache would happily serve the stale texture).
// Content-keying tolerates that case at the cost of one
// `DefaultHasher` pass over the bytes per draw call — fast for
// any reasonable icon size.
image_cache: HashMap<(u32, u32, u64), (glow::Texture, u32, u32)>,
// Gradient LUT cache: FNV-ish hash of the 512×RGBA8 LUT bytes → texture.
// Gradients are theme-derived and constant across frames; caching avoids
// a glTexImage2D round-trip (create + upload + delete) on every draw call.
// Cleared via `clear_gradient_cache()` on theme changes.
gradient_lut_cache: HashMap<u64, glow::Texture>,
/// Current scissor: `Some(rect)` when a clip is installed
/// (GL_SCISSOR_TEST is enabled), `None` when cleared.
clip_scissor: Option<Rect>,
/// Persistent shadow framebuffer. All draw methods bind this;
/// [`Self::present`](framebuffer) is the only call that switches
/// to the default framebuffer.
fbo: glow::Framebuffer,
/// Color attachment for `fbo`. Reallocated on resize.
fbo_tex: glow::Texture,
/// Auxiliary FBO + texture used as a snapshot of `fbo`
/// for framebuffer-fetch-style effects (CSS `Overlay` blend,
/// backdrop blur). Lazily allocated on first use; dropped on
/// resize so the next user re-allocates at the new size.
aux_a: Option<( glow::Framebuffer, glow::Texture )>,
/// Second auxiliary FBO, used as ping-pong target for the
/// separable Gaussian blur in backdrop compositing. Uses LINEAR
/// filtering for the V-pass bilinear sampling (vs `aux_a`'s
/// NEAREST).
aux_b: Option<( glow::Framebuffer, glow::Texture )>,
}
// ─── Drop ────────────────────────────────────────────────────────────────────
/// Free this canvas's owned GL resources: FBO, color attachment, aux
/// FBOs if allocated, and any cached glyph / image textures. Shader
/// programs and the quad VAO/VBO are shared with sub-canvases and
/// intentionally NOT deleted here — they leak at process exit, which
/// is fine for a process-wide GL context.
impl Drop for GlesCanvas
{
fn drop( &mut self )
{
// SAFETY: every handle freed below was created through `self.gl` —
// either in `setup.rs::new` / `sub_canvas` (`fbo`, `fbo_tex`),
// `framebuffer.rs::ensure_aux_a` / `ensure_aux_b` (`aux_a`, `aux_b`),
// `text.rs::draw_text` (`glyph_cache`), `image.rs::draw_image_data`
// (`image_cache`), or `primitives.rs::ensure_lut_texture`
// (`gradient_lut_cache`). Each container `drain` / `take` is
// consumed once so no double-free is possible. Caller must keep
// the GL context current at drop time — this is documented on
// `core::UiSurface::from_current_gles_loader` and
// `from_canvas_with_egl_context`.
unsafe
{
self.gl.delete_framebuffer( self.fbo );
self.gl.delete_texture( self.fbo_tex );
if let Some( ( fbo, tex ) ) = self.aux_a.take()
{
self.gl.delete_framebuffer( fbo );
self.gl.delete_texture( tex );
}
if let Some( ( fbo, tex ) ) = self.aux_b.take()
{
self.gl.delete_framebuffer( fbo );
self.gl.delete_texture( tex );
}
for ( _, entry ) in self.glyph_cache.drain()
{
self.gl.delete_texture( entry.texture );
}
for ( _, ( tex, _, _ ) ) in self.image_cache.drain()
{
self.gl.delete_texture( tex );
}
for ( _, tex ) in self.gradient_lut_cache.drain()
{
self.gl.delete_texture( tex );
}
}
}
}

View File

@@ -0,0 +1,545 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Primitive draw ops for [`GlesCanvas`]: solid and gradient rect
//! fills, inner and outer shadows, stroke, line. All go through the
//! shared quad VAO + one of the pre-compiled shader programs from
//! [`super::shaders`], with uniforms set per-call.
//!
//! # Shared `unsafe` invariants
//!
//! Every `unsafe` block below relies on the same canvas-wide contract
//! and only adds one note per block when something specific applies:
//!
//! * The GL context behind `self.gl` is current on this thread — the
//! `GlesCanvas` constructors only return a value when this is true,
//! and every `&mut self` method runs on the construction thread.
//! * Every `program` / `uniform_*` / `vertex_array` / `texture` handle
//! stored on `self` was produced by the same context in `setup.rs`
//! and outlives the draw call.
//! * Each draw method calls `activate_target` first, which re-binds the
//! canvas FBO and re-applies viewport / scissor — so the unsafe block
//! never inherits a stranded binding from a sibling canvas.
//! * `bind_vertex_array(None)` and `bind_texture(_, None)` at the end
//! of the unsafe block leaves the global GL state in the same shape
//! the next draw method assumes (no stranded VAO / texture binding).
use glow::HasContext;
use crate::theme::{ gradient_lut, BlendMode, InsetShadow, LinearGradient, RadialGradient, Shadow };
use crate::types::{ Color, Corners, Rect };
use super::helpers::ortho_rect;
use super::GlesCanvas;
impl GlesCanvas
{
/// Returns `true` when `rect` (expanded by `margin` on every side)
/// is entirely outside the active scissor — the GPU would cull
/// every fragment pre-shader anyway, so skipping the draw saves
/// the `activate_target` / `use_program` / uniform / VAO / draw
/// sequence. No scissor = no cull (the whole canvas is fair game).
fn rect_culled( &self, rect: Rect, margin: f32 ) -> bool
{
let Some( clip ) = self.clip_scissor else { return false };
let r_x0 = rect.x - margin;
let r_y0 = rect.y - margin;
let r_x1 = rect.x + rect.width + margin;
let r_y1 = rect.y + rect.height + margin;
let c_x1 = clip.x + clip.width;
let c_y1 = clip.y + clip.height;
r_x1 <= clip.x || c_x1 <= r_x0 || r_y1 <= clip.y || c_y1 <= r_y0
}
pub fn fill_rect( &mut self, rect: Rect, color: Color, corners: Corners )
{
if self.rect_culled( rect, 1.0 ) { return; }
self.activate_target();
// Expand the quad 1 px on each side so the outer half of the SDF
// antialiasing band (d ∈ [0, 0.5]) has fragments to cover along the
// straight edges of pills / rounded rects. `u_size` and `u_radii`
// 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 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
// branch of `RECT_FRAG_SRC`; the SDF reads `u_size` / `u_radii` of
// the original rect while `u_pad` remaps `v_uv` from the expanded
// quad — so the rasteriser sees the padded geometry but the shader
// computes coverage in original-rect coordinates.
unsafe
{
self.gl.use_program( Some( self.rect_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_rect_mvp ), false, &mvp );
self.gl.uniform_4_f32( Some( &self.u_rect_color ), color.r, color.g, color.b, alpha );
self.gl.uniform_2_f32( Some( &self.u_rect_size ), rect.width, rect.height );
self.gl.uniform_4_f32_slice( Some( &self.u_rect_radii ), &corners.to_uniform() );
self.gl.uniform_1_f32( Some( &self.u_rect_stroke ), 0.0 );
self.gl.uniform_1_f32( Some( &self.u_rect_pad ), pad );
self.gl.bind_vertex_array( Some( self.quad_vao ) );
self.gl.draw_arrays( glow::TRIANGLES, 0, 6 );
self.gl.bind_vertex_array( None );
}
}
/// Return the cached gradient LUT texture for `lut_bytes`, uploading
/// it on the first call for each unique byte sequence. Subsequent calls
/// with the same bytes skip `glTexImage2D` entirely. The texture lives
/// until `clear_gradient_cache` is called (e.g. on theme change) or
/// the canvas is dropped.
fn ensure_lut_texture( &mut self, lut_bytes: &[u8] ) -> glow::Texture
{
use std::hash::{ Hash, Hasher };
let mut h = std::collections::hash_map::DefaultHasher::new();
lut_bytes.hash( &mut h );
let key = h.finish();
if let Some( &tex ) = self.gradient_lut_cache.get( &key )
{
return tex;
}
// SAFETY: see module doc. `lut_bytes` is the contiguous
// `LUT_SAMPLES * 4` byte LUT produced by `gradient_lut::build_lut_bytes`
// (RGBA8, one row); the texture allocation matches that exact shape.
// We unbind TEXTURE_2D on exit to keep the unit-0 binding shape the
// rest of the canvas assumes.
unsafe
{
let tex = self.gl.create_texture().expect( "gradient LUT texture" );
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( tex ) );
self.gl.tex_image_2d(
glow::TEXTURE_2D,
0,
glow::RGBA as i32,
gradient_lut::LUT_SAMPLES as i32,
1,
0,
glow::RGBA,
glow::UNSIGNED_BYTE,
glow::PixelUnpackData::Slice( Some( lut_bytes ) ),
);
self.gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::LINEAR as i32 );
self.gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::LINEAR as i32 );
self.gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32 );
self.gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32 );
self.gl.bind_texture( glow::TEXTURE_2D, None );
self.gradient_lut_cache.insert( key, tex );
tex
}
}
/// Fill a rectangle with a linear gradient.
///
/// Bakes a CPU-side LUT from `g.stops` and fetches (or creates) the
/// corresponding cached GPU texture via `ensure_lut_texture`,
/// then draws the quad with the gradient shader.
pub fn fill_linear_gradient_rect( &mut self, rect: Rect, g: &LinearGradient, corners: Corners )
{
if self.rect_culled( rect, 1.0 ) { return; }
let lut_bytes = gradient_lut::build_lut_bytes( &g.stops, g.space );
let tex = self.ensure_lut_texture( &lut_bytes );
let theta = g.angle_deg.to_radians();
// CSS convention: 0° points up. dir.y is negative-up in screen space.
let dir_x = theta.sin();
let dir_y = -theta.cos();
let line_length = ( rect.width * dir_x ).abs() + ( rect.height * dir_y ).abs();
let line_length = if line_length.abs() < 1e-3 { 1e-3 } else { line_length };
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 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);
// `dir_x`, `dir_y`, `line_length` are derived from finite inputs
// (`line_length` is clamped above 1e-3 so the shader's divide is
// well-defined).
unsafe
{
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( tex ) );
self.gl.use_program( Some( self.linear_gradient_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_lingrad_mvp ), false, &mvp );
self.gl.uniform_1_i32( Some( &self.u_lingrad_lut ), 0 );
self.gl.uniform_2_f32( Some( &self.u_lingrad_dir ), dir_x, dir_y );
self.gl.uniform_2_f32( Some( &self.u_lingrad_size ), rect.width, rect.height );
self.gl.uniform_1_f32( Some( &self.u_lingrad_line_length ), line_length );
self.gl.uniform_4_f32_slice( Some( &self.u_lingrad_radii ), &corners.to_uniform() );
self.gl.uniform_1_f32( Some( &self.u_lingrad_pad ), pad );
self.gl.uniform_1_f32( Some( &self.u_lingrad_lut_domain_min ), gradient_lut::LUT_DOMAIN.0 );
self.gl.uniform_1_f32( Some( &self.u_lingrad_lut_domain_span ), gradient_lut::LUT_DOMAIN.1 - gradient_lut::LUT_DOMAIN.0 );
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 );
}
}
/// Fill a rectangle with a radial gradient.
///
/// `g.center` is interpreted in box-relative fractions (as declared by
/// the theme), `g.radius` is the fractional radial extent. Same cached
/// LUT strategy as [`Self::fill_linear_gradient_rect`].
pub fn fill_radial_gradient_rect( &mut self, rect: Rect, g: &RadialGradient, corners: Corners )
{
if self.rect_culled( rect, 1.0 ) { return; }
let lut_bytes = gradient_lut::build_lut_bytes( &g.stops, g.space );
let tex = self.ensure_lut_texture( &lut_bytes );
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 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
// from the theme parser (validated at load time).
unsafe
{
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( tex ) );
self.gl.use_program( Some( self.radial_gradient_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_radgrad_mvp ), false, &mvp );
self.gl.uniform_1_i32( Some( &self.u_radgrad_lut ), 0 );
self.gl.uniform_2_f32( Some( &self.u_radgrad_center ), g.center[0], g.center[1] );
self.gl.uniform_1_f32( Some( &self.u_radgrad_radius_frac ), g.radius );
self.gl.uniform_2_f32( Some( &self.u_radgrad_size ), rect.width, rect.height );
self.gl.uniform_4_f32_slice( Some( &self.u_radgrad_radii ), &corners.to_uniform() );
self.gl.uniform_1_f32( Some( &self.u_radgrad_pad ), pad );
self.gl.uniform_1_f32( Some( &self.u_radgrad_lut_domain_min ), gradient_lut::LUT_DOMAIN.0 );
self.gl.uniform_1_f32( Some( &self.u_radgrad_lut_domain_span ), gradient_lut::LUT_DOMAIN.1 - gradient_lut::LUT_DOMAIN.0 );
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 );
}
}
/// Paint an outer drop shadow behind a rounded rect.
///
/// Analytic Gaussian approximation over the shape SDF — see the note
/// above `SHADOW_OUTER_FRAG_SRC`. The drawing quad is expanded on each
/// side by `max(blur, 0) + max(spread, 0) + 1` (the `+ 1` leaves a
/// single antialias pixel of slack) and offset by `shadow.offset` so
/// the fragment shader sees the full falloff region.
///
/// Only `BlendMode::Normal` is honoured today; other modes silently
/// fall through to `Normal` because the analytic shader only outputs
/// `over`.
pub fn fill_shadow_outer( &mut self, target: Rect, shadow: &Shadow, corners: Corners )
{
let blur_margin = shadow.blur.max( 0.0 );
let spread_margin = shadow.spread.max( 0.0 );
let margin = blur_margin + spread_margin + 1.0;
// Outer shadows draw a quad expanded by `margin` on each side
// (to capture the Gaussian falloff outside the shape); offset
// the target by `shadow.offset` for the cull test so a shadow
// that sits off-centre is not skipped prematurely.
let culled_rect = Rect
{
x: target.x + shadow.offset[0],
y: target.y + shadow.offset[1],
width: target.width,
height: target.height,
};
if self.rect_culled( culled_rect, margin ) { return; }
let quad = Rect
{
x: target.x + shadow.offset[0] - margin,
y: target.y + shadow.offset[1] - margin,
width: target.width + 2.0 * margin,
height: target.height + 2.0 * margin,
};
let sigma = shadow.sigma().max( 0.5 );
let alpha = shadow.color.a * self.global_alpha;
self.activate_target();
let mvp = ortho_rect( self.width, self.height, quad );
// SAFETY: see module doc. `sigma` is clamped above 0.5 so the
// shader's divide is well-defined; `margin` covers the full
// Gaussian falloff so the rasteriser sees every fragment the
// SDF wants to shade.
unsafe
{
self.gl.use_program( Some( self.shadow_outer_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_shadow_mvp ), false, &mvp );
self.gl.uniform_2_f32( Some( &self.u_shadow_size ), target.width, target.height );
self.gl.uniform_2_f32( Some( &self.u_shadow_padding ), margin, margin );
self.gl.uniform_4_f32_slice( Some( &self.u_shadow_radii ), &corners.to_uniform() );
self.gl.uniform_1_f32( Some( &self.u_shadow_spread ), shadow.spread );
self.gl.uniform_1_f32( Some( &self.u_shadow_sigma ), sigma );
self.gl.uniform_4_f32( Some( &self.u_shadow_color ),
shadow.color.r, shadow.color.g, shadow.color.b, alpha );
self.gl.bind_vertex_array( Some( self.quad_vao ) );
self.gl.draw_arrays( glow::TRIANGLES, 0, 6 );
self.gl.bind_vertex_array( None );
}
}
/// Paint an inner (inset) shadow inside a rounded rect.
///
/// Differences versus [`Self::fill_shadow_outer`]:
///
/// * The drawing quad matches the target rect exactly — the inset is
/// clipped to the outer SDF by the shader, so no external padding
/// is needed and there is no spatial offset of the geometry.
/// * The shader carries the per-shadow `offset` as a uniform rather
/// than translating the quad, because the inset is biased *inside*
/// the shape rather than cast outside it.
/// * The pipeline blend state is switched for the duration of the
/// draw to honour `InsetShadow::blend` and restored afterwards.
///
/// Blend modes: `Normal` stays on the pipeline default
/// `(ONE, ONE_MINUS_SRC_ALPHA)`. `PlusLighter` uses `(ONE, ONE)` —
/// pure additive on premultiplied inputs, naturally clamped by the
/// framebuffer to `[0, 1]`, which is exactly the CSS definition.
/// `Multiply` uses `(DST_COLOR, ZERO)` on RGB and `(DST_ALPHA, ZERO)`
/// on alpha — a straight multiplicative blend. `Screen` uses
/// `(ONE_MINUS_DST_COLOR, ONE)`, the canonical `a + b a·b` form.
/// `Overlay` cannot be expressed with GL's fixed-function blend
/// state alone — it needs to read the destination pixel. This
/// branch snapshots the current FBO into `aux_a` via
/// `glCopyTexSubImage2D`, then draws through
/// `shadow_inset_overlay_program` which samples that snapshot at
/// `gl_FragCoord.xy / canvas_size`, computes the per-channel CSS
/// Overlay formula in-shader, and emits premultiplied
/// `(overlay * mask, mask)` — so the usual premul over blend
/// composes the blended colour on top of the base. One FBO
/// snapshot per Overlay shadow.
pub fn fill_shadow_inset( &mut self, target: Rect, shadow: &InsetShadow, corners: Corners )
{
// Inset shadows draw a quad at `target` ± 1 px AA pad; the
// shape lives entirely inside. If that quad is outside the
// scissor, every fragment is culled — skip the whole path
// (including the Overlay snapshot, which is the expensive
// bit).
if self.rect_culled( target, 1.0 ) { return; }
let sigma = shadow.sigma().max( 0.5 );
let alpha = shadow.color.a * self.global_alpha;
// Overlay goes through the framebuffer-fetch path. Everything
// else uses the original SDF inset shader with a blend-state
// swap.
if matches!( shadow.blend, BlendMode::Overlay )
{
// Snapshot the inset's draw rect plus the 1 px AA pad so the
// quad's expanded edge still samples valid snapshot data.
// The shader samples `aux_a` at `gl_FragCoord.xy /
// canvas_size`, so reads outside the snapshotted region
// would pull stale content from a previous frame.
//
// Use the scissor-tight variant: Overlay samples at exactly
// one point per fragment, and any fragment outside the
// 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,
};
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 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
// `snapshot_fbo_region_tight` so it carries a valid copy of
// the live FBO at full canvas resolution; the shader samples
// it through `gl_FragCoord.xy / canvas_size`. We unbind unit-0
// after the draw to avoid stranding the snapshot binding.
unsafe
{
self.gl.use_program( Some( self.shadow_inset_overlay_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_inset_ov_mvp ), false, &mvp );
self.gl.uniform_2_f32( Some( &self.u_inset_ov_size ), target.width, target.height );
self.gl.uniform_2_f32( Some( &self.u_inset_ov_padding ), pad, pad );
self.gl.uniform_4_f32_slice( Some( &self.u_inset_ov_radii ), &corners.to_uniform() );
self.gl.uniform_1_f32( Some( &self.u_inset_ov_spread ), shadow.spread );
self.gl.uniform_1_f32( Some( &self.u_inset_ov_sigma ), sigma );
self.gl.uniform_2_f32( Some( &self.u_inset_ov_offset ), shadow.offset[0], shadow.offset[1] );
self.gl.uniform_4_f32( Some( &self.u_inset_ov_color ),
shadow.color.r, shadow.color.g, shadow.color.b, alpha );
self.gl.uniform_2_f32( Some( &self.u_inset_ov_canvas_size ), self.width as f32, self.height as f32 );
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( aux_tex ) );
self.gl.uniform_1_i32( Some( &self.u_inset_ov_snapshot ), 0 );
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 );
}
return;
}
self.activate_target();
// 1 px AA pad on the quad so the outer-silhouette clip
// (`outer_coverage`) renders its full smoothstep band instead
// 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 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
// `(ONE, ONE_MINUS_SRC_ALPHA)` at the end of the block so the
// next draw inherits the expected pipeline blend.
unsafe
{
// Switch the blend state for this one draw.
match shadow.blend
{
BlendMode::Normal => { /* already the pipeline default */ }
BlendMode::PlusLighter => self.gl.blend_func( glow::ONE, glow::ONE ),
BlendMode::Multiply => self.gl.blend_func_separate
(
glow::DST_COLOR, glow::ZERO,
glow::DST_ALPHA, glow::ZERO,
),
BlendMode::Screen => self.gl.blend_func( glow::ONE_MINUS_DST_COLOR, glow::ONE ),
BlendMode::Overlay => unreachable!( "Overlay handled above via snapshot" ),
}
self.gl.use_program( Some( self.shadow_inset_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_inset_mvp ), false, &mvp );
self.gl.uniform_2_f32( Some( &self.u_inset_size ), target.width, target.height );
self.gl.uniform_2_f32( Some( &self.u_inset_padding ), pad, pad );
self.gl.uniform_4_f32_slice( Some( &self.u_inset_radii ), &corners.to_uniform() );
self.gl.uniform_1_f32( Some( &self.u_inset_spread ), shadow.spread );
self.gl.uniform_1_f32( Some( &self.u_inset_sigma ), sigma );
self.gl.uniform_2_f32( Some( &self.u_inset_offset ), shadow.offset[0], shadow.offset[1] );
self.gl.uniform_4_f32( Some( &self.u_inset_color ),
shadow.color.r, shadow.color.g, shadow.color.b, alpha );
self.gl.bind_vertex_array( Some( self.quad_vao ) );
self.gl.draw_arrays( glow::TRIANGLES, 0, 6 );
self.gl.bind_vertex_array( None );
// Restore the pipeline default.
if !matches!( shadow.blend, BlendMode::Normal )
{
self.gl.blend_func( glow::ONE, glow::ONE_MINUS_SRC_ALPHA );
}
}
}
/// Stroke a rectangle outline. The stroke is centered on the (rounded)
/// boundary, matching tiny-skia's stroke_path so software and GPU paths
/// produce the same shape (e.g. a circular focus ring around an icon
/// button stays circular).
///
/// The drawing quad is expanded by `width / 2` so the outer half of the
/// stroke — which lies *outside* the original rect — has fragments to
/// cover; the SDF in the rect shader then clamps to the ring.
pub fn stroke_rect( &mut self, rect: Rect, color: Color, width: f32, corners: Corners )
{
let half = width * 0.5;
if self.rect_culled( rect, half + 1.0 ) { return; }
self.activate_target();
// Expand the *quad* outward so the outer half of the stroke has
// fragments to cover, plus 1 px extra so the 2 px AA band on the
// outer side of the stroke (half_w + 1 in the shader) has
// fragments too. `u_size` and `u_radii` keep their ORIGINAL
// values — they define the SDF, and the stroke's centerline must
// sit on the SDF zero-line (the original rect boundary). `u_pad`
// tells the fragment shader to remap `v_uv` from the larger quad
// back into original-rect space, so the SDF stays anchored to
// the original geometry. Growing `u_size`/`u_radii` instead
// 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 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
// the stroke branch of `RECT_FRAG_SRC`. Same SDF-anchored-to-original
// remap as `fill_rect`; here the quad is padded by `half + 1.0` so
// the outer half of the stroke plus its 1 px AA band have fragments.
unsafe
{
self.gl.use_program( Some( self.rect_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_rect_mvp ), false, &mvp );
self.gl.uniform_4_f32( Some( &self.u_rect_color ), color.r, color.g, color.b, alpha );
self.gl.uniform_2_f32( Some( &self.u_rect_size ), rect.width, rect.height );
self.gl.uniform_4_f32_slice( Some( &self.u_rect_radii ), &corners.to_uniform() );
self.gl.uniform_1_f32( Some( &self.u_rect_stroke ), width );
self.gl.uniform_1_f32( Some( &self.u_rect_pad ), pad );
self.gl.bind_vertex_array( Some( self.quad_vao ) );
self.gl.draw_arrays( glow::TRIANGLES, 0, 6 );
self.gl.bind_vertex_array( None );
}
}
/// Draw a line as a thin axis-aligned rect (diagonal lines fall back to
/// stamping small squares).
pub fn draw_line( &mut self, x0: f32, y0: f32, x1: f32, y1: f32, color: Color, width: f32 )
{
let dx = x1 - x0;
let dy = y1 - y0;
let len = ( dx * dx + dy * dy ).sqrt();
if len < 0.1 { return; }
let min_x = x0.min( x1 );
let min_y = y0.min( y1 );
if dy.abs() < 0.1
{
self.fill_rect( Rect { x: min_x, y: min_y - width / 2.0, width: dx.abs(), height: width }, color, Corners::ZERO );
} else if dx.abs() < 0.1 {
self.fill_rect( Rect { x: min_x - width / 2.0, y: min_y, width, height: dy.abs() }, color, Corners::ZERO );
} else {
let steps = len.ceil() as usize;
for i in 0..steps
{
let t = i as f32 / len;
let px = x0 + dx * t;
let py = y0 + dy * t;
self.fill_rect( Rect { x: px - width / 2.0, y: py - width / 2.0, width, height: width }, color, Corners::ZERO );
}
}
}
}

107
src/gles_render/raii.rs Normal file
View File

@@ -0,0 +1,107 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! RAII guards for scoped GL bindings.
//!
//! The renderer normally restores GL state lazily: every draw method
//! calls [`super::GlesCanvas::activate_target`] at entry, which
//! re-binds the canvas FBO and reasserts viewport / scissor. So a
//! method that temporarily binds a different FBO (e.g. `aux_a` for a
//! snapshot) does not need to restore the previous binding — the next
//! draw will. That is a deliberate optimisation; introducing a RAII
//! restore at every site would double-bind the canvas FBO on every
//! frame.
//!
//! These guards exist for the *opposite* shape — operations that
//! genuinely change global state for the duration of a scope and
//! must guarantee restoration **even on early return / panic**.
//! Today that fits exactly one site: [`super::GlesCanvas::present`],
//! which binds the default framebuffer (id 0) and the blit program,
//! draws a fullscreen quad, and rebinds the canvas FBO + previous
//! program. Without RAII, an early return between the two binds
//! would leave the GL context with the default FBO and the wrong
//! program active — the next draw on this canvas would render to
//! the wrong target until `activate_target` ran (FBO is corrected
//! by `activate_target`; `use_program` is **not**).
//!
//! When in doubt: do not wrap `bind_framebuffer` / `use_program` in
//! a guard "for safety". The lazy-restore convention is part of the
//! renderer's contract and the guard pays for itself only when the
//! scope can exit through a path that bypasses `activate_target`.
use glow::HasContext;
/// Scoped framebuffer binding. Binds `target` on construction and
/// restores `previous` on Drop. Caller passes `previous` explicitly
/// because every site that needs this guard already knows what it
/// wants to restore (typically `self.fbo`) — querying
/// `GL_FRAMEBUFFER_BINDING` would force a sync round-trip we do not
/// need.
pub( super ) struct FboBinding<'a>
{
gl: &'a glow::Context,
previous: Option<glow::Framebuffer>,
}
impl<'a> FboBinding<'a>
{
/// Bind `target` immediately; remember `previous` to rebind on Drop.
///
/// # Safety
///
/// The GL context behind `gl` must be current on the calling thread
/// for the entire lifetime of the returned guard. Both `target` and
/// `previous` must be names produced by this same context (or
/// `None` for the default framebuffer).
pub( super ) unsafe fn scoped( gl: &'a glow::Context, target: Option<glow::Framebuffer>, previous: Option<glow::Framebuffer> ) -> Self
{
// SAFETY: forwarded from the fn's own `# Safety` contract.
unsafe { gl.bind_framebuffer( glow::FRAMEBUFFER, target ); }
Self { gl, previous }
}
}
impl<'a> Drop for FboBinding<'a>
{
fn drop( &mut self )
{
// SAFETY: the construction-time invariant (current context) is the
// guard's lifetime invariant. Restoring `previous` is a pure
// state-machine mutation.
unsafe { self.gl.bind_framebuffer( glow::FRAMEBUFFER, self.previous ); }
}
}
/// Scoped program binding. Same shape as [`FboBinding`] but for
/// `glUseProgram`. Caller passes the program to restore explicitly
/// (typically the program the next pipeline stage will need, or
/// `None` to leave nothing bound).
pub( super ) struct ProgramBinding<'a>
{
gl: &'a glow::Context,
previous: Option<glow::Program>,
}
impl<'a> ProgramBinding<'a>
{
/// Activate `target` immediately; remember `previous` to restore on Drop.
///
/// # Safety
///
/// Same as [`FboBinding::scoped`].
pub( super ) unsafe fn scoped( gl: &'a glow::Context, target: Option<glow::Program>, previous: Option<glow::Program> ) -> Self
{
// SAFETY: forwarded from the fn's own `# Safety` contract.
unsafe { gl.use_program( target ); }
Self { gl, previous }
}
}
impl<'a> Drop for ProgramBinding<'a>
{
fn drop( &mut self )
{
// SAFETY: see `FboBinding::drop`.
unsafe { self.gl.use_program( self.previous ); }
}
}

657
src/gles_render/setup.rs Normal file
View File

@@ -0,0 +1,657 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Construction + accessors for [`GlesCanvas`].
//!
//! The bulk of `GlesCanvas::new` is one big shader-compile +
//! uniform-lookup block: each of the shader programs gets compiled
//! from [`super::shaders`], its uniform locations are pulled out via
//! `get_uniform_location`, and both end up as `Copy` handles on the
//! struct so sub-canvases can share them for free. `sub_canvas` is
//! the same struct-literal again with the `new`-only bootstrap
//! elided (programs, VAO, default font all come from the parent).
use std::collections::HashMap;
use std::sync::{ Arc, OnceLock };
use fontdue::{ Font, FontSettings, LineMetrics, Metrics };
use glow::HasContext;
use crate::theme::{ FontRegistry, FontStyle };
use super::helpers::{ alloc_fbo_tex, bytemuck_cast_slice, compile_program, load_default_font_bytes };
use super::shaders::
{
BACKDROP_BLUR_H_FRAG_SRC, BACKDROP_COMPOSITE_FRAG_SRC,
BACKDROP_FAST_BLUR_H_FRAG_SRC, BACKDROP_FAST_COMPOSITE_FRAG_SRC,
BLIT_FRAG_SRC, BLIT_VERT_SRC,
GLYPH_FRAG_SRC,
LINEAR_GRADIENT_FRAG_SRC, RADIAL_GRADIENT_FRAG_SRC,
RECT_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<Arc<Font>> = OnceLock::new();
fn default_font_gles() -> Arc<Font>
{
Arc::clone( 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" );
Arc::new( font )
} ) )
}
impl GlesCanvas
{
pub fn new( gl: Arc<glow::Context>, version: GlesVersion, width: u32, height: u32 ) -> Self
{
let font = default_font_gles();
let rect_program = compile_program( &gl, VERT_SRC, RECT_FRAG_SRC );
let tex_program = compile_program( &gl, VERT_SRC, TEX_FRAG_SRC );
let glyph_program = compile_program( &gl, VERT_SRC, GLYPH_FRAG_SRC );
let blit_program = compile_program( &gl, BLIT_VERT_SRC, BLIT_FRAG_SRC );
let sub_blit_program = compile_program( &gl, VERT_SRC, SUB_BLIT_FRAG_SRC );
let linear_gradient_program = compile_program( &gl, VERT_SRC, LINEAR_GRADIENT_FRAG_SRC );
let radial_gradient_program = compile_program( &gl, VERT_SRC, RADIAL_GRADIENT_FRAG_SRC );
let shadow_outer_program = compile_program( &gl, VERT_SRC, SHADOW_OUTER_FRAG_SRC );
let shadow_inset_program = compile_program( &gl, VERT_SRC, SHADOW_INSET_FRAG_SRC );
let shadow_inset_overlay_program = compile_program( &gl, VERT_SRC, SHADOW_INSET_OVERLAY_FRAG_SRC );
let backdrop_blur_h_program = compile_program( &gl, BLIT_VERT_SRC, BACKDROP_BLUR_H_FRAG_SRC );
let backdrop_composite_program = compile_program( &gl, VERT_SRC, BACKDROP_COMPOSITE_FRAG_SRC );
let backdrop_fast_blur_h_program = compile_program( &gl, BLIT_VERT_SRC, BACKDROP_FAST_BLUR_H_FRAG_SRC );
let backdrop_fast_composite_program = compile_program( &gl, VERT_SRC, BACKDROP_FAST_COMPOSITE_FRAG_SRC );
// SAFETY: every program in scope has just been linked successfully by
// `compile_program` (which panics on link failure), so `get_uniform_location`
// on these programs is well-defined. Each name argument is a `'static`
// ASCII literal — `glow` will not invoke UB on a malformed C string.
// `get_uniform_location` does not mutate the GL state machine, so this
// block has no interaction with whatever bindings precede it.
let (
u_rect_mvp, u_rect_color, u_rect_size, u_rect_radii, u_rect_stroke, u_rect_pad,
u_tex_mvp, u_tex_opacity, u_tex_sampler,
u_glyph_mvp, u_glyph_color, u_glyph_opacity, u_glyph_sampler,
u_blit_sampler,
u_subblit_mvp, u_subblit_sampler, u_subblit_opacity, u_subblit_fade_bottom, u_subblit_height_px,
u_lingrad_mvp, u_lingrad_lut, u_lingrad_dir, u_lingrad_size, u_lingrad_line_length,
u_lingrad_radii, u_lingrad_pad, u_lingrad_lut_domain_min, u_lingrad_lut_domain_span,
u_radgrad_mvp, u_radgrad_lut, u_radgrad_center, u_radgrad_radius_frac, u_radgrad_size,
u_radgrad_radii, u_radgrad_pad, u_radgrad_lut_domain_min, u_radgrad_lut_domain_span,
u_shadow_mvp, u_shadow_size, u_shadow_padding, u_shadow_radii, u_shadow_spread, u_shadow_sigma, u_shadow_color,
u_inset_mvp, u_inset_size, u_inset_padding, u_inset_radii, u_inset_spread, u_inset_sigma, u_inset_offset, u_inset_color,
u_inset_ov_mvp, u_inset_ov_size, u_inset_ov_padding, u_inset_ov_radii,
u_inset_ov_spread, u_inset_ov_sigma, u_inset_ov_offset, u_inset_ov_color,
u_inset_ov_snapshot, u_inset_ov_canvas_size,
u_bd_h_source, u_bd_h_texel, u_bd_h_canvas_size, u_bd_h_sigma,
u_bd_c_mvp, u_bd_c_source, u_bd_c_canvas_size, u_bd_c_texel, u_bd_c_sigma,
u_bd_c_size, u_bd_c_padding, u_bd_c_radii, u_bd_c_tint,
u_bd_fh_source, u_bd_fh_texel, u_bd_fh_canvas_size, u_bd_fh_sigma,
u_bd_fc_mvp, u_bd_fc_source, u_bd_fc_canvas_size, u_bd_fc_texel, u_bd_fc_sigma,
u_bd_fc_size, u_bd_fc_padding, u_bd_fc_radii, u_bd_fc_tint,
) = unsafe
{(
gl.get_uniform_location( rect_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( rect_program, "u_color" ).unwrap(),
gl.get_uniform_location( rect_program, "u_size" ).unwrap(),
gl.get_uniform_location( rect_program, "u_radii" ).unwrap(),
gl.get_uniform_location( rect_program, "u_stroke" ).unwrap(),
gl.get_uniform_location( rect_program, "u_pad" ).unwrap(),
gl.get_uniform_location( tex_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( tex_program, "u_opacity" ).unwrap(),
gl.get_uniform_location( tex_program, "u_sampler" ).unwrap(),
gl.get_uniform_location( glyph_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( glyph_program, "u_color" ).unwrap(),
gl.get_uniform_location( glyph_program, "u_opacity" ).unwrap(),
gl.get_uniform_location( glyph_program, "u_sampler" ).unwrap(),
gl.get_uniform_location( blit_program, "u_sampler" ).unwrap(),
gl.get_uniform_location( sub_blit_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( sub_blit_program, "u_sampler" ).unwrap(),
gl.get_uniform_location( sub_blit_program, "u_opacity" ).unwrap(),
gl.get_uniform_location( sub_blit_program, "u_fade_bottom_px" ).unwrap(),
gl.get_uniform_location( sub_blit_program, "u_height_px" ).unwrap(),
gl.get_uniform_location( linear_gradient_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( linear_gradient_program, "u_lut" ).unwrap(),
gl.get_uniform_location( linear_gradient_program, "u_dir" ).unwrap(),
gl.get_uniform_location( linear_gradient_program, "u_size" ).unwrap(),
gl.get_uniform_location( linear_gradient_program, "u_line_length" ).unwrap(),
gl.get_uniform_location( linear_gradient_program, "u_radii" ).unwrap(),
gl.get_uniform_location( linear_gradient_program, "u_pad" ).unwrap(),
gl.get_uniform_location( linear_gradient_program, "u_lut_domain_min" ).unwrap(),
gl.get_uniform_location( linear_gradient_program, "u_lut_domain_span" ).unwrap(),
gl.get_uniform_location( radial_gradient_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( radial_gradient_program, "u_lut" ).unwrap(),
gl.get_uniform_location( radial_gradient_program, "u_center" ).unwrap(),
gl.get_uniform_location( radial_gradient_program, "u_radius_frac" ).unwrap(),
gl.get_uniform_location( radial_gradient_program, "u_size" ).unwrap(),
gl.get_uniform_location( radial_gradient_program, "u_radii" ).unwrap(),
gl.get_uniform_location( radial_gradient_program, "u_pad" ).unwrap(),
gl.get_uniform_location( radial_gradient_program, "u_lut_domain_min" ).unwrap(),
gl.get_uniform_location( radial_gradient_program, "u_lut_domain_span" ).unwrap(),
gl.get_uniform_location( shadow_outer_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( shadow_outer_program, "u_size" ).unwrap(),
gl.get_uniform_location( shadow_outer_program, "u_padding" ).unwrap(),
gl.get_uniform_location( shadow_outer_program, "u_radii" ).unwrap(),
gl.get_uniform_location( shadow_outer_program, "u_spread" ).unwrap(),
gl.get_uniform_location( shadow_outer_program, "u_sigma" ).unwrap(),
gl.get_uniform_location( shadow_outer_program, "u_color" ).unwrap(),
gl.get_uniform_location( shadow_inset_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( shadow_inset_program, "u_size" ).unwrap(),
gl.get_uniform_location( shadow_inset_program, "u_padding" ).unwrap(),
gl.get_uniform_location( shadow_inset_program, "u_radii" ).unwrap(),
gl.get_uniform_location( shadow_inset_program, "u_spread" ).unwrap(),
gl.get_uniform_location( shadow_inset_program, "u_sigma" ).unwrap(),
gl.get_uniform_location( shadow_inset_program, "u_offset" ).unwrap(),
gl.get_uniform_location( shadow_inset_program, "u_color" ).unwrap(),
gl.get_uniform_location( shadow_inset_overlay_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( shadow_inset_overlay_program, "u_size" ).unwrap(),
gl.get_uniform_location( shadow_inset_overlay_program, "u_padding" ).unwrap(),
gl.get_uniform_location( shadow_inset_overlay_program, "u_radii" ).unwrap(),
gl.get_uniform_location( shadow_inset_overlay_program, "u_spread" ).unwrap(),
gl.get_uniform_location( shadow_inset_overlay_program, "u_sigma" ).unwrap(),
gl.get_uniform_location( shadow_inset_overlay_program, "u_offset" ).unwrap(),
gl.get_uniform_location( shadow_inset_overlay_program, "u_color" ).unwrap(),
gl.get_uniform_location( shadow_inset_overlay_program, "u_snapshot" ).unwrap(),
gl.get_uniform_location( shadow_inset_overlay_program, "u_canvas_size" ).unwrap(),
gl.get_uniform_location( backdrop_blur_h_program, "u_source" ).unwrap(),
gl.get_uniform_location( backdrop_blur_h_program, "u_texel" ).unwrap(),
gl.get_uniform_location( backdrop_blur_h_program, "u_canvas_size" ).unwrap(),
gl.get_uniform_location( backdrop_blur_h_program, "u_sigma" ).unwrap(),
gl.get_uniform_location( backdrop_composite_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( backdrop_composite_program, "u_source" ).unwrap(),
gl.get_uniform_location( backdrop_composite_program, "u_canvas_size" ).unwrap(),
gl.get_uniform_location( backdrop_composite_program, "u_texel" ).unwrap(),
gl.get_uniform_location( backdrop_composite_program, "u_sigma" ).unwrap(),
gl.get_uniform_location( backdrop_composite_program, "u_size" ).unwrap(),
gl.get_uniform_location( backdrop_composite_program, "u_padding" ).unwrap(),
gl.get_uniform_location( backdrop_composite_program, "u_radii" ).unwrap(),
gl.get_uniform_location( backdrop_composite_program, "u_tint" ).unwrap(),
gl.get_uniform_location( backdrop_fast_blur_h_program, "u_source" ).unwrap(),
gl.get_uniform_location( backdrop_fast_blur_h_program, "u_texel" ).unwrap(),
gl.get_uniform_location( backdrop_fast_blur_h_program, "u_canvas_size" ).unwrap(),
gl.get_uniform_location( backdrop_fast_blur_h_program, "u_sigma" ).unwrap(),
gl.get_uniform_location( backdrop_fast_composite_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( backdrop_fast_composite_program, "u_source" ).unwrap(),
gl.get_uniform_location( backdrop_fast_composite_program, "u_canvas_size" ).unwrap(),
gl.get_uniform_location( backdrop_fast_composite_program, "u_texel" ).unwrap(),
gl.get_uniform_location( backdrop_fast_composite_program, "u_sigma" ).unwrap(),
gl.get_uniform_location( backdrop_fast_composite_program, "u_size" ).unwrap(),
gl.get_uniform_location( backdrop_fast_composite_program, "u_padding" ).unwrap(),
gl.get_uniform_location( backdrop_fast_composite_program, "u_radii" ).unwrap(),
gl.get_uniform_location( backdrop_fast_composite_program, "u_tint" ).unwrap(),
)};
let quad_vertices: [f32; 12] = [
0.0, 0.0, 1.0, 0.0, 0.0, 1.0,
1.0, 0.0, 1.0, 1.0, 0.0, 1.0,
];
// SAFETY: the GL context is current (caller contract). We allocate a
// VAO + VBO, upload `quad_vertices` (24 bytes, statically known size,
// matches `STATIC_DRAW` semantics), and configure attribute 0 to read
// 2-float vertices from the VBO at offset 0 with stride 8. The vertex
// attrib pointer is bound to `vbo` because `vbo` is the current
// `ARRAY_BUFFER` binding when `vertex_attrib_pointer_f32` runs. We
// unbind the VAO at the end so we don't strand a binding the rest of
// `new` might inherit; the VBO remains attached to the VAO and is not
// touched again until `Drop`.
let ( quad_vao, quad_vbo ) = unsafe
{
let vao = gl.create_vertex_array().unwrap();
let vbo = gl.create_buffer().unwrap();
gl.bind_vertex_array( Some( vao ) );
gl.bind_buffer( glow::ARRAY_BUFFER, Some( vbo ) );
gl.buffer_data_u8_slice(
glow::ARRAY_BUFFER,
bytemuck_cast_slice( &quad_vertices ),
glow::STATIC_DRAW,
);
gl.enable_vertex_attrib_array( 0 );
gl.vertex_attrib_pointer_f32( 0, 2, glow::FLOAT, false, 8, 0 );
gl.bind_vertex_array( None );
( vao, vbo )
};
// Build the persistent FBO (shadow canvas) and bind it as the active
// draw target. From here on, every draw method writes into the FBO;
// `present` is the only call that switches to the default framebuffer.
//
// SAFETY: the GL context is current. `create_framebuffer` allocates a
// fresh FBO name. `alloc_fbo_tex` (an `unsafe fn`) requires the same
// invariant and returns a colour-renderable RGBA texture sized to the
// caller's width × height — the call is sound because both invariants
// are established. The bind + framebuffer_texture_2d pair attach the
// texture to COLOR_ATTACHMENT0; `check_framebuffer_status` is the
// canonical post-condition check and will assert before we return a
// handle to a broken FBO. The trailing GL state (`BLEND`, `blend_func`,
// `viewport`, `clear`) is the canvas-wide default state every draw
// method assumes — see the comment block beside `blend_func` for why
// `(ONE, ONE_MINUS_SRC_ALPHA)` is the correct pair for premultiplied
// shaders.
let ( fbo, fbo_tex ) = unsafe
{
let fbo = gl.create_framebuffer().expect( "create_framebuffer" );
let fbo_tex = alloc_fbo_tex( &gl, version, width, height );
gl.bind_framebuffer( glow::FRAMEBUFFER, Some( fbo ) );
gl.framebuffer_texture_2d(
glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT0,
glow::TEXTURE_2D, Some( fbo_tex ), 0,
);
let status = gl.check_framebuffer_status( glow::FRAMEBUFFER );
assert_eq!( status, glow::FRAMEBUFFER_COMPLETE, "FBO incomplete: 0x{status:x}" );
gl.enable( glow::BLEND );
// Premultiplied-alpha "over" composite: `result = src + dst * (1 - src.a)`
// applied uniformly to both colour and alpha. All shaders in this
// pipeline emit premul colour (see the note above `RECT_FRAG_SRC`),
// so `(ONE, ONE_MINUS_SRC_ALPHA)` is correct for both channels.
// Premultiplied inputs are also a requirement for the plus-lighter
// and overlay blend modes.
gl.blend_func( glow::ONE, glow::ONE_MINUS_SRC_ALPHA );
gl.viewport( 0, 0, width as i32, height as i32 );
// Clear the FBO once at startup so the first frame is not garbage.
gl.clear_color( 0.0, 0.0, 0.0, 0.0 );
gl.clear( glow::COLOR_BUFFER_BIT );
( fbo, fbo_tex )
};
Self
{
gl,
version,
font,
font_registry: None,
dpi_scale: 1.0,
global_alpha: 1.0,
width,
height,
rect_program,
tex_program,
glyph_program,
blit_program,
sub_blit_program,
linear_gradient_program,
radial_gradient_program,
shadow_outer_program,
shadow_inset_program,
shadow_inset_overlay_program,
quad_vao,
_quad_vbo: quad_vbo,
u_rect_mvp,
u_rect_color,
u_rect_size,
u_rect_radii,
u_rect_stroke,
u_rect_pad,
u_tex_mvp,
u_tex_opacity,
u_tex_sampler,
u_glyph_mvp,
u_glyph_color,
u_glyph_opacity,
u_glyph_sampler,
u_blit_sampler,
u_subblit_mvp,
u_subblit_sampler,
u_subblit_opacity,
u_subblit_fade_bottom,
u_subblit_height_px,
u_lingrad_mvp,
u_lingrad_lut,
u_lingrad_dir,
u_lingrad_size,
u_lingrad_line_length,
u_lingrad_radii,
u_lingrad_pad,
u_lingrad_lut_domain_min,
u_lingrad_lut_domain_span,
u_radgrad_mvp,
u_radgrad_lut,
u_radgrad_center,
u_radgrad_radius_frac,
u_radgrad_size,
u_radgrad_radii,
u_radgrad_pad,
u_radgrad_lut_domain_min,
u_radgrad_lut_domain_span,
u_shadow_mvp,
u_shadow_size,
u_shadow_padding,
u_shadow_radii,
u_shadow_spread,
u_shadow_sigma,
u_shadow_color,
u_inset_mvp,
u_inset_size,
u_inset_padding,
u_inset_radii,
u_inset_spread,
u_inset_sigma,
u_inset_offset,
u_inset_color,
u_inset_ov_mvp,
u_inset_ov_size,
u_inset_ov_padding,
u_inset_ov_radii,
u_inset_ov_spread,
u_inset_ov_sigma,
u_inset_ov_offset,
u_inset_ov_color,
u_inset_ov_snapshot,
u_inset_ov_canvas_size,
backdrop_blur_h_program,
u_bd_h_source,
u_bd_h_texel,
u_bd_h_canvas_size,
u_bd_h_sigma,
backdrop_composite_program,
u_bd_c_mvp,
u_bd_c_source,
u_bd_c_canvas_size,
u_bd_c_texel,
u_bd_c_sigma,
u_bd_c_size,
u_bd_c_padding,
u_bd_c_radii,
u_bd_c_tint,
backdrop_fast_blur_h_program,
u_bd_fh_source,
u_bd_fh_texel,
u_bd_fh_canvas_size,
u_bd_fh_sigma,
backdrop_fast_composite_program,
u_bd_fc_mvp,
u_bd_fc_source,
u_bd_fc_canvas_size,
u_bd_fc_texel,
u_bd_fc_sigma,
u_bd_fc_size,
u_bd_fc_padding,
u_bd_fc_radii,
u_bd_fc_tint,
glyph_cache: HashMap::new(),
image_cache: HashMap::new(),
gradient_lut_cache: HashMap::new(),
clip_scissor: None,
fbo,
fbo_tex,
aux_a: None,
aux_b: None,
}
}
/// Build a sub-canvas: a separate render target sharing this canvas's GL
/// context, font, shader programs, geometry, and uniform locations, but
/// with its own FBO + color texture sized to `width × height`. Used to
/// render content into an off-screen target that can then be composited
/// back via [`Self::blit`].
///
/// The returned canvas inherits the parent's `dpi_scale` and `global_alpha`
/// (so glyphs render at the same pixel size). Its glyph cache starts empty
/// — re-rasterising on first use is the cost of not sharing GL textures
/// across canvases.
pub fn sub_canvas( &self, width: u32, height: u32 ) -> GlesCanvas
{
let gl = Arc::clone( &self.gl );
// SAFETY: `self.gl` is the same context held by `self`; if `self` was
// constructed soundly its context is current on this thread. We
// allocate a new FBO + colour texture and attach them, then assert
// completeness. We deliberately leave `gl` with the new FBO bound
// instead of restoring the parent's binding — every draw method goes
// through `activate_target`, which re-binds the canvas's own FBO
// before issuing any draw call, so the transient binding cannot be
// observed by other code on this thread.
let ( fbo, fbo_tex ) = unsafe
{
let fbo = gl.create_framebuffer().expect( "create_framebuffer" );
let fbo_tex = alloc_fbo_tex( &gl, self.version, width, height );
gl.bind_framebuffer( glow::FRAMEBUFFER, Some( fbo ) );
gl.framebuffer_texture_2d(
glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT0,
glow::TEXTURE_2D, Some( fbo_tex ), 0,
);
let status = gl.check_framebuffer_status( glow::FRAMEBUFFER );
assert_eq!( status, glow::FRAMEBUFFER_COMPLETE, "sub-FBO incomplete: 0x{status:x}" );
( fbo, fbo_tex )
};
GlesCanvas
{
gl,
version: self.version,
font: Arc::clone( &self.font ),
font_registry: self.font_registry.as_ref().map( Arc::clone ),
dpi_scale: self.dpi_scale,
global_alpha: self.global_alpha,
width,
height,
rect_program: self.rect_program,
tex_program: self.tex_program,
glyph_program: self.glyph_program,
blit_program: self.blit_program,
sub_blit_program: self.sub_blit_program,
linear_gradient_program: self.linear_gradient_program,
radial_gradient_program: self.radial_gradient_program,
shadow_outer_program: self.shadow_outer_program,
shadow_inset_program: self.shadow_inset_program,
shadow_inset_overlay_program: self.shadow_inset_overlay_program,
backdrop_blur_h_program: self.backdrop_blur_h_program,
backdrop_composite_program: self.backdrop_composite_program,
backdrop_fast_blur_h_program: self.backdrop_fast_blur_h_program,
backdrop_fast_composite_program: self.backdrop_fast_composite_program,
quad_vao: self.quad_vao,
_quad_vbo: self._quad_vbo,
u_rect_mvp: self.u_rect_mvp,
u_rect_color: self.u_rect_color,
u_rect_size: self.u_rect_size,
u_rect_radii: self.u_rect_radii,
u_rect_stroke: self.u_rect_stroke,
u_rect_pad: self.u_rect_pad,
u_tex_mvp: self.u_tex_mvp,
u_tex_opacity: self.u_tex_opacity,
u_tex_sampler: self.u_tex_sampler,
u_glyph_mvp: self.u_glyph_mvp,
u_glyph_color: self.u_glyph_color,
u_glyph_opacity: self.u_glyph_opacity,
u_glyph_sampler: self.u_glyph_sampler,
u_blit_sampler: self.u_blit_sampler,
u_subblit_mvp: self.u_subblit_mvp,
u_subblit_sampler: self.u_subblit_sampler,
u_subblit_opacity: self.u_subblit_opacity,
u_subblit_fade_bottom: self.u_subblit_fade_bottom,
u_subblit_height_px: self.u_subblit_height_px,
u_lingrad_mvp: self.u_lingrad_mvp,
u_lingrad_lut: self.u_lingrad_lut,
u_lingrad_dir: self.u_lingrad_dir,
u_lingrad_size: self.u_lingrad_size,
u_lingrad_line_length: self.u_lingrad_line_length,
u_lingrad_radii: self.u_lingrad_radii,
u_lingrad_pad: self.u_lingrad_pad,
u_lingrad_lut_domain_min: self.u_lingrad_lut_domain_min,
u_lingrad_lut_domain_span: self.u_lingrad_lut_domain_span,
u_radgrad_mvp: self.u_radgrad_mvp,
u_radgrad_lut: self.u_radgrad_lut,
u_radgrad_center: self.u_radgrad_center,
u_radgrad_radius_frac: self.u_radgrad_radius_frac,
u_radgrad_size: self.u_radgrad_size,
u_radgrad_radii: self.u_radgrad_radii,
u_radgrad_pad: self.u_radgrad_pad,
u_radgrad_lut_domain_min: self.u_radgrad_lut_domain_min,
u_radgrad_lut_domain_span: self.u_radgrad_lut_domain_span,
u_shadow_mvp: self.u_shadow_mvp,
u_shadow_size: self.u_shadow_size,
u_shadow_padding: self.u_shadow_padding,
u_shadow_radii: self.u_shadow_radii,
u_shadow_spread: self.u_shadow_spread,
u_shadow_sigma: self.u_shadow_sigma,
u_shadow_color: self.u_shadow_color,
u_inset_mvp: self.u_inset_mvp,
u_inset_size: self.u_inset_size,
u_inset_padding: self.u_inset_padding,
u_inset_radii: self.u_inset_radii,
u_inset_spread: self.u_inset_spread,
u_inset_sigma: self.u_inset_sigma,
u_inset_offset: self.u_inset_offset,
u_inset_color: self.u_inset_color,
u_inset_ov_mvp: self.u_inset_ov_mvp,
u_inset_ov_size: self.u_inset_ov_size,
u_inset_ov_padding: self.u_inset_ov_padding,
u_inset_ov_radii: self.u_inset_ov_radii,
u_inset_ov_spread: self.u_inset_ov_spread,
u_inset_ov_sigma: self.u_inset_ov_sigma,
u_inset_ov_offset: self.u_inset_ov_offset,
u_inset_ov_color: self.u_inset_ov_color,
u_inset_ov_snapshot: self.u_inset_ov_snapshot,
u_inset_ov_canvas_size: self.u_inset_ov_canvas_size,
u_bd_h_source: self.u_bd_h_source,
u_bd_h_texel: self.u_bd_h_texel,
u_bd_h_canvas_size: self.u_bd_h_canvas_size,
u_bd_h_sigma: self.u_bd_h_sigma,
u_bd_c_mvp: self.u_bd_c_mvp,
u_bd_c_source: self.u_bd_c_source,
u_bd_c_canvas_size: self.u_bd_c_canvas_size,
u_bd_c_texel: self.u_bd_c_texel,
u_bd_c_sigma: self.u_bd_c_sigma,
u_bd_c_size: self.u_bd_c_size,
u_bd_c_padding: self.u_bd_c_padding,
u_bd_c_radii: self.u_bd_c_radii,
u_bd_c_tint: self.u_bd_c_tint,
u_bd_fh_source: self.u_bd_fh_source,
u_bd_fh_texel: self.u_bd_fh_texel,
u_bd_fh_canvas_size: self.u_bd_fh_canvas_size,
u_bd_fh_sigma: self.u_bd_fh_sigma,
u_bd_fc_mvp: self.u_bd_fc_mvp,
u_bd_fc_source: self.u_bd_fc_source,
u_bd_fc_canvas_size: self.u_bd_fc_canvas_size,
u_bd_fc_texel: self.u_bd_fc_texel,
u_bd_fc_sigma: self.u_bd_fc_sigma,
u_bd_fc_size: self.u_bd_fc_size,
u_bd_fc_padding: self.u_bd_fc_padding,
u_bd_fc_radii: self.u_bd_fc_radii,
u_bd_fc_tint: self.u_bd_fc_tint,
glyph_cache: HashMap::new(),
image_cache: HashMap::new(),
gradient_lut_cache: HashMap::new(),
clip_scissor: None,
fbo,
fbo_tex,
aux_a: None,
aux_b: None,
}
}
pub fn size( &self ) -> ( u32, u32 ) { ( self.width, self.height ) }
/// Discard all cached gradient LUT textures. Call after a theme change
/// so stale LUTs for old palette colours are freed and rebuilt fresh.
pub fn clear_gradient_cache( &mut self )
{
// SAFETY: GL context is current (canvas invariant). Each texture in
// `gradient_lut_cache` was created through this same context in
// `gradient.rs::ensure_lut`, so deleting them through the same context
// is well-defined. `drain` consumes the entries so the same name is
// never deleted twice.
unsafe
{
for ( _, tex ) in self.gradient_lut_cache.drain()
{
self.gl.delete_texture( tex );
}
}
}
pub fn dpi_scale( &self ) -> f32 { self.dpi_scale }
pub fn set_dpi_scale( &mut self, s: f32 ) { self.dpi_scale = s; }
pub fn global_alpha( &self ) -> f32 { self.global_alpha }
pub fn set_global_alpha( &mut self, a: f32 ) { self.global_alpha = a; }
pub fn font( &self ) -> &Font { &self.font }
/// Install a theme font registry so [`Self::font_for`] can resolve
/// family+weight+style triples declared by the theme's `fonts` block.
pub fn set_font_registry( &mut self, registry: Arc<FontRegistry> )
{
self.font_registry = Some( registry );
}
/// Resolve a specific font from the theme registry, falling back to the
/// canvas' default [`Self::font`] when no registry is installed or the
/// triple cannot be satisfied.
pub fn font_for( &self, family: &str, weight: u16, style: FontStyle ) -> Arc<Font>
{
self.font_registry
.as_ref()
.and_then( |r| r.resolve( family, weight, style ) )
.unwrap_or_else( || Arc::clone( &self.font ) )
}
/// Pick the right font for `ch`. Tries the primary [`Self::font`]
/// first; on a miss, delegates to the crate-private system-fonts
/// fallback chain (lazy load of the relevant Noto pack). Falls
/// back to the primary (which paints a `.notdef` box) when no
/// installed fallback covers the codepoint.
pub fn font_for_char( &self, ch: char ) -> Arc<Font>
{
if self.font.lookup_glyph_index( ch ) != 0
{
return Arc::clone( &self.font );
}
crate::system_fonts::lookup( ch ).unwrap_or_else( || Arc::clone( &self.font ) )
}
pub fn font_metrics( &self, ch: char, size: f32 ) -> Metrics
{
self.font_for_char( ch ).metrics( ch, size * self.dpi_scale )
}
pub fn font_line_metrics( &self, size: f32 ) -> Option<LineMetrics>
{
self.font.horizontal_line_metrics( size )
}
/// Resize the FBO and viewport. The previous color attachment is freed and
/// a fresh one of the new size is attached — frame-N pixels are dropped, so
/// the caller should expect to do a full redraw immediately after a resize.
pub fn resize( &mut self, width: u32, height: u32 )
{
if width == self.width && height == self.height { return; }
self.width = width;
self.height = height;
// SAFETY: GL context is current (canvas invariant). Sequence:
// release the old colour attachment (created via this context in
// `new`/`sub_canvas`/last `resize`), allocate a fresh one of the
// new size, swap it in for `COLOR_ATTACHMENT0` of the existing FBO,
// resize the viewport to match, and clear so the first frame after
// resize is well-defined RGBA. `self.fbo` is unchanged and remains
// valid; only its colour attachment is replaced.
unsafe
{
self.gl.delete_texture( self.fbo_tex );
self.fbo_tex = alloc_fbo_tex( &self.gl, self.version, width, height );
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( self.fbo ) );
self.gl.framebuffer_texture_2d(
glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT0,
glow::TEXTURE_2D, Some( self.fbo_tex ), 0,
);
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 );
}
// 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();
}
}

812
src/gles_render/shaders.rs Normal file
View File

@@ -0,0 +1,812 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! GLES 2/3 shader sources used by `GlesCanvas`. All fragment shaders
//! emit premultiplied-alpha colour so they compose correctly under
//! the pipeline's default `glBlendFunc(ONE, ONE_MINUS_SRC_ALPHA)`.
//!
//! Every constant is `pub(super)` so only files inside
//! `crate::gles_render` can reach them — callers always go through
//! `GlesCanvas`'s public methods, not the raw shader source.
// Vertex shader shared by both programs (just transforms a unit quad).
// GLSL ES 1.00 — works on both GLES2 and GLES3 contexts (forward compatible
// when no `#version` directive is present).
pub( super ) const VERT_SRC: &str = r#"
attribute vec2 a_pos;
varying vec2 v_uv;
uniform mat4 u_mvp;
void main()
{
v_uv = a_pos;
gl_Position = u_mvp * vec4(a_pos, 0.0, 1.0);
}
"#;
// Fragment shader for solid/rounded rects (signed-distance smoothstep at edge).
//
// `u_stroke == 0` ⇒ filled rect. The shader fills the rounded shape with
// `u_color`, antialiased over a 1-pixel band at the edge.
// `u_stroke > 0` ⇒ stroked outline of width `u_stroke`, centered on the
// rounded boundary, so outlines keep their corner radius.
//
// Distance formula is Iñigo Quílez's exact SDF for a rounded box, extended
// to per-corner radii by picking `r` per-quadrant:
// r = u_radii[ corner_index_from_sign( p - center ) ]
// q = abs(p - center) - (size/2 - r)
// d = min(max(q.x, q.y), 0) + length(max(q, 0)) - r
// `u_radii` is ordered `(tl, tr, br, bl)` clockwise from top-left and is
// uploaded as `Corners::to_uniform()`. A uniform-radii fill is the
// degenerate case where every component is equal — the per-quadrant
// branch collapses to the single-`r` formula.
//
// `u_pad` lets the caller draw the quad larger than `u_size` (used by
// `stroke_rect`, which expands the quad by `stroke/2` on each side so the
// outer half of the stroke has fragments to cover). The shader maps `v_uv`
// from the larger quad back into the original-rect space:
// p = v_uv * (size + 2*pad) - pad
// so `p` ranges `[-pad, size+pad]`. Keeping `u_size` and `u_radii` at the
// *original* values is essential — expanding them shifts the SDF zero-line
// outward and breaks the circle case (radius = size/2).
//
// Each component of `u_radii` is clamped to `min(size.x, size.y) * 0.5`
// before use. Callers frequently pass very large values (e.g.
// `theme::RADIUS = 100`) as a "please make this a pill / capsule"
// sentinel. Without the clamp, `size/2 - r` goes negative in the shorter
// dimension and the rounded-box formula degenerates — rendering an
// ellipse in the middle of the rect instead of a pill.
//
// Emits premultiplied-alpha colour. The pipeline blend is
// `(ONE, ONE_MINUS_SRC_ALPHA)`, so every shader that writes colour must
// premultiply its RGB by the final alpha. This matters for non-opaque
// fills (coverage from the SDF, translucent `u_color.a`) where straight-
// alpha output would under-saturate antialiased edges.
pub( super ) const RECT_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform vec4 u_color;
uniform vec2 u_size;
uniform vec4 u_radii;
uniform float u_stroke;
uniform float u_pad;
// Per-fragment corner radius lookup. `c` is the fragment position
// relative to the rect's centre. Inside the shader, p.y grows UPWARD:
// `ortho_rect` flips the v_uv axis so v_uv.y=0 maps to the bottom edge
// of the rect in screen space and v_uv.y=1 to the top. So with `c =
// p - size/2`:
// c.x < 0, c.y > 0 → top-left → r.x
// c.x > 0, c.y > 0 → top-right → r.y
// c.x > 0, c.y < 0 → bottom-right → r.z
// c.x < 0, c.y < 0 → bottom-left → r.w
float corner_radius(vec2 c, vec4 r)
{
float top = (c.x < 0.0) ? r.x : r.y;
float bottom = (c.x < 0.0) ? r.w : r.z;
return (c.y > 0.0) ? top : bottom;
}
void main()
{
float r_max = max(max(u_radii.x, u_radii.y), max(u_radii.z, u_radii.w));
if (r_max <= 0.0 && u_stroke <= 0.0 && u_pad <= 0.0)
{
float a = u_color.a;
gl_FragColor = vec4(u_color.rgb * a, a);
return;
}
vec2 p = v_uv * (u_size + 2.0 * vec2(u_pad)) - vec2(u_pad);
vec2 c = p - u_size * 0.5;
float r = corner_radius(c, u_radii);
r = min(r, min(u_size.x, u_size.y) * 0.5);
vec2 q = abs(c) - (u_size * 0.5 - vec2(r));
float d = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
// 2-px-wide AA band (half-width 1.0). A narrower (±0.5) band is
// only sampled when pixel centres happen to fall inside it — when
// the rasterizer grid aligns with the edge at subpixel ±0.5 the
// band is jumped over and the transition becomes a binary step
// (visible stair-stepping on curved edges). Doubling the band
// guarantees at least one partial-coverage sample per scanline
// regardless of alignment. The quad pad set by the caller is 1 px
// so this still fits inside the geometry at d ≤ 1.
float coverage;
if (u_stroke > 0.0)
{
float half_w = u_stroke * 0.5;
coverage = 1.0 - smoothstep(half_w - 1.0, half_w + 1.0, abs(d));
} else {
coverage = 1.0 - smoothstep(-1.0, 1.0, d);
}
float a = u_color.a * coverage;
gl_FragColor = vec4(u_color.rgb * a, a);
}
"##;
// Fragment shader for RGBA textured quads (images / icons).
//
// Flips uv.y because images are uploaded top-down (CPU convention: row 0 =
// top of source) but `glTexImage2D` puts the first row at the texture's
// lower-left. With the quad orientation produced by `ortho_rect`, sampling
// `v_uv` directly would draw the source upside-down on screen.
//
// Texture data arrives PREMULTIPLIED — `upload_rgba_texture` premuls the
// straight-alpha CPU buffer once at upload. GL_LINEAR sampling can then
// blend across texel boundaries without halo artefacts (a fully opaque
// black texel next to a fully transparent white texel interpolates to
// `( 0, 0, 0, 0.5 )` instead of the halo-producing `( 0.5, 0.5, 0.5, 0.5 )`
// of straight-alpha interpolation). Multiplying premul by uniform opacity
// preserves the invariant `rgb == rgb_straight * a`.
pub( super ) const TEX_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_sampler;
uniform float u_opacity;
void main()
{
gl_FragColor = texture2D(u_sampler, vec2(v_uv.x, 1.0 - v_uv.y)) * u_opacity;
}
"##;
// Fragment shader for single-channel glyph textures with color tint. Same
// Y-flip rationale as `TEX_FRAG_SRC`. The texture is `GL_LUMINANCE`, which
// replicates the uploaded byte into `.r`, `.g`, `.b` (with `.a = 1`), so the
// coverage value is read from `.r`. We deliberately avoid `GL_ALPHA` here:
// some Mesa GLES3 paths handle the legacy alpha-only format inconsistently
// (sampled `.a` returns near-zero in glyph interiors, leaving only the
// antialias edges visible — text appears as thin faded outlines instead of
// solid strokes). `LUMINANCE` is also a legacy format but its mapping to
// `.r=.g=.b=data` is well-supported across ES2/ES3 drivers.
pub( super ) const GLYPH_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_sampler;
uniform vec4 u_color;
uniform float u_opacity;
void main()
{
float coverage = texture2D(u_sampler, vec2(v_uv.x, 1.0 - v_uv.y)).r;
float a = u_color.a * coverage * u_opacity;
gl_FragColor = vec4(u_color.rgb * a, a);
}
"##;
// Vertex shader for the present-blit: maps the unit quad to fullscreen NDC
// without going through an MVP. UVs are equal to a_pos so the FBO appears
// the same way up on the default framebuffer as it was rendered into the FBO
// (both use GL pixel coords with origin at bottom-left).
pub( super ) const BLIT_VERT_SRC: &str = r#"
attribute vec2 a_pos;
varying vec2 v_uv;
void main()
{
v_uv = a_pos;
gl_Position = vec4(a_pos * 2.0 - 1.0, 0.0, 1.0);
}
"#;
// Fragment shader for the present-blit: straight texture sample, no opacity.
pub( super ) const BLIT_FRAG_SRC: &str = r#"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_sampler;
void main()
{
gl_FragColor = texture2D(u_sampler, v_uv);
}
"#;
// Fragment shader for inter-FBO blits (used by `blit` to composite a sub-canvas
// back onto its parent). Reuses `VERT_SRC` so the quad is positioned via
// `u_mvp` like any other textured draw. No Y-flip: source and destination are
// both FBOs storing content in GL's native bottom-up convention, so sampling
// at `v_uv` puts the visually-top row of the source on the top of the dest.
//
// `u_fade_bottom_px` feathers the bottom edge of the blit: the last
// `u_fade_bottom_px` visible rows ramp the source alpha linearly from 1.0
// (at the inner edge of the band) to 0.0 (at the very bottom row), so a
// growing viewport does not look like a knife cut against whatever is
// behind it. `v_uv.y == 1.0` is the visually-top row of the source (FBO
// origin is bottom-left), so `v_uv.y * u_height_px` is the distance in
// source pixels from the bottom; the linear ramp falls out of dividing
// that by `u_fade_bottom_px` and clamping. With `u_fade_bottom_px == 0`
// the divide is skipped and the blit stays hard-edged. Linear (not
// smoothstep) because premultiplied output multiplied by a linear alpha
// already reads as a soft fade — smoothstep would compress the ramp into
// fewer effective rows and reintroduce a faint shoulder.
pub( super ) const SUB_BLIT_FRAG_SRC: &str = r#"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_sampler;
uniform float u_opacity;
uniform float u_fade_bottom_px;
uniform float u_height_px;
void main()
{
vec4 c = texture2D(u_sampler, v_uv);
float fade = 1.0;
if (u_fade_bottom_px > 0.0)
{
float y_from_bottom = v_uv.y * u_height_px;
fade = clamp(y_from_bottom / u_fade_bottom_px, 0.0, 1.0);
}
gl_FragColor = c * (u_opacity * fade);
}
"#;
// Linear gradient shader. Samples a CPU-baked 1D LUT (`u_lut`, size
// `gradient_lut::LUT_SAMPLES × 1`, RGBA8, straight-alpha) along the
// CSS linear-gradient convention: the gradient line passes through the
// centre of the rect, `0°` points up, positive angles rotate clockwise.
// Stop positions outside `[0, 1]` are already baked into the LUT via
// linear extrapolation, so the shader only remaps `t` from the extended
// domain `[u_lut_domain_min, u_lut_domain_min + u_lut_domain_span]`
// into the texture's `[0, 1]` sampling range.
//
// The same SDF as `RECT_FRAG_SRC` is reused for the rounded-rect / pill
// silhouette; the coverage multiplies the fragment's alpha before
// premultiplying.
pub( super ) const LINEAR_GRADIENT_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_lut;
uniform vec2 u_dir;
uniform vec2 u_size;
uniform float u_line_length;
uniform vec4 u_radii;
uniform float u_pad;
uniform float u_lut_domain_min;
uniform float u_lut_domain_span;
// Per-fragment corner radius lookup — see RECT_FRAG_SRC for the
// quadrant convention.
float corner_radius(vec2 c, vec4 r)
{
float top = (c.x < 0.0) ? r.x : r.y;
float bottom = (c.x < 0.0) ? r.w : r.z;
return (c.y > 0.0) ? top : bottom;
}
void main()
{
vec2 p = v_uv * (u_size + 2.0 * vec2(u_pad)) - vec2(u_pad);
vec2 c = p - u_size * 0.5;
float r = corner_radius(c, u_radii);
r = min(r, min(u_size.x, u_size.y) * 0.5);
vec2 q = abs(c) - (u_size * 0.5 - vec2(r));
float d = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
// 2-px AA band — see RECT_FRAG_SRC for the grid-alignment rationale.
float coverage = 1.0 - smoothstep(-1.0, 1.0, d);
// Gradient evaluated in rect-local pixel space. `p` already accounts
// for `u_pad` (set by the caller to expand the quad for AA), so the
// gradient direction stays anchored to the original rect even when
// the quad spills outside.
float dist = dot(c, u_dir);
float t = 0.5 + dist / u_line_length;
float t_lut = (t - u_lut_domain_min) / u_lut_domain_span;
vec4 grad = texture2D(u_lut, vec2(clamp(t_lut, 0.0, 1.0), 0.5));
float a = grad.a * coverage;
gl_FragColor = vec4(grad.rgb * a, a);
}
"##;
// Radial gradient shader. `u_center` is in box-relative fractions
// (`[0, 1]` on each axis), `u_radius_frac` is the radial extent in the
// same fractional space. `t = distance(v_uv, u_center) / u_radius_frac`
// so stops at `position == 1.0` fall exactly at the chosen radius.
pub( super ) const RADIAL_GRADIENT_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_lut;
uniform vec2 u_center;
uniform float u_radius_frac;
uniform vec2 u_size;
uniform vec4 u_radii;
uniform float u_pad;
uniform float u_lut_domain_min;
uniform float u_lut_domain_span;
// Per-fragment corner radius lookup — see RECT_FRAG_SRC for the
// quadrant convention.
float corner_radius(vec2 c, vec4 r)
{
float top = (c.x < 0.0) ? r.x : r.y;
float bottom = (c.x < 0.0) ? r.w : r.z;
return (c.y > 0.0) ? top : bottom;
}
void main()
{
vec2 p = v_uv * (u_size + 2.0 * vec2(u_pad)) - vec2(u_pad);
vec2 c = p - u_size * 0.5;
float r = corner_radius(c, u_radii);
r = min(r, min(u_size.x, u_size.y) * 0.5);
vec2 q = abs(c) - (u_size * 0.5 - vec2(r));
float d = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
// 2-px AA band — see RECT_FRAG_SRC for the grid-alignment rationale.
float coverage = 1.0 - smoothstep(-1.0, 1.0, d);
// Centre/extent in box-relative fractions evaluated against `p`
// (which already accounts for `u_pad`), so the radial centre stays
// anchored to the original rect when the quad spills outside.
vec2 t_uv = p / u_size;
float t = distance(t_uv, u_center) / max(u_radius_frac, 1e-6);
float t_lut = (t - u_lut_domain_min) / u_lut_domain_span;
vec4 grad = texture2D(u_lut, vec2(clamp(t_lut, 0.0, 1.0), 0.5));
float a = grad.a * coverage;
gl_FragColor = vec4(grad.rgb * a, a);
}
"##;
// Outer drop shadow shader.
//
// Rather than baking a blurred shape into an intermediate texture via a
// separable Gaussian pass, this uses the analytical soft-shadow
// approximation `exp(-d² / (2σ²))` over the rounded-rect SDF. The maths:
//
// • `d` is the signed distance from the fragment to the (possibly
// spread-expanded) shape. `d < 0` is inside, `d > 0` is outside.
// • `intensity = 1.0` inside the shape, `exp(-d²/2σ²)` outside.
// • `σ = shadow.blur / 2` (CSS blur radius → Gaussian sigma).
//
// The result is visually indistinguishable from a true Gaussian blur for
// small to moderate σ. For very large σ the tail of `exp()` departs from
// a real Gaussian, at which point a real two-pass blur or a correction
// factor would be needed.
//
// Running the shadow as a single analytic pass means there's no need for
// a per-shadow FBO, no ping-pong, and no framebuffer readback: it is all
// one cheap draw call.
// Inner (inset) shadow shader.
//
// Two SDFs cooperate here:
//
// • `d_outer` — the signed distance to the surface itself. Positive
// outside, negative inside. We multiply the final intensity by the
// smooth-step coverage of this SDF so the inset never leaks past the
// shape's own silhouette.
//
// • `d_inner` — the signed distance to the shape shifted by
// `shadow.offset` and eroded by `shadow.spread`. This is the "hole"
// the inset falls into: `d_inner ≥ 0` means the pixel is on the
// shadow side of the offset edge (full intensity); `d_inner < 0`
// means the pixel is deeper into the unshadowed interior, where the
// intensity decays as `exp(-d_inner² / (2σ²))`.
//
// Together they reproduce the CSS `inset` semantics: the shadow sits
// inside the rounded rect, biased toward the side opposite `offset`,
// and fades toward the middle with a Gaussian falloff. Premul output
// matches the rest of the pipeline.
pub( super ) const SHADOW_INSET_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform vec2 u_size;
uniform vec2 u_padding;
uniform vec4 u_radii;
uniform float u_spread;
uniform float u_sigma;
uniform vec2 u_offset;
uniform vec4 u_color;
// Per-fragment corner radius lookup — see RECT_FRAG_SRC for the
// quadrant convention.
float corner_radius(vec2 c, vec4 r)
{
float top = (c.x < 0.0) ? r.x : r.y;
float bottom = (c.x < 0.0) ? r.w : r.z;
return (c.y > 0.0) ? top : bottom;
}
void main()
{
vec2 p = v_uv * (u_size + 2.0 * u_padding) - u_padding;
vec2 c = p - u_size * 0.5;
// Outer SDF: the shape itself, untransformed. Used to clip the inset
// to the silhouette. 2-px AA band — see RECT_FRAG_SRC.
vec2 half_outer = u_size * 0.5;
float r_outer = corner_radius(c, u_radii);
r_outer = min(r_outer, min(half_outer.x, half_outer.y));
vec2 q_outer = abs(c) - (half_outer - vec2(r_outer));
float d_outer = min(max(q_outer.x, q_outer.y), 0.0) + length(max(q_outer, 0.0)) - r_outer;
float outer_coverage = 1.0 - smoothstep(-1.0, 1.0, d_outer);
// Inner SDF: the shape shifted by offset and eroded by spread. Its
// distance drives the Gaussian falloff. The inner radii match the
// outer per-corner shape, eroded by `u_spread` (clamped at zero).
vec2 p_shifted = p - u_offset;
vec2 c_shifted = p_shifted - u_size * 0.5;
vec2 half_inner = half_outer - vec2(u_spread);
// Degenerate case: spread larger than half the shape erodes it to a
// point. Guard so `half_inner` never goes negative.
half_inner = max(half_inner, vec2(1e-3));
vec4 inner_radii = max(u_radii - vec4(u_spread), vec4(0.0));
float r_inner = corner_radius(c_shifted, inner_radii);
r_inner = min(r_inner, min(half_inner.x, half_inner.y));
vec2 q_inner = abs(c_shifted) - (half_inner - vec2(r_inner));
float d_inner = min(max(q_inner.x, q_inner.y), 0.0) + length(max(q_inner, 0.0)) - r_inner;
float intensity;
if (d_inner >= 0.0)
{
intensity = 1.0;
}
else
{
float s = max(u_sigma, 0.5);
intensity = exp(-(d_inner * d_inner) / (2.0 * s * s));
}
intensity *= outer_coverage;
float a = u_color.a * intensity;
gl_FragColor = vec4(u_color.rgb * a, a);
}
"##;
pub( super ) const SHADOW_OUTER_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform vec2 u_size;
uniform vec2 u_padding;
uniform vec4 u_radii;
uniform float u_spread;
uniform float u_sigma;
uniform vec4 u_color;
// Per-fragment corner radius lookup — see RECT_FRAG_SRC for the
// quadrant convention.
float corner_radius(vec2 c, vec4 r)
{
float top = (c.x < 0.0) ? r.x : r.y;
float bottom = (c.x < 0.0) ? r.w : r.z;
return (c.y > 0.0) ? top : bottom;
}
void main()
{
vec2 p = v_uv * (u_size + 2.0 * u_padding) - u_padding;
vec2 c = p - u_size * 0.5;
vec2 half_sz = u_size * 0.5 + vec2(u_spread);
// Per-corner shadow radius — outer shape grown uniformly by spread.
vec4 r_base = max(u_radii + vec4(u_spread), vec4(0.0));
float r = corner_radius(c, r_base);
r = min(r, min(half_sz.x, half_sz.y));
vec2 q = abs(c) - (half_sz - vec2(r));
float d = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
float intensity;
if (d <= 0.0)
{
intensity = 1.0;
}
else
{
float s = max(u_sigma, 0.5);
intensity = exp(-(d * d) / (2.0 * s * s));
}
float a = u_color.a * intensity;
gl_FragColor = vec4(u_color.rgb * a, a);
}
"##;
// Horizontal Gaussian blur for the backdrop pipeline. Part one of the
// separable-Gaussian pair: samples the aux_a snapshot horizontally and
// writes to aux_b at the fragment's pixel position. Drawn over a
// vertically-extended surface rect so the vertical pass — which reads
// aux_b up to ±3σ away from each target pixel — has valid data wherever
// it samples. `u_canvas_size` maps `gl_FragCoord` into the aux texture,
// and the aux pair is sized to the main FBO so that mapping is trivial.
//
// Kernel: 41 taps (`RADIUS = 20`), weights computed inline as
// `exp(-i²/(2σ²))` and normalized by the sum. At σ ≈ 11 this captures
// ~93 % of the Gaussian mass — the remainder is a faint tail that is
// visually imperceptible. For σ past ~15 the kernel radius would need
// to grow or a downsample pass would be required; everything else in
// the pipeline already deals in σ and would not change.
pub( super ) const BACKDROP_BLUR_H_FRAG_SRC: &str = r#"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_source;
uniform vec2 u_texel;
uniform vec2 u_canvas_size;
uniform float u_sigma;
void main()
{
const int RADIUS = 20;
vec2 uv = gl_FragCoord.xy / u_canvas_size;
vec4 total = vec4(0.0);
float total_w = 0.0;
for (int i = -RADIUS; i <= RADIUS; i++)
{
float fi = float(i);
float w = exp(-(fi * fi) / (2.0 * u_sigma * u_sigma));
total += texture2D(u_source, uv + fi * u_texel) * w;
total_w += w;
}
gl_FragColor = total / total_w;
}
"#;
// Vertical Gaussian blur + SDF clip + tint, the last pass of the
// backdrop pipeline. Runs on a quad matching the surface rect and
// writes to the main FBO.
//
// Per fragment: (1) computes the rounded-rect SDF coverage just like
// the rect shader so the backdrop is clipped to the surface shape with
// a 1-pixel anti-aliased edge; (2) samples `u_source` (the H-blurred
// aux_b) vertically with the same 41-tap Gaussian as the H pass; (3)
// applies the optional `u_tint` over the blurred sample using standard
// premul-over math; (4) outputs premultiplied with `alpha = coverage`.
//
// Alpha handling. The output is `(result_rgb * coverage, coverage)` —
// not `(result_rgb * coverage * result_a, coverage * result_a)`. The
// snapshot holds premultiplied content that is effectively opaque
// inside the canvas (every pixel has been written by some draw, even
// if that draw was `clear(0,0,0,0)`; the FBO is never sampled outside
// the canvas bounds). Treating the blurred sample's alpha as 1.0 at
// output time means the composite pass REPLACES the base pixel inside
// the surface shape with the tinted blurred content, rather than
// alpha-blending on top of it. That is the correct semantics for
// `backdrop-filter`: you want the original content to disappear
// entirely where the surface covers it, replaced by the blurred
// version.
//
// `fill_backdrop` runs this before outer shadows / fill / insets so
// later passes composite on top of the blurred backdrop.
pub( super ) const BACKDROP_COMPOSITE_FRAG_SRC: &str = r#"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_source;
uniform vec2 u_canvas_size;
uniform vec2 u_texel;
uniform float u_sigma;
uniform vec2 u_size;
uniform vec2 u_padding;
uniform vec4 u_radii;
uniform vec4 u_tint;
// Per-fragment corner radius lookup — see RECT_FRAG_SRC for the
// quadrant convention.
float corner_radius(vec2 c, vec4 r)
{
float top = (c.x < 0.0) ? r.x : r.y;
float bottom = (c.x < 0.0) ? r.w : r.z;
return (c.y > 0.0) ? top : bottom;
}
void main()
{
const int RADIUS = 20;
// Rounded-rect SDF clip. 2-px AA band — see RECT_FRAG_SRC.
vec2 p = v_uv * (u_size + 2.0 * u_padding) - u_padding;
vec2 c = p - u_size * 0.5;
vec2 half_sz = u_size * 0.5;
float r = corner_radius(c, u_radii);
r = min(r, min(half_sz.x, half_sz.y));
vec2 q = abs(c) - (half_sz - vec2(r));
float d = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
float coverage = 1.0 - smoothstep(-1.0, 1.0, d);
if (coverage <= 0.0) { discard; }
// Vertical Gaussian on the H-blurred source.
vec2 uv = gl_FragCoord.xy / u_canvas_size;
vec4 total = vec4(0.0);
float total_w = 0.0;
for (int i = -RADIUS; i <= RADIUS; i++)
{
float fi = float(i);
float w = exp(-(fi * fi) / (2.0 * u_sigma * u_sigma));
total += texture2D(u_source, uv + fi * u_texel) * w;
total_w += w;
}
vec4 blurred = total / total_w;
// Optional tint, "tint over blurred" in premul space.
vec3 tint_premul = u_tint.rgb * u_tint.a;
vec3 result_rgb = tint_premul + blurred.rgb * (1.0 - u_tint.a);
gl_FragColor = vec4(result_rgb * coverage, coverage);
}
"#;
// ── Fast (low-quality) variants of the backdrop shaders ────────────────
//
// Same separable-Gaussian pipeline as the full-quality pair above, but
// with `RADIUS = 4` instead of `RADIUS = 20`. That cuts each pass from
// 41 taps to 9 — a ~4.5× reduction in fragment-shader work — and
// shrinks the snapshot region the renderer has to copy from the main
// FBO from `target ± 21` to `target ± 5` pixels. Used during motion
// via [`super::low_quality_paint`]; the static frame is painted with
// the full-quality pair.
//
// `u_sigma` is still a uniform so the CPU side can clamp it to a
// value compatible with the smaller kernel (typically ≤ 2.0), keeping
// the kernel a sensible Gaussian rather than a sharply truncated one.
// Visually this means a thinner blur band during motion, which fades
// back to the full blur on the static frame.
pub( super ) const BACKDROP_FAST_BLUR_H_FRAG_SRC: &str = r#"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_source;
uniform vec2 u_texel;
uniform vec2 u_canvas_size;
uniform float u_sigma;
void main()
{
const int RADIUS = 4;
vec2 uv = gl_FragCoord.xy / u_canvas_size;
vec4 total = vec4(0.0);
float total_w = 0.0;
for (int i = -RADIUS; i <= RADIUS; i++)
{
float fi = float(i);
float w = exp(-(fi * fi) / (2.0 * u_sigma * u_sigma));
total += texture2D(u_source, uv + fi * u_texel) * w;
total_w += w;
}
gl_FragColor = total / total_w;
}
"#;
pub( super ) const BACKDROP_FAST_COMPOSITE_FRAG_SRC: &str = r#"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_source;
uniform vec2 u_canvas_size;
uniform vec2 u_texel;
uniform float u_sigma;
uniform vec2 u_size;
uniform vec2 u_padding;
uniform vec4 u_radii;
uniform vec4 u_tint;
// Per-fragment corner radius lookup — see RECT_FRAG_SRC for the
// quadrant convention.
float corner_radius(vec2 c, vec4 r)
{
float top = (c.x < 0.0) ? r.x : r.y;
float bottom = (c.x < 0.0) ? r.w : r.z;
return (c.y > 0.0) ? top : bottom;
}
void main()
{
const int RADIUS = 4;
// Rounded-rect SDF clip — identical to the full-quality variant.
vec2 p = v_uv * (u_size + 2.0 * u_padding) - u_padding;
vec2 c = p - u_size * 0.5;
vec2 half_sz = u_size * 0.5;
float r = corner_radius(c, u_radii);
r = min(r, min(half_sz.x, half_sz.y));
vec2 q = abs(c) - (half_sz - vec2(r));
float d = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
float coverage = 1.0 - smoothstep(-1.0, 1.0, d);
if (coverage <= 0.0) { discard; }
vec2 uv = gl_FragCoord.xy / u_canvas_size;
vec4 total = vec4(0.0);
float total_w = 0.0;
for (int i = -RADIUS; i <= RADIUS; i++)
{
float fi = float(i);
float w = exp(-(fi * fi) / (2.0 * u_sigma * u_sigma));
total += texture2D(u_source, uv + fi * u_texel) * w;
total_w += w;
}
vec4 blurred = total / total_w;
vec3 tint_premul = u_tint.rgb * u_tint.a;
vec3 result_rgb = tint_premul + blurred.rgb * (1.0 - u_tint.a);
gl_FragColor = vec4(result_rgb * coverage, coverage);
}
"#;
// Inset shadow with CSS `Overlay` blend. Uses the same SDF dance as
// `SHADOW_INSET_FRAG_SRC` to compute `intensity` (outer-silhouette clip
// + Gaussian falloff from the offset-shifted inner SDF), then samples
// the snapshotted FBO content under the fragment and applies the
// per-channel Overlay formula:
//
// overlay(base, src) = base < 0.5 ? 2 * base * src
// : 1 - 2 * (1 - base) * (1 - src)
//
// Output is premultiplied with `mask = u_color.a * intensity` — so the
// standard `(ONE, ONE_MINUS_SRC_ALPHA)` blend on top of the main FBO
// yields `result = overlay_rgb * mask + base * (1 - mask)`, i.e. the
// Overlay effect modulated by the shadow mask, composed onto the
// original backdrop. The snapshot texture holds premultiplied content
// matching the main FBO, so we unpremultiply before applying Overlay.
//
// `u_canvas_size` is the main FBO's pixel size. `gl_FragCoord` has its
// origin at bottom-left in GLES, which matches the FBO's native
// orientation, so `gl_FragCoord.xy / u_canvas_size` gives the texture
// coordinates of the pixel we're about to write to. No Y-flip needed.
pub( super ) const SHADOW_INSET_OVERLAY_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform vec2 u_size;
uniform vec2 u_padding;
uniform vec4 u_radii;
uniform float u_spread;
uniform float u_sigma;
uniform vec2 u_offset;
uniform vec4 u_color;
uniform sampler2D u_snapshot;
uniform vec2 u_canvas_size;
// Per-fragment corner radius lookup — see RECT_FRAG_SRC for the
// quadrant convention.
float corner_radius(vec2 c, vec4 r)
{
float top = (c.x < 0.0) ? r.x : r.y;
float bottom = (c.x < 0.0) ? r.w : r.z;
return (c.y > 0.0) ? top : bottom;
}
void main()
{
vec2 p = v_uv * (u_size + 2.0 * u_padding) - u_padding;
vec2 c = p - u_size * 0.5;
// Outer silhouette clip — same as SHADOW_INSET_FRAG_SRC, 2-px AA band.
vec2 half_outer = u_size * 0.5;
float r_outer = corner_radius(c, u_radii);
r_outer = min(r_outer, min(half_outer.x, half_outer.y));
vec2 q_outer = abs(c) - (half_outer - vec2(r_outer));
float d_outer = min(max(q_outer.x, q_outer.y), 0.0) + length(max(q_outer, 0.0)) - r_outer;
float outer_coverage = 1.0 - smoothstep(-1.0, 1.0, d_outer);
// Offset-shifted inner SDF → Gaussian intensity. Per-corner inner
// radii match the outer shape eroded by `u_spread`.
vec2 p_shifted = p - u_offset;
vec2 c_shifted = p_shifted - u_size * 0.5;
vec2 half_inner = half_outer - vec2(u_spread);
half_inner = max(half_inner, vec2(1e-3));
vec4 inner_radii = max(u_radii - vec4(u_spread), vec4(0.0));
float r_inner = corner_radius(c_shifted, inner_radii);
r_inner = min(r_inner, min(half_inner.x, half_inner.y));
vec2 q_inner = abs(c_shifted) - (half_inner - vec2(r_inner));
float d_inner = min(max(q_inner.x, q_inner.y), 0.0) + length(max(q_inner, 0.0)) - r_inner;
float intensity;
if (d_inner >= 0.0)
{
intensity = 1.0;
}
else
{
float s = max(u_sigma, 0.5);
intensity = exp(-(d_inner * d_inner) / (2.0 * s * s));
}
intensity *= outer_coverage;
float mask = u_color.a * intensity;
// Sample the snapshot at the fragment's FBO position. The snapshot
// holds premultiplied content; divide by alpha before applying the
// straight-alpha Overlay formula. Guard alpha=0 to avoid NaNs.
vec2 snap_uv = gl_FragCoord.xy / u_canvas_size;
vec4 snap = texture2D(u_snapshot, snap_uv);
vec3 base = snap.a > 0.0 ? snap.rgb / snap.a : vec3(0.0);
vec3 src = u_color.rgb;
// Per-channel Overlay. `step(0.5, base)` yields 0 where base < 0.5
// and 1 otherwise; `mix` picks multiply vs screen accordingly.
vec3 multiply = 2.0 * base * src;
vec3 screen = 1.0 - 2.0 * (1.0 - base) * (1.0 - src);
vec3 overlay = mix(multiply, screen, step(0.5, base));
// Output premul: `(overlay * mask, mask)`. Combined with the premul
// over blend this replaces the base by `overlay` wherever mask == 1
// and leaves it untouched where mask == 0.
gl_FragColor = vec4(overlay * mask, mask);
}
"##;

176
src/gles_render/text.rs Normal file
View File

@@ -0,0 +1,176 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Text rendering for [`GlesCanvas`]: glyph-atlas cache + per-glyph
//! draw call. Each glyph is rasterised once via fontdue, uploaded as
//! a one-off `GL_LUMINANCE` texture, and stored on the canvas; the
//! per-frame hot path picks positions and issues one draw call per
//! glyph.
//!
//! `GL_LUMINANCE` is deliberate — `GL_ALPHA` has inconsistent
//! handling across Mesa GLES3 drivers (sampled `.a` returns near-zero
//! in glyph interiors, leaving only antialias edges visible), and
//! luminance's `.r = .g = .b = data` mapping is well-supported
//! across ES2/ES3.
use std::sync::Arc;
use fontdue::Font;
use glow::HasContext;
use crate::types::{ Color, Rect };
use super::helpers::{ ortho_rect, upload_alpha_texture };
use super::{ GlesCanvas, GlyphEntry };
const GLYPH_CACHE_SOFT_CAP: usize = 8192;
fn font_id( font: &Arc<Font> ) -> usize
{
Arc::as_ptr( font ) as usize
}
impl GlesCanvas
{
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 );
}
/// Draw `text` using `font` instead of the canvas default + lazy
/// system-font fallback chain. Glyphs from this font live under
/// their own atlas keys.
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 ) );
}
fn draw_text_inner( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color, font: Option<&Arc<Font>> )
{
self.activate_target();
let scaled = size * self.dpi_scale;
let mut cursor_x = x;
for ch in text.chars()
{
let size_key = (scaled * 10.0) as u32;
let ( id, font_arc ): ( usize, Arc<Font> ) = match font
{
Some( f ) =>
{
if f.lookup_glyph_index( ch ) != 0
{
( font_id( f ), Arc::clone( f ) )
}
else
{
self.font_id_for_char( ch )
}
}
None => self.font_id_for_char( ch ),
};
let key = ( ch, size_key, id );
if !self.glyph_cache.contains_key( &key )
{
if self.glyph_cache.len() >= GLYPH_CACHE_SOFT_CAP
{
self.evict_glyph_cache_half();
}
let ( metrics, bitmap ) = font_arc.rasterize( ch, scaled );
if metrics.width > 0 && metrics.height > 0
{
let tex = upload_alpha_texture( &self.gl, &bitmap, metrics.width as i32, metrics.height as i32 );
self.glyph_cache.insert( key, GlyphEntry
{
texture: tex,
metrics,
tex_w: metrics.width as i32,
tex_h: metrics.height as i32,
} );
} else {
cursor_x += metrics.advance_width;
continue;
}
}
if let Some( entry ) = self.glyph_cache.get( &key )
{
let gx = ( cursor_x + entry.metrics.xmin as f32 ).round();
let gy = ( y - entry.metrics.height as f32 - entry.metrics.ymin as f32 + 1.0 ).round();
let dest = Rect
{
x: gx,
y: gy,
width: entry.tex_w as f32,
height: entry.tex_h as f32,
};
self.draw_glyph_texture( entry.texture, dest, color, self.global_alpha );
cursor_x += entry.metrics.advance_width;
}
}
}
pub fn measure_text( &self, text: &str, size: f32 ) -> f32
{
text.chars().map( |ch|
{
self.font_for_char( ch ).metrics( ch, size * self.dpi_scale ).advance_width
} ).sum()
}
pub fn measure_text_with_font( &self, text: &str, size: f32, font: &Arc<Font> ) -> f32
{
text.chars().map( |ch|
{
let f = if font.lookup_glyph_index( ch ) != 0
{
Arc::clone( font )
}
else
{
self.font_for_char( ch )
};
f.metrics( ch, size * self.dpi_scale ).advance_width
} ).sum()
}
fn font_id_for_char( &self, ch: char ) -> ( usize, Arc<Font> )
{
let font = self.font_for_char( ch );
let id = Arc::as_ptr( &font ) as usize;
( id, font )
}
fn evict_glyph_cache_half( &mut self )
{
let drop_n = self.glyph_cache.len() / 2;
let victims: Vec<_> = self.glyph_cache.keys().copied().take( drop_n ).collect();
for key in victims
{
if let Some( entry ) = self.glyph_cache.remove( &key )
{
unsafe { self.gl.delete_texture( entry.texture ); }
}
}
}
fn draw_glyph_texture( &self, texture: glow::Texture, dest: Rect, color: Color, opacity: f32 )
{
let mvp = ortho_rect( self.width, self.height, dest );
unsafe
{
self.gl.use_program( Some( self.glyph_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_glyph_mvp ), false, &mvp );
self.gl.uniform_4_f32( Some( &self.u_glyph_color ), color.r, color.g, color.b, color.a );
self.gl.uniform_1_f32( Some( &self.u_glyph_opacity ), opacity );
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( texture ) );
self.gl.uniform_1_i32( Some( &self.u_glyph_sampler ), 0 );
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 );
}
}
}

251
src/input/dispatch.rs Normal file
View File

@@ -0,0 +1,251 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Dispatch helpers shared by the pointer and touch handlers.
//!
//! The gesture state machine returns abstract outcomes; this file is
//! where those outcomes turn into concrete side-effects on `AppData`:
//! pending-message pushes, app-level callbacks (`on_swipe_*`,
//! `on_drag_move`, `on_drop`, `on_tap`, `overlay_dismiss_msg`), and
//! the redraw / cache invalidation flags that keep the loop moving
//! after a non-Message-producing gesture.
//!
//! Splitting these into a dedicated file means pointer.rs and touch.rs
//! only carry the Wayland-event translation. A future input source
//! (stylus, gamepad) can reuse the same dispatcher by feeding the
//! state machine the same way.
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
use crate::types::Point;
use crate::widget::WidgetHandlers;
use super::gesture::{ MoveOutcome, ReleaseEvent };
impl<A: App> AppData<A>
{
/// If the press at `pos` lands on a password-toggle icon zone of
/// the widget at `idx`, push the toggle message and return
/// `true` so the caller can skip the rest of the cursor /
/// selection placement that would otherwise consume the press.
/// Returns `false` when there is no toggle on that widget or
/// the press fell outside the icon's hit area, leaving the
/// caller to dispatch the press normally.
pub( super ) fn handle_password_toggle_press
(
&mut self,
focus: SurfaceFocus,
idx: usize,
pos: Point,
) -> bool
{
let toggle_msg = self.surface( focus ).widget_rects.iter()
.find( |w| w.flat_idx == idx )
.and_then( |w| match &w.handlers
{
WidgetHandlers::TextEdit { password_toggle_msg: Some( msg ), .. } =>
{
let zone = crate::widget::text_edit::password_toggle_hit_zone( w.rect );
if zone.contains( pos ) { Some( msg.clone() ) } else { None }
}
_ => None,
} );
if let Some( msg ) = toggle_msg
{
self.pending_msgs.push( msg );
self.surface_mut( focus ).request_redraw();
true
}
else
{
false
}
}
}
impl<A: App> AppData<A>
{
/// Apply the outcome of a motion event. Non-blocking side-effects
/// only: push messages, call app callbacks, set redraw flags.
pub( super ) fn apply_move_outcome
(
&mut self,
focus: SurfaceFocus,
outcome: MoveOutcome<A::Message>,
)
{
match outcome
{
MoveOutcome::Idle => {}
MoveOutcome::Drag { pos } =>
{
self.app.on_drag_move( pos.x, pos.y );
self.dirty_caches();
self.surface_mut( focus ).request_redraw();
self.main.request_redraw();
}
MoveOutcome::Slider { msg } =>
{
if let Some( m ) = msg
{
self.pending_msgs.push( m );
}
self.surface_mut( focus ).request_redraw();
}
MoveOutcome::Scroll =>
{
self.surface_mut( focus ).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();
for ss in self.overlays.values_mut()
{
ss.request_redraw();
}
}
}
}
/// Apply the ordered list of events from a release. A single
/// release can emit more than one event (e.g. horizontal
/// fall-through followed by a vertical commit or fall-through), so
/// we walk the list and run each event's side-effects in order.
pub( super ) fn apply_release_events
(
&mut self,
focus: SurfaceFocus,
events: Vec<ReleaseEvent<A::Message>>,
)
{
for event in events
{
self.apply_release_event( focus, event );
}
}
fn apply_release_event
(
&mut self,
focus: SurfaceFocus,
event: ReleaseEvent<A::Message>,
)
{
match event
{
ReleaseEvent::Drop { pos } =>
{
if let Some( msg ) = self.app.on_drop( pos.x, pos.y )
{
self.pending_msgs.push( msg );
}
self.clear_long_press_drag();
self.dirty_caches();
self.main.request_redraw();
self.main.frame_pending = false;
}
ReleaseEvent::SwipeLeft =>
{
if let Some( msg ) = self.app.on_swipe_left()
{
self.pending_msgs.push( msg );
}
// When the app handles the release internally (settle
// animation, state-only mutation) it returns no
// Message, so the update-driven redraw never fires.
// Kick the main surface here so `is_animating()` has a
// frame to latch onto.
self.dirty_caches();
self.main.request_redraw();
self.main.frame_pending = false;
}
ReleaseEvent::SwipeRight =>
{
if let Some( msg ) = self.app.on_swipe_right()
{
self.pending_msgs.push( msg );
}
self.dirty_caches();
self.main.request_redraw();
self.main.frame_pending = false;
}
ReleaseEvent::SwipeUp =>
{
if let Some( msg ) = self.app.on_swipe_up()
{
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::SwipeDown =>
{
if let Some( msg ) = self.app.on_swipe_down()
{
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
// vertical-dominant gesture that drifted laterally
// does not leave the app stuck on a stale horizontal
// progress.
self.app.on_swipe_horizontal_progress( 0.0 );
self.dirty_caches();
self.main.request_redraw();
self.main.frame_pending = false;
}
ReleaseEvent::VerticalFellThrough =>
{
self.app.on_swipe_progress( 0.0 );
self.app.on_swipe_down_progress( 0.0 );
self.dirty_caches();
}
ReleaseEvent::PushMsg( msg ) =>
{
self.pending_msgs.push( msg );
}
ReleaseEvent::EmptyRelease =>
{
match focus
{
SurfaceFocus::Main =>
{
if let Some( msg ) = self.app.on_tap()
{
self.pending_msgs.push( msg );
}
}
SurfaceFocus::Overlay( id ) =>
{
if let Some( msg ) = self.overlay_dismiss_msg( id )
{
self.pending_msgs.push( msg );
}
}
}
}
}
}
}

1070
src/input/gesture.rs Normal file

File diff suppressed because it is too large Load Diff

637
src/input/keyboard.rs Normal file
View File

@@ -0,0 +1,637 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Wayland keyboard → ltk dispatch.
//!
//! Translates `wl_keyboard` events into focus / text-insertion /
//! widget-submit actions. Does not share state with the gesture state
//! machine — keyboard tracks its own focus (`AppData::keyboard_focus`)
//! and modifier flags (`shift_pressed`, `ctrl_pressed`).
//!
//! The only cross-cutting state it reads is `SurfaceState::focused_idx`
//! (set by pointer / touch focus updates) so keyboard navigation can
//! branch on "is a widget focused?" — Return submits / Space presses /
//! typing inserts all funnel through that check.
use std::time::Duration;
use smithay_client_toolkit::seat::keyboard::
{
KeyboardHandler, KeyEvent, Keysym, Modifiers, RawModifiers, RepeatInfo,
};
use smithay_client_toolkit::reexports::client::
{
protocol::{ wl_keyboard::WlKeyboard, wl_surface::WlSurface },
Connection, QueueHandle,
};
use calloop::timer::{ Timer, TimeoutAction };
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
use crate::event_loop::app_data::KeyRepeatState;
use crate::tree::{ find_handlers, next_focusable_index };
/// Built-in fallback for the initial key-repeat delay when the
/// compositor does not advertise one and the app does not override
/// [`crate::App::key_repeat_delay`].
const DEFAULT_REPEAT_DELAY: Duration = Duration::from_millis( 500 );
/// Built-in fallback for the inter-repeat interval when the compositor
/// does not advertise a rate. ~30 Hz, matching the GNOME / KDE default
/// for "fast" without straying into uncomfortably-twitchy territory.
const DEFAULT_REPEAT_INTERVAL: Duration = Duration::from_millis( 33 );
impl<A: App> KeyboardHandler for AppData<A>
{
fn enter(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &WlKeyboard,
surface: &WlSurface,
_serial: u32,
_raw: &[u32],
_keysyms: &[Keysym],
)
{
let focus = self.focus_for_surface( surface ).unwrap_or( SurfaceFocus::Main );
self.keyboard_focus = focus;
self.surface_mut( focus ).request_redraw();
}
fn leave(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &WlKeyboard,
_surface: &WlSurface,
_serial: u32,
)
{
self.stop_key_repeat();
self.keyboard_focus = SurfaceFocus::Main;
}
fn press_key(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &WlKeyboard,
_serial: u32,
event: KeyEvent,
)
{
let focus = self.keyboard_focus;
// Raw observer hook (e.g. forwarding to an embedded WPE view).
// Fires before the focus-aware dispatch.
self.app.on_raw_key( event.keysym, event.raw_code, true, self.ctrl_pressed, self.shift_pressed );
// A new press always cancels any in-flight repeat — the user
// has either released the previous key or is pressing a
// different one. Either way, the prior timer's keysym should
// not keep firing.
self.stop_key_repeat();
self.dispatch_key( focus, event.clone() );
self.start_key_repeat( focus, event );
}
fn release_key(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &WlKeyboard,
_serial: u32,
event: KeyEvent,
)
{
self.app.on_raw_key( event.keysym, event.raw_code, false, self.ctrl_pressed, self.shift_pressed );
// Only stop if the released key matches the one currently
// repeating; releasing a non-repeating key (e.g. a shift that
// snuck through, or any key we never armed) leaves the timer
// untouched.
if let Some( ref state ) = self.key_repeat
{
if state.event.keysym == event.keysym
{
self.stop_key_repeat();
}
}
}
fn update_modifiers(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &WlKeyboard,
_serial: u32,
modifiers: Modifiers,
_raw_modifiers: RawModifiers,
_layout: u32,
)
{
self.shift_pressed = modifiers.shift;
self.ctrl_pressed = modifiers.ctrl;
}
fn update_repeat_info(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &WlKeyboard,
info: RepeatInfo,
)
{
match info
{
RepeatInfo::Repeat { rate, delay } =>
{
self.compositor_repeat_rate = rate.get();
self.compositor_repeat_delay = delay;
}
RepeatInfo::Disable =>
{
self.compositor_repeat_rate = 0;
self.compositor_repeat_delay = 0;
self.stop_key_repeat();
}
}
}
fn repeat_key(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &WlKeyboard,
_serial: u32,
event: KeyEvent,
)
{
// Compositor-driven repeat (wl_keyboard v10). When the
// compositor takes the repeat job we just re-dispatch — and
// suppress our internal timer to avoid double-firing.
self.stop_key_repeat();
let focus = self.keyboard_focus;
self.dispatch_key( focus, event );
}
}
impl<A: App> AppData<A>
{
/// Run the same dispatch logic that the Wayland press-key handler
/// runs, but without the trait-callback signature — used both by
/// the trait method and by the key-repeat timer.
fn dispatch_key( &mut self, focus: SurfaceFocus, event: KeyEvent )
{
// `set_focus` (Tab handling) needs a `QueueHandle`. Cloning is
// cheap (an `Arc`-equivalent under the hood) and avoids
// threading the qh through every helper.
let qh = self.qh.clone();
let qh = &qh;
let focused = self.surface( focus ).focused_idx;
match event.keysym
{
Keysym::BackSpace =>
{
if focused.is_some() { self.handle_backspace( focus ); }
else if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed )
{
self.pending_msgs.push( msg );
}
}
Keysym::Delete =>
{
// Supr / Forward-Delete: same shape as Backspace but
// removes the char *after* the cursor. When no widget
// is focused, the keysym bubbles to the app.
if focused.is_some() { self.handle_delete_forward( focus ); }
else if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed )
{
self.pending_msgs.push( msg );
}
}
Keysym::Return | Keysym::KP_Enter =>
{
// Enter targets, in order: (a) the focused widget's submit
// message (TextEdit), (b) its press message (Button etc),
// (c) the press message of the keyboard-hovered list item
// in the topmost scroll. The hovered fallback lets users
// confirm a combo / list choice with the keyboard after
// arrow-navigating to it without ever taking widget focus.
//
// Multiline text-input override: when the focused widget
// is a `text_edit().multiline( true )`, Enter inserts 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 )
.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 );
if let Some( idx ) = target
{
let msg = find_handlers( &self.surface( focus ).widget_rects, idx )
.and_then( |h| h.submit_msg().or_else( || h.press_msg() ) );
if let Some( m ) = msg
{
self.pending_msgs.push( m );
}
} else if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed )
{
self.pending_msgs.push( msg );
}
}
}
Keysym::Down | Keysym::Up =>
{
// When a text input is focused, Up/Down move the cursor
// between lines first. They only fall through to list
// hover-navigation (combo / scrollable list) when the
// cursor was already on the topmost / bottommost line —
// that lets a user keyboard-step out of a multiline
// `text_edit` into surrounding navigable items without
// changing focus first.
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
let reverse = event.keysym == Keysym::Up;
let extend = self.shift_pressed;
let consumed = if is_text
{
if reverse { self.handle_cursor_up( focus, extend ) }
else { self.handle_cursor_down( focus, extend ) }
} else { false };
if !consumed && !self.move_keyboard_hover( focus, reverse )
{
if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed )
{
self.pending_msgs.push( msg );
}
}
}
Keysym::Left | Keysym::Right =>
{
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
let extend = self.shift_pressed;
if is_text
{
if event.keysym == Keysym::Left
{
self.handle_cursor_left( focus, extend );
} else {
self.handle_cursor_right( focus, extend );
}
} else if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed )
{
self.pending_msgs.push( msg );
}
}
Keysym::Home =>
{
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
if is_text { self.handle_cursor_home( focus, self.shift_pressed ); }
}
Keysym::End =>
{
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
if is_text { self.handle_cursor_end( focus, self.shift_pressed ); }
}
Keysym::a | Keysym::A if self.ctrl_pressed =>
{
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
if is_text { self.handle_select_all( focus ); }
}
Keysym::c | Keysym::C if self.ctrl_pressed =>
{
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
if is_text { self.handle_copy( focus ); }
}
Keysym::x | Keysym::X if self.ctrl_pressed =>
{
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
if is_text { self.handle_cut( focus ); }
}
Keysym::v | Keysym::V if self.ctrl_pressed =>
{
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
if is_text { self.handle_paste( focus ); }
}
Keysym::Tab | Keysym::ISO_Left_Tab =>
{
let reverse = event.keysym == Keysym::ISO_Left_Tab || self.shift_pressed;
let ss = self.surface( focus );
let next_idx = next_focusable_index( &ss.widget_rects, ss.focused_idx, reverse );
if let Some( next_idx ) = next_idx
{
self.set_focus( focus, Some( next_idx ), qh );
}
}
Keysym::Escape =>
{
// Esc peels off transient UI one layer at a time.
// Order: (1) open xdg-popup overlays → dismiss them;
// (2) context menu → close it; (3) active selection in
// a focused text input → collapse to cursor; (4) any
// laid-out pressable carrying an `on_escape` message
// (typically the topmost `dialog`'s cancel) → fire
// that; (5) otherwise → drop focus and let the app see
// Esc.
if !self.overlays.is_empty() && self.app.overlays().iter().any( | s | s.anchor_widget_id.is_some() )
{
self.dismiss_all_popups();
} else if self.surface( focus ).context_menu.is_some()
{
self.hide_context_menu( focus );
} 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()
.rev()
.find_map( |w| w.handlers.escape_msg() )
{
self.pending_msgs.push( msg );
} else {
self.set_focus( focus, None, qh );
if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed )
{
self.pending_msgs.push( msg );
}
}
}
Keysym::space =>
{
if let Some( idx ) = focused
{
let press = find_handlers( &self.surface( focus ).widget_rects, idx )
.and_then( |h| h.press_msg() );
if let Some( msg ) = press
{
self.pending_msgs.push( msg );
} else {
// Focused widget is a TextEdit (or has no on_press) — insert space as text
self.handle_text_insert( focus, " " );
}
} else if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed )
{
self.pending_msgs.push( msg );
}
}
_ =>
{
if self.ctrl_pressed
{
if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, true, self.shift_pressed )
{
self.pending_msgs.push( msg );
}
} else if focused.is_some()
{
if let Some( txt ) = event.utf8
{
if !txt.is_empty() && txt.chars().all( |c| !c.is_control() )
{
self.handle_text_insert( focus, &txt );
}
}
} else if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, false, self.shift_pressed )
{
self.pending_msgs.push( msg );
}
}
}
self.surface_mut( focus ).request_redraw();
}
/// Compute the effective initial repeat delay — app override wins,
/// then compositor info, then a built-in default.
pub( crate ) fn effective_repeat_delay( &self ) -> Duration
{
if let Some( d ) = self.app.key_repeat_delay() { return d; }
if self.compositor_repeat_delay > 0
{
Duration::from_millis( self.compositor_repeat_delay as u64 )
} else {
DEFAULT_REPEAT_DELAY
}
}
/// Compute the effective inter-repeat interval. Same precedence as
/// [`Self::effective_repeat_delay`]: app override → compositor →
/// built-in default. Returns `None` when repeat is disabled by the
/// active source (compositor `RepeatInfo::Disable` with no app
/// override, or an app override of `Some(Duration::ZERO)`).
pub( crate ) fn effective_repeat_interval( &self ) -> Option<Duration>
{
if let Some( d ) = self.app.key_repeat_interval()
{
if d.is_zero() { return None; }
return Some( d );
}
if self.compositor_repeat_rate == 0
{
// Compositor explicitly disabled repeat, app did not
// override — fall back to a built-in default rather than
// disabling, because most environments where the
// compositor reports 0 also fail to send the event in
// the first place. The default keeps the feel close to
// what people expect from a desktop toolkit.
return Some( DEFAULT_REPEAT_INTERVAL );
}
let ms = ( 1000 / self.compositor_repeat_rate ).max( 1 );
Some( Duration::from_millis( ms as u64 ) )
}
/// Schedule a key-repeat timer for the given event. No-op when the
/// app's [`crate::App::key_repeats`] gate returns `false` for this
/// keysym, when repeat is disabled, or when the calloop timer
/// insertion fails (in which case held keys simply do not repeat).
pub( crate ) fn start_key_repeat( &mut self, focus: SurfaceFocus, event: KeyEvent )
{
if !self.app.key_repeats( event.keysym ) { return; }
let interval = match self.effective_repeat_interval()
{
Some( i ) => i,
None => return,
};
let delay = self.effective_repeat_delay();
let event_for_timer = event.clone();
let timer = Timer::from_duration( delay );
let token = self.loop_handle.insert_source( timer, move |_, _, data: &mut AppData<A>|
{
data.dispatch_key( focus, event_for_timer.clone() );
TimeoutAction::ToDuration( interval )
} );
match token
{
Ok( token ) => { self.key_repeat = Some( KeyRepeatState { event, token } ); }
Err( _ ) => {}
}
}
/// Cancel any active key-repeat timer.
pub( crate ) fn stop_key_repeat( &mut self )
{
if let Some( state ) = self.key_repeat.take()
{
self.loop_handle.remove( state.token );
}
}
/// Schedule a button-press repeat timer. The runtime fires the
/// `on_press` message *immediately* on press too — this fn only
/// arms the held-down repeat path; the first fire happens at
/// the call site so a quick tap still registers as a single
/// press.
///
/// Each timer tick re-reads the live `on_press` from the
/// current widget tree (via the snapshotted `flat_idx`) rather
/// than replaying a captured message. This is what makes
/// stepper-style buttons work: a stepper builds an `on_press`
/// like `"go to value + 5"` at view-build time, so replaying
/// the press-time snapshot after the first fire would re-issue
/// the same target and the value would freeze. By reading
/// `on_press` afresh each tick we pick up the new target the
/// view rebuilt with the updated value.
///
/// No-op when [`Self::effective_repeat_interval`] reports
/// repeat disabled (zero rate, app override of
/// `Duration::ZERO`). Self-cancels on the first tick where the
/// widget at `idx` no longer exists or no longer carries a
/// press message.
pub( crate ) fn start_button_repeat( &mut self, focus: SurfaceFocus, idx: usize )
{
// Cancel any pre-existing repeat first — only one button can
// be in repeat mode at a time, and a fresh press should
// supersede a previous one.
self.stop_button_repeat();
// Button repeat ticks at a fixed 120 ms (~8 Hz). The keyboard
// repeat interval is ~33 ms (30 Hz), which suits cursor /
// character entry but is too aggressive for pointer steppers
// — a date / time picker would walk a full minute in under
// two seconds. 120 ms is fast enough to ramp through values,
// slow enough that the user can still release on the value
// they want.
if matches!( self.effective_repeat_interval(), None ) { return; }
let interval = std::time::Duration::from_millis( 120 );
let delay = self.effective_repeat_delay();
let timer = Timer::from_duration( delay );
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,
idx,
)
.and_then( |h| h.press_msg() );
match live_msg
{
Some( m ) =>
{
data.pending_msgs.push( m );
TimeoutAction::ToDuration( interval )
}
None =>
{
// Widget gone (view restructured) or no longer
// has an on_press → drop ourselves so the timer
// does not fire forever against a stale slot.
data.button_repeat = None;
TimeoutAction::Drop
}
}
} );
if let Ok( token ) = token
{
self.button_repeat = Some( crate::event_loop::app_data::ButtonRepeatState { token } );
}
}
/// Cancel any active button-press repeat timer.
pub( crate ) fn stop_button_repeat( &mut self )
{
if let Some( state ) = self.button_repeat.take()
{
self.loop_handle.remove( state.token );
}
}
/// Step the topmost scroll's `hovered_idx` one item up or down with
/// the keyboard. Returns `true` when the move was applied (a scroll
/// with at least one navigable item was on screen and the new item
/// is different from the current hover) so the caller can fall
/// through to the application's own `on_key` only on a no-op.
///
/// The "topmost scroll" is the last entry in `scroll_rects`, which
/// matches Stack-overlay layout order: a popup pushed after the
/// main content sits above it. Auto-scrolls the new item into view
/// by adjusting `scroll_offsets[scroll_idx]`.
pub( crate ) fn move_keyboard_hover( &mut self, focus: SurfaceFocus, reverse: bool ) -> bool
{
// Find the topmost scroll that has a navigable item list.
let scroll_meta = {
let ss = self.surface( focus );
ss.scroll_rects.iter().rev()
.find_map( |( rect, idx )|
{
ss.scroll_navigable_items.get( idx )
.filter( |list| !list.is_empty() )
.map( |list| ( *rect, *idx, list.clone() ) )
} )
};
let Some( ( scroll_rect, scroll_idx, items ) ) = scroll_meta else { return false; };
let current = self.surface( focus ).hovered_idx;
let pos = current.and_then( |h| items.iter().position( |( i, _, _ )| *i == h ) );
let next_pos = match ( reverse, pos )
{
( false, None ) => 0,
( false, Some( p ) ) => ( p + 1 ).min( items.len() - 1 ),
( true, None ) => items.len() - 1,
( true, Some( p ) ) => p.saturating_sub( 1 ),
};
let ( new_idx, content_y, content_h ) = items[ next_pos ];
// Auto-scroll to bring the new item fully into view. Item Y is
// in pre-offset content coordinates, so the offset that places
// the item flush with the top of the viewport is `content_y`,
// and flush with the bottom is `content_y + content_h - viewport_h`.
let viewport_h = scroll_rect.height;
let current_offset = self.surface( focus )
.scroll_offsets.get( &scroll_idx )
.copied().unwrap_or( 0.0 );
let new_offset = if content_y < current_offset
{
content_y
}
else if content_y + content_h > current_offset + viewport_h
{
( content_y + content_h - viewport_h ).max( 0.0 )
}
else
{
current_offset
};
let ss = self.surface_mut( focus );
ss.hovered_idx = Some( new_idx );
ss.scroll_offsets.insert( scroll_idx, new_offset );
ss.request_redraw();
true
}
}

36
src/input/mod.rs Normal file
View File

@@ -0,0 +1,36 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Input-event layer.
//!
//! Three Wayland input sources — `wl_keyboard`, `wl_pointer`,
//! `wl_touch` — land in their respective submodules and translate
//! into ltk-level actions. Keyboard is standalone (it drives focus
//! and text insertion, no gesture lifecycle); pointer and touch share
//! the [`gesture::GestureState`] state machine so press → move →
//! release logic (long-press, slider drag, scroll, swipe commit, tap,
//! drop) is written once and fed by both sources.
//!
//! The [`dispatch`] submodule turns gesture outcomes into concrete
//! side-effects on [`AppData`](crate::event_loop::AppData): pending
//! messages, app callbacks, surface redraws, cache invalidation. It
//! is `pub( super )`-scoped so pointer.rs and touch.rs can call it
//! without exposing the helpers at the crate root.
//!
//! With [`GestureState`] owning the press / move / release lifecycle
//! and [`dispatch`] owning the side-effects, the Wayland-facing
//! handlers stay small and a hypothetical new input source (stylus,
//! gamepad) can plug in by feeding the state machine the same way.
pub( crate ) mod gesture;
pub( crate ) mod keyboard;
pub( crate ) mod pointer;
pub( crate ) mod touch;
pub( crate ) mod dispatch;
// `GestureState` is the only type consumers outside `input/` need —
// `AppData` stores one per `SurfaceState` and the long-press deadline
// poller reaches through it. The rest (`MoveOutcome`, `PressOutcome`,
// `ReleaseEvent`, `SwipeConfig`) stay at `super::gesture::*` because
// only the sibling modules in `input/` touch them.
pub use gesture::GestureState;

495
src/input/pointer.rs Normal file
View File

@@ -0,0 +1,495 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Wayland pointer → ltk dispatch.
//!
//! Each `wl_pointer` event (motion / press / release / axis) translates
//! into a call on the surface's [`GestureState`](super::gesture::GestureState)
//! plus the follow-up side-effects that can only live at the
//! [`AppData`] level (pending-message push, surface redraw, dirty-cache
//! invalidation, window move). The pointer handler adds three things
//! touch does not have:
//!
//! * **Hover tracking** — motion updates `SurfaceState::hovered_idx`
//! before delegating the motion to the gesture machine, since the
//! hovered widget drives visual state independent of whether a press
//! is active.
//! * **Title-bar interaction** — a press inside the client-side title
//! bar either closes the window (close button hit) or initiates a
//! compositor-driven window move. Neither path goes through the
//! gesture machine.
//! * **Wheel / axis scroll** — routed directly into the per-viewport
//! `scroll_offsets` map; axis events do not have a press/release
//! lifecycle so they bypass the state machine entirely.
use smithay_client_toolkit::seat::pointer::{ PointerEvent, PointerEventKind, PointerHandler };
use smithay_client_toolkit::reexports::client::
{
protocol::{ wl_pointer, wl_pointer::WlPointer },
Connection, QueueHandle,
};
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
use crate::tree::{ find_handlers, find_widget_at };
use crate::widget::WidgetHandlers;
use super::gesture::SwipeConfig;
impl<A: App> PointerHandler for AppData<A>
{
fn pointer_frame(
&mut self,
_conn: &Connection,
qh: &QueueHandle<Self>,
_pointer: &WlPointer,
events: &[PointerEvent],
)
{
for event in events
{
let focus = self.focus_for_surface( &event.surface )
.unwrap_or( SurfaceFocus::Main );
self.pointer_focus = focus;
match event.kind
{
PointerEventKind::Enter { serial } =>
{
// Pointer just entered our surface — capture the
// serial that wp_cursor_shape_device_v1::set_shape
// requires, then push the initial cursor shape
// *unconditionally*. Resetting `current_cursor_shape`
// to `None` is what forces the dispatch: the
// compositor was showing whatever the previous
// client asked for (e.g. a text I-beam from a
// terminal under our window), and we must claim
// the cursor for our surface even when the target
// equals what we last pushed.
self.last_pointer_enter_serial = serial;
self.current_cursor_shape = None;
self.dispatch_cursor_shape( focus );
}
PointerEventKind::Motion { .. } =>
{
let pp = self.surface( focus ).to_physical( event.position.0, event.position.1 );
self.pointer_pos = pp;
self.app.on_pointer_move( pp.x, pp.y );
// 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 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 ss = self.surface_mut( focus );
ss.hovered_idx = new_hover;
if redraw { ss.needs_redraw = true; }
}
// Mouse drag-promotion: a left-button press whose hit
// widget carries `on_drag_start` should arm a drag as
// soon as the cursor moves past the threshold, without
// waiting for the touch hold timer. Touch keeps its
// hold-then-drag path because scroll / swipe gestures
// need the in-between motion budget; mouse has a
// dedicated right-click for the menu so left-button
// can be drag-only.
//
// Threshold of 24 px (logical) sits comfortably above
// the gesture machine's 6 px long-press cancel
// tolerance and above crustace's 16 px drag-commit
// threshold, so by the time we promote the next
// motion sample is already past the app's commit
// distance and drag mode latches without flashing
// any half-state.
//
// Promotion is synchronous (`self.app.update(...)`
// directly) so the app's drag state is armed BEFORE
// the `on_drag_move` call below runs — otherwise the
// seed coords land on a `dragging_item = None`
// shell and get lost. We pay the cost of bypassing
// `invalidate_after` for this one msg, but the next
// frame will repaint everything anyway because the
// drag is in flight.
let promote = {
let ss = self.surface( focus );
match ( ss.gesture.long_press_origin, ss.gesture.drag_start_msg.is_some() )
{
( Some( o ), true ) => ( pp.x - o.x ).hypot( pp.y - o.y ) > 24.0,
_ => false,
}
};
if promote
{
let ( ds_msg, origin ) = {
let ss = self.surface_mut( focus );
let m = ss.gesture.drag_start_msg.take().expect( "promote checked is_some" );
let o = ss.gesture.long_press_origin.expect( "promote checked Some(origin)" );
ss.gesture.long_press_start = None;
ss.gesture.long_press_origin = None;
ss.gesture.long_press_msg = None;
ss.gesture.long_press_text_idx = None;
ss.gesture.long_press_fired = true;
ss.request_redraw();
( m, o )
};
self.app.update( ds_msg );
self.app.on_drag_move( origin.x, origin.y );
self.dirty_caches();
self.stop_button_repeat();
}
// `global_drag` must be sampled AFTER the promotion
// above — promotion flips `long_press_fired` and we
// want the gesture machine to take the drag branch
// for the same motion event that triggered the
// promotion.
let global_drag = self.has_active_long_press_drag();
let swipe = self.swipe_config( focus );
let outcome =
{
let ss = self.surface_mut( focus );
ss.gesture.on_move( pp, &ss.widget_rects, &mut ss.scroll_offsets, &swipe, global_drag )
};
self.apply_move_outcome( focus, outcome );
// Drag-to-select inside a TextEdit. Runs after the
// gesture machine so the gesture's "did we leave
// the press's hit rect?" reasoning still applies
// to the press itself; for text fields the answer
// is "fine, keep selecting" because we only widen
// the selection while the pointer is still inside
// the same widget rect.
let pressed_text = self.surface( focus ).gesture.pressed_idx
.and_then( |idx|
{
let is_text = find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ).unwrap_or( false );
if is_text { Some( idx ) } else { None }
} );
if let Some( idx ) = pressed_text
{
self.handle_text_pointer_drag( focus, idx, pp );
}
// Cursor shape: hover may have changed → push the
// new shape to the compositor (no-op when
// unchanged).
self.dispatch_cursor_shape( focus );
}
PointerEventKind::Press { button: 0x111, .. } =>
{
// Right-click: the desktop equivalent of a touch
// long-press. Three cases on the press target:
//
// * Widget with `on_long_press` (Button or
// Pressable that opted in) — fire the message.
// The drag-arm slot (`on_drag_start`) is NOT
// consumed: right-click never enters drag mode,
// so an icon's context menu opens but no drag
// is armed.
// * TextEdit with no user `on_long_press` — open
// the built-in Copy / Cut / Paste menu near the
// click. Selection is preserved (we deliberately
// skip `handle_text_pointer_down`) so the common
// "select text → right-click → Copy" flow works.
// * Anywhere else — dismiss any already-open menu.
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 lp_msg = hit_idx.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.and_then( |h| h.long_press_msg() ) );
if let Some( msg ) = lp_msg
{
self.pending_msgs.push( msg );
self.surface_mut( focus ).request_redraw();
} else {
let is_text = hit_idx.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
if is_text
{
let idx = hit_idx.unwrap();
if self.surface( focus ).focused_idx != Some( idx )
{
self.set_focus( focus, hit_idx, qh );
}
self.show_context_menu( focus, idx, pos );
} else {
self.hide_context_menu( focus );
}
}
}
PointerEventKind::Press { button: 0x110, serial, .. } =>
{
self.last_pointer_serial = serial;
self.last_input_serial = serial;
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 );
if matches!( focus, SurfaceFocus::Main ) && !self.overlays.is_empty()
{
self.dismiss_main_outside_popups( pos );
}
// Built-in context menu intercepts the press
// before the regular gesture machine. Either an
// item activates (Copy / Cut / Paste) or the
// click is outside the menu and dismisses it —
// in both cases we consume the event.
if self.surface( focus ).context_menu.is_some()
{
if self.handle_context_menu_press( focus, pos )
{
continue;
}
}
// Client-side title bar interaction — pointer-only
// (touch never hits a titlebar; layer-shell surfaces
// have titlebar_height == 0).
let sf = self.surface( focus ).scale_factor.max( 1 ) as f32;
let tb_h = self.surface( focus ).titlebar_height * sf;
if tb_h > 0.0 && pos.y < tb_h
{
let close_rect = self.surface( focus ).titlebar_close_rect;
if close_rect.contains( pos )
{
if self.app.on_close_requested()
{
self.exit_requested = true;
}
return;
}
}
// Resize-edge interception. Wins over the titlebar
// drag-move (top-left / top-right corners overlap
// the titlebar) and over the gesture machine.
if let Some( edge ) = self.resize_edge_under_pointer( focus )
{
if let crate::event_loop::SurfaceKind::Window( ref window ) = self.main.surface
{
let seats: Vec<_> = self.seat_state.seats().collect();
if let Some( seat ) = seats.into_iter().next()
{
window.resize( &seat, serial, edge );
}
}
continue;
}
if tb_h > 0.0 && pos.y < tb_h
{
// Drag to move the window.
if matches!( focus, SurfaceFocus::Main )
{
if let crate::event_loop::SurfaceKind::Window( ref window ) = self.main.surface
{
let seats: Vec<_> = self.seat_state.seats().collect();
if let Some( seat ) = seats.into_iter().next()
{
window.move_( &seat, serial );
}
}
}
continue;
}
// Past every chrome interception — surface the
// press to the app so embeddings (e.g. an
// embedded WPE WebView) see real button-down
// events that were not consumed by the window
// frame.
self.app.on_pointer_button( pos.x, pos.y, true );
let outcome =
{
let ss = self.surface_mut( focus );
let result = ss.gesture.on_press( pos, &ss.widget_rects, &ss.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
// motion is intentional and should never
// drop the candidate before the pointer-side
// promotion at 24 px gets to fire.
ss.gesture.mouse_press = true;
ss.needs_redraw = true;
result
};
self.set_focus( focus, outcome.hit_idx, qh );
if let Some( msg ) = outcome.initial_slider_msg
{
self.pending_msgs.push( msg );
}
// Press-and-hold repeat: when the hit is a button
// that opted into `.repeating( true )`, fire the
// `on_press` message immediately and arm the
// repeat timer. The release handler in
// `gesture.rs` knows to suppress the regular tap-
// on-release fire for repeating buttons so a
// quick click still counts as exactly one press.
// The timer re-reads `on_press` from the live
// widget tree on every tick — see
// `start_button_repeat` for why.
if let Some( idx ) = outcome.hit_idx
{
let immediate = {
let handlers = find_handlers( &self.surface( focus ).widget_rects, idx );
if matches!( handlers, Some( WidgetHandlers::Button { repeating: true, .. } ) )
{
handlers.and_then( |h| h.press_msg() )
} else { None }
};
if let Some( msg ) = immediate
{
self.pending_msgs.push( msg );
self.start_button_repeat( focus, idx );
}
}
// Click-to-position the text cursor when the press
// landed on a TextEdit. `set_focus` above moves the
// cursor to the end of the value; this overrides
// it with the byte offset under the pointer and
// collapses the selection there so a subsequent
// drag widens the selection from the click point.
//
// Double-click on a TextEdit selects the word
// 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 )
.map( |h| h.is_text_input() ).unwrap_or( false );
if is_text
{
// Eye icon hit on a password field
// short-circuits the text-edit
// dispatch — fire the toggle msg and
// skip cursor placement.
if self.handle_password_toggle_press( focus, idx, pos )
{
let _ = self.note_press_for_double_click( pos );
}
else
{
let is_double = self.note_press_for_double_click( pos );
if is_double
{
self.handle_text_select_word( focus, idx, pos );
} else {
self.handle_text_pointer_down( focus, idx, pos );
}
}
} else {
let _ = self.note_press_for_double_click( pos );
}
} else {
let _ = self.note_press_for_double_click( pos );
}
// Slider press → drag may have started; refresh
// the cursor shape so it switches to `Grabbing`.
self.dispatch_cursor_shape( focus );
}
PointerEventKind::Release { button: 0x110, .. } =>
{
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 );
self.app.on_pointer_button( pos.x, pos.y, false );
let global_drag = self.has_active_long_press_drag();
let swipe = self.swipe_config( focus );
let events_out =
{
let ss = self.surface_mut( focus );
ss.needs_redraw = true;
ss.gesture.on_release( pos, &ss.widget_rects, &swipe, global_drag )
};
self.apply_release_events( focus, events_out );
// Cancel any held-button repeat — the press is
// over, so the timer no longer has anything to
// fire against.
self.stop_button_repeat();
// Slider drag (if any) just ended — cursor reverts
// from `Grabbing` to whatever the hovered widget
// asks for.
self.dispatch_cursor_shape( focus );
}
PointerEventKind::Axis { horizontal, vertical, source, .. } =>
{
let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 );
let scroll_idx_opt =
{
let ss = self.surface( focus );
ss.scroll_rects.iter().rev()
.find( |( r, _ )| r.contains( pos ) )
.map( |( _, idx )| *idx )
};
if let Some( scroll_idx ) = scroll_idx_opt
{
let multiplier = match source
{
Some( wl_pointer::AxisSource::Wheel ) => 10.0,
_ => 1.0,
};
let step = vertical.absolute as f32 * multiplier;
let ss = self.surface_mut( focus );
let entry = ss.scroll_offsets.entry( scroll_idx ).or_insert( 0.0 );
*entry = ( *entry + step ).max( 0.0 );
ss.request_redraw();
} else {
// No LTK scroll viewport under the cursor —
// surface the raw axis to the app so embedded
// content (e.g. a WPE view) can handle scrolling
// itself.
let multiplier = match source
{
Some( wl_pointer::AxisSource::Wheel ) => 10.0,
_ => 1.0,
};
let dx = horizontal.absolute as f32 * multiplier;
let dy = vertical.absolute as f32 * multiplier;
self.app.on_pointer_axis( pos.x, pos.y, dx, dy );
}
}
_ => {}
}
}
}
}
fn hover_affects_paint<Msg: Clone>(
widget_rects: &[crate::widget::LaidOutWidget<Msg>],
idx: Option<usize>,
) -> bool
{
idx.and_then( |i| find_handlers( widget_rects, i ) )
.map( |h| !h.is_slider() )
.unwrap_or( false )
}
/// Pointer-side helpers on `AppData`. Split out so touch can call the
/// same swipe-config factory; `apply_*` helpers live in `dispatch.rs`
/// because they are shared.
impl<A: App> AppData<A>
{
/// Snapshot the swipe thresholds + surface dimensions into a
/// [`SwipeConfig`] for the gesture machine. Called once per
/// motion / release event.
pub( super ) fn swipe_config( &self, focus: SurfaceFocus ) -> SwipeConfig
{
let ss = self.surface( focus );
SwipeConfig
{
up_thresh: self.app.swipe_threshold(),
down_thresh: self.app.swipe_down_threshold(),
down_edge: self.app.swipe_down_edge(),
horizontal_thresh: self.app.swipe_horizontal_threshold(),
surface_width: ss.physical_width(),
surface_height: ss.physical_height(),
}
}
}

223
src/input/touch.rs Normal file
View File

@@ -0,0 +1,223 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Wayland touch → ltk dispatch.
//!
//! `wl_touch` down / up / motion map directly onto the three
//! lifecycle methods of [`GestureState`](super::gesture::GestureState),
//! same as pointer press / release / motion. Touch has no hover and
//! no scroll-axis event, so this file is shorter than `pointer.rs`;
//! the two handlers share `apply_move_outcome` /
//! `apply_release_events` in `dispatch.rs`.
//!
//! The only touch-specific state is `AppData::touch_focus`, a
//! `HashMap<touch_id, SurfaceFocus>` so a finger that landed on an
//! overlay continues to route to that overlay even if it drifts over
//! the main surface. Multi-finger tracking is not yet modelled — the
//! gesture machine is single-gesture; a second finger arriving while
//! the first is pressed overwrites the same slot. Good enough for
//! sliders, swipes and taps; a proper multi-touch rewrite is a
//! separate refactor.
use smithay_client_toolkit::seat::touch::TouchHandler;
use smithay_client_toolkit::reexports::client::
{
protocol::{ wl_surface::WlSurface, wl_touch::WlTouch },
Connection, QueueHandle,
};
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus, SurfaceState };
use crate::tree::find_handlers;
impl<A: App> TouchHandler for AppData<A>
{
fn down(
&mut self,
_conn: &Connection,
qh: &QueueHandle<Self>,
_touch: &WlTouch,
serial: u32,
_time: u32,
surface: WlSurface,
id: i32,
position: ( f64, f64 ),
)
{
self.last_input_serial = serial;
let focus = self.focus_for_surface( &surface ).unwrap_or( SurfaceFocus::Main );
self.touch_focus.insert( id, focus );
let pos = self.surface( focus ).to_physical( position.0, position.1 );
self.pointer_pos = pos;
if matches!( focus, SurfaceFocus::Main ) && !self.overlays.is_empty()
{
self.dismiss_main_outside_popups( pos );
}
// Built-in context menu intercepts the touch before the
// regular gesture machine — same logic as the pointer path.
if self.surface( focus ).context_menu.is_some()
{
if self.handle_context_menu_press( focus, pos )
{
return;
}
}
let outcome =
{
let ss = self.surface_mut( focus );
let result = ss.gesture.on_press( pos, &ss.widget_rects, &ss.scroll_rects );
ss.needs_redraw = true;
result
};
self.set_focus( focus, outcome.hit_idx, qh );
if let Some( msg ) = outcome.initial_slider_msg
{
self.pending_msgs.push( msg );
}
// Press-and-hold repeat — same wiring as the pointer path.
if let Some( idx ) = outcome.hit_idx
{
let immediate = {
let handlers = find_handlers( &self.surface( focus ).widget_rects, idx );
if matches!( handlers, Some( crate::widget::WidgetHandlers::Button { repeating: true, .. } ) )
{
handlers.and_then( |h| h.press_msg() )
} else { None }
};
if let Some( msg ) = immediate
{
self.pending_msgs.push( msg );
self.start_button_repeat( focus, idx );
}
}
// Click-to-position the text cursor for touch presses too,
// 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 )
.map( |h| h.is_text_input() ).unwrap_or( false );
if is_text
{
// Eye icon hit on a password field short-circuits
// the text-edit dispatch — fire the toggle msg and
// skip cursor placement.
if self.handle_password_toggle_press( focus, idx, pos )
{
let _ = self.note_press_for_double_click( pos );
}
else
{
let is_double = self.note_press_for_double_click( pos );
if is_double
{
self.handle_text_select_word( focus, idx, pos );
} else {
self.handle_text_pointer_down( focus, idx, pos );
}
}
} else {
let _ = self.note_press_for_double_click( pos );
}
} else {
let _ = self.note_press_for_double_click( pos );
}
}
fn up(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_touch: &WlTouch,
_serial: u32,
_time: u32,
id: i32,
)
{
let focus = self.touch_focus.remove( &id ).unwrap_or( SurfaceFocus::Main );
// Touch-up does not carry a position in wl_touch — the last
// motion's position is the release point.
let pos = self.pointer_pos;
let global_drag = self.has_active_long_press_drag();
let swipe = self.swipe_config( focus );
let events_out =
{
let ss = self.surface_mut( focus );
ss.needs_redraw = true;
ss.gesture.on_release( pos, &ss.widget_rects, &swipe, global_drag )
};
self.apply_release_events( focus, events_out );
self.stop_button_repeat();
}
fn motion(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_touch: &WlTouch,
_time: u32,
id: i32,
position: ( f64, f64 ),
)
{
let focus = *self.touch_focus.get( &id ).unwrap_or( &SurfaceFocus::Main );
let pp = self.surface( focus ).to_physical( position.0, position.1 );
self.pointer_pos = pp;
let global_drag = self.has_active_long_press_drag();
let swipe = self.swipe_config( focus );
let outcome =
{
let ss = self.surface_mut( focus );
ss.gesture.on_move( pp, &ss.widget_rects, &mut ss.scroll_offsets, &swipe, global_drag )
};
self.apply_move_outcome( focus, outcome );
// Drag-to-select inside a TextEdit (touch path).
let pressed_text = self.surface( focus ).gesture.pressed_idx
.and_then( |idx|
{
let is_text = find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ).unwrap_or( false );
if is_text { Some( idx ) } else { None }
} );
if let Some( idx ) = pressed_text
{
self.handle_text_pointer_drag( focus, idx, pp );
}
}
fn shape(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_touch: &WlTouch,
_id: i32,
_major: f64,
_minor: f64,
) {}
fn orientation(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_touch: &WlTouch,
_id: i32,
_orientation: f64,
) {}
fn cancel( &mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _touch: &WlTouch )
{
self.touch_focus.clear();
// The compositor is stealing every active touch — drop all
// in-flight gesture state across every surface.
let clear = |ss: &mut SurfaceState<A::Message>| { ss.gesture.on_cancel(); };
clear( &mut self.main );
for ss in self.overlays.values_mut()
{
clear( ss );
}
self.stop_button_repeat();
}
}

359
src/layout/column.rs Normal file
View File

@@ -0,0 +1,359 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::Rect;
use crate::render::Canvas;
use crate::widget::Element;
/// A vertical layout container.
///
/// Children are arranged top-to-bottom with optional spacing and padding.
/// Spacers absorb remaining vertical space, enabling push-to-bottom layouts.
///
/// ```rust,no_run
/// # use ltk::{ button, column, spacer, text, Element };
/// # #[ derive( Clone ) ] enum Msg { Ok }
/// # fn _ex() -> Element<Msg> {
/// column()
/// .padding( 24.0 )
/// .spacing( 12.0 )
/// .push( text( "Title" ) )
/// .push( spacer() )
/// .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,
}
impl<Msg: Clone> Column<Msg>
{
pub fn new() -> Self
{
Self
{
children: Vec::new(),
spacing: 8.0,
padding: 16.0,
align_center_x: true,
center_y: false,
max_width: None,
fit_content: false,
}
}
/// Append a child widget or layout.
pub fn push( mut self, e: impl Into<Element<Msg>> ) -> Self
{
self.children.push( e.into() );
self
}
/// Set the vertical gap between children in pixels. Default: `8.0`.
pub fn spacing( mut self, s: f32 ) -> Self
{
self.spacing = s;
self
}
/// Set the padding (all sides) in pixels. Default: `16.0`.
pub fn padding( mut self, p: f32 ) -> Self
{
self.padding = p;
self
}
/// When `true` (default), children are centered horizontally.
pub fn align_center_x( mut self, c: bool ) -> Self
{
self.align_center_x = c;
self
}
/// When `true`, center the content block vertically (only when no spacers are present).
pub fn center_y( mut self, c: bool ) -> Self
{
self.center_y = c;
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
{
self.max_width = Some( w );
self
}
/// 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
/// [`Row`](crate::layout::row::Row) — without this flag, two columns in a
/// row each claim the full row width and overflow their siblings.
///
/// The preferred width is computed as the max of children's preferred
/// widths plus padding, capped by the external `max_width` the parent
/// offers and by any `max_width` setting on the column itself.
pub fn fit_content( mut self ) -> Self
{
self.fit_content = true;
self
}
fn inner_w( &self, available: f32 ) -> f32
{
let w = available - self.padding * 2.0;
self.max_width.map( |m| w.min( m ) ).unwrap_or( w )
}
fn content_h( &self, inner_w: f32, canvas: &Canvas ) -> f32
{
// Spacers contribute 0 to natural height; spacing still applies between all children.
self.children.iter()
.map( |c| match c
{
Element::Spacer( s ) => s.fixed_height.unwrap_or( 0.0 ),
other => other.preferred_size( inner_w, canvas ).1,
} )
.sum::<f32>()
+ self.spacing * (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 w = if self.fit_content
{
// "Filler" widgets (Spacer, Separator, Scroll, ProgressBar, Slider,
// Toggle, TextEdit) all report `max_width` as their preferred width:
// they stretch across whatever rect the parent allocates. Including
// them when picking the intrinsic content width would claim
// `max_width` and defeat the flag, so skip them — only content-sized
// children (Text, Button, Image, nested fit-content Columns/Rows)
// drive the natural width.
let content_w = self.children.iter()
.map( |c| match c
{
Element::Spacer( _ ) => 0.0,
Element::Separator( _ ) => 0.0,
Element::Scroll( _ ) => 0.0,
Element::ProgressBar( _ ) => 0.0,
Element::Slider( _ ) => 0.0,
// TextEdit defaults to claiming `max_width`, but
// a field built with `.fixed_width( w )` reports
// a pinned natural size — let those through so a
// numeric digit field inside a `fit_content`
// stepper column can drive the column's width.
Element::TextEdit( t ) => if t.fixed_width.is_some()
{
t.preferred_size( inner_w, canvas ).0
} else { 0.0 },
other => other.preferred_size( inner_w, canvas ).0,
} )
.fold( 0.0_f32, f32::max );
( content_w + self.padding * 2.0 ).min( max_width )
} else {
max_width
};
( w, total_h )
}
pub fn draw( &self, _canvas: &mut Canvas, _rect: Rect, _focused: bool ) {}
/// 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 );
// 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,
_ => 0,
} )
.sum();
let fixed_h: f32 = self.children.iter()
.map( |c|
{
if matches!( c, Element::Scroll( _ ) )
{
0.0
} else if let Element::Spacer( s ) = c {
s.fixed_height.unwrap_or( 0.0 )
} else {
c.preferred_size( inner_w, canvas ).1
}
} )
.sum::<f32>()
+ self.spacing * (self.children.len().saturating_sub( 1 )) as f32;
let avail_h = rect.height - self.padding * 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
} else {
rect.y + self.padding
};
let start_x = rect.x + (rect.width - inner_w) / 2.0;
let mut y = start_y;
let mut result = Vec::new();
for ( i, child ) in self.children.iter().enumerate()
{
let ( w, h ) = match child
{
Element::Spacer( s ) =>
{
let h = if let Some( fixed ) = s.fixed_height
{
fixed
} else if total_weight > 0
{
avail_spare * s.weight as f32 / total_weight as f32
} else {
0.0
};
( inner_w, h )
},
Element::Scroll( _ ) =>
{
let h = if total_weight > 0
{
avail_spare / total_weight as f32
} else {
0.0
};
( inner_w, h )
},
other => other.preferred_size( inner_w, canvas ),
};
let x = if self.align_center_x && !matches!( child, Element::Spacer( _ ) )
{
start_x + (inner_w - w) / 2.0
} else {
start_x
};
result.push( ( Rect { x, y, width: w, height: h }, i ) );
y += h + self.spacing;
}
result
}
pub( crate ) fn map_msg<U>( self, f: &crate::widget::MapFn<Msg, U> ) -> Column<U>
where
U: Clone + 'static,
Msg: 'static,
{
Column
{
children: self.children.into_iter().map( |c| c.map_arc( f ) ).collect(),
spacing: self.spacing,
padding: self.padding,
align_center_x: self.align_center_x,
center_y: self.center_y,
max_width: self.max_width,
fit_content: self.fit_content,
}
}
}
/// Create an empty column layout.
pub fn column<Msg: Clone>() -> Column<Msg>
{
Column::new()
}
impl<Msg: Clone> Default for Column<Msg>
{
fn default() -> Self
{
Self::new()
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
use crate::render::Canvas;
fn make_canvas() -> Canvas { Canvas::new( 800, 600 ) }
#[ test ]
fn preferred_size_width_equals_max_width()
{
let canvas = make_canvas();
let col = column::<()>().padding( 10.0 );
let ( w, _ ) = col.preferred_size( 200.0, &canvas );
assert_eq!( w, 200.0 );
}
#[ test ]
fn empty_column_height_is_two_paddings()
{
let canvas = make_canvas();
let col = column::<()>().padding( 10.0 );
let ( _, h ) = col.preferred_size( 200.0, &canvas );
assert_eq!( h, 20.0 );
}
#[ test ]
fn max_width_caps_inner_w_not_preferred_w()
{
let canvas = make_canvas();
// preferred_size always returns available max_width; max_width caps inner layout only
let col = column::<()>().padding( 0.0 ).max_width( 100.0 );
let ( w, _ ) = col.preferred_size( 200.0, &canvas );
assert_eq!( w, 200.0 );
}
#[ test ]
fn inner_w_respects_padding_and_max_width()
{
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 );
}
#[ test ]
fn inner_w_without_max_width_subtracts_padding()
{
let col = column::<()>().padding( 10.0 );
assert_eq!( col.inner_w( 200.0 ), 180.0 );
}
#[ test ]
fn spacing_between_children_accumulates()
{
let canvas = make_canvas();
// Three zero-height spacers, two 8 px gaps between them = 16.
let col = column::<()>()
.padding( 0.0 )
.spacing( 8.0 )
.push( crate::spacer() )
.push( crate::spacer() )
.push( crate::spacer() );
let ( _, h ) = col.preferred_size( 100.0, &canvas );
assert_eq!( h, 16.0 );
}
}

47
src/layout/mod.rs Normal file
View File

@@ -0,0 +1,47 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Layouts — composable arrangers for [`Element`](crate::Element) trees.
//!
//! Layouts decide *where* their children sit; they don't paint anything of
//! their own. Each layout exposes a free constructor (`column()`, `row()`,
//! `stack()`, `grid(N)`, `spacer()`) and a builder-style API for spacing,
//! padding, alignment and sizing. Layouts and [widgets](crate::widget)
//! share the same [`Element<Msg>`](crate::Element) tree — anything that
//! converts into an `Element` can be pushed into any layout.
//!
//! ## What's available
//!
//! * **Flow** — [`column::Column`] (top-to-bottom),
//! [`row::Row`] (left-to-right). Both honour `padding`, `spacing`,
//! `max_width`, `align_center_*` and inline `Spacer` distribution.
//! * **Overlay** — [`stack::Stack`] for FrameLayout-style layering with
//! per-child [`HAlign`](stack::HAlign) / [`VAlign`](stack::VAlign) and
//! margin / pixel-translation overrides. Useful for foreground HUD on
//! top of a background image, or a floating action button anchored to
//! the bottom-right.
//! * **Grid** — [`wrap_grid::WrapGrid`] for fixed-column-count grids that
//! wrap their children into rows (icon launchers, photo galleries).
//! * **Filler** — [`spacer::Spacer`], an invisible child that absorbs
//! leftover space along the parent's main axis. Pair with
//! [`flex::Flex`](crate::Flex) when the filler is non-trivial (a card
//! that should stretch a row).
//!
//! Most layouts default to "fill the parent's available rect" and only
//! shrink to their content with `fit_content()`.
//!
//! ## Sizing model
//!
//! Layouts implement `preferred_size( max_width, &Canvas ) -> ( w, h )`
//! the same way widgets do. The convention is: the layout claims the
//! parent-supplied `max_width` (or the explicit `max_width(...)` setting)
//! and reports the height needed for its children. Spacers and
//! `Scroll`/`Flex` declare zero intrinsic main-axis size and absorb the
//! leftover space the parent has after fixed-size siblings have been laid
//! out.
pub mod column;
pub mod row;
pub mod spacer;
pub mod stack;
pub mod wrap_grid;

299
src/layout/row.rs Normal file
View File

@@ -0,0 +1,299 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::Rect;
use crate::render::Canvas;
use crate::widget::Element;
/// A horizontal layout container.
///
/// Children are arranged left-to-right. Use [`Row::align_right`] to
/// push the content block to the right edge of the available width.
///
/// ```rust,no_run
/// # use std::sync::Arc;
/// # use ltk::{ icon_button, row, 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()
/// .spacing( 16.0 )
/// .align_right()
/// .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,
}
impl<Msg: Clone> Row<Msg>
{
pub fn new() -> Self
{
Self { children: Vec::new(), spacing: 8.0, padding: 0.0, align_right: false }
}
/// Append a child widget or layout.
pub fn push( mut self, e: impl Into<Element<Msg>> ) -> Self
{
self.children.push( e.into() );
self
}
/// Set the horizontal gap between children in pixels. Default: `8.0`.
pub fn spacing( mut self, s: f32 ) -> Self
{
self.spacing = s;
self
}
/// Set the padding (all sides) in pixels. Default: `0.0`.
pub fn padding( mut self, p: f32 ) -> Self
{
self.padding = p;
self
}
/// Push the content block to the right edge of the available width.
pub fn align_right( mut self ) -> Self
{
self.align_right = true;
self
}
/// Return the preferred `(width, height)` given available `max_width`.
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{
// 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 fixed_w: f32 = self.children.iter()
.filter( |c| match c
{
Element::Flex( _ ) => false,
Element::Spacer( s ) => s.fixed_width.is_some(),
_ => true,
} )
.map( |c| c.preferred_size( max_width, canvas ).0 )
.sum();
let residual = ( inner_w - fixed_w - gaps ).max( 0.0 );
let max_h: f32 = self.children.iter()
.map( |c| match c
{
Element::Flex( _ ) => c.preferred_size( residual, canvas ).1,
Element::Spacer( _ ) => c.preferred_size( max_width, canvas ).1,
_ => c.preferred_size( max_width, canvas ).1,
} )
.fold( 0.0_f32, f32::max );
// `align_right` and any flex / weight-only spacer child make the row
// claim the full `max_width`: in those cases the row's rendered width
// comes from leftover distribution (or the right-edge anchor), not
// from the sum of children's preferred widths. Reporting only the
// natural sum would leave the parent allocating a too-narrow rect and
// the flex children would collapse to 0.
let has_flex = self.children.iter().any( |c| match c
{
Element::Flex( _ ) => true,
Element::Spacer( s ) => s.fixed_width.is_none(),
_ => false,
} );
let w = if self.align_right || has_flex
{
max_width
} else {
let total_w: f32 = self.children.iter()
.map( |c| c.preferred_size( max_width, canvas ).0 )
.sum::<f32>()
+ gaps
+ self.padding * 2.0;
total_w.min( max_width )
};
( w, max_h + self.padding * 2.0 )
}
pub fn draw( &self, _canvas: &mut Canvas, _rect: Rect, _focused: bool ) {}
/// Layout children within rect and return `(rect, child_index)` pairs.
///
/// Flexible [`Spacer`](crate::layout::spacer::Spacer) children claim the
/// leftover horizontal space: non-spacer widgets keep their preferred
/// width, the remaining width (after subtracting spacing + padding) is
/// distributed between spacers in proportion to their `weight`. When no
/// spacers are present the cluster is centered (or right-aligned via
/// [`Row::align_right`]).
pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<(Rect, usize)>
{
let inner_h = rect.height - self.padding * 2.0;
// Spacers and `Flex` wrappers report 0 width here; their real width
// comes from the flex distribution below.
let sizes: Vec<(f32, f32)> = self.children.iter()
.map( |c| c.preferred_size( rect.width, canvas ) )
.collect();
let gaps = self.spacing * self.children.len().saturating_sub( 1 ) as f32;
let fixed_w: f32 = self.children.iter().zip( sizes.iter() )
.filter( |( c, _ )| match c
{
// Pure flex children (`Flex` and weight-only `Spacer`) take
// width from the leftover pool; everything else, including
// `Spacer::width(...)`-pinned spacers, contributes to the
// fixed-width tally.
Element::Flex( _ ) => false,
Element::Spacer( s ) => s.fixed_width.is_some(),
_ => true,
} )
.map( |( _, ( w, _ ) )| *w )
.sum();
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::Flex( f ) => Some( f.weight ),
_ => None,
} )
.sum();
let inner_w = ( rect.width - self.padding * 2.0 ).max( 0.0 );
let leftover = ( inner_w - fixed_w - gaps ).max( 0.0 );
let has_spacers = total_weight > 0;
let ( start_x, flex_unit ) = if has_spacers
{
// 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 )
}
else if self.align_right
{
( rect.x + rect.width - (fixed_w + gaps) - self.padding, 0.0 )
}
else
{
( rect.x + (rect.width - fixed_w - gaps) / 2.0, 0.0 )
};
let mut x = start_x;
let mut result = Vec::with_capacity( self.children.len() );
for ( i, ( (w, h), child) ) in sizes.into_iter().zip( self.children.iter() ).enumerate()
{
let width = match child
{
Element::Spacer( s ) => match s.fixed_width
{
Some( fw ) => fw,
None => flex_unit * s.weight as f32,
},
Element::Flex( f ) => flex_unit * f.weight as f32,
_ => w,
};
let y = rect.y + self.padding + (inner_h - h) / 2.0;
result.push( ( Rect { x, y, width, height: h }, i ) );
x += width + self.spacing;
}
result
}
pub( crate ) fn map_msg<U>( self, f: &crate::widget::MapFn<Msg, U> ) -> Row<U>
where
U: Clone + 'static,
Msg: 'static,
{
Row
{
children: self.children.into_iter().map( |c| c.map_arc( f ) ).collect(),
spacing: self.spacing,
padding: self.padding,
align_right: self.align_right,
}
}
}
/// Create an empty row layout.
pub fn row<Msg: Clone>() -> Row<Msg>
{
Row::new()
}
impl<Msg: Clone> Default for Row<Msg>
{
fn default() -> Self
{
Self::new()
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
use crate::render::Canvas;
use crate::types::Rect;
fn make_canvas() -> Canvas { Canvas::new( 800, 600 ) }
#[ test ]
fn align_right_returns_full_max_width()
{
let canvas = make_canvas();
let r = row::<()>().align_right();
let ( w, _ ) = r.preferred_size( 500.0, &canvas );
assert_eq!( w, 500.0 );
}
#[ test ]
fn align_right_true_regardless_of_children()
{
let canvas = make_canvas();
let r = row::<()>().align_right().spacing( 999.0 );
let ( w, _ ) = r.preferred_size( 300.0, &canvas );
assert_eq!( w, 300.0 );
}
#[ test ]
fn centered_empty_row_returns_zero_width()
{
let canvas = make_canvas();
let r = row::<()>();
let ( w, _ ) = r.preferred_size( 500.0, &canvas );
assert_eq!( w, 0.0 );
}
#[ test ]
fn centered_empty_row_returns_zero_height()
{
let canvas = make_canvas();
let r = row::<()>().padding( 0.0 );
let ( _, h ) = r.preferred_size( 500.0, &canvas );
assert_eq!( h, 0.0 );
}
#[ test ]
fn padding_adds_to_height()
{
let canvas = make_canvas();
let r = row::<()>().padding( 8.0 );
let ( _, h ) = r.preferred_size( 500.0, &canvas );
assert_eq!( h, 16.0 );
}
#[ test ]
fn layout_of_empty_row_is_empty()
{
let canvas = make_canvas();
let r = row::<()>().align_right();
let rect = Rect { x: 0., y: 0., width: 400., height: 48. };
assert!( r.layout( rect, &canvas ).is_empty() );
}
}

143
src/layout/spacer.rs Normal file
View File

@@ -0,0 +1,143 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::widget::Element;
/// A flexible, invisible spacer that expands to fill available space.
///
/// The optional `weight` controls how much of the remaining space this spacer
/// claims relative to other spacers in the same layout. A spacer with `weight = 2`
/// takes twice as much space as one with `weight = 1`.
///
/// Place a `Spacer` between two widgets inside a [`Column`](crate::layout::column::Column)
/// or [`Row`](crate::layout::row::Row) to push them apart:
///
/// ```rust,no_run
/// # use ltk::{ column, spacer, text, Element };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex() -> Element<Msg> {
/// column()
/// .push( text( "Top" ) )
/// .push( spacer() ) // pushes "Bottom" to the bottom
/// .push( text( "Bottom" ) )
/// .into()
/// # }
/// ```
///
/// Use [`.weight(n)`](Spacer::weight) to replace several consecutive spacers:
///
/// ```rust,no_run
/// # use ltk::{ column, spacer, Column };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex() {
/// // These two are equivalent:
/// let _: Column<Msg> = column().push( spacer().weight( 3 ) );
/// let _: Column<Msg> = column().push( spacer() ).push( spacer() ).push( spacer() );
/// # }
/// ```
///
/// Use [`.height(px)`](Spacer::height) to create a fixed-size vertical spacer:
///
/// ```rust,no_run
/// # use ltk::{ column, spacer, text, Element };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex() -> Element<Msg> {
/// column()
/// .push( text( "Header" ) )
/// .push( spacer().height( 20.0 ) ) // Exactly 20px gap
/// .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>,
}
impl Spacer
{
/// Set the relative weight of this spacer (default 1).
pub fn weight( mut self, w: u32 ) -> Self
{
self.weight = w;
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
{
self.fixed_height = Some( h );
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
{
self.fixed_width = Some( w );
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)
{
( self.fixed_width.unwrap_or( 0.0 ), self.fixed_height.unwrap_or( 0.0 ) )
}
/// No-op — spacers are invisible.
pub fn draw( &self ) {}
}
impl<Msg: Clone + 'static> From<Spacer> for Element<Msg>
{
fn from( s: Spacer ) -> Self
{
Element::Spacer( s )
}
}
/// Create a flexible spacer with weight 1.
///
/// Call [`.weight(n)`](Spacer::weight) to set a relative weight greater than 1:
///
/// ```rust,no_run
/// # use ltk::{ column, spacer, Column };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex() {
/// // These two are equivalent:
/// let _: Column<Msg> = column().push( spacer().weight( 3 ) );
/// let _: Column<Msg> = column().push( spacer() ).push( spacer() ).push( spacer() );
/// # }
/// ```
///
/// Call [`.height(px)`](Spacer::height) to create a fixed-size vertical gap:
///
/// ```rust,no_run
/// # use ltk::{ column, spacer, text, Element };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex() -> Element<Msg> {
/// column()
/// .push( text( "First" ) )
/// .push( spacer().height( 24.0 ) ) // Fixed 24px gap
/// .push( text( "Second" ) )
/// .into()
/// # }
/// ```
pub fn spacer() -> Spacer
{
Spacer { weight: 1, fixed_height: None, fixed_width: None }
}

182
src/layout/stack.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>
use crate::types::Rect;
use crate::render::Canvas;
use crate::widget::Element;
/// Horizontal alignment of a child within a [`Stack`] rect.
#[ derive( Debug, Clone, Copy, PartialEq ) ]
pub enum HAlign
{
/// Align to the left edge.
Start,
/// Center horizontally.
Center,
/// Align to the right edge.
End,
/// Stretch to fill the full width.
Fill,
}
/// Vertical alignment of a child within a [`Stack`] rect.
#[ derive( Debug, Clone, Copy, PartialEq ) ]
pub enum VAlign
{
/// Align to the top edge.
Top,
/// Center vertically.
Center,
/// Align to the bottom edge.
Bottom,
/// Stretch to fill the full height.
Fill,
}
/// A layout that draws all its children stacked on top of each other.
/// Each child can be positioned within the Stack rect via [`HAlign`]/[`VAlign`].
///
/// Useful for overlaying a foreground widget on top of a background image:
///
/// ```rust,no_run
/// # use std::sync::Arc;
/// # use ltk::{ column, img_widget, stack, text, Element, HAlign, VAlign };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex( bg_rgba: Arc<Vec<u8>>, w: u32, h: u32 ) -> Element<Msg> {
/// stack()
/// .push( img_widget( bg_rgba, w, h ) )
/// .push_aligned( column().push( text( "Bottom right" ) ), HAlign::End, VAlign::Bottom )
/// .into()
/// # }
/// ```
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 )>,
}
impl<Msg: Clone> Stack<Msg>
{
/// Create an empty stack.
pub fn new() -> Self
{
Self { children: Vec::new() }
}
/// Append a child that fills the entire Stack rect (Android FrameLayout default).
pub fn push( self, e: impl Into<Element<Msg>> ) -> Self
{
self.push_aligned_margin( e, HAlign::Fill, VAlign::Fill, 0.0 )
}
/// Append a child with explicit horizontal and vertical alignment.
pub fn push_aligned(
self,
e: impl Into<Element<Msg>>,
h_align: HAlign,
v_align: VAlign,
) -> Self
{
self.push_aligned_margin( e, h_align, v_align, 0.0 )
}
/// Append a child with alignment and a uniform margin (inset from the Stack edges).
pub fn push_aligned_margin(
mut self,
e: impl Into<Element<Msg>>,
h_align: HAlign,
v_align: VAlign,
margin: f32,
) -> Self
{
self.children.push( ( e.into(), h_align, v_align, margin, 0.0, 0.0 ) );
self
}
/// Append a child with alignment plus an extra `(x, y)` translation in
/// logical pixels. Useful when a child needs to shift outside the normal
/// alignment grid without giving up the margin or alignment shorthand.
/// Positive `x` / `y` move the child right / down.
pub fn push_translated(
mut self,
e: impl Into<Element<Msg>>,
h_align: HAlign,
v_align: VAlign,
offset_x: f32,
offset_y: f32,
) -> Self
{
self.children.push( ( e.into(), h_align, v_align, 0.0, offset_x, offset_y ) );
self
}
/// Return the preferred `(width, height)` — the maximum height among children.
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{
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 )
}
/// 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 ) )|
{
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 );
let ( x, width ) = match h_align
{
HAlign::Start => ( rect.x + margin, pref_w ),
HAlign::Center => ( rect.x + ( rect.width - pref_w ) / 2.0, pref_w ),
HAlign::End => ( rect.x + rect.width - pref_w - margin, pref_w ),
HAlign::Fill => ( rect.x + margin, inner_w ),
};
let ( y, height ) = match v_align
{
VAlign::Top => ( rect.y + margin, pref_h ),
VAlign::Center => ( rect.y + ( rect.height - pref_h ) / 2.0, pref_h ),
VAlign::Bottom => ( rect.y + rect.height - pref_h - margin, pref_h ),
VAlign::Fill => ( rect.y + margin, inner_h ),
};
( Rect { x: x + ox, y: y + oy, width, height }, i )
} ).collect()
}
/// No-op — children are drawn directly by the event loop during layout.
pub fn draw( &self, _canvas: &mut Canvas, _rect: Rect, _focused: bool ) {}
pub( crate ) fn map_msg<U>( self, f: &crate::widget::MapFn<Msg, U> ) -> Stack<U>
where
U: Clone + 'static,
Msg: 'static,
{
Stack
{
children: self.children.into_iter()
.map( |( child, ha, va, margin, ox, oy )|
( child.map_arc( f ), ha, va, margin, ox, oy ) )
.collect(),
}
}
}
impl<Msg: Clone> Default for Stack<Msg>
{
fn default() -> Self
{
Self::new()
}
}
/// Create an empty [`Stack`].
pub fn stack<Msg: Clone>() -> Stack<Msg>
{
Stack::new()
}

347
src/layout/wrap_grid.rs Normal file
View File

@@ -0,0 +1,347 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::render::Canvas;
use crate::types::Rect;
use crate::widget::Element;
/// A grid layout that wraps children into rows of a fixed column count.
///
/// All cells in a row share the same height (the tallest item in that row).
/// Column widths are equal, dividing the available width minus padding and spacing.
///
/// Designed for app-drawer style layouts — combine with [`scroll()`](crate::widget::scroll::scroll)
/// for vertically scrollable grids:
///
/// ```rust,no_run
/// # use std::sync::Arc;
/// # use ltk::{ grid, icon_button, scroll, Element };
/// # #[ derive( Clone ) ] enum Msg { Open( usize ) }
/// # fn _ex( data: Arc<Vec<u8>>, w: u32, h: u32 ) -> Element<Msg> {
/// scroll(
/// grid( 4 )
/// .padding( 16.0 )
/// .spacing( 12.0 )
/// .push( icon_button( data.clone(), w, h ).on_press( Msg::Open( 0 ) ) )
/// .push( icon_button( data, w, h ).on_press( Msg::Open( 1 ) ) )
/// // ...
/// )
/// .into()
/// # }
/// ```
pub struct WrapGrid<Msg: Clone>
{
/// Child widgets laid out in row-major order.
pub 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,
}
impl<Msg: Clone> WrapGrid<Msg>
{
/// Append a child widget to the grid.
pub fn push( mut self, child: impl Into<Element<Msg>> ) -> Self
{
self.children.push( child.into() );
self
}
/// Set both horizontal and vertical gap between cells (default 8.0).
pub fn spacing( mut self, s: f32 ) -> Self
{
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
{
self.spacing_x = s;
self
}
/// Set only the vertical gap between rows; leaves horizontal spacing untouched.
pub fn spacing_y( mut self, s: f32 ) -> Self
{
self.spacing_y = s;
self
}
/// Set the padding on all sides (default 0.0).
pub fn padding( mut self, p: f32 ) -> Self
{
self.padding = p;
self
}
/// Compute the preferred size given an available width.
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{
if self.children.is_empty() || self.columns == 0
{
return ( max_width, 0.0 );
}
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 row_count = (self.children.len() + cols - 1) / cols;
let mut total_h = self.padding * 2.0;
for row in 0..row_count
{
let start = row * cols;
let end = (start + cols).min( self.children.len() );
let row_h = self.children[start..end]
.iter()
.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; }
}
( max_width, total_h )
}
/// Compute child rects. Returns `(child_rect, index_in_children)` pairs.
pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<(Rect, usize)>
{
if self.children.is_empty() || self.columns == 0
{
return Vec::new();
}
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 row_count = (self.children.len() + cols - 1) / cols;
let mut out = Vec::with_capacity( self.children.len() );
for row in 0..row_count
{
let start = row * cols;
let end = (start + cols).min( self.children.len() );
let row_h = self.children[start..end]
.iter()
.map( |c| c.preferred_size( cell_w, canvas ).1 )
.fold( 0.0_f32, f32::max );
for col in 0..(end - start)
{
let x = x0 + col as f32 * (cell_w + self.spacing_x);
let crect = Rect { x, y, width: cell_w, height: row_h };
out.push( ( crect, start + col ) );
}
y += row_h + self.spacing_y;
}
out
}
pub( crate ) fn map_msg<U>( self, f: &crate::widget::MapFn<Msg, U> ) -> WrapGrid<U>
where
U: Clone + 'static,
Msg: 'static,
{
WrapGrid
{
children: self.children.into_iter().map( |c| c.map_arc( f ) ).collect(),
columns: self.columns,
spacing_x: self.spacing_x,
spacing_y: self.spacing_y,
padding: self.padding,
}
}
}
impl<Msg: Clone + 'static> From<WrapGrid<Msg>> for Element<Msg>
{
fn from( g: WrapGrid<Msg> ) -> Self
{
Element::WrapGrid( g )
}
}
#[cfg(test)]
mod tests
{
use super::*;
use crate::render::Canvas;
use crate::layout::spacer::spacer;
fn canvas() -> Canvas { Canvas::new( 1, 1 ) }
// Helper: build a grid of N spacer children with the given settings.
fn spacer_grid( cols: usize, n: usize, spacing: f32, padding: f32 ) -> WrapGrid<()>
{
let mut g = grid( cols ).spacing( spacing ).padding( padding );
for _ in 0..n { g = g.push( spacer() ); }
g
}
// --- preferred_size ---
#[test]
fn empty_grid_height_is_zero()
{
let g: WrapGrid<()> = grid( 4 );
let ( _, h ) = g.preferred_size( 400.0, &canvas() );
assert_eq!( h, 0.0 );
}
#[test]
fn preferred_width_equals_max_width()
{
let g = spacer_grid( 4, 8, 0.0, 0.0 );
let ( w, _ ) = g.preferred_size( 320.0, &canvas() );
assert_eq!( w, 320.0 );
}
// --- layout: cell widths ---
#[test]
fn cell_width_no_spacing_no_padding()
{
// 400px / 4 cols = 100px each
let g = spacer_grid( 4, 4, 0.0, 0.0 );
let c = canvas();
let rect = Rect { x: 0.0, y: 0.0, width: 400.0, height: 200.0 };
let rects = g.layout( rect, &c );
assert_eq!( rects.len(), 4 );
for ( r, _ ) in &rects { assert!( (r.width - 100.0).abs() < 0.01 ); }
}
#[test]
fn cell_width_with_spacing()
{
// (400 - 3 * 10) / 4 = 370 / 4 = 92.5
let g = spacer_grid( 4, 4, 10.0, 0.0 );
let c = canvas();
let rect = Rect { x: 0.0, y: 0.0, width: 400.0, height: 200.0 };
let rects = g.layout( rect, &c );
for ( r, _ ) in &rects { assert!( (r.width - 92.5).abs() < 0.01 ); }
}
#[test]
fn cell_width_with_padding()
{
// inner = 400 - 2*20 = 360; 360 / 4 = 90
let g = spacer_grid( 4, 4, 0.0, 20.0 );
let c = canvas();
let rect = Rect { x: 0.0, y: 0.0, width: 400.0, height: 200.0 };
let rects = g.layout( rect, &c );
for ( r, _ ) in &rects { assert!( (r.width - 90.0).abs() < 0.01 ); }
}
// --- layout: child count and indices ---
#[test]
fn layout_yields_one_rect_per_child()
{
let g = spacer_grid( 4, 7, 0.0, 0.0 );
let c = canvas();
let rect = Rect { x: 0.0, y: 0.0, width: 400.0, height: 400.0 };
let rects = g.layout( rect, &c );
assert_eq!( rects.len(), 7 );
}
#[test]
fn layout_indices_are_sequential()
{
let g = spacer_grid( 3, 5, 0.0, 0.0 );
let c = canvas();
let rect = Rect { x: 0.0, y: 0.0, width: 300.0, height: 300.0 };
let rects = g.layout( rect, &c );
let indices: Vec<usize> = rects.iter().map( |( _, i )| *i ).collect();
assert_eq!( indices, vec![ 0, 1, 2, 3, 4 ] );
}
// --- layout: column x-positions ---
#[test]
fn column_x_positions_no_spacing()
{
// 300px / 3 cols = 100px each, starting at x=0
let g = spacer_grid( 3, 3, 0.0, 0.0 );
let c = canvas();
let rect = Rect { x: 0.0, y: 0.0, width: 300.0, height: 100.0 };
let rects = g.layout( rect, &c );
let xs: Vec<f32> = rects.iter().map( |( r, _ )| r.x ).collect();
assert!( (xs[0] - 0.0).abs() < 0.01 );
assert!( (xs[1] - 100.0).abs() < 0.01 );
assert!( (xs[2] - 200.0).abs() < 0.01 );
}
#[test]
fn column_x_positions_with_spacing()
{
// (300 - 2*10) / 3 = 280/3 ≈ 93.33; x[0]=0, x[1]=103.33, x[2]=206.67
let g = spacer_grid( 3, 3, 10.0, 0.0 );
let c = canvas();
let rect = Rect { x: 0.0, y: 0.0, width: 300.0, height: 100.0 };
let rects = g.layout( rect, &c );
let cell_w = 280.0_f32 / 3.0;
let xs: Vec<f32> = rects.iter().map( |( r, _ )| r.x ).collect();
assert!( (xs[0] - 0.0).abs() < 0.01 );
assert!( (xs[1] - (cell_w + 10.0)).abs() < 0.01 );
assert!( (xs[2] - (2.0 * (cell_w + 10.0))).abs() < 0.01 );
}
// --- layout: partial last row ---
#[test]
fn partial_last_row_has_correct_count()
{
// 7 children, 4 cols => row 0: 4, row 1: 3.
let g = spacer_grid( 4, 7, 0.0, 0.0 );
let c = canvas();
let rect = Rect { x: 0.0, y: 0.0, width: 400.0, height: 400.0 };
let rects = g.layout( rect, &c );
assert_eq!( rects.len(), 7 );
for ( r, _ ) in &rects[..4] { assert!( r.y.abs() < 0.01 ); }
}
// --- layout: rect origin offset ---
#[test]
fn layout_respects_rect_origin()
{
let g = spacer_grid( 2, 2, 0.0, 0.0 );
let c = canvas();
let rect = Rect { x: 50.0, y: 30.0, width: 200.0, height: 100.0 };
let rects = g.layout( rect, &c );
assert!( (rects[0].0.x - 50.0).abs() < 0.01 );
assert!( (rects[0].0.y - 30.0).abs() < 0.01 );
}
}
/// Create a grid layout with the given number of columns.
///
/// Use [`.push()`](WrapGrid::push), [`.spacing()`](WrapGrid::spacing), and
/// [`.padding()`](WrapGrid::padding) to populate and style the grid.
///
/// ```rust,no_run
/// # use ltk::{ button, grid, WrapGrid };
/// # #[ derive( Clone ) ] enum Msg { A }
/// # fn _ex() -> WrapGrid<Msg> {
/// grid( 4 ).padding( 16.0 ).spacing( 8.0 ).push( button( "A" ).on_press( Msg::A ) )
/// # }
/// ```
pub fn grid<Msg: Clone>( columns: usize ) -> WrapGrid<Msg>
{
WrapGrid
{
children: Vec::new(),
columns,
spacing_x: 8.0,
spacing_y: 8.0,
padding: 0.0,
}
}

429
src/lib.rs Executable file
View File

@@ -0,0 +1,429 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
// Inside `unsafe fn` bodies, every unsafe op must still be wrapped in
// its own `unsafe { ... }` block. Without this lint the compiler treats
// the whole body as one implicit `unsafe` scope and a SAFETY: comment
// per call site becomes impossible to enforce — the kind of drift that
// hides UB in renderer code where every glow::* call is `unsafe fn`.
#![ deny( unsafe_op_in_unsafe_fn ) ]
//! # ltk — Liberux ToolKit
//!
//! A lightweight Wayland UI toolkit built on top of
//! [smithay-client-toolkit](https://crates.io/crates/smithay-client-toolkit),
//! [tiny-skia](https://crates.io/crates/tiny-skia) and
//! [fontdue](https://crates.io/crates/fontdue).
//!
//! ltk is the UI toolkit for the Liberux desktop. The Liberux compositor (Forge)
//! handles window management, decorations, and positioning — ltk focuses on
//! rendering the content of each Wayland surface.
//!
//! `ltk` is also a public library for third-party developers building native
//! Wayland applications. If you are approaching the crate through `cargo doc`,
//! the API is grouped conceptually into three navigation modules:
//!
//! - [`window`] — the basic application window path most apps should start with
//! - [`shell`] — layer-shell and overlay APIs for shell-like surfaces
//! - [`runtime`] — advanced runtime hooks, invalidation, channels, and
//! runtime-free embedding via [`core::UiSurface`]
//!
//! ## Quick start
//!
//! ```rust,no_run
//! use ltk::{App, Element, column, text, button, spacer, Color, ButtonVariant};
//!
//! #[derive(Clone)]
//! enum Msg { Quit }
//!
//! struct MyApp;
//!
//! impl App for MyApp
//! {
//! type Message = Msg;
//!
//! fn view( &self ) -> Element<Msg>
//! {
//! column()
//! .push( text( "Hello, ltk!" ).size( 32.0 ).color( Color::WHITE ) )
//! .push( spacer() )
//! .push( button( "Quit" ).on_press( Msg::Quit ) )
//! .into()
//! }
//!
//! fn update( &mut self, msg: Msg )
//! {
//! match msg { Msg::Quit => std::process::exit( 0 ) }
//! }
//! }
//!
//! fn main() { ltk::run( MyApp ); }
//! ```
//!
//! ## Architecture
//!
//! - **[`App`]** trait — implement this to define your application.
//! - **[`Element`]** enum — the widget tree returned by [`App::view`].
//! - Widgets, layouts and primitive types are listed below in their own
//! sidebar sections; the [`widgets`], [`layouts`] and [`types`] modules
//! are concept-oriented landing pages that `cargo doc` exposes for the
//! same set, grouped by category.
//!
//! ## Widgets
//!
//! The interactive and decorative leaves of the [`Element`] tree:
//!
//! - **Buttons / activations** — [`button()`], [`icon_button`],
//! [`pressable`], [`window_button`], [`list_item()`].
//! - **Stateful binary controls** — [`toggle()`], [`checkbox()`],
//! [`radio()`].
//! - **Continuous controls** — [`slider()`], [`vslider()`],
//! [`progress_bar()`].
//! - **Text** — [`text()`], [`text_edit()`].
//! - **Decoration** — [`container()`], [`separator()`], [`img_widget()`].
//! - **Clipping wrappers** — [`scroll()`], [`viewport()`], [`flex()`].
//! - **Modal overlays** — [`dialog()`] (centered confirmation card with
//! optional title, subtitle, custom body and action row; built-in
//! scrim, ESC-to-cancel, and tap-outside-to-dismiss for the
//! non-modal variant).
//!
//! See [`widgets`] for the grouped landing page and
//! `docs/widgets.md` for the per-widget catalogue.
//!
//! ## Layouts
//!
//! Composable arrangers for [`Element`] trees:
//!
//! - [`column()`] — vertical flow.
//! - [`row()`] — horizontal flow.
//! - [`stack()`] — z-order overlay with per-child alignment.
//! - [`grid()`] — fixed-column-count wrapping grid.
//! - [`spacer()`] — invisible flexible filler.
//!
//! See [`layouts`] for the grouped landing page.
//!
//! ## Types
//!
//! Geometry and primitive values that flow through every builder:
//!
//! - [`Color`], [`Rect`], [`Point`], [`Size`], [`Corners`], [`WidgetId`].
//!
//! See [`types`] for the full module with `//!` description.
//!
//! ## Runtime-free embedding
//!
//! Use [`core::UiSurface`] when you need ltk's layout, drawing and
//! hit-testing without [`run()`] — typically for compositor-side
//! decorations, embedding ltk widgets in another render loop, or
//! offscreen rendering / previews.
//!
//! ## Licence and third-party assets
//!
//! `ltk` itself is distributed under `LGPL-2.1-only`. The default
//! theme bundles two third-party asset sets that travel under their
//! own licences and must be credited when the toolkit (or a binary
//! that embeds the default theme) is redistributed:
//!
//! - **Symbolic icons** under `themes/default/icons/catalogue/` —
//! Streamline's *Core Line Free* set, [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/),
//! © Streamline. Some files modified for the symbolic-tinting
//! pipeline; details in `themes/default/icons/catalogue/LICENSE.md`.
//! Upstream: <https://www.streamlinehq.com/icons/core-line-free>.
//! - **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.
//!
//! The remaining artwork in the default theme (wallpapers, lockscreens,
//! launcher logo, brand-mark variants, per-application icons) is
//! original to Liberux Labs and travels under the toolkit's own
//! `LGPL-2.1-only` licence. The full Debian-style declaration lives in
//! `debian/copyright` of the source tree.
// Load YAML locale files from `ltk/locales/*.yaml`. The `t!()` macro is
// re-exported (see below) so consuming applications can also use it; their
// own `i18n!()` invocations merge into the same runtime registry. The
// fallback locale is English — built-in widget strings always have an
// English entry.
rust_i18n::i18n!( "locales", fallback = "en" );
pub mod types;
pub( crate ) mod render;
pub( crate ) mod system_fonts;
pub( crate ) mod widget;
pub( crate ) mod layout;
pub( crate ) mod app;
pub mod theme;
pub mod wallpaper;
pub( crate ) mod tree;
pub( crate ) mod draw;
pub( crate ) mod input;
pub( crate ) mod event_loop;
pub( crate ) mod secure_mem;
pub mod gles_render;
pub mod egl_context;
pub mod core;
pub use app::
{
Anchor, App, ChannelSender, ShellMode, Layer, OverlayId, OverlaySpec,
InvalidationScope, SurfaceTarget,
};
pub use theme::
{
Palette, ThemeMode, ThemePreference, ThemeError,
ThemeDocument, Mode, SlotStore,
WallpaperSpec, WallpaperFit, LauncherSpec, WindowControlsSpec,
active_document, active_mode, active_theme_id,
is_fallback_active,
set_active_document, set_active_mode,
tint_symbolic,
};
pub use theme::{ color as theme_color, color_or as theme_color_or };
pub use theme::{ paint as theme_paint, shadows as theme_shadows };
pub use theme::{ surface as theme_surface, text_style as theme_text_style };
pub use theme::resolve_surface as theme_resolve_surface;
pub use theme::palette as theme_palette;
pub use theme::window_controls as theme_window_controls;
pub use theme::wallpaper as theme_wallpaper;
pub use theme::lockscreen as theme_lockscreen;
pub use theme::app_icon as theme_app_icon;
pub use theme::app_default_icon as theme_app_default_icon;
pub use theme::launcher_icon as theme_launcher_icon;
pub use theme::logo as theme_logo;
pub use theme::logo_square as theme_logo_square;
pub use theme::logo_horizontal as theme_logo_horizontal;
pub use theme::branding_asset as theme_branding_asset;
pub use theme::branding_raster as theme_branding_raster;
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 wallpaper::{ WallpaperBundle, ImageData };
pub use types::{ Color, Corners, CursorShape, Point, Rect, Size, WidgetId };
pub use widget::{ Element, button, icon_button, text_edit, image as img_widget, text, container };
pub use widget::button::ButtonVariant;
pub use widget::slider::{ Slider, slider, SliderAxis };
pub use widget::vslider::{ VSlider, vslider };
pub use widget::text::TextAlign;
pub use widget::toggle::{ Toggle, toggle };
pub use widget::separator::{ Separator, separator };
pub use widget::progress_bar::{ ProgressBar, progress_bar };
pub use widget::checkbox::{ Checkbox, checkbox };
pub use widget::radio::{ Radio, radio };
pub use widget::list_item::{ ListItem, list_item };
pub use widget::window_button::{ WindowButton, WindowButtonKind, window_button, window_controls };
pub use widget::pressable::{ Pressable, pressable };
pub use widget::flex::{ Flex, flex };
pub use widget::combo::{ Combo, ComboState, combo };
pub use widget::spinner::{ Spinner, spinner };
pub use widget::tab_bar::{ TabBar, tabs };
pub use widget::toast::{ Toast, toast };
pub use widget::tooltip::{ Tooltip, tooltip };
pub use widget::notebook::{ Notebook, NotebookPage, notebook };
pub use widget::external::{ External, ExternalSource };
pub use widget::external as widget_external;
pub use widget::date_picker::
{
Date, DatePicker, Locale as DateLocale, date_picker,
is_leap_year, days_in_month, day_of_week, add_months,
};
pub use widget::time_picker::{ Time, TimePicker, time_picker };
pub use widget::color_picker::
{
ColorPicker, color_picker, color_to_hex, parse_hex,
};
pub use widget::dialog::{ Dialog, dialog };
pub use layout::spacer::{ Spacer, spacer };
pub use layout::column::{ Column, column };
pub use layout::row::{ Row, row };
pub use layout::stack::{ Stack, stack, HAlign, VAlign };
// push_aligned_margin is available as a method on Stack — no separate re-export needed.
pub use layout::wrap_grid::{ WrapGrid, grid };
pub use widget::scroll::scroll;
pub use widget::viewport::{ Viewport, viewport };
pub use app::run;
pub use app::{ try_run, RunError };
pub use smithay_client_toolkit::seat::keyboard::Keysym;
/// Widgets — the interactive and decorative leaves of the [`Element`]
/// tree.
///
/// Concept-oriented sidebar entry: every widget the toolkit ships is also
/// available at the crate root (`ltk::button`, `ltk::toggle`, …); this
/// module groups them so `cargo doc` shows a single landing page when you
/// are looking for "what controls can I draw".
///
/// See [`docs/widgets.md`](https://github.com/liberux/ltk/blob/master/docs/widgets.md)
/// for the per-widget catalogue with usage notes and minimal examples.
pub mod widgets
{
pub use crate::
{
// Buttons / activations
button, icon_button,
Pressable, pressable,
WindowButton, WindowButtonKind, window_button, window_controls,
ListItem, list_item,
// Stateful binary controls
Toggle, toggle,
Checkbox, checkbox,
Radio, radio,
// Continuous controls
Slider, slider, SliderAxis,
VSlider, vslider,
ProgressBar, progress_bar,
// Composite picker
Combo, ComboState, combo,
// Activity / hint indicators
Spinner, spinner,
// Segmented selector + paginated tabs
TabBar, tabs,
Notebook, NotebookPage, notebook,
// Date / time / color pickers
Date, DatePicker, DateLocale, date_picker,
Time, TimePicker, time_picker,
ColorPicker, color_picker, color_to_hex, parse_hex,
// Transient overlays (return OverlaySpec)
Toast, toast,
Tooltip, tooltip,
// Modal / non-modal centered overlays
Dialog, dialog,
// Text input and display
text, text_edit, TextAlign,
// Decoration and chrome
container, Separator, separator,
img_widget,
// Clipping wrappers
scroll,
Viewport, viewport,
Flex, flex,
// Button styling token
ButtonVariant,
};
}
/// Layouts — composable arrangers for [`Element`] trees.
///
/// Concept-oriented sidebar entry. Each layout has a free constructor
/// (`column()`, `row()`, …) and a builder-style API for spacing, padding
/// and alignment. Layouts and [widgets] share the same `Element<Msg>`
/// tree.
pub mod layouts
{
pub use crate::
{
Column, column,
Row, row,
Stack, stack, HAlign, VAlign,
WrapGrid, grid,
Spacer, spacer,
};
}
/// Basic application-window API.
///
/// Start here if you are building a normal Wayland client window.
///
/// This module is documentation-first: it re-exports the common entry points
/// that most applications need so `cargo doc` presents a smaller and more
/// approachable surface before the user gets into overlays, gestures, and
/// shell-specific features.
///
/// The default path is:
///
/// 1. implement [`App`]
/// 2. return an [`Element`] tree from [`App::view`]
/// 3. mutate state in [`App::update`]
/// 4. start the event loop with [`run`]
///
/// If you are looking for layer-shell, overlays, or advanced runtime hooks,
/// move on to [`crate::shell`] or [`crate::runtime`].
pub mod window
{
pub use crate::
{
App, ButtonVariant, Color, Element, Keysym, Point, Rect, Size,
Column, Row, Stack, WrapGrid, Spacer,
button, icon_button, text, text_edit, img_widget,
container, checkbox, radio, toggle, separator,
progress_bar, list_item, slider, vslider, scroll, viewport,
column, row, stack, grid, spacer,
TextAlign, SliderAxis,
run,
};
}
/// Shell and layer-surface API.
///
/// Use this module when you are building shell-like surfaces rather than a
/// plain application window:
///
/// - panels
/// - docks
/// - homescreens
/// - greeters
/// - lock screens
/// - transient overlays
///
/// These items are also available at the crate root; this module exists so
/// `cargo doc` exposes a concept-oriented entry point for layer-shell users.
pub mod shell
{
pub use crate::
{
Anchor, App, Layer, OverlayId, OverlaySpec, ShellMode,
SurfaceTarget, InvalidationScope,
WindowButton, WindowButtonKind, window_button, window_controls,
};
}
/// Advanced runtime and embedding API.
///
/// This module groups the hooks that are useful once the basic app-window flow
/// is no longer enough:
///
/// - external wakeups via [`ChannelSender`]
/// - redraw narrowing via [`InvalidationScope`]
/// - surface-level invalidation targets via [`SurfaceTarget`]
/// - runtime-free embedding with [`crate::core::UiSurface`]
/// - direct theme/runtime state access
///
/// Most applications do not need to start here.
pub mod runtime
{
pub use crate::
{
ChannelSender, InvalidationScope, SurfaceTarget,
Palette, ThemeDocument, ThemeError, ThemeMode, ThemePreference,
WallpaperBundle, ImageData,
active_document, active_mode, active_theme_id,
is_fallback_active,
set_active_document, set_active_mode,
theme_color, theme_color_or, theme_paint, theme_palette,
theme_resolve_surface, theme_shadows, theme_surface, theme_text_style,
theme_wallpaper, theme_lockscreen, theme_window_controls,
theme_branding_asset, theme_branding_raster, theme_branding_image,
tint_symbolic,
};
pub use crate::core::{ RenderOptions, RenderOutput, UiSurface };
}
/// Internal helpers re-exported for the integration tests under `tests/` and
/// the criterion benches under `benches/`. The items themselves are `pub` but
/// live inside `pub(crate)` modules, so the only path that reaches them from
/// outside the crate is `ltk::test_support::*` — which is exactly what test
/// crates (and benches, which are also external) need.
///
/// **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 ) ]
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::vslider::value_from_y_in_rect;
pub use crate::event_loop::diff_overlay_ids;
}

100
src/render/clip.rs Normal file
View File

@@ -0,0 +1,100 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! 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.
use tiny_skia::{ FillRule, Mask, PathBuilder, Transform };
use crate::types::Rect;
use super::SoftwareCanvas;
impl SoftwareCanvas
{
/// Set the active clip region to the union of `rects` (physical pixels).
pub fn set_clip_rects( &mut self, rects: &[Rect] )
{
let w = self.pixmap.width();
let h = self.pixmap.height();
let Some( mut mask ) = Mask::new( w, h ) else
{
self.clip_mask = None;
self.clip_bounds = Vec::new();
return;
};
let mut pb = PathBuilder::new();
for r in rects
{
let x0 = r.x.max( 0.0 ).min( w as f32 );
let y0 = r.y.max( 0.0 ).min( h as f32 );
let x1 = ( r.x + r.width ).max( 0.0 ).min( w as f32 );
let y1 = ( r.y + r.height ).max( 0.0 ).min( h as f32 );
if x1 <= x0 || y1 <= y0 { continue; }
pb.push_rect( tiny_skia::Rect::from_ltrb( x0, y0, x1, y1 )
.expect( "valid rect" ) );
}
if let Some( path ) = pb.finish()
{
mask.fill_path( &path, FillRule::Winding, false, Transform::identity() );
self.clip_mask = Some( mask );
self.clip_bounds = rects.to_vec();
} else {
self.clip_mask = None;
self.clip_bounds = Vec::new();
}
}
/// Remove the active clip so subsequent paints cover the full canvas.
pub fn clear_clip( &mut self )
{
self.clip_mask = None;
self.clip_bounds = Vec::new();
}
pub ( super ) fn has_clip( &self ) -> bool
{
self.clip_mask.is_some()
}
/// Snapshot of the active clip bounds (empty when no clip is set).
pub fn clip_bounds_snapshot( &self ) -> Vec<Rect>
{
if self.has_clip() { self.clip_bounds.clone() } else { Vec::new() }
}
/// True when a horizontal strip `y` in `[y0, y1]` touches any clip bound.
pub ( super ) fn strip_intersects_clip( &self, y0: f32, y1: f32 ) -> bool
{
if self.clip_bounds.is_empty() { return !self.has_clip(); }
self.clip_bounds.iter().any( |r|
{
y1 > r.y && y0 < r.y + r.height
} )
}
/// Zero the alpha+RGB bytes inside each rect, used by the
/// partial-redraw path when the surface background is fully
/// transparent.
pub fn clear_rects_transparent( &mut self, rects: &[Rect] )
{
let pw = self.pixmap.width() as i32;
let ph = self.pixmap.height() as i32;
let bytes = self.pixmap.data_mut();
for r in rects
{
let x0 = ( r.x as i32 ).max( 0 );
let y0 = ( r.y as i32 ).max( 0 );
let x1 = ( ( r.x + r.width ).ceil() as i32 ).min( pw );
let y1 = ( ( r.y + r.height ).ceil() as i32 ).min( ph );
if x1 <= x0 || y1 <= y0 { continue; }
for py in y0..y1
{
let row_start = ( py * pw + x0 ) as usize * 4;
let row_end = ( py * pw + x1 ) as usize * 4;
bytes[ row_start..row_end ].fill( 0 );
}
}
}
}

112
src/render/helpers.rs Normal file
View File

@@ -0,0 +1,112 @@
// 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.
use tiny_skia::{ Path, PathBuilder };
use crate::types::Corners;
/// 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 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,
/// so callers can pass theme pill sentinels (e.g. `RADIUS = 100`) and
/// still get a well-formed pill on a small rect.
pub ( super ) fn build_rounded_rect( rect: tiny_skia::Rect, corners: Corners ) -> Option<Path>
{
let c = corners.clamp_to_size( rect.width(), rect.height() );
let tl = c.tl;
let tr = c.tr;
let br = c.br;
let bl = c.bl;
let x0 = rect.left();
let y0 = rect.top();
let x1 = rect.right();
let y1 = rect.bottom();
let mut pb = PathBuilder::new();
pb.move_to( x0 + tl, y0 );
pb.line_to( x1 - tr, y0 );
if tr > 0.0
{
let kk = tr * KAPPA;
pb.cubic_to( x1 - tr + kk, y0, x1, y0 + tr - kk, x1, y0 + tr );
}
pb.line_to( x1, y1 - br );
if br > 0.0
{
let kk = br * KAPPA;
pb.cubic_to( x1, y1 - br + kk, x1 - br + kk, y1, x1 - br, y1 );
}
pb.line_to( x0 + bl, y1 );
if bl > 0.0
{
let kk = bl * KAPPA;
pb.cubic_to( x0 + bl - kk, y1, x0, y1 - bl + kk, x0, y1 - bl );
}
pb.line_to( x0, y0 + tl );
if tl > 0.0
{
let kk = tl * KAPPA;
pb.cubic_to( x0, y0 + tl - kk, x0 + tl - kk, y0, x0 + tl, y0 );
}
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()
}

115
src/render/image.rs Normal file
View File

@@ -0,0 +1,115 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Image draw + SHM serialisation for [`SoftwareCanvas`].
//!
//! `draw_image_data` premultiplies the incoming straight-alpha RGBA
//! into a thread-local scratch buffer, wraps the result in a
//! short-lived tiny-skia pixmap, and composites it into `self.pixmap`
//! honouring the active clip mask + global alpha + `opacity`.
//!
//! `write_to_wayland_buf` is the serialisation path to the `wl_shm`
//! pool used by the software draw path. Either memcpys straight
//! (Abgr8888 matches tiny-skia's byte order) or swaps R/B in blocks
//! of four pixels (Argb8888 fallback).
use tiny_skia::{ Pixmap, PixmapPaint, Transform };
use crate::types::Rect;
use super::SoftwareCanvas;
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
{
eprintln!(
"[ltk] SoftwareCanvas::draw_image_data: refusing draw — {}×{} declared, {} bytes provided, expected {}",
img_w, img_h, rgba_data.len(), expected,
);
return;
}
let Some( int_size ) = tiny_skia::IntSize::from_wh( img_w, img_h ) else { return };
thread_local! {
static PREMUL_BUF: std::cell::RefCell<Vec<u8>> = std::cell::RefCell::new( Vec::new() );
}
PREMUL_BUF.with( |cell|
{
let mut premul = cell.borrow_mut();
let needed = rgba_data.len();
premul.resize( needed, 0 );
for ( dst, src ) in premul.chunks_exact_mut( 4 ).zip( rgba_data.chunks_exact( 4 ) )
{
let a = (src[3] as f32 / 255.0) * opacity * self.global_alpha;
dst[0] = (src[0] as f32 * a) as u8;
dst[1] = (src[1] as f32 * a) as u8;
dst[2] = (src[2] as f32 * a) as u8;
dst[3] = (a * 255.0) as u8;
}
if let Some( src_pixmap ) = Pixmap::from_vec( std::mem::take( &mut *premul ), int_size )
{
let sx = dest.width / img_w as f32;
let sy = dest.height / img_h as f32;
let t = Transform::from_scale( sx, sy ).post_translate( dest.x, dest.y );
let paint = PixmapPaint
{
quality: tiny_skia::FilterQuality::Bilinear,
..PixmapPaint::default()
};
self.pixmap.draw_pixmap( 0, 0, src_pixmap.as_ref(), &paint, t, self.clip_mask.as_ref() );
*premul = src_pixmap.take();
}
} );
}
pub fn write_to_wayland_buf( &self, buf: &mut [u8], swap_rb: bool )
{
let src = self.pixmap.data();
let len = src.len().min( buf.len() );
if !swap_rb
{
buf[..len].copy_from_slice( &src[..len] );
return;
}
let chunks = len / 16;
let remainder = len % 16;
let mut i = 0;
for _ in 0..chunks
{
buf[i] = src[i + 2];
buf[i + 1] = src[i + 1];
buf[i + 2] = src[i];
buf[i + 3] = src[i + 3];
buf[i + 4] = src[i + 6];
buf[i + 5] = src[i + 5];
buf[i + 6] = src[i + 4];
buf[i + 7] = src[i + 7];
buf[i + 8] = src[i + 10];
buf[i + 9] = src[i + 9];
buf[i + 10] = src[i + 8];
buf[i + 11] = src[i + 11];
buf[i + 12] = src[i + 14];
buf[i + 13] = src[i + 13];
buf[i + 14] = src[i + 12];
buf[i + 15] = src[i + 15];
i += 16;
}
for _ in 0..(remainder / 4)
{
buf[i] = src[i + 2];
buf[i + 1] = src[i + 1];
buf[i + 2] = src[i];
buf[i + 3] = src[i + 3];
i += 4;
}
}
}

634
src/render/mod.rs Normal file
View File

@@ -0,0 +1,634 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Rendering surface used by every widget.
//!
//! [`Canvas`] is a thin enum wrapper over the per-frame rendering
//! backend. The CPU backend is [`SoftwareCanvas`] (tiny-skia + fontdue
//! rasterised into a `Pixmap`). The GPU backend is
//! [`crate::gles_render::GlesCanvas`] (EGL + GLES2/3).
//!
//! Widgets only ever see `&mut Canvas` — they call `fill_rect`,
//! `draw_text`, etc. The enum dispatches by `match self` (no `dyn`,
//! so the call sites stay monomorphic and inlinable). Field-style
//! access to backend internals (`pixmap`, `font`, `dpi_scale`…) is
//! replaced by accessor methods that the GPU variant can also
//! implement.
//!
//! # Submodule layout
//!
//! * [`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}`.
//! * [`primitives`] — `SoftwareCanvas::{clear, fill, fill_rect,
//! stroke_rect, draw_line}`.
//! * [`text`] — `SoftwareCanvas::{draw_text, measure_text,
//! rasterize_cached}`.
//! * [`image`] — `SoftwareCanvas::{draw_image_data,
//! write_to_wayland_buf}`.
//! * [`helpers`] — free functions: `build_rounded_rect`,
//! `find_font`, `find_font_opt`, `SYSTEM_FONT_CANDIDATES`.
use std::cell::Cell;
use std::sync::Arc;
use fontdue::{ Font, LineMetrics, Metrics };
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 };
pub( crate ) mod setup;
pub( crate ) mod clip;
pub( crate ) mod primitives;
pub( crate ) mod text;
pub( crate ) mod image;
pub( crate ) mod helpers;
// ─── Backend flag ────────────────────────────────────────────────────────────
thread_local!
{
/// `true` when this thread's surfaces are rendered through the
/// software (tiny-skia / SHM) path, `false` when they go through
/// the GLES path. Set once at startup based on EGL availability
/// and read by view code that needs to branch on backend (e.g. a
/// layout that costs something specific to one path and isn't
/// worth replicating on the other). Stays a thread-local so view
/// code does not need to plumb a flag through every layout call.
static SOFTWARE_RENDER: Cell<bool> = const { Cell::new( false ) };
}
/// Toggle the software-render flag for this thread. Consumers read
/// with [`is_software_render`].
pub fn set_software_render( on: bool )
{
SOFTWARE_RENDER.with( | c | c.set( on ) );
}
/// `true` when the active surfaces on this thread render through the
/// software path. Used by view code that wants to avoid pipeline
/// effects the software backend doesn't implement.
pub fn is_software_render() -> bool
{
SOFTWARE_RENDER.with( | c | c.get() )
}
// ─── Glyph cache ─────────────────────────────────────────────────────────────
/// Cache key for a rasterized glyph. `size_bits` is the f32 bit
/// pattern of `size * dpi_scale`; `font_id` is the address of the
/// `Arc<Font>` used for the rasterisation, so distinct weights /
/// families of the same `(char, size)` do not collide on the cache.
#[ derive( Hash, PartialEq, Eq, Clone, Copy ) ]
pub ( super ) struct GlyphKey
{
pub ( super ) ch: char,
pub ( super ) size_bits: u32,
pub ( super ) font_id: usize,
}
/// Cached glyph bitmap and metrics. Fontdue's rasterize call is the
/// dominant per-frame CPU cost for text-heavy UIs; reusing across
/// frames avoids that work.
pub ( super ) struct GlyphEntry
{
pub ( super ) metrics: Metrics,
pub ( super ) bitmap: Vec<u8>,
}
// ─── SoftwareCanvas ──────────────────────────────────────────────────────────
/// Software rendering backend backed by a tiny-skia [`Pixmap`] and a
/// fontdue [`Font`].
///
/// Wrapped by [`Canvas`] so the GPU backend can be slotted in by the
/// runtime without changing widget code. Widgets themselves never see
/// `SoftwareCanvas` directly.
pub struct SoftwareCanvas
{
/// The pixel buffer drawn into each frame.
pub pixmap: Pixmap,
/// The loaded system font used for all text rendering.
///
/// 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.
pub font: Arc<Font>,
/// Optional theme font registry. When present,
/// [`SoftwareCanvas::font_for`] consults it before falling back
/// to `font`. Populated by the caller once the theme's `fonts`
/// block has been loaded.
pub font_registry: Option<Arc<FontRegistry>>,
/// DPI scale factor applied to font sizes.
pub dpi_scale: f32,
/// Global alpha multiplier for all drawing operations (0.0 =
/// transparent, 1.0 = opaque).
pub global_alpha: f32,
/// Persistent cache of rasterized glyphs, indexed by (char, scaled size).
/// Grows on demand; not LRU-bounded since typical UIs use few sizes.
glyph_cache: std::collections::HashMap<GlyphKey, GlyphEntry>,
/// Optional clip mask applied to all paint operations. Set via
/// [`Canvas::set_clip_rects`] during a partial redraw so only
/// pixels inside the dirty rects are touched. `None` means "draw
/// everywhere".
clip_mask: Option<Mask>,
/// Bounding boxes of the clip rects in physical pixels. Used by
/// [`SoftwareCanvas::draw_text`] to do an early reject without
/// poking the mask byte by byte (the Mask buffer is still
/// authoritative inside the pixel loop).
clip_bounds: Vec<Rect>,
}
// ─── Canvas enum + dispatch ─────────────────────────────────────────────────
/// Per-frame rendering surface. Wraps a backend (software or GPU)
/// behind an enum so widgets can stay backend-agnostic.
///
/// All drawing methods are dispatched by `match self` — no `dyn`
/// indirection, so the backend branch stays predictable and
/// inlinable in the hot path.
pub enum Canvas
{
/// CPU rasterisation via tiny-skia + fontdue, written to a
/// `wl_shm` buffer.
Software( SoftwareCanvas ),
/// GPU rasterisation via EGL + GLES 2/3. Presents via
/// `eglSwapBuffers`; [`Canvas::write_to_wayland_buf`] is a no-op
/// for this variant.
Gles( GlesCanvas ),
}
impl Canvas
{
/// Build a software canvas. The GPU backend requires an EGL
/// context — see [`Canvas::new_gles`].
pub fn new( width: u32, height: u32 ) -> Self
{
Canvas::Software( SoftwareCanvas::new( width, height ) )
}
/// Build a GPU canvas on an already-current EGL context.
pub fn new_gles(
gl: Arc<glow::Context>, version: GlesVersion, width: u32, height: u32,
) -> Self
{
Canvas::Gles( GlesCanvas::new( gl, version, width, height ) )
}
/// `(width, height)` of the underlying surface in physical pixels.
pub fn size( &self ) -> ( u32, u32 )
{
match self
{
Canvas::Software( c ) => ( c.pixmap.width(), c.pixmap.height() ),
Canvas::Gles( c ) => c.size(),
}
}
/// Borrow the GLES texture backing this canvas, when the canvas
/// is GPU-backed.
pub fn borrowed_gles_texture( &self ) -> Option<BorrowedGlesTexture>
{
match self
{
Canvas::Software( _ ) => None,
Canvas::Gles( c ) => Some( c.borrowed_texture() ),
}
}
/// Read a GLES canvas into tightly packed RGBA8, top-left row
/// first. Intentionally unavailable for software canvases because
/// the software backend's canonical export path is
/// [`Self::write_to_wayland_buf`].
pub fn read_gles_rgba_pixels( &self, out: &mut [u8] ) -> Result<(), String>
{
match self
{
Canvas::Software( _ ) => Err( "read_gles_rgba_pixels requires Canvas::Gles".to_string() ),
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 —
/// the producer keeps ownership of the texture name; this call
/// only samples it through the standard texture program.
pub fn draw_external_texture( &mut self, texture: glow::Texture, dest: Rect, opacity: f32 )
{
match self
{
Canvas::Software( _ ) => {}
Canvas::Gles( c ) => c.draw_external_texture( texture, dest, opacity ),
}
}
pub fn dpi_scale( &self ) -> f32
{
match self
{
Canvas::Software( c ) => c.dpi_scale,
Canvas::Gles( c ) => c.dpi_scale(),
}
}
pub fn set_dpi_scale( &mut self, s: f32 )
{
match self
{
Canvas::Software( c ) => c.dpi_scale = s,
Canvas::Gles( c ) => c.set_dpi_scale( s ),
}
}
pub fn global_alpha( &self ) -> f32
{
match self
{
Canvas::Software( c ) => c.global_alpha,
Canvas::Gles( c ) => c.global_alpha(),
}
}
pub fn set_global_alpha( &mut self, a: f32 )
{
match self
{
Canvas::Software( c ) => c.global_alpha = a,
Canvas::Gles( c ) => c.set_global_alpha( a ),
}
}
/// Shared font handle. Exposed so widgets that need raw `fontdue`
/// access (e.g. `Text` for ascent/descent) do not have to go
/// through wrappers for every metric they read.
pub fn font( &self ) -> &Font
{
match self
{
Canvas::Software( c ) => &c.font,
Canvas::Gles( c ) => c.font(),
}
}
/// Install a theme font registry on the active backend.
pub fn set_font_registry( &mut self, registry: Arc<FontRegistry> )
{
match self
{
Canvas::Software( c ) => c.set_font_registry( registry ),
Canvas::Gles( c ) => c.set_font_registry( registry ),
}
}
/// Resolve a specific font via the theme registry, falling back
/// to the system-default [`Self::font`] when no registry is
/// installed or the triple cannot be satisfied.
pub fn font_for( &self, family: &str, weight: u16, style: FontStyle ) -> Arc<Font>
{
match self
{
Canvas::Software( c ) => c.font_for( family, weight, style ),
Canvas::Gles( c ) => c.font_for( family, weight, style ),
}
}
/// Convenience wrapper around `font().metrics(...)` already
/// pre-scaled by `dpi_scale`. Most callers want this rather than
/// the raw font handle.
pub fn font_metrics( &self, ch: char, size: f32 ) -> Metrics
{
self.font().metrics( ch, size * self.dpi_scale() )
}
/// Convenience wrapper around `font().horizontal_line_metrics(...)`.
pub fn font_line_metrics( &self, size: f32 ) -> Option<LineMetrics>
{
self.font().horizontal_line_metrics( size )
}
pub fn resize( &mut self, width: u32, height: u32 )
{
match self
{
Canvas::Software( c ) => c.resize( width, height ),
Canvas::Gles( c ) => c.resize( width, height ),
}
}
pub fn sub_canvas( &self, width: u32, height: u32 ) -> Canvas
{
match self
{
Canvas::Software( c ) => Canvas::Software( c.sub_canvas( width, height ) ),
Canvas::Gles( c ) => Canvas::Gles( c.sub_canvas( width, height ) ),
}
}
pub fn blit( &mut self, src: &Canvas, dest_x: i32, dest_y: i32 )
{
self.blit_fade_bottom( src, dest_x, dest_y, 0.0 )
}
/// Like [`Self::blit`] but feathers the last `fade_bottom_px` source
/// rows so the bottom edge fades to transparent. The software backend
/// currently ignores `fade_bottom_px`, so the dissolve is GLES-only.
pub fn blit_fade_bottom( &mut self, src: &Canvas, dest_x: i32, dest_y: i32, fade_bottom_px: f32 )
{
match ( self, src )
{
( Canvas::Software( dst ), Canvas::Software( s ) ) =>
{
let _ = fade_bottom_px;
dst.blit( s, dest_x, dest_y );
}
( Canvas::Gles( dst ), Canvas::Gles( s ) ) =>
{
dst.blit_fade_bottom( s, dest_x, dest_y, fade_bottom_px );
}
// Cross-backend blits would need an SHM↔texture upload.
// The toolkit only ever creates sub-canvases of the same
// kind as their parent, so this is unreachable in practice.
_ => unimplemented!( "cross-backend blit not supported" ),
}
}
pub fn set_clip_rects( &mut self, rects: &[Rect] )
{
match self
{
Canvas::Software( c ) => c.set_clip_rects( rects ),
Canvas::Gles( c ) => c.set_clip_rects( rects ),
}
}
/// 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
/// partial-redraw or sub-canvas clip was — there is no stack
/// internally, so round-tripping through
/// [`Self::set_clip_rects`] with the snapshot is how to compose.
pub fn clip_bounds( &self ) -> Vec<Rect>
{
match self
{
Canvas::Software( c ) => c.clip_bounds_snapshot(),
Canvas::Gles( c ) => c.clip_bounds_snapshot(),
}
}
pub fn clear_clip( &mut self )
{
match self
{
Canvas::Software( c ) => c.clear_clip(),
Canvas::Gles( c ) => c.clear_clip(),
}
}
pub fn clear( &mut self )
{
match self
{
Canvas::Software( c ) => c.clear(),
Canvas::Gles( c ) => c.clear(),
}
}
pub fn fill( &mut self, color: Color )
{
match self
{
Canvas::Software( c ) => c.fill( color ),
Canvas::Gles( c ) => c.fill( color ),
}
}
pub fn fill_rect( &mut self, rect: Rect, color: Color, corners: impl Into<Corners> )
{
let corners = corners.into();
match self
{
Canvas::Software( c ) => c.fill_rect( rect, color, corners ),
Canvas::Gles( c ) => c.fill_rect( rect, color, corners ),
}
}
/// Paint-driven rectangle fill.
///
/// Dispatches on the [`crate::theme::Paint`] variant. Solid
/// fills go straight through [`Self::fill_rect`]. Gradients
/// (linear and radial) are routed to dedicated shaders on the
/// GPU backend; on the Software backend they still collapse to a
/// flat fill from the first stop — tiny-skia can render
/// gradients natively, but wiring that up is left for a
/// follow-up.
pub fn fill_paint_rect( &mut self, rect: Rect, paint: &ThemePaint, corners: impl Into<Corners> )
{
let corners = corners.into();
match paint
{
ThemePaint::Solid( c ) => self.fill_rect( rect, *c, corners ),
ThemePaint::Linear( g ) =>
{
match self
{
Canvas::Software( sc ) =>
{
let c = g.stops.first().map( |s| s.color ).unwrap_or( Color::TRANSPARENT );
sc.fill_rect( rect, c, corners );
}
Canvas::Gles( gc ) => gc.fill_linear_gradient_rect( rect, g, corners ),
}
}
ThemePaint::Radial( g ) =>
{
match self
{
Canvas::Software( sc ) =>
{
let c = g.stops.first().map( |s| s.color ).unwrap_or( Color::TRANSPARENT );
sc.fill_rect( rect, c, corners );
}
Canvas::Gles( gc ) => gc.fill_radial_gradient_rect( rect, g, corners ),
}
}
}
}
pub fn stroke_rect( &mut self, rect: Rect, color: Color, width: f32, corners: impl Into<Corners> )
{
let corners = corners.into();
match self
{
Canvas::Software( c ) => c.stroke_rect( rect, color, width, corners ),
Canvas::Gles( c ) => c.stroke_rect( rect, color, width, corners ),
}
}
/// Paint an outer drop shadow behind the rounded rect `target`.
///
/// On the GPU backend this runs an analytic soft-shadow shader
/// in one draw call — no FBO, no cache, no readback. On the
/// Software backend it is a no-op today.
pub fn fill_shadow_outer( &mut self, target: Rect, shadow: &Shadow, corners: impl Into<Corners> )
{
let corners = corners.into();
match self
{
Canvas::Software( _ ) => { /* TODO: tiny-skia BlurDropShadow */ }
Canvas::Gles( c ) => c.fill_shadow_outer( target, shadow, corners ),
}
}
/// Paint an inner (inset) shadow inside the rounded rect
/// `target`.
///
/// On the GPU backend, uses a dedicated shader whose inner SDF
/// encodes `shadow.offset` and `shadow.spread`. The blend state
/// is switched per-call to honour `shadow.blend`: `Normal`,
/// `PlusLighter`, `Multiply` and `Screen` map to fixed-function
/// blend modes; `Overlay` routes through a dedicated shader that
/// snapshots the FBO and computes the CSS Overlay formula
/// in-shader.
///
/// On the Software backend this is a no-op today.
pub fn fill_shadow_inset( &mut self, target: Rect, shadow: &InsetShadow, corners: impl Into<Corners> )
{
let corners = corners.into();
match self
{
Canvas::Software( _ ) => { /* TODO: tiny-skia inner shadow */ }
Canvas::Gles( c ) => c.fill_shadow_inset( target, shadow, corners ),
}
}
/// Unified surface painter. Composes a themed surface in the canonical
/// paint order: outer shadows → fill → insets.
pub fn fill_surface
(
&mut self,
rect: Rect,
fill: &ThemePaint,
outer_shadows: &[Shadow],
inset_shadows: &[InsetShadow],
corners: impl Into<Corners>,
)
{
let corners = corners.into();
for shadow in outer_shadows
{
self.fill_shadow_outer( rect, shadow, corners );
}
self.fill_paint_rect( rect, fill, corners );
for inset in inset_shadows
{
self.fill_shadow_inset( rect, inset, corners );
}
}
pub fn draw_line( &mut self, x0: f32, y0: f32, x1: f32, y1: f32, color: Color, width: f32 )
{
match self
{
Canvas::Software( c ) => c.draw_line( x0, y0, x1, y1, color, width ),
Canvas::Gles( c ) => c.draw_line( x0, y0, x1, y1, color, width ),
}
}
pub fn draw_text( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color )
{
match self
{
Canvas::Software( c ) => c.draw_text( text, x, y, size, color ),
Canvas::Gles( c ) => c.draw_text( text, x, y, size, color ),
}
}
/// Draw `text` with an explicitly supplied font instead of the
/// canvas default. Use [`Self::font_for`] to resolve a `(family,
/// weight, style)` triple from the active theme registry first.
pub fn draw_text_with_font( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color, font: &Arc<Font> )
{
match self
{
Canvas::Software( c ) => c.draw_text_with_font( text, x, y, size, color, font ),
Canvas::Gles( c ) => c.draw_text_with_font( text, x, y, size, color, font ),
}
}
pub fn measure_text( &self, text: &str, size: f32 ) -> f32
{
match self
{
Canvas::Software( c ) => c.measure_text( text, size ),
Canvas::Gles( c ) => c.measure_text( text, size ),
}
}
/// Width of `text` rendered with `font`. Mirrors
/// [`Self::measure_text`] but bypasses the canvas default font 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
{
match self
{
Canvas::Software( c ) => c.measure_text_with_font( text, size, font ),
Canvas::Gles( c ) => c.measure_text_with_font( text, size, font ),
}
}
pub fn draw_image_data( &mut self, rgba_data: &[u8], img_w: u32, img_h: u32, dest: Rect, opacity: f32 )
{
match self
{
Canvas::Software( c ) => c.draw_image_data( rgba_data, img_w, img_h, dest, opacity ),
Canvas::Gles( c ) => c.draw_image_data( rgba_data, img_w, img_h, dest, opacity ),
}
}
/// Zero pixels inside each rect — used by the partial-redraw
/// path when the surface background is fully transparent.
pub fn clear_rects_transparent( &mut self, rects: &[Rect] )
{
match self
{
Canvas::Software( c ) => c.clear_rects_transparent( rects ),
Canvas::Gles( c ) => c.clear_rects_transparent( rects ),
}
}
/// Copy / present the rendered frame. For software this fills a
/// `wl_shm` buffer (with optional R/B swap for Argb8888). For
/// GPU the commit happens via `eglSwapBuffers` elsewhere — this
/// call is a no-op.
pub fn write_to_wayland_buf( &self, buf: &mut [u8], swap_rb: bool )
{
match self
{
Canvas::Software( c ) => c.write_to_wayland_buf( buf, swap_rb ),
Canvas::Gles( _ ) => {}
}
}
/// Publish the in-progress GPU frame: blit the FBO onto the EGL
/// window's default framebuffer. The follow-up `eglSwapBuffers`
/// (done outside the canvas) is what actually commits to the
/// compositor. No-op on software, where presentation is the SHM
/// `attach_to`/`commit` pair.
pub fn present( &mut self )
{
match self
{
Canvas::Software( _ ) => {}
Canvas::Gles( c ) => c.present(),
}
}
}

103
src/render/primitives.rs Normal file
View File

@@ -0,0 +1,103 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Primitive draw ops for [`SoftwareCanvas`]: clear, solid fill,
//! rounded-rect fill, stroke, line. tiny-skia does the heavy
//! lifting; this file just converts from ltk's `Rect` / `Color` to
//! tiny-skia's and threads `global_alpha` + `clip_mask` through
//! every call.
use tiny_skia::{ Paint, PathBuilder, Stroke, Transform };
use crate::types::{ Color, Corners, Rect };
use super::helpers::build_rounded_rect;
use super::SoftwareCanvas;
impl SoftwareCanvas
{
pub fn clear( &mut self )
{
self.pixmap.fill( tiny_skia::Color::TRANSPARENT );
}
pub fn fill( &mut self, color: Color )
{
if self.clip_mask.is_none()
{
self.pixmap.fill( color.to_tiny_skia() );
return;
}
let w = self.pixmap.width() as f32;
let h = self.pixmap.height() as f32;
let Some( ts_rect ) = tiny_skia::Rect::from_ltrb( 0.0, 0.0, w, h ) else { return };
let mut paint = Paint::default();
paint.set_color( color.to_tiny_skia() );
self.pixmap.fill_rect( ts_rect, &paint, Transform::identity(), self.clip_mask.as_ref() );
}
pub fn fill_rect( &mut self, rect: Rect, color: Color, corners: Corners )
{
let pw = self.pixmap.width() as f32;
let ph = self.pixmap.height() as f32;
if rect.x + rect.width < 0.0 || rect.x > pw
|| rect.y + rect.height < 0.0 || rect.y > ph { return; }
let Some( ts_rect ) = rect.to_tiny_skia() else { return };
let mut paint = Paint::default();
let adjusted_color = Color::rgba( color.r, color.g, color.b, color.a * self.global_alpha );
paint.set_color( adjusted_color.to_tiny_skia() );
paint.anti_alias = true;
if !corners.is_zero()
{
if let Some( path ) = build_rounded_rect( ts_rect, corners )
{
self.pixmap.fill_path(
&path,
&paint,
tiny_skia::FillRule::Winding,
Transform::identity(),
self.clip_mask.as_ref(),
);
}
} else {
self.pixmap.fill_rect( ts_rect, &paint, Transform::identity(), self.clip_mask.as_ref() );
}
}
pub fn stroke_rect( &mut self, rect: Rect, color: Color, width: f32, corners: Corners )
{
let Some( ts_rect ) = rect.to_tiny_skia() else { return };
let mut paint = Paint::default();
let adjusted_color = Color::rgba( color.r, color.g, color.b, color.a * self.global_alpha );
paint.set_color( adjusted_color.to_tiny_skia() );
paint.anti_alias = true;
let mut stroke = Stroke::default();
stroke.width = width;
if !corners.is_zero()
{
if let Some( path ) = build_rounded_rect( ts_rect, corners )
{
self.pixmap.stroke_path( &path, &paint, &stroke, Transform::identity(), self.clip_mask.as_ref() );
}
} else {
let path = PathBuilder::from_rect( ts_rect );
self.pixmap.stroke_path( &path, &paint, &stroke, Transform::identity(), self.clip_mask.as_ref() );
}
}
pub fn draw_line( &mut self, x0: f32, y0: f32, x1: f32, y1: f32, color: Color, width: f32 )
{
let mut pb = PathBuilder::new();
pb.move_to( x0, y0 );
pb.line_to( x1, y1 );
let Some( path ) = pb.finish() else { return };
let mut paint = Paint::default();
let adjusted_color = Color::rgba( color.r, color.g, color.b, color.a * self.global_alpha );
paint.set_color( adjusted_color.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() );
}
}

124
src/render/setup.rs Normal file
View File

@@ -0,0 +1,124 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Construction + accessors for [`SoftwareCanvas`]: new / sub_canvas
//! / resize plus the font-registry installer and `blit`.
use std::collections::HashMap;
use std::sync::{ Arc, OnceLock };
use fontdue::{ Font, FontSettings };
use tiny_skia::{ Pixmap, PixmapPaint, Transform };
use crate::theme::{ FontRegistry, FontStyle };
use super::helpers::load_default_font_bytes;
use super::SoftwareCanvas;
/// Process-wide cache of the default font face. Avoids re-reading +
/// re-parsing the file on every surface bring-up. 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<Arc<Font>> = OnceLock::new();
fn default_font() -> Arc<Font>
{
Arc::clone( DEFAULT_FONT.get_or_init( ||
{
let bytes = load_default_font_bytes();
let font = Font::from_bytes( bytes.as_slice(), FontSettings::default() )
.expect( "bad font" );
Arc::new( font )
} ) )
}
impl SoftwareCanvas
{
/// Create a canvas of the given pixel dimensions, loading a system font.
///
/// Fallback fonts (Noto Sans / CJK / Devanagari / …) are NOT loaded
/// here — they are owned by the crate-private system-fonts chain
/// and loaded lazily per codepoint. A canvas that only ever paints
/// Latin text will never touch those files; the first non-Latin
/// glyph triggers a single targeted load and the rest of the
/// process reuses the cached `Arc<Font>`.
pub fn new( width: u32, height: u32 ) -> Self
{
Self
{
pixmap: Pixmap::new( width, height ).expect( "pixmap" ),
font: default_font(),
font_registry: None,
dpi_scale: 1.0,
global_alpha: 1.0,
glyph_cache: HashMap::new(),
clip_mask: None,
clip_bounds: Vec::new(),
}
}
/// Create a blank sub-canvas sharing the same font and DPI scale.
pub fn sub_canvas( &self, width: u32, height: u32 ) -> SoftwareCanvas
{
SoftwareCanvas
{
pixmap: Pixmap::new( width, height ).expect( "pixmap" ),
font: Arc::clone( &self.font ),
font_registry: self.font_registry.as_ref().map( Arc::clone ),
dpi_scale: self.dpi_scale,
global_alpha: self.global_alpha,
glyph_cache: HashMap::new(),
clip_mask: None,
clip_bounds: Vec::new(),
}
}
/// Install a theme font registry so [`Self::font_for`] can
/// resolve family+weight+style triples declared by the theme's
/// `fonts` block. The default [`Self::font`] stays in place as a
/// fallback.
pub fn set_font_registry( &mut self, registry: Arc<FontRegistry> )
{
self.font_registry = Some( registry );
}
/// Resolve a specific font from the theme registry, falling back
/// to the canvas' default [`Self::font`] when no registry is
/// installed or the triple cannot be satisfied.
pub fn font_for( &self, family: &str, weight: u16, style: FontStyle ) -> Arc<Font>
{
self.font_registry
.as_ref()
.and_then( |r| r.resolve( family, weight, style ) )
.unwrap_or_else( || Arc::clone( &self.font ) )
}
/// Pick the right font for `ch`. Tries the primary [`Self::font`]
/// first; on a miss, delegates to the crate-private system-fonts
/// fallback chain (lazy load of the relevant Noto pack). Falls
/// back to the primary (which paints a `.notdef` box) when no
/// installed fallback covers the codepoint.
pub fn font_for_char( &self, ch: char ) -> Arc<Font>
{
if self.font.lookup_glyph_index( ch ) != 0
{
return Arc::clone( &self.font );
}
crate::system_fonts::lookup( ch ).unwrap_or_else( || Arc::clone( &self.font ) )
}
pub fn blit( &mut self, src: &SoftwareCanvas, dest_x: i32, dest_y: i32 )
{
let paint = PixmapPaint::default();
let t = Transform::from_translate( dest_x as f32, dest_y as f32 );
self.pixmap.draw_pixmap( 0, 0, src.pixmap.as_ref(), &paint, t, self.clip_mask.as_ref() );
}
pub fn resize( &mut self, width: u32, height: u32 )
{
self.pixmap = Pixmap::new( width, height ).expect( "pixmap" );
}
}

203
src/render/text.rs Normal file
View File

@@ -0,0 +1,203 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Text rendering for [`SoftwareCanvas`]. fontdue rasterises each
//! glyph once into the persistent [`super::GlyphEntry`] cache; the
//! per-frame hot path just lays out positions and blends cached
//! bitmaps into the pixmap with the active clip mask + global alpha.
use std::sync::Arc;
use fontdue::Font;
use crate::types::Color;
use super::{ GlyphEntry, GlyphKey, SoftwareCanvas };
const GLYPH_CACHE_SOFT_CAP: usize = 8192;
/// Stable identifier for an `Arc<Font>`: the address of the font's
/// allocation. Different reweights / families always live in
/// distinct allocations, so the address is enough to disambiguate
/// glyph cache entries.
fn font_id( font: &Arc<Font> ) -> usize
{
Arc::as_ptr( font ) as usize
}
impl SoftwareCanvas
{
/// Per-glyph default font lookup. Routes through
/// [`Self::font_for_char`], which already consults the lazy
/// system-font fallback chain. Returns an owned [`Arc<Font>`] so
/// callers can hold the handle across `&mut self` borrows of the
/// glyph cache.
fn default_font_for_char( &self, ch: char ) -> ( usize, Arc<Font> )
{
let font = self.font_for_char( ch );
let id = Arc::as_ptr( &font ) as usize;
( id, font )
}
pub ( super ) fn rasterize_cached( &mut self, ch: char, scaled: f32 ) -> &GlyphEntry
{
let ( id, font ) = self.default_font_for_char( ch );
let key = GlyphKey { ch, size_bits: scaled.to_bits(), font_id: id };
self.evict_if_full( &key );
if !self.glyph_cache.contains_key( &key )
{
let ( metrics, bitmap ) = font.rasterize( ch, scaled );
self.glyph_cache.insert( key, GlyphEntry { metrics, bitmap } );
}
self.glyph_cache.get( &key ).expect( "inserted above on miss" )
}
/// Pick the font to use for `ch` given a "preferred" override.
/// Falls through to the canvas default + Noto chain when the
/// preferred font does not own the glyph — so Sora Bold rendering
/// of CJK / Devanagari / etc. still works.
fn font_for_char_with_pref( &self, ch: char, pref: &Arc<Font> ) -> ( usize, Arc<Font> )
{
if pref.lookup_glyph_index( ch ) != 0
{
return ( font_id( pref ), Arc::clone( pref ) );
}
self.default_font_for_char( ch )
}
fn rasterize_cached_with( &mut self, ch: char, scaled: f32, pref: &Arc<Font> ) -> &GlyphEntry
{
let ( id, font ) = self.font_for_char_with_pref( ch, pref );
let key = GlyphKey { ch, size_bits: scaled.to_bits(), font_id: id };
self.evict_if_full( &key );
if !self.glyph_cache.contains_key( &key )
{
let ( metrics, bitmap ) = font.rasterize( ch, scaled );
self.glyph_cache.insert( key, GlyphEntry { metrics, bitmap } );
}
self.glyph_cache.get( &key ).expect( "inserted above on miss" )
}
fn evict_if_full( &mut self, key: &GlyphKey )
{
if !self.glyph_cache.contains_key( key )
&& self.glyph_cache.len() >= GLYPH_CACHE_SOFT_CAP
{
let drop_n = self.glyph_cache.len() / 2;
let victims: Vec<_> = self.glyph_cache.keys().copied().take( drop_n ).collect();
for k in victims
{
self.glyph_cache.remove( &k );
}
}
}
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 );
}
/// Draw `text` using the explicitly supplied font instead of the
/// canvas default + fallback chain.
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 ) );
}
fn draw_text_inner( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color, font: Option<&Arc<Font>> )
{
let scaled = size * self.dpi_scale;
let line_h = scaled * 1.5;
let ph = self.pixmap.height() as f32;
let pw = self.pixmap.width() as f32;
if y + line_h < 0.0 || y - line_h > ph { return; }
if x > pw { return; }
if self.has_clip() && !self.strip_intersects_clip( y - line_h, y + 0.5 * line_h )
{
return;
}
let mut layout: Vec<( GlyphKey, f32 )> = Vec::with_capacity( text.chars().count() );
{
let mut cursor_x = x;
for ch in text.chars()
{
let ( id, advance ) = match font
{
Some( f ) =>
{
let id = self.font_for_char_with_pref( ch, f ).0;
let advance = self.rasterize_cached_with( ch, scaled, f ).metrics.advance_width;
( id, advance )
}
None =>
{
let id = self.default_font_for_char( ch ).0;
let advance = self.rasterize_cached( ch, scaled ).metrics.advance_width;
( id, advance )
}
};
layout.push( ( GlyphKey { ch, size_bits: scaled.to_bits(), font_id: id }, cursor_x ) );
cursor_x += advance;
}
}
let w = self.pixmap.width() as i32;
let h = self.pixmap.height() as i32;
let cr = (color.r * 255.0) as u8;
let cg = (color.g * 255.0) as u8;
let cb = (color.b * 255.0) as u8;
let color_a = color.a * self.global_alpha;
let pixels = self.pixmap.data_mut();
let cache = &self.glyph_cache;
let mask_data = self.clip_mask.as_ref().map( |m| ( m.data(), m.width() as i32 ) );
for ( key, cursor_x ) in layout
{
let entry = cache.get( &key ).expect( "warmed above" );
let metrics = &entry.metrics;
let bitmap = &entry.bitmap;
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 as i32
- metrics.ymin as i32
- metrics.height as i32
+ 1
+ (i / metrics.width) as i32;
if px < 0 || py < 0 || px >= w || py >= h { continue; }
if let Some( ( md, mw ) ) = mask_data
{
if md[ ( py * mw + px ) as usize ] == 0 { continue; }
}
let idx = (py as usize * w as usize + px as usize) * 4;
let a = (alpha as f32 / 255.0) * color_a;
let inv = 1.0 - a;
pixels[idx] = (cr as f32 * a + pixels[idx] as f32 * inv) as u8;
pixels[idx + 1] = (cg as f32 * a + pixels[idx + 1] as f32 * inv) as u8;
pixels[idx + 2] = (cb as f32 * a + pixels[idx + 2] as f32 * inv) as u8;
let a_dst = pixels[idx + 3] as f32 / 255.0;
pixels[idx + 3] = ( ( a + a_dst * inv ) * 255.0 ) as u8;
}
}
}
pub fn measure_text( &self, text: &str, size: f32 ) -> f32
{
text.chars().map( |ch|
{
self.font_for_char( ch ).metrics( ch, size * self.dpi_scale ).advance_width
} ).sum()
}
pub fn measure_text_with_font( &self, text: &str, size: f32, font: &Arc<Font> ) -> f32
{
text.chars().map( |ch|
{
let ( _, picked ) = self.font_for_char_with_pref( ch, font );
picked.metrics( ch, size * self.dpi_scale ).advance_width
} ).sum()
}
}

78
src/secure_mem.rs Normal file
View File

@@ -0,0 +1,78 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Defensive primitives for credential handling.
//!
//! [`secure_zero`] overwrites a byte slice with zeros using volatile stores
//! so the optimiser cannot elide the wipe even when it can prove the buffer
//! is no longer read. It is the building block the `Drop` impls of secure
//! widgets (currently [`crate::widget::text_edit::TextEdit`] when
//! `secure( true )` is set) use to scrub credential text before the heap is
//! returned to the allocator.
//!
//! This is the minimal stand-in for the well-known `zeroize` crate: ltk is
//! a UI toolkit, the only call site is text-input wiping, and the cost of
//! pulling another dependency is not justified.
use core::ptr;
use core::sync::atomic::{ compiler_fence, Ordering };
/// Overwrite `buf` with zeros. The writes go through `write_volatile` so
/// the compiler treats them as observable side effects — the elision pass
/// cannot drop them even when the buffer is about to be freed.
///
/// A `compiler_fence(SeqCst)` after the loop pins the wipe to "before any
/// later memory operation", so a subsequent `Drop` that hands the
/// underlying allocation back to the allocator cannot be reordered above
/// the zero stores.
pub( crate ) fn secure_zero( buf: &mut [u8] )
{
for b in buf.iter_mut()
{
// SAFETY: writing a primitive byte through a unique mutable
// reference; volatile reflects the intent that the store has an
// observer beyond ordinary Rust semantics (the ex-credential).
unsafe { ptr::write_volatile( b, 0u8 ); }
}
compiler_fence( Ordering::SeqCst );
}
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ test ]
fn empty_slice_is_a_noop()
{
let mut buf: [u8; 0] = [];
secure_zero( &mut buf );
// Nothing to assert beyond "did not panic" — exercised so the
// fence + zero-iter loop compiles for the empty case.
}
#[ test ]
fn fills_every_byte_with_zero()
{
let mut buf = [ 0xAAu8; 64 ];
secure_zero( &mut buf );
assert!( buf.iter().all( |&b| b == 0 ) );
}
#[ test ]
fn wipes_a_credential_string_in_place()
{
let mut password = String::from( "hunter2" );
// SAFETY: as_mut_vec lets us reach the underlying byte buffer.
// We only write zeros, leaving an empty / NUL-filled UTF-8 byte
// sequence which is still valid UTF-8 (NUL is U+0000).
let bytes = unsafe { password.as_mut_vec() };
secure_zero( bytes );
assert!( bytes.iter().all( |&b| b == 0 ) );
// After the wipe the String is technically all NULs, not empty;
// the consumer drops it immediately so the heap allocation is
// returned to the allocator already overwritten.
assert_eq!( password.len(), 7 );
assert!( password.bytes().all( |b| b == 0 ) );
}
}

133
src/system_fonts.rs Normal file
View File

@@ -0,0 +1,133 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Lazy per-glyph fallback font resolution.
//!
//! The default font ([`crate::theme::fallback::FALLBACK_FONT`], Sora)
//! covers Latin and a portion of extended Latin; everything outside
//! that band — Cyrillic, Devanagari, Arabic, Hebrew, Thai, the CJK
//! ideographic block — relies on a chain of system Noto fonts loaded
//! on demand: a fallback file is read off disk the first time some
//! glyph asks for it and the resulting `Arc<Font>` is cached
//! process-wide.
//!
//! Each `(path, face)` entry in [`FALLBACK_FONT_CANDIDATES`] gets its
//! own `OnceLock<Option<Arc<Font>>>` slot. `Some(font)` means the
//! file was read and parsed successfully; `None` means it's missing
//! or unparseable, locked in for the rest of the process so we don't
//! re-`stat` the same path on every glyph miss. The `OnceLock`
//! handles concurrent first-loads correctly: at most one thread runs
//! the closure for a given slot, the rest wait for the result.
//!
//! Renderers (`SoftwareCanvas` and `GlesCanvas`) consult this module
//! through [`lookup`] in their `font_for_char` paths.
use std::sync::{ Arc, OnceLock };
use fontdue::{ Font, FontSettings };
/// Per-script fallback font path with the TrueType-collection face
/// index fontdue should load (most files are single-face → 0; CJK
/// `.ttc` archives carry many faces, see the SC face on the canonical
/// Adobe-built `NotoSansCJK-Regular.ttc`).
struct FallbackFontSpec
{
path: &'static str,
face: u32,
}
/// Ordered fallback chain consulted on a glyph miss. Order matters:
/// the first slot whose font owns a non-zero glyph index for the
/// codepoint wins, so the broadest families come first (Noto Sans
/// covers Cyrillic, Greek, extended Latin) and the script-specific
/// + CJK packs trail. DejaVu is the last resort — most distros carry
/// it under one path or another.
const FALLBACK_FONT_CANDIDATES: &[ FallbackFontSpec ] =
&[
// Noto Sans — Cyrillic, Greek, extended Latin.
FallbackFontSpec { path: "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", face: 0 },
FallbackFontSpec { path: "/usr/share/fonts/noto/NotoSans-Regular.ttf", face: 0 },
// Devanagari (Hindi).
FallbackFontSpec { path: "/usr/share/fonts/truetype/noto/NotoSansDevanagari-Regular.ttf", face: 0 },
FallbackFontSpec { path: "/usr/share/fonts/noto/NotoSansDevanagari-Regular.ttf", face: 0 },
// Arabic, Hebrew, Thai — common scripts cheap to keep.
FallbackFontSpec { path: "/usr/share/fonts/truetype/noto/NotoSansArabic-Regular.ttf", face: 0 },
FallbackFontSpec { path: "/usr/share/fonts/truetype/noto/NotoSansHebrew-Regular.ttf", face: 0 },
FallbackFontSpec { path: "/usr/share/fonts/truetype/noto/NotoSansThai-Regular.ttf", face: 0 },
// CJK packs ship as `.ttc`. The collection layout on the canonical
// Adobe-built `NotoSansCJK-Regular.ttc` is 0=JP, 1=KR, 2=SC, 3=TC,
// 4=HK; we want CJK shared ideographs available in any locale, so
// any of the faces is fine — pick SC (face 2) as the broadest
// baseline. ~30 MB on disk; under the lazy loader this only fires
// when a user-visible string actually contains a CJK codepoint.
FallbackFontSpec { path: "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", face: 2 },
FallbackFontSpec { path: "/usr/share/fonts/opentype/noto-cjk/NotoSansCJK-Regular.ttc", face: 2 },
FallbackFontSpec { path: "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", face: 2 },
// Last resort — DejaVu has broad-but-shallow coverage of most
// scripts and ships almost everywhere.
FallbackFontSpec { path: "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", face: 0 },
];
/// Per-slot lazy state. `Vec` length matches
/// [`FALLBACK_FONT_CANDIDATES`]; each `OnceLock` resolves
/// independently so the act of looking up a Devanagari codepoint
/// doesn't drag the CJK pack into memory. `None` inside a resolved
/// slot means the file was missing or fontdue rejected it — a sticky
/// negative result so subsequent misses skip the slot in O(1).
fn slots() -> &'static [ OnceLock<Option<Arc<Font>>> ]
{
static SLOTS: OnceLock<Vec<OnceLock<Option<Arc<Font>>>>> = OnceLock::new();
SLOTS.get_or_init( ||
{
( 0..FALLBACK_FONT_CANDIDATES.len() )
.map( |_| OnceLock::new() )
.collect()
} )
}
/// Try to load and parse the fallback font at slot `idx`. Each
/// `OnceLock` wraps `Option<Arc<Font>>` so a missing or malformed
/// file is recorded as `None` and never re-attempted.
fn slot_font( idx: usize ) -> Option<Arc<Font>>
{
let slot = &slots()[ idx ];
slot.get_or_init( ||
{
let spec = &FALLBACK_FONT_CANDIDATES[ idx ];
let bytes = std::fs::read( spec.path ).ok()?;
let opts = FontSettings { collection_index: spec.face, ..FontSettings::default() };
Font::from_bytes( bytes.as_slice(), opts ).ok().map( Arc::new )
} )
.as_ref()
.map( Arc::clone )
}
/// Find the first fallback font that has a non-zero glyph index for
/// `ch`, loading it from disk on the first hit and caching the
/// `Arc<Font>` for the rest of the process. Returns `None` if no
/// installed fallback covers the codepoint — the caller then paints
/// the primary font's `.notdef` rather than dropping the glyph.
///
/// Side effect: walking the chain may load and cache a slot even if
/// it doesn't end up covering `ch` (`lookup_glyph_index` reads the
/// `cmap` table, which requires the font to be parsed). That's
/// acceptable — the slot is cached on the first encounter regardless,
/// and most coverage gaps in early slots are the small Noto Sans
/// scripts (Devanagari, Arabic, …) whose total weight is a fraction
/// of the CJK pack everyone was paying for unconditionally.
pub fn lookup( ch: char ) -> Option<Arc<Font>>
{
for idx in 0..FALLBACK_FONT_CANDIDATES.len()
{
let Some( font ) = slot_font( idx ) else { continue };
if font.lookup_glyph_index( ch ) != 0
{
return Some( font );
}
}
None
}

111
src/theme/document.rs Normal file
View File

@@ -0,0 +1,111 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! The top-level theme document: metadata, fonts block and per-mode slots.
//!
//! This is the runtime representation of a fully parsed `theme.json`,
//! and the single source of truth for the active theme — installed via
//! [`super::set_active_document`] and read back via
//! [`super::active_document`].
//!
//! # Modes
//!
//! `fonts` is shared between light and dark and only what actually differs
//! — slot tables, wallpaper paths, optional window-controls overrides — is
//! nested under `modes.light` / `modes.dark`.
use std::collections::HashMap;
use std::path::{ Path, PathBuf };
use super::fonts::FontFamilyDef;
use super::slots::SlotStore;
use super::{ search_paths, LauncherSpec, ThemeError, WallpaperSpec, WindowControlsSpec };
// ─── Mode ────────────────────────────────────────────────────────────────────
/// Per-variant content: the slot table plus the surfaces that don't fit
/// cleanly in slots (wallpaper image, window-controls payload).
#[ derive( Debug, Clone ) ]
pub struct Mode
{
/// Homescreen / shell wallpaper. Absent means "solid background, no
/// image" (the renderer falls back to whatever the theme's surface slot
/// resolves to for the viewport).
pub wallpaper: Option<WallpaperSpec>,
/// Lockscreen / greeter wallpaper. Distinct from `wallpaper` because the
/// lockscreen is typically quieter (often a darker crop).
pub lockscreen: Option<WallpaperSpec>,
/// Launcher surface styling. Aggregate (background + border_radius)
/// rather than a slot because the radius is a scalar that widgets treat
/// as theme-imposed geometry, not a design-system colour token.
pub launcher: Option<LauncherSpec>,
/// Window-decoration controls payload. Kept out of the slot table
/// because it is a contract with an external app, not a
/// design-system token.
pub window_controls: Option<WindowControlsSpec>,
/// The typed slot table for this mode.
pub slots: SlotStore,
}
// ─── ThemeDocument ───────────────────────────────────────────────────────────
/// A fully parsed theme, as loaded from a `theme.json` on disk.
#[ derive( Debug, Clone ) ]
pub struct ThemeDocument
{
/// Stable identifier used to look up the theme across search paths.
pub id: String,
/// Human-readable display name.
pub name: String,
/// Directory the document was loaded from. `None` for documents built
/// in-memory (e.g. test fixtures).
pub root: Option<PathBuf>,
/// Font families declared in the document. Indexed by the id used by
/// [`super::FontRef::Named`]. The same registry is shared between modes.
pub fonts: HashMap<String, FontFamilyDef>,
/// Light-mode content.
pub light: Mode,
/// Dark-mode content.
pub dark: Mode,
}
impl ThemeDocument
{
/// Return the mode matching `mode`.
pub fn mode( &self, mode: super::ThemeMode ) -> &Mode
{
match mode
{
super::ThemeMode::Light => &self.light,
super::ThemeMode::Dark => &self.dark,
}
}
/// Load a document from a directory containing a `theme.json`.
///
/// Paths inside the document (wallpaper, lockscreen, font sources) are
/// resolved against `dir`.
pub fn load_from_dir( dir: &Path ) -> Result<Self, ThemeError>
{
super::schema::load_document_from_dir( dir )
}
/// Look up a document by id across the standard search paths.
///
/// Order, highest priority first:
/// 1. `$LTK_THEMES_DIR/<id>/` (when the env var is set)
/// 2. `$XDG_DATA_HOME/ltk/themes/<id>/` (defaults to `~/.local/share/...`)
/// 3. `/usr/share/ltk/themes/<id>/`
pub fn find( id: &str ) -> Result<Self, ThemeError>
{
for base in search_paths()
{
let dir = base.join( id );
if dir.join( "theme.json" ).is_file()
{
return Self::load_from_dir( &dir );
}
}
Err( ThemeError::NotFound( id.to_string() ) )
}
}

View File

@@ -0,0 +1,134 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: sora
Upstream-Contact: The Sora Project Authors
Files-Excluded: docs fonts/*
Source: https://github.com/sora-xor/sora-font
Files: *
Copyright: 2019-2020 The Sora Project Authors
2020 Jonathan Barnbrook <jb@barnbrook.net>
2020 Julián Moncada <julian@moncada.work>
License: OFL-1.1
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
.
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
.
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
.
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
.
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Files: sources/sora-stat-table.py
Copyright: 2020 Google Sans Authors
License: Apache-2.0
Licensed under the Apache License, Version 2.0 (the "License"); you
may not use this file except in compliance with the License. You may
obtain a copy of the License at
.
http://www.apache.org/licenses/LICENSE-2.0
.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied. See the License for the specific language governing
permissions and limitations under the License.
.
On Debian systems, the complete text of the Apache version 2.0
license can be found in "/usr/share/common-licenses/Apache-2.0".
Files: debian/*
Copyright: 2020-2024 Alex Myczko <tar@debian.org>
License: GPL-2+
This package is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
.
This package is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>
.
On Debian systems, the complete text of the GNU General
Public License version 2 can be found in "/usr/share/common-licenses/GPL-2".

Binary file not shown.

112
src/theme/fallback/mod.rs Normal file
View File

@@ -0,0 +1,112 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! "Last resort" assets used when the canonical `default` theme or the
//! system font search chain come up empty. Two pieces:
//!
//! * [`document`] builds a black/white [`ThemeDocument`] with the
//! eight canonical palette slots populated — no wallpaper, no
//! launcher spec, no decorated surface slots. Widgets that look up
//! surface or shadow slots fall through to their flat-colour branch;
//! the UI is usable but not decorated.
//! * [`FALLBACK_FONT`] bundles Sora Regular (~50 KB, SIL OFL 1.1) so
//! `Canvas::new` / `Canvas::new_gles` can always load a font even
//! on systems without `fonts-sora`, `fonts-liberation` or
//! `fonts-dejavu`.
//!
//! Both are activated by [`super::ensure_active`] when
//! `ThemeDocument::find("default")` fails. The crate stamps every
//! frame with a red banner so the user can't miss the
//! "install `ltk-theme-default`" signal.
//!
//! # Font licence
//!
//! `Sora-Regular.otf` is distributed under the SIL Open Font Licence
//! 1.1. The full licence text is in
//! [`Sora-LICENSE.txt`](./Sora-LICENSE.txt) next to this source
//! file. A binary-only redistribution must carry the OFL text
//! alongside — see the crate's `debian/copyright`.
use std::collections::HashMap;
use crate::types::Color;
use super::slots::{ Metadata, Slot, SlotStore };
use super::{ Mode, ThemeDocument };
/// Sora Regular, OFL 1.1. Loaded by `Canvas::new` / `Canvas::new_gles`
/// when no system font is found through the candidate chain. ~50 KB,
/// one weight, one style — enough to render the fallback banner and
/// any app that only asks for default typography.
pub( crate ) const FALLBACK_FONT: &[u8] = include_bytes!( "Sora-Regular.otf" );
/// Build the B/W fallback document. Eight canonical palette slots per
/// mode (`bg-page`, `surface`, `surface-alt`, `text-primary`,
/// `text-secondary`, `accent`, `divider`, `icon`), no wallpaper, no
/// launcher, no window_controls, no fonts registered (the default
/// canvas font is used for every widget).
///
/// Light mode: pure white backgrounds, pure black text + accent.
/// Dark mode: pure black backgrounds, pure white text + accent.
/// `accent` doubles as brand colour and focus-ring colour; setting
/// it to black/white keeps the fallback visually consistent with the
/// rest of the palette and gives the focus ring maximum contrast.
pub( super ) fn document() -> ThemeDocument
{
ThemeDocument
{
id: "fallback".to_string(),
name: "Fallback (B/W)".to_string(),
root: None,
fonts: HashMap::new(),
light: light_mode(),
dark: dark_mode(),
}
}
fn mk_color_slot( c: Color ) -> Slot
{
Slot::Color { value: c, meta: Metadata::default() }
}
fn light_mode() -> Mode
{
let mut slots = SlotStore::new();
slots.insert( "bg-page", mk_color_slot( Color::WHITE ) );
slots.insert( "surface", mk_color_slot( Color::WHITE ) );
slots.insert( "surface-alt", mk_color_slot( Color::rgb( 0.95, 0.95, 0.95 ) ) );
slots.insert( "text-primary", mk_color_slot( Color::BLACK ) );
slots.insert( "text-secondary", mk_color_slot( Color::rgba( 0.0, 0.0, 0.0, 0.6 ) ) );
slots.insert( "accent", mk_color_slot( Color::BLACK ) );
slots.insert( "divider", mk_color_slot( Color::rgba( 0.0, 0.0, 0.0, 0.1 ) ) );
slots.insert( "icon", mk_color_slot( Color::BLACK ) );
Mode
{
wallpaper: None,
lockscreen: None,
launcher: None,
window_controls: None,
slots,
}
}
fn dark_mode() -> Mode
{
let mut slots = SlotStore::new();
slots.insert( "bg-page", mk_color_slot( Color::BLACK ) );
slots.insert( "surface", mk_color_slot( Color::BLACK ) );
slots.insert( "surface-alt", mk_color_slot( Color::rgb( 0.1, 0.1, 0.1 ) ) );
slots.insert( "text-primary", mk_color_slot( Color::WHITE ) );
slots.insert( "text-secondary", mk_color_slot( Color::rgba( 1.0, 1.0, 1.0, 0.6 ) ) );
slots.insert( "accent", mk_color_slot( Color::WHITE ) );
slots.insert( "divider", mk_color_slot( Color::rgba( 1.0, 1.0, 1.0, 0.1 ) ) );
slots.insert( "icon", mk_color_slot( Color::WHITE ) );
Mode
{
wallpaper: None,
lockscreen: None,
launcher: None,
window_controls: None,
slots,
}
}

357
src/theme/font_registry.rs Normal file
View File

@@ -0,0 +1,357 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Runtime font registry: families → (weight, style) → `fontdue::Font`.
//!
//! The registry is the live, loaded-in-memory counterpart of the theme's
//! `fonts` block ([`super::FontFamilyDef`]). It is built once at theme load
//! time by calling [`FontRegistry::from_families`], then handed to the
//! render backends as an `Arc<FontRegistry>` so they can resolve specific
//! sources on demand without re-reading TTFs from disk.
//!
//! # Resolution order
//!
//! [`FontRegistry::resolve`] looks up a family / weight / style triple with
//! the following precedence:
//!
//! 1. Exact `(family, weight, style)` match.
//! 2. Same family and style, weight closest to the request (absolute diff).
//! 3. Same family, any style, weight closest to the request.
//! 4. Walk the family's `fallbacks` chain, recursing into each fallback
//! family with the original weight/style.
//!
//! When nothing matches, [`FontRegistry::resolve`] returns `None`; callers
//! fall back to the canvas' system font.
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use fontdue::{ Font, FontSettings };
use super::fonts::FontFamilyDef;
use super::text_style::FontStyle;
// ─── Key ─────────────────────────────────────────────────────────────────────
/// Composite key identifying a single font source within a family.
///
/// `family` is the id under which the family was declared in the theme's
/// `fonts` block (e.g. `"sora"`), not its human-readable name.
#[ derive( Debug, Clone, PartialEq, Eq, Hash ) ]
pub struct FontKey
{
pub family: String,
pub weight: u16,
pub style: FontStyle,
}
// ─── Errors ──────────────────────────────────────────────────────────────────
/// Error raised when loading a font source fails.
#[ derive( Debug ) ]
pub enum FontLoadError
{
/// The source file could not be read.
Io( PathBuf, std::io::Error ),
/// The source file was read but `fontdue` rejected its contents.
Parse( PathBuf, String ),
}
impl std::fmt::Display for FontLoadError
{
fn fmt( &self, f: &mut std::fmt::Formatter<'_> ) -> std::fmt::Result
{
match self
{
FontLoadError::Io( p, e ) => write!( f, "reading font {}: {}", p.display(), e ),
FontLoadError::Parse( p, m ) => write!( f, "parsing font {}: {}", p.display(), m ),
}
}
}
impl std::error::Error for FontLoadError {}
// ─── Registry ────────────────────────────────────────────────────────────────
/// A loaded font registry: the theme's declared families materialised into
/// live `fontdue::Font` handles, indexed by family id / weight / style.
#[ derive( Debug, Default ) ]
pub struct FontRegistry
{
by_key: HashMap<FontKey, Arc<Font>>,
fallbacks: HashMap<String, Vec<String>>,
}
impl FontRegistry
{
/// Create an empty registry. Use [`Self::insert`] / [`Self::set_fallbacks`]
/// to populate it, or [`Self::from_families`] to load a whole theme in
/// one go.
pub fn new() -> Self
{
Self { by_key: HashMap::new(), fallbacks: HashMap::new() }
}
/// Register a single font source.
pub fn insert
(
&mut self,
family: impl Into<String>,
weight: u16,
style: FontStyle,
font: Arc<Font>,
)
{
self.by_key.insert( FontKey { family: family.into(), weight, style }, font );
}
/// Set the fallback chain for a family.
pub fn set_fallbacks( &mut self, family: impl Into<String>, chain: Vec<String> )
{
self.fallbacks.insert( family.into(), chain );
}
/// Number of loaded sources across all families. Useful in tests.
pub fn len( &self ) -> usize { self.by_key.len() }
/// Whether the registry has no sources loaded.
pub fn is_empty( &self ) -> bool { self.by_key.is_empty() }
/// Resolve a triple to a loaded [`Font`]. See the module docs for the
/// precedence order.
pub fn resolve( &self, family: &str, weight: u16, style: FontStyle ) -> Option<Arc<Font>>
{
// 1. Exact match.
let exact = FontKey { family: family.to_string(), weight, style };
if let Some( f ) = self.by_key.get( &exact )
{
return Some( Arc::clone( f ) );
}
// 2. Same family + style, closest weight.
let best_same_style = self.by_key.iter()
.filter( |( k, _ )| k.family == family && k.style == style )
.min_by_key( |( k, _ )| (k.weight as i32 - weight as i32).abs() );
if let Some( ( _, f ) ) = best_same_style
{
return Some( Arc::clone( f ) );
}
// 3. Same family, any style, closest weight.
let best_same_family = self.by_key.iter()
.filter( |( k, _ )| k.family == family )
.min_by_key( |( k, _ )| (k.weight as i32 - weight as i32).abs() );
if let Some( ( _, f ) ) = best_same_family
{
return Some( Arc::clone( f ) );
}
// 4. Walk fallback chain.
if let Some( chain ) = self.fallbacks.get( family )
{
for fb in chain
{
if let Some( f ) = self.resolve( fb, weight, style )
{
return Some( f );
}
}
}
None
}
/// Load every source declared in `families` into the registry.
///
/// `families` is keyed by family id (the string the theme JSON uses in
/// `fonts.<id>` and that [`super::FontRef::Named`] references). The
/// family's `name` field is carried for human display only; lookups go
/// through the id.
pub fn from_families
(
families: &HashMap<String, FontFamilyDef>,
) -> Result<Self, FontLoadError>
{
let mut reg = Self::new();
for ( id, family ) in families
{
if !family.fallbacks.is_empty()
{
reg.set_fallbacks( id.clone(), family.fallbacks.clone() );
}
for src in &family.sources
{
let bytes = std::fs::read( &src.path )
.map_err( |e| FontLoadError::Io( src.path.clone(), e ) )?;
let font = Font::from_bytes( bytes.as_slice(), FontSettings::default() )
.map_err( |e| FontLoadError::Parse( src.path.clone(), e.to_string() ) )?;
reg.insert( id.clone(), src.weight, src.style, Arc::new( font ) );
}
}
Ok( reg )
}
/// Like [`Self::from_families`] but tolerant: sources that fail to load
/// are logged via `eprintln!` and skipped instead of aborting the whole
/// registry build. Intended for the runtime path where a missing TTF
/// on a user's machine should degrade to "fall back to system font for
/// that weight" rather than crash the shell.
///
/// Returns a registry that may be partial — callers cannot assume any
/// specific weight is loaded, only that what COULD be loaded is
/// loaded. Font resolution's fallback ladder (family → closest weight
/// → fallback chain → `Canvas::font`) handles the gaps gracefully.
pub fn from_families_lenient
(
families: &HashMap<String, FontFamilyDef>,
) -> Self
{
let mut reg = Self::new();
for ( id, family ) in families
{
if !family.fallbacks.is_empty()
{
reg.set_fallbacks( id.clone(), family.fallbacks.clone() );
}
for src in &family.sources
{
let bytes = match std::fs::read( &src.path )
{
Ok( b ) => b,
Err( e ) =>
{
eprintln!
(
"[ltk] skipping font {} (weight {}, {:?}): {}",
src.path.display(), src.weight, src.style, e
);
continue;
}
};
let font = match Font::from_bytes( bytes.as_slice(), FontSettings::default() )
{
Ok( f ) => f,
Err( e ) =>
{
eprintln!
(
"[ltk] skipping font {} (weight {}, {:?}): parse error: {}",
src.path.display(), src.weight, src.style, e
);
continue;
}
};
reg.insert( id.clone(), src.weight, src.style, Arc::new( font ) );
}
}
reg
}
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[ cfg( test ) ]
mod tests
{
use super::*;
/// Load a real TTF from the system via the same search chain the
/// software canvas uses. Only used by tests that need an actual
/// `Font` — skipped when no font is found (keeps CI green on images
/// without the usual system fonts).
fn system_font() -> Option<Arc<Font>>
{
let path = crate::render::helpers::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 ) )
}
#[ test ]
fn empty_registry_resolves_to_none()
{
let reg = FontRegistry::new();
assert!( reg.resolve( "sora", 400, FontStyle::Normal ).is_none() );
assert!( reg.is_empty() );
}
#[ test ]
fn exact_match_wins()
{
let Some( font ) = system_font() else { return; };
let mut reg = FontRegistry::new();
reg.insert( "sora", 400, FontStyle::Normal, Arc::clone( &font ) );
reg.insert( "sora", 700, FontStyle::Normal, Arc::clone( &font ) );
assert!( reg.resolve( "sora", 400, FontStyle::Normal ).is_some() );
assert!( reg.resolve( "sora", 700, FontStyle::Normal ).is_some() );
assert_eq!( reg.len(), 2 );
}
#[ test ]
fn closest_weight_is_picked_when_exact_missing()
{
let Some( font ) = system_font() else { return; };
let mut reg = FontRegistry::new();
reg.insert( "sora", 300, FontStyle::Normal, Arc::clone( &font ) );
reg.insert( "sora", 700, FontStyle::Normal, Arc::clone( &font ) );
// Ask for 400: closer to 300 than to 700. Both rounds 100 away or
// 300 away respectively; 300 wins.
assert!( reg.resolve( "sora", 400, FontStyle::Normal ).is_some() );
// Ask for 800: closer to 700.
assert!( reg.resolve( "sora", 800, FontStyle::Normal ).is_some() );
}
#[ test ]
fn fallback_chain_is_walked_when_family_unknown()
{
let Some( font ) = system_font() else { return; };
let mut reg = FontRegistry::new();
reg.insert( "sora", 400, FontStyle::Normal, Arc::clone( &font ) );
reg.set_fallbacks( "display", vec![ "sora".to_string() ] );
// `display` has no direct entries, but its fallback chain leads to
// `sora` which is loaded.
assert!( reg.resolve( "display", 400, FontStyle::Normal ).is_some() );
}
#[ test ]
fn unreachable_family_returns_none()
{
let Some( font ) = system_font() else { return; };
let mut reg = FontRegistry::new();
reg.insert( "sora", 400, FontStyle::Normal, font );
assert!( reg.resolve( "roboto", 400, FontStyle::Normal ).is_none() );
}
#[ test ]
fn from_families_reports_io_error_for_missing_path()
{
use crate::theme::fonts::FontSource;
let mut fams = HashMap::new();
fams.insert( "sora".to_string(), FontFamilyDef
{
name: "Sora".to_string(),
fallbacks: Vec::new(),
sources: vec!
[
FontSource
{
weight: 400,
style: FontStyle::Normal,
path: "/this/path/does/not/exist.ttf".into(),
},
],
});
match FontRegistry::from_families( &fams )
{
Err( FontLoadError::Io( p, _ ) ) =>
{
assert!( p.to_string_lossy().contains( "does/not/exist" ) );
}
other => panic!( "expected Io error, got {:?}", other ),
}
}
}

77
src/theme/fonts.rs Normal file
View File

@@ -0,0 +1,77 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Font family definitions as declared by the theme document.
//!
//! This module only models the **declaration**: a family name, a set of
//! source files indexed by weight/style, and a fallback chain. Loading the
//! `.ttf` bytes into `fontdue` and resolving text styles against a live
//! registry is the runtime registry's job (see [`super::FontRegistry`]).
use std::path::PathBuf;
use super::text_style::FontStyle;
// ─── FontFamilyDef ───────────────────────────────────────────────────────────
/// A font family as declared by the theme document.
///
/// `sources` is a flat list indexed by weight + style, not a nested map, so
/// the JSON round-trips without ambiguity on ordering.
#[ derive( Debug, Clone, PartialEq ) ]
pub struct FontFamilyDef
{
/// Human-readable family name (e.g. `"Sora"`). Shown in telemetry and
/// in any eventual font-picker UI.
pub name: String,
/// Fallback chain if the family or a given weight/style is missing.
/// Resolved in order: the first entry that the font registry can
/// satisfy is used.
pub fallbacks: Vec<String>,
/// One source per weight/style the family ships. The runtime font
/// registry registers each source in `fontdue` at load time.
pub sources: Vec<FontSource>,
}
// ─── FontSource ──────────────────────────────────────────────────────────────
/// A single font file, specified by its numeric weight and style.
#[ derive( Debug, Clone, PartialEq, Eq ) ]
pub struct FontSource
{
/// CSS numeric weight (100..=900).
pub weight: u16,
/// Italic vs upright.
pub style: FontStyle,
/// Path to the `.ttf` / `.otf` file. Resolved to absolute at load time
/// (relative paths are taken to be relative to the theme directory root).
pub path: PathBuf,
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ test ]
fn family_def_holds_multiple_weights_of_same_family()
{
let sora = FontFamilyDef
{
name: "Sora".to_string(),
fallbacks: vec![ "system-ui".to_string(), "sans-serif".to_string() ],
sources: vec!
[
FontSource { weight: 300, style: FontStyle::Normal, path: "fonts/Sora-Light.ttf".into() },
FontSource { weight: 400, style: FontStyle::Normal, path: "fonts/Sora-Regular.ttf".into() },
FontSource { weight: 600, style: FontStyle::Normal, path: "fonts/Sora-SemiBold.ttf".into() },
FontSource { weight: 700, style: FontStyle::Normal, path: "fonts/Sora-Bold.ttf".into() },
],
};
assert_eq!( sora.sources.len(), 4 );
assert_eq!( sora.sources[2].weight, 600 );
assert_eq!( sora.fallbacks[0], "system-ui" );
}
}

304
src/theme/gradient_lut.rs Normal file
View File

@@ -0,0 +1,304 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! CPU-side gradient sampling and LUT baking.
//!
//! The GPU gradient path in `gles_render` shaders samples a 1D lookup
//! texture baked on the CPU: for each gradient we precompute N equally
//! spaced samples across an extended `t` domain (so stops outside
//! `[0, 1]` are covered without extra shader logic), already colour-space
//! converted, and upload those N × 4 bytes as an RGBA8 texture.
//!
//! # Extrapolation
//!
//! Stops whose `position` falls outside `[0, 1]` are supported by
//! **linear extrapolation**: below the first stop we prolong the
//! `(first, second)` slope, above the last stop we prolong the
//! `(last-1, last)` slope. Values are not clamped. This is the
//! physically correct behaviour for CSS `linear-gradient` stops defined
//! with positions outside the visible range.
//!
//! # Colour spaces
//!
//! [`GradientSpace::Srgb`] interpolates raw sRGB channels (cheap, looks
//! muddy on saturated gradients). [`GradientSpace::LinearRgb`] — the
//! default — converts each stop to linear light, interpolates, and
//! converts the result back to sRGB. [`GradientSpace::Oklab`] is not yet
//! implemented and silently falls back to `LinearRgb`.
use crate::types::Color;
use super::paint::{ ColorStop, GradientSpace };
/// How many samples the LUT stores along the `t` axis.
pub const LUT_SAMPLES: usize = 512;
/// Extended `t` domain the LUT covers. Wide enough to comfortably contain
/// the typical out-of-`[0, 1]` stops produced by design-tool exports.
pub const LUT_DOMAIN: ( f32, f32 ) = ( -2.0, 3.0 );
// ─── sRGB ↔ linear ───────────────────────────────────────────────────────────
/// Convert one sRGB gamma-encoded channel to linear light.
#[ inline ]
pub fn srgb_to_linear( x: f32 ) -> f32
{
if x <= 0.04045 { x / 12.92 } else { ((x + 0.055) / 1.055).powf( 2.4 ) }
}
/// Convert one linear-light channel to sRGB gamma-encoded.
#[ inline ]
pub fn linear_to_srgb( x: f32 ) -> f32
{
if x <= 0.0031308 { x * 12.92 } else { 1.055 * x.powf( 1.0 / 2.4 ) - 0.055 }
}
// ─── Sampling ────────────────────────────────────────────────────────────────
/// Sample the stops at position `t` using the requested interpolation
/// space. Stops need not be sorted. `t` may fall outside `[0, 1]`.
pub fn sample_stops( stops: &[ColorStop], t: f32, space: GradientSpace ) -> Color
{
if stops.is_empty() { return Color::TRANSPARENT; }
if stops.len() == 1 { return stops[0].color; }
// Sort by position. We do this each call because gradients hold at
// most a handful of stops and the LUT builder only calls us N times
// per gradient build (not per pixel).
let mut sorted: Vec<&ColorStop> = stops.iter().collect();
sorted.sort_by( |a, b|
a.position.partial_cmp( &b.position ).unwrap_or( std::cmp::Ordering::Equal )
);
// Pick the bracketing pair. Below the first stop we extrapolate from
// `(first, second)`; above the last, from `(last-1, last)`.
let n = sorted.len();
let ( a, b ) = if t <= sorted[0].position
{
( sorted[0], sorted[1] )
}
else if t >= sorted[n - 1].position
{
( sorted[n - 2], sorted[n - 1] )
}
else
{
let mut pair = ( sorted[0], sorted[1] );
for win in sorted.windows( 2 )
{
if t >= win[0].position && t <= win[1].position
{
pair = ( win[0], win[1] );
break;
}
}
pair
};
let dt = b.position - a.position;
let u = if dt.abs() < 1e-6 { 0.0 } else { ( t - a.position ) / dt };
mix_colors( a.color, b.color, u, space )
}
/// Linear mix of two colours at parameter `u` (not clamped — the caller
/// has already chosen the right bracketing pair).
fn mix_colors( a: Color, b: Color, u: f32, space: GradientSpace ) -> Color
{
let alpha = a.a + ( b.a - a.a ) * u;
match space
{
GradientSpace::Srgb => Color
{
r: a.r + ( b.r - a.r ) * u,
g: a.g + ( b.g - a.g ) * u,
b: a.b + ( b.b - a.b ) * u,
a: alpha,
},
GradientSpace::LinearRgb => mix_linear( a, b, u, alpha ),
// TODO: proper Oklab mix. For now fall back to linear-light.
GradientSpace::Oklab => mix_linear( a, b, u, alpha ),
}
}
fn mix_linear( a: Color, b: Color, u: f32, alpha: f32 ) -> Color
{
let ar = srgb_to_linear( a.r );
let ag = srgb_to_linear( a.g );
let ab = srgb_to_linear( a.b );
let br = srgb_to_linear( b.r );
let bg = srgb_to_linear( b.g );
let bb = srgb_to_linear( b.b );
let r = linear_to_srgb( ar + ( br - ar ) * u );
let g = linear_to_srgb( ag + ( bg - ag ) * u );
let b = linear_to_srgb( ab + ( bb - ab ) * u );
Color { r, g, b, a: alpha }
}
// ─── LUT baking ──────────────────────────────────────────────────────────────
/// Build an RGBA8 LUT of `LUT_SAMPLES` equally spaced samples spanning
/// [`LUT_DOMAIN`]. The returned vector has `LUT_SAMPLES * 4` bytes in
/// straight-alpha, row-major. The GPU shader expects this layout and
/// premultiplies at sample time.
pub fn build_lut_bytes( stops: &[ColorStop], space: GradientSpace ) -> Vec<u8>
{
let ( t0, t1 ) = LUT_DOMAIN;
let n = LUT_SAMPLES;
let mut out = Vec::with_capacity( n * 4 );
for i in 0..n
{
let t = t0 + ( t1 - t0 ) * ( i as f32 / ( n - 1 ) as f32 );
let c = sample_stops( stops, t, space );
out.push( ( c.r.clamp( 0.0, 1.0 ) * 255.0 + 0.5 ) as u8 );
out.push( ( c.g.clamp( 0.0, 1.0 ) * 255.0 + 0.5 ) as u8 );
out.push( ( c.b.clamp( 0.0, 1.0 ) * 255.0 + 0.5 ) as u8 );
out.push( ( c.a.clamp( 0.0, 1.0 ) * 255.0 + 0.5 ) as u8 );
}
out
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[ cfg( test ) ]
mod tests
{
use super::*;
fn approx( a: f32, b: f32 ) -> bool { ( a - b ).abs() < 2e-2 }
#[ test ]
fn srgb_linear_roundtrip_is_stable()
{
for &v in &[ 0.0, 0.04, 0.1, 0.25, 0.5, 0.75, 1.0 ]
{
let back = linear_to_srgb( srgb_to_linear( v ) );
assert!( ( v - back ).abs() < 1e-5, "roundtrip {} -> {}", v, back );
}
}
#[ test ]
fn sample_at_exact_stop_returns_that_stop()
{
let stops = vec!
[
ColorStop { position: 0.0, color: Color::WHITE },
ColorStop { position: 1.0, color: Color::BLACK },
];
let a = sample_stops( &stops, 0.0, GradientSpace::Srgb );
let b = sample_stops( &stops, 1.0, GradientSpace::Srgb );
assert_eq!( a, Color::WHITE );
assert_eq!( b, Color::BLACK );
}
#[ test ]
fn sample_midpoint_srgb_is_halfway()
{
let stops = vec!
[
ColorStop { position: 0.0, color: Color::rgb( 0.0, 0.0, 0.0 ) },
ColorStop { position: 1.0, color: Color::rgb( 1.0, 1.0, 1.0 ) },
];
let m = sample_stops( &stops, 0.5, GradientSpace::Srgb );
assert!( approx( m.r, 0.5 ) && approx( m.g, 0.5 ) && approx( m.b, 0.5 ) );
}
#[ test ]
fn sample_midpoint_linear_rgb_is_brighter_than_srgb()
{
// Classic demonstration that linear-light midpoint is brighter
// than the naive sRGB midpoint when interpolating 0 → 1.
let stops = vec!
[
ColorStop { position: 0.0, color: Color::rgb( 0.0, 0.0, 0.0 ) },
ColorStop { position: 1.0, color: Color::rgb( 1.0, 1.0, 1.0 ) },
];
let m_srgb = sample_stops( &stops, 0.5, GradientSpace::Srgb );
let m_linear = sample_stops( &stops, 0.5, GradientSpace::LinearRgb );
assert!( m_linear.r > m_srgb.r, "linear {:?} should be brighter than srgb {:?}", m_linear, m_srgb );
}
#[ test ]
fn extrapolation_below_first_stop_continues_slope()
{
// stops at 0.0 (white) and 1.0 (black). Extrapolating to -1.0
// along the same slope lands above 1.0 — we do NOT clamp; the
// LUT baker will clip when converting to u8.
let stops = vec!
[
ColorStop { position: 0.0, color: Color::WHITE },
ColorStop { position: 1.0, color: Color::BLACK },
];
let c = sample_stops( &stops, -1.0, GradientSpace::Srgb );
// Slope is (-1, -1, -1) per unit of t; at t=-1 we get rgb=(2,2,2).
assert!( c.r > 1.0, "extrapolation should exceed 1.0 before clamp: {}", c.r );
}
#[ test ]
fn extrapolation_above_last_stop_continues_slope()
{
let stops = vec!
[
ColorStop { position: 0.0, color: Color::rgb( 0.2, 0.2, 0.2 ) },
ColorStop { position: 1.0, color: Color::rgb( 0.8, 0.8, 0.8 ) },
];
let c = sample_stops( &stops, 2.0, GradientSpace::Srgb );
// Slope (0.6, 0.6, 0.6) per unit. At t=2.0, rgb=(1.4, …) — exceeds 1.0.
assert!( c.r > 1.0, "extrapolation should exceed 1.0: {}", c.r );
}
#[ test ]
fn alpha_mixes_linearly_across_spaces()
{
let stops = vec!
[
ColorStop { position: 0.0, color: Color::rgba( 1.0, 0.0, 0.0, 1.0 ) },
ColorStop { position: 1.0, color: Color::rgba( 1.0, 0.0, 0.0, 0.0 ) },
];
for space in [ GradientSpace::Srgb, GradientSpace::LinearRgb, GradientSpace::Oklab ]
{
let m = sample_stops( &stops, 0.5, space );
assert!( approx( m.a, 0.5 ), "alpha should mix linearly in {:?}: got {}", space, m.a );
}
}
#[ test ]
fn unsorted_stops_are_handled()
{
let stops = vec!
[
ColorStop { position: 1.0, color: Color::BLACK },
ColorStop { position: 0.0, color: Color::WHITE },
];
let a = sample_stops( &stops, 0.0, GradientSpace::Srgb );
let b = sample_stops( &stops, 1.0, GradientSpace::Srgb );
assert_eq!( a, Color::WHITE );
assert_eq!( b, Color::BLACK );
}
#[ test ]
fn build_lut_has_expected_length()
{
let stops = vec!
[
ColorStop { position: 0.0, color: Color::WHITE },
ColorStop { position: 1.0, color: Color::BLACK },
];
let bytes = build_lut_bytes( &stops, GradientSpace::LinearRgb );
assert_eq!( bytes.len(), LUT_SAMPLES * 4 );
}
#[ test ]
fn build_lut_clips_extrapolation_to_u8_range()
{
let stops = vec!
[
ColorStop { position: 0.0, color: Color::WHITE },
ColorStop { position: 1.0, color: Color::BLACK },
];
let bytes = build_lut_bytes( &stops, GradientSpace::Srgb );
// All bytes must be in [0, 255] — no panics from out-of-range casts.
for b in &bytes { let _ = *b; }
}
}

1103
src/theme/mod.rs Normal file

File diff suppressed because it is too large Load Diff

207
src/theme/paint.rs Normal file
View File

@@ -0,0 +1,207 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Paint primitives: the "what do we fill a shape with" side of theming.
//!
//! A [`Paint`] is either a flat [`Color`], a [`LinearGradient`] or a
//! [`RadialGradient`]. Gradients carry their colour stops, a direction (angle
//! for linear, center+radius for radial) and the [`GradientSpace`] in which
//! stops are interpolated. Sampling happens downstream in the renderer; these
//! types are pure data and have no rendering logic of their own.
//!
//! # Stop positions
//!
//! Stops are represented as fractions (`0.0..=1.0` for the caller's mental
//! model) but the [`ColorStop::position`] field accepts **values outside
//! that range**. This is intentional: design-tool exports often emit
//! gradients whose stops fall outside `[0, 1]`, meaning the visible region
//! of the shape only covers a middle slice of the interpolation. The
//! renderer is expected to extrapolate linearly, not clamp.
//!
//! # Colour space
//!
//! Picking the right interpolation space matters for saturated gradients:
//! interpolating `#04D9FE → #8A38F5` in sRGB produces a muddy grey in the
//! middle, while Oklab keeps the chroma. The space is resolved at theme-load
//! time (stops converted once), not per pixel.
use crate::types::Color;
// ─── Gradient space ──────────────────────────────────────────────────────────
/// Colour space in which a gradient's stops are interpolated.
///
/// The default is [`GradientSpace::LinearRgb`]: cheap, physically correct, and
/// a clear win over naive sRGB interpolation. [`GradientSpace::Oklab`] is kept
/// as an opt-in for brand gradients with high-chroma endpoints where even
/// linear-light shows an undesirable darkening in the mid-point. [`GradientSpace::Srgb`]
/// exists primarily to reproduce designs that were authored against it
/// byte-for-byte.
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
pub enum GradientSpace
{
/// Interpolate directly in sRGB gamma-encoded space. Cheap, but midpoints
/// of saturated gradients look muddy.
Srgb,
/// Interpolate in linear-light RGB. Default. Physically correct and fast.
LinearRgb,
/// Interpolate in the Oklab perceptual colour space. Best for saturated
/// brand gradients; slightly more expensive.
Oklab,
}
impl Default for GradientSpace
{
fn default() -> Self { GradientSpace::LinearRgb }
}
// ─── Colour stops ────────────────────────────────────────────────────────────
/// One stop of a gradient: a `position` along the gradient axis (for linear)
/// or along the radius (for radial), plus the [`Color`] at that point.
///
/// `position` is a fraction but **may fall outside `[0.0, 1.0]`**. See the
/// module-level note on extrapolation.
#[ derive( Debug, Clone, Copy, PartialEq ) ]
pub struct ColorStop
{
/// Position along the gradient. `0.0` is the start, `1.0` is the end;
/// values outside this range are allowed and will be extrapolated.
pub position: f32,
/// Colour at this position.
pub color: Color,
}
// ─── Linear gradient ─────────────────────────────────────────────────────────
/// A straight gradient swept along a vector set by an angle.
///
/// The angle convention follows CSS `linear-gradient`: `0deg` points from the
/// bottom edge towards the top, `90deg` from left to right, `180deg` from top
/// to bottom, and so on.
#[ derive( Debug, Clone, PartialEq ) ]
pub struct LinearGradient
{
/// Direction of the gradient, in degrees (CSS convention).
pub angle_deg: f32,
/// Stops along the axis, in source order. The renderer does not require
/// them to be sorted by position — it sorts internally — but keeping them
/// in increasing order is conventional and makes diffs readable.
pub stops: Vec<ColorStop>,
/// Space in which stops are interpolated. See [`GradientSpace`].
pub space: GradientSpace,
}
// ─── Radial gradient ─────────────────────────────────────────────────────────
/// A gradient radiating from a centre point out to a radius.
///
/// `center` and `radius` are expressed as **fractions of the bounding box**
/// (not pixels) so the gradient scales with the widget. `center: [0.5, 0.5]`
/// and `radius: 0.5` describes a circle inscribed in a square widget.
#[ derive( Debug, Clone, PartialEq ) ]
pub struct RadialGradient
{
/// Centre of the gradient in box-relative coordinates, `[0.0, 1.0]`.
pub center: [f32; 2],
/// Radius of the gradient in box-relative units. `0.5` reaches the edge
/// of a square box, `1.0` reaches the corner diagonally.
pub radius: f32,
/// Stops along the radius, in source order.
pub stops: Vec<ColorStop>,
/// Space in which stops are interpolated. See [`GradientSpace`].
pub space: GradientSpace,
}
// ─── Paint ───────────────────────────────────────────────────────────────────
/// How a shape is filled: a flat colour or one of the gradient variants.
///
/// Theming consumers rarely construct [`Paint`] directly: a slot of kind
/// `color` is promoted to [`Paint::Solid`] automatically when the widget asks
/// for a paint. Only slots that actually declare a gradient in the theme JSON
/// round-trip through [`Paint::Linear`] / [`Paint::Radial`].
#[ derive( Debug, Clone, PartialEq ) ]
pub enum Paint
{
/// A uniform fill with a single [`Color`].
Solid( Color ),
/// A linear gradient sweep.
Linear( LinearGradient ),
/// A radial gradient sweep.
Radial( RadialGradient ),
}
impl Paint
{
/// Convenience constructor for the common solid case.
pub fn solid( color: Color ) -> Self
{
Paint::Solid( color )
}
}
impl From<Color> for Paint
{
fn from( c: Color ) -> Self { Paint::Solid( c ) }
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ test ]
fn stops_positions_outside_unit_range_are_accepted()
{
// Construction must succeed; the renderer will extrapolate.
let grad = LinearGradient
{
angle_deg: 152.77,
stops: vec!
[
ColorStop { position: -1.1654, color: Color::hex( 0xFF, 0x93, 0xA9 ) },
ColorStop { position: 1.2332, color: Color::WHITE },
],
space: GradientSpace::LinearRgb,
};
assert_eq!( grad.stops.len(), 2 );
assert!( grad.stops[0].position < 0.0 );
assert!( grad.stops[1].position > 1.0 );
}
#[ test ]
fn default_gradient_space_is_linear_rgb()
{
assert_eq!( GradientSpace::default(), GradientSpace::LinearRgb );
}
#[ test ]
fn paint_promotes_from_color()
{
let c = Color::hex( 0x04, 0xD9, 0xFE );
let p: Paint = c.into();
assert_eq!( p, Paint::Solid( c ) );
}
#[ test ]
fn radial_gradient_uses_box_relative_coordinates()
{
// Documenting the contract: center + radius are fractions, not pixels.
let g = RadialGradient
{
center: [ 0.5, 0.5 ],
radius: 0.5,
stops: vec!
[
ColorStop { position: 0.0, color: Color::WHITE },
ColorStop { position: 1.0, color: Color::TRANSPARENT },
],
space: GradientSpace::LinearRgb,
};
assert_eq!( g.center, [ 0.5, 0.5 ] );
assert_eq!( g.radius, 0.5 );
}
}

1449
src/theme/schema.rs Normal file

File diff suppressed because it is too large Load Diff

193
src/theme/shadow.rs Normal file
View File

@@ -0,0 +1,193 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Shadow primitives: outer drop shadows, inner inset shadows, and the
//! blend modes used to composite them.
//!
//! # Units
//!
//! [`Shadow::blur`] and [`InsetShadow::blur`] store the **CSS blur radius**,
//! not the SVG `stdDeviation`. The relationship is `blur = 2 × stdDeviation`,
//! which is what browsers compute for `box-shadow: … blur …`. The shader
//! integrates against `sigma`, so it applies `sigma = blur / 2` internally
//! (see [`Shadow::sigma`]).
//!
//! # Order
//!
//! A theme's `shadows` list is stored **back-to-front**, mirroring SVG's
//! `feBlend` stacking order. The first entry is painted first (lowest layer),
//! the last entry is painted last (topmost). This is the inverse of CSS
//! `box-shadow` string order. Documented here so the renderer loop (`for
//! shadow in shadows { ... }`) produces the visually correct result without
//! reversing.
use crate::types::Color;
// ─── Blend modes ─────────────────────────────────────────────────────────────
/// How a shadow composites against the layers below it.
///
/// All modes assume **premultiplied** colour and alpha. The GPU pipeline is
/// expected to be premul-correct; the software pipeline must premultiply
/// before applying these formulas.
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
pub enum BlendMode
{
/// Standard `src-over`: `result = src + dst × (1 src.a)`.
Normal,
/// CSS `plus-lighter`: `result = min(1, src + dst)`, channel-wise on
/// premultiplied values. Adds light; never darkens.
PlusLighter,
/// Overlay (multiply on dark base, screen on light base). Preserves the
/// base's luminance while pushing local contrast.
Overlay,
/// Multiplicative blend: `result = src × dst`. Only darkens.
Multiply,
/// Screen blend: `result = 1 (1 src) × (1 dst)`. Only lightens.
Screen,
}
impl Default for BlendMode
{
fn default() -> Self { BlendMode::Normal }
}
// ─── Outer shadow ────────────────────────────────────────────────────────────
/// An outer drop shadow cast by a shape.
///
/// Modelled after CSS `box-shadow`: an offset, a blur radius, an optional
/// spread that dilates (positive) or erodes (negative) the silhouette before
/// blurring, a colour, and a blend mode.
#[ derive( Debug, Clone, Copy, PartialEq ) ]
pub struct Shadow
{
/// `[dx, dy]` offset in CSS pixels. Positive `dy` is downward.
pub offset: [f32; 2],
/// CSS blur radius in pixels (2 × SVG `stdDeviation`).
pub blur: f32,
/// Spread in CSS pixels. Positive values dilate the silhouette before
/// blurring; negative values erode it. Usually `0.0`.
pub spread: f32,
/// Shadow colour, including alpha.
pub color: Color,
/// Compositing mode. Most drop shadows use [`BlendMode::Normal`].
pub blend: BlendMode,
}
impl Shadow
{
/// Gaussian sigma derived from the CSS blur radius.
///
/// The shader integrates Gaussian kernels against `sigma`, but the public
/// field stores the CSS blur radius for parity with CSS `box-shadow`.
/// The relationship is `sigma = blur / 2`.
pub fn sigma( &self ) -> f32 { self.blur * 0.5 }
}
// ─── Inner shadow ────────────────────────────────────────────────────────────
/// An inset shadow: a shadow painted **inside** the shape's silhouette, as
/// opposed to the outer drop shadow cast behind it.
///
/// Structurally identical to [`Shadow`]; kept as a separate type so the
/// renderer and theme JSON can't accidentally treat an inset as an outer
/// (and vice versa) — the dispatch is at the type level.
#[ derive( Debug, Clone, Copy, PartialEq ) ]
pub struct InsetShadow
{
/// `[dx, dy]` offset in CSS pixels. Positive `dy` is downward.
pub offset: [f32; 2],
/// CSS blur radius in pixels (2 × SVG `stdDeviation`).
pub blur: f32,
/// Spread in CSS pixels.
pub spread: f32,
/// Shadow colour, including alpha.
pub color: Color,
/// Compositing mode against the layers below. Insets routinely use
/// non-`Normal` modes (`PlusLighter` for highlights, `Overlay` for rim).
pub blend: BlendMode,
}
impl InsetShadow
{
/// Gaussian sigma derived from the CSS blur radius. See [`Shadow::sigma`].
pub fn sigma( &self ) -> f32 { self.blur * 0.5 }
}
// ─── Shadow reference ────────────────────────────────────────────────────────
/// How a [`crate::theme::Surface`] refers to its outer shadow stack: either
/// by name (reused across several surfaces — the common case for elevation
/// tokens) or inline (one-off, uncommon).
#[ derive( Debug, Clone, PartialEq ) ]
pub enum ShadowsRef
{
/// Reference to another slot in the theme, by id.
Named( String ),
/// The shadow list, carried inline. Used when the surface is exotic
/// enough that the stack isn't worth a dedicated slot.
Inline( Vec<Shadow> ),
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ test ]
fn sigma_is_half_of_css_blur()
{
// CSS blur 4 → SVG stdDev 2. The shader needs sigma == stdDev == 2.
let s = Shadow
{
offset: [ 0.0, 2.0 ],
blur: 4.0,
spread: 0.0,
color: Color::rgba( 0.0, 0.0, 0.0, 0.04 ),
blend: BlendMode::Normal,
};
assert_eq!( s.sigma(), 2.0 );
}
#[ test ]
fn inset_sigma_follows_same_convention()
{
let i = InsetShadow
{
offset: [ -3.6, -3.6 ],
blur: 13.5,
spread: 0.0,
color: Color::hex( 0x55, 0x55, 0x55 ),
blend: BlendMode::PlusLighter,
};
assert!( ( i.sigma() - 6.75 ).abs() < 1e-6 );
}
#[ test ]
fn default_blend_mode_is_normal()
{
assert_eq!( BlendMode::default(), BlendMode::Normal );
}
#[ test ]
fn shadows_ref_distinguishes_named_and_inline()
{
let n = ShadowsRef::Named( "shadows-2".to_string() );
let i = ShadowsRef::Inline( vec!
[
Shadow
{
offset: [ 0.0, 4.0 ],
blur: 10.0,
spread: 0.0,
color: Color::rgba( 0.0, 0.0, 0.0, 0.08 ),
blend: BlendMode::Normal,
},
]);
match n { ShadowsRef::Named( ref s ) => assert_eq!( s, "shadows-2" ), _ => panic!() }
match i { ShadowsRef::Inline( v ) => assert_eq!( v.len(), 1 ), _ => panic!() }
}
}

345
src/theme/slots.rs Normal file
View File

@@ -0,0 +1,345 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! The slot-typed lookup table that backs the new theme API.
//!
//! A [`Slot`] is a typed entry in the theme document: it is always either a
//! [`Color`], a [`Paint`] (that is a gradient — solid colours live in the
//! `Color` variant), an outer [`Shadow`] stack, a composite [`Surface`] or
//! a [`TextStyle`]. Widgets resolve them through [`SlotStore`].
//!
//! # Promotion
//!
//! The store offers five accessors (`color`, `paint`, `shadows`, `surface`,
//! `text_style`). They promote automatically wherever it makes sense: asking
//! for a paint against a colour slot returns `Paint::Solid(c)`; asking for a
//! surface against a colour returns a `Surface` with `fill: Solid(c)` and
//! empty decorations; asking for a surface against a paint returns a
//! `Surface` wrapping that paint. Stricter requests that cannot be satisfied
//! (asking for a `TextStyle` against a colour, for example) return `None`.
use std::collections::HashMap;
use crate::types::Color;
use super::paint::Paint;
use super::shadow::Shadow;
use super::surface::Surface;
use super::text_style::TextStyle;
// ─── Metadata ────────────────────────────────────────────────────────────────
/// Optional free-form annotations a theme author can attach to any slot. The
/// runtime ignores these — inspection tools (and human readers of the JSON)
/// use them.
///
/// All fields are optional and default to `None`. Missing fields in the JSON
/// do not raise an error.
#[ derive( Debug, Clone, Default, PartialEq, Eq ) ]
pub struct Metadata
{
/// Canonical name of the token in the design system
/// (e.g. `"primary/500"`).
pub semantic: Option<String>,
/// Equivalent name in another system (e.g. `"NeutralColors.white"` for
/// Fluent). Useful when migrating from or cross-referencing other kits.
pub fluent: Option<String>,
/// Human-readable guidance on where to use this slot.
pub usage: Option<String>,
/// Free-form note, typically for quirks worth flagging in the JSON.
pub note: Option<String>,
}
// ─── Slot ────────────────────────────────────────────────────────────────────
/// One typed entry of the theme.
#[ derive( Debug, Clone, PartialEq ) ]
pub enum Slot
{
/// A single opaque or translucent colour.
Color { value: Color, meta: Metadata },
/// A gradient (linear or radial). Solid colours are kept in
/// [`Slot::Color`] — they do not round-trip through this variant.
Paint { value: Paint, meta: Metadata },
/// An ordered stack of outer shadows, typically an elevation level.
Shadows { value: Vec<Shadow>, meta: Metadata },
/// A composite surface: fill, outer shadows (ref or inline), inset
/// shadows and an optional backdrop.
Surface { value: Surface, meta: Metadata },
/// A resolved text style (family, weight, size, line-height, …).
TextStyle { value: TextStyle, meta: Metadata },
}
impl Slot
{
/// Annotations attached to the slot, if any.
pub fn metadata( &self ) -> &Metadata
{
match self
{
Slot::Color { meta, .. } => meta,
Slot::Paint { meta, .. } => meta,
Slot::Shadows { meta, .. } => meta,
Slot::Surface { meta, .. } => meta,
Slot::TextStyle { meta, .. } => meta,
}
}
/// Short tag identifying the slot's kind. Useful in error messages.
pub fn kind_tag( &self ) -> &'static str
{
match self
{
Slot::Color { .. } => "color",
Slot::Paint { .. } => "paint",
Slot::Shadows { .. } => "shadows",
Slot::Surface { .. } => "surface",
Slot::TextStyle { .. } => "typography",
}
}
}
// ─── Store ───────────────────────────────────────────────────────────────────
/// Lookup table indexed by slot id.
///
/// The store is immutable from a consumer's standpoint: themes are built
/// once at load time and handed to the renderer. All accessors return
/// references bound to the store's lifetime.
#[ derive( Debug, Clone, Default ) ]
pub struct SlotStore
{
entries: HashMap<String, Slot>,
}
impl SlotStore
{
/// Build an empty store. Used by tests and by the JSON loader.
pub fn new() -> Self
{
Self { entries: HashMap::new() }
}
/// Insert a slot under `id`. Returns the previous slot under that id if
/// there was one. Used by the JSON loader; not typically called from
/// widget code.
pub fn insert( &mut self, id: impl Into<String>, slot: Slot ) -> Option<Slot>
{
self.entries.insert( id.into(), slot )
}
/// The raw entry, if present.
pub fn get( &self, id: &str ) -> Option<&Slot>
{
self.entries.get( id )
}
/// Number of slots in the store.
pub fn len( &self ) -> usize { self.entries.len() }
/// Whether the store is empty.
pub fn is_empty( &self ) -> bool { self.entries.is_empty() }
// ─── Typed accessors ─────────────────────────────────────────────────
/// Resolve `id` to a [`Color`]. Only `Slot::Color` matches — gradients
/// do not promote down to a single colour.
pub fn color( &self, id: &str ) -> Option<Color>
{
match self.entries.get( id )?
{
Slot::Color { value, .. } => Some( *value ),
_ => None,
}
}
/// Resolve `id` to a [`Paint`]. A colour slot promotes to
/// [`Paint::Solid`]; a paint slot returns directly.
pub fn paint( &self, id: &str ) -> Option<Paint>
{
match self.entries.get( id )?
{
Slot::Color { value, .. } => Some( Paint::Solid( *value ) ),
Slot::Paint { value, .. } => Some( value.clone() ),
_ => None,
}
}
/// Resolve `id` to an outer-shadow stack. Only `Slot::Shadows` matches.
pub fn shadows( &self, id: &str ) -> Option<&[Shadow]>
{
match self.entries.get( id )?
{
Slot::Shadows { value, .. } => Some( value.as_slice() ),
_ => None,
}
}
/// Resolve `id` to a [`Surface`]. A colour slot promotes to a surface
/// with a solid fill and no decorations; a paint slot promotes to a
/// surface with that paint as fill and no decorations; a surface slot
/// returns directly.
pub fn surface( &self, id: &str ) -> Option<Surface>
{
match self.entries.get( id )?
{
Slot::Color { value, .. } => Some( Surface::from_paint( Paint::Solid( *value ) ) ),
Slot::Paint { value, .. } => Some( Surface::from_paint( value.clone() ) ),
Slot::Surface { value, .. } => Some( value.clone() ),
_ => None,
}
}
/// Resolve `id` to a [`TextStyle`]. Only `Slot::TextStyle` matches —
/// typography does not promote from anything else.
pub fn text_style( &self, id: &str ) -> Option<&TextStyle>
{
match self.entries.get( id )?
{
Slot::TextStyle { value, .. } => Some( value ),
_ => None,
}
}
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[ cfg( test ) ]
mod tests
{
use super::*;
use crate::theme::paint::{ ColorStop, GradientSpace, LinearGradient };
use crate::theme::shadow::{ BlendMode };
use crate::theme::text_style::{ FontRef, LineHeight };
fn sample_color_slot() -> ( &'static str, Slot )
{
(
"primary-500",
Slot::Color
{
value: Color::hex( 0x04, 0xD9, 0xFE ),
meta: Metadata { semantic: Some( "primary/500".to_string() ), ..Metadata::default() },
},
)
}
fn sample_gradient_slot() -> ( &'static str, Slot )
{
(
"gradient-error-light",
Slot::Paint
{
value: Paint::Linear( LinearGradient
{
angle_deg: 152.77,
stops: vec!
[
ColorStop { position: -1.1654, color: Color::hex( 0xFF, 0x93, 0xA9 ) },
ColorStop { position: 1.2332, color: Color::WHITE },
],
space: GradientSpace::LinearRgb,
}),
meta: Metadata::default(),
},
)
}
#[ test ]
fn color_slot_promotes_to_paint_and_surface()
{
let mut store = SlotStore::new();
let ( id, slot ) = sample_color_slot();
store.insert( id, slot );
assert!( store.color( id ).is_some() );
assert_eq!( store.color( id ).unwrap(), Color::hex( 0x04, 0xD9, 0xFE ) );
assert!( matches!( store.paint( id ).unwrap(), Paint::Solid( _ ) ) );
let surface = store.surface( id ).unwrap();
assert!( matches!( surface.fill, Paint::Solid( _ ) ) );
assert!( surface.inset_shadows.is_empty() );
}
#[ test ]
fn paint_slot_does_not_downgrade_to_color()
{
let mut store = SlotStore::new();
let ( id, slot ) = sample_gradient_slot();
store.insert( id, slot );
assert!( store.color( id ).is_none(), "gradient must not answer as color" );
assert!( matches!( store.paint( id ).unwrap(), Paint::Linear( _ ) ) );
assert!( matches!( store.surface( id ).unwrap().fill, Paint::Linear( _ ) ) );
}
#[ test ]
fn shadows_and_text_style_are_strict()
{
let mut store = SlotStore::new();
let ( c_id, c_slot ) = sample_color_slot();
store.insert( c_id, c_slot );
// Color does not promote into shadows or text_style.
assert!( store.shadows( c_id ).is_none() );
assert!( store.text_style( c_id ).is_none() );
// A text-style slot answers as such.
store.insert
(
"body-m",
Slot::TextStyle
{
value: TextStyle::new
(
FontRef::Named( "sora".to_string() ),
400,
16.0,
LineHeight::Px( 24.0 ),
),
meta: Metadata::default(),
},
);
let ts = store.text_style( "body-m" ).unwrap();
assert_eq!( ts.size, 16.0 );
}
#[ test ]
fn missing_id_returns_none_everywhere()
{
let store = SlotStore::new();
assert!( store.color ( "nope" ).is_none() );
assert!( store.paint ( "nope" ).is_none() );
assert!( store.shadows ( "nope" ).is_none() );
assert!( store.surface ( "nope" ).is_none() );
assert!( store.text_style( "nope" ).is_none() );
}
#[ test ]
fn metadata_accessor_is_uniform_across_variants()
{
let ( id, slot ) = sample_color_slot();
assert_eq!( slot.metadata().semantic.as_deref(), Some( "primary/500" ) );
assert_eq!( slot.kind_tag(), "color" );
let shadow_slot = Slot::Shadows
{
value: vec!
[
Shadow
{
offset: [ 0.0, 2.0 ],
blur: 4.0,
spread: 0.0,
color: Color::rgba( 0.0, 0.0, 0.0, 0.04 ),
blend: BlendMode::Normal,
},
],
meta: Metadata { note: Some( "elevation 1 - topmost layer".to_string() ), ..Default::default() },
};
assert_eq!( shadow_slot.kind_tag(), "shadows" );
assert!( shadow_slot.metadata().note.is_some() );
let _ = id;
}
}

107
src/theme/surface.rs Normal file
View File

@@ -0,0 +1,107 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Composite surfaces: fill + outer shadows + inset shadows.
//!
//! A [`Surface`] is the richest kind of theme slot. It packages together a
//! fill (possibly a gradient), an elevation stack, and inset shadows with
//! their own blend modes. Widgets that only need a flat colour or a plain
//! gradient do not construct [`Surface`] values directly; slot promotion
//! (in `theme::slots`) hands them a [`Surface`] with empty decorations when
//! they ask for one against a simpler slot.
use super::paint::Paint;
use super::shadow::{ InsetShadow, ShadowsRef };
use crate::types::Color;
// ─── Surface ─────────────────────────────────────────────────────────────────
/// A composite theme surface: fill, elevation, insets.
///
/// All decorations are optional. A surface with `fill: Paint::Solid(...)`,
/// no shadows and no insets behaves exactly like a flat-colour fill, which
/// is why the promotion path from a `color` slot to a [`Surface`] is trivial.
#[ derive( Debug, Clone, PartialEq ) ]
pub struct Surface
{
/// What the surface is filled with.
pub fill: Paint,
/// Outer shadow stack. When `Some`, can reference another slot by name
/// or inline the full list.
pub shadows: Option<ShadowsRef>,
/// Inset shadows, in back-to-front order. Each entry carries its own
/// [`crate::theme::BlendMode`].
pub inset_shadows: Vec<InsetShadow>,
}
impl Surface
{
/// Build a surface from just a [`Paint`], with no shadows or insets.
pub fn from_paint( paint: Paint ) -> Self
{
Self
{
fill: paint,
shadows: None,
inset_shadows: Vec::new(),
}
}
}
impl From<Paint> for Surface
{
fn from( p: Paint ) -> Self { Surface::from_paint( p ) }
}
impl From<Color> for Surface
{
fn from( c: Color ) -> Self { Surface::from_paint( Paint::Solid( c ) ) }
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[ cfg( test ) ]
mod tests
{
use super::*;
use crate::types::Color;
use crate::theme::shadow::{ BlendMode, InsetShadow, Shadow, ShadowsRef };
#[ test ]
fn surface_promotes_from_color()
{
let s: Surface = Color::hex( 0x04, 0xD9, 0xFE ).into();
assert_eq!( s.fill, Paint::Solid( Color::hex( 0x04, 0xD9, 0xFE ) ) );
assert!( s.shadows.is_none() );
assert!( s.inset_shadows.is_empty() );
}
#[ test ]
fn glass_accent_shape_composes()
{
let s = Surface
{
fill: Paint::Solid( Color::hex( 0x04, 0xD9, 0xFE ) ),
shadows: Some( ShadowsRef::Named( "shadows-glass".to_string() ) ),
inset_shadows: vec!
[
InsetShadow { offset: [ -3.6, -3.6 ], blur: 13.5, spread: 0.0, color: Color::hex( 0x55, 0x55, 0x55 ), blend: BlendMode::PlusLighter },
InsetShadow { offset: [ 1.8, 1.8 ], blur: 1.8, spread: 0.0, color: Color::hex( 0x55, 0x55, 0x55 ), blend: BlendMode::PlusLighter },
InsetShadow { offset: [ 0.45, 0.45 ], blur: 0.9, spread: 0.0, color: Color::BLACK, blend: BlendMode::Overlay },
InsetShadow { offset: [ 1.8, 1.8 ], blur: 7.2, spread: 0.0, color: Color::rgba( 0.0, 0.0, 0.0, 0.15 ), blend: BlendMode::Normal },
],
};
assert_eq!( s.inset_shadows.len(), 4 );
assert_eq!( s.inset_shadows[0].blend, BlendMode::PlusLighter );
assert_eq!( s.inset_shadows[2].blend, BlendMode::Overlay );
let _ = Shadow
{
offset: [ 0.0, 0.0 ],
blur: 9.0,
spread: 0.0,
color: Color::rgba( 33.0 / 255.0, 33.0 / 255.0, 33.0 / 255.0, 0.25 ),
blend: BlendMode::Normal,
};
}
}

225
src/theme/text_style.rs Normal file
View File

@@ -0,0 +1,225 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Typography tokens: a resolved text style (family + weight + size + …) as
//! the theme knows it.
//!
//! The struct is deliberately named [`TextStyle`] rather than `Typography`
//! to avoid colliding with the [`super::typography`] constants module that
//! ships alongside it.
//!
//! # Resolution
//!
//! A [`TextStyle`] carries a [`FontRef`] (by name) and optionally a slot
//! reference for its default colour. Both are resolved lazily by the theme's
//! slot table: the string IDs live here, the actual font bytes and
//! [`crate::types::Color`] value come from the font registry and the slot
//! store respectively. This keeps the struct copy-cheap and JSON-friendly.
// ─── Font reference ──────────────────────────────────────────────────────────
/// Reference to a font family registered in the theme's `fonts` block.
///
/// A family name like `"sora"` maps to a [`super::FontFamilyDef`] that
/// lists the actual `.ttf` paths for each weight/style. Keeping the
/// reference by name lets multiple text styles share a family without
/// duplicating the source list.
#[ derive( Debug, Clone, PartialEq, Eq, Hash ) ]
pub enum FontRef
{
/// Reference by family id, looked up in the theme's `fonts` block.
Named( String ),
}
// ─── Style axes ──────────────────────────────────────────────────────────────
/// Italic vs upright.
#[ derive( Debug, Clone, Copy, PartialEq, Eq, Hash ) ]
pub enum FontStyle
{
Normal,
Italic,
}
impl Default for FontStyle
{
fn default() -> Self { FontStyle::Normal }
}
/// Case transform applied at render time.
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
pub enum TextTransform
{
/// Render the string unchanged.
None,
/// Uppercase every character.
Uppercase,
/// Lowercase every character.
Lowercase,
/// Uppercase the first letter of each word, leave the rest untouched.
Capitalize,
}
impl Default for TextTransform
{
fn default() -> Self { TextTransform::None }
}
/// Underline / strikethrough decoration.
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
pub enum TextDecoration
{
None,
Underline,
Strikethrough,
}
impl Default for TextDecoration
{
fn default() -> Self { TextDecoration::None }
}
// ─── Line height ─────────────────────────────────────────────────────────────
/// How the vertical advance between lines is specified.
///
/// Both forms are supported because design-tool exports usually emit absolute
/// pixel heights, while hand-authored themes more often prefer relative
/// line-heights that scale with the font size.
#[ derive( Debug, Clone, Copy, PartialEq ) ]
pub enum LineHeight
{
/// Absolute pixel box height for the line.
Px( f32 ),
/// Multiplier of the font size. `1.5` means line-height is 1.5× the size.
Multiplier( f32 ),
}
impl LineHeight
{
/// Resolve to an absolute pixel height given the font size.
pub fn resolve( self, font_size: f32 ) -> f32
{
match self
{
LineHeight::Px( h ) => h,
LineHeight::Multiplier( m ) => font_size * m,
}
}
}
// ─── TextStyle ───────────────────────────────────────────────────────────────
/// A resolved text style: family, weight, size, line-height and the visual
/// modifiers that go with them.
///
/// `color` is a slot id (string) rather than a resolved [`crate::types::Color`]
/// because the slot store is what knows how to resolve names to values — and
/// widgets can override the colour with [`.color()`] at the call-site, which
/// always wins over the style's default.
#[ derive( Debug, Clone, PartialEq ) ]
pub struct TextStyle
{
/// Font family, looked up in the theme's `fonts` block.
pub family: FontRef,
/// Weight as a CSS numeric value (100..=900).
pub weight: u16,
/// Italic vs upright.
pub style: FontStyle,
/// Font size in CSS pixels.
pub size: f32,
/// Line height.
pub line_height: LineHeight,
/// Letter spacing in `em` (fraction of the font size). `0.0` by default.
pub letter_spacing: f32,
/// Case transform applied before shaping.
pub transform: TextTransform,
/// Underline / strikethrough.
pub decoration: TextDecoration,
/// Default colour slot id. `None` means "inherit from the widget's own
/// colour setting". When `Some`, widgets that don't override with
/// [`.color()`] get this.
pub color: Option<String>,
}
impl TextStyle
{
/// Convenience constructor that fills the non-essential fields with
/// sensible defaults (upright, no transform, no decoration, no default
/// colour, no letter spacing).
pub fn new( family: FontRef, weight: u16, size: f32, line_height: LineHeight ) -> Self
{
Self
{
family,
weight,
style: FontStyle::Normal,
size,
line_height,
letter_spacing: 0.0,
transform: TextTransform::None,
decoration: TextDecoration::None,
color: None,
}
}
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ test ]
fn new_constructor_sets_sensible_defaults()
{
let s = TextStyle::new
(
FontRef::Named( "sora".to_string() ),
400,
16.0,
LineHeight::Px( 24.0 ),
);
assert_eq!( s.weight, 400 );
assert_eq!( s.size, 16.0 );
assert_eq!( s.line_height.resolve( 16.0 ), 24.0 );
assert_eq!( s.transform, TextTransform::None );
assert_eq!( s.letter_spacing, 0.0 );
assert!( s.color.is_none() );
}
#[ test ]
fn caption_l_upcases_at_render_time()
{
let s = TextStyle
{
family: FontRef::Named( "sora".to_string() ),
weight: 400,
style: FontStyle::Normal,
size: 16.0,
line_height: LineHeight::Px( 20.0 ),
letter_spacing: 0.0,
transform: TextTransform::Uppercase,
decoration: TextDecoration::None,
color: None,
};
assert_eq!( s.transform, TextTransform::Uppercase );
}
#[ test ]
fn line_height_multiplier_scales_with_size()
{
let lh = LineHeight::Multiplier( 1.5 );
assert_eq!( lh.resolve( 16.0 ), 24.0 );
assert_eq!( lh.resolve( 12.0 ), 18.0 );
}
#[ test ]
fn defaults_are_normal_and_none()
{
assert_eq!( FontStyle::default(), FontStyle::Normal );
assert_eq!( TextTransform::default(), TextTransform::None );
assert_eq!( TextDecoration::default(), TextDecoration::None );
}
}

90
src/tree.rs Executable file
View File

@@ -0,0 +1,90 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::Point;
use crate::widget::{ LaidOutWidget, WidgetHandlers };
/// Hit test `pos` against the laid-out focusable widgets. Returns the
/// `flat_idx` of the topmost widget under the point, or `None` if the point
/// hits nothing focusable. Topmost-wins because layout pushes parents before
/// children — the reverse iteration order makes the visually-on-top widget
/// (drawn last) hit-tested first.
pub fn find_widget_at<Msg: Clone>(
widget_rects: &[ LaidOutWidget<Msg> ],
pos: Point,
) -> Option<usize>
{
for w in widget_rects.iter().rev()
{
if w.rect.contains( pos ) { return Some( w.flat_idx ); }
}
None
}
/// O(N) lookup of a single widget by `flat_idx`. N is the number of
/// *focusable leaves*, not the size of the [`crate::widget::Element`] tree.
/// If the hot path ever needs O(1), swap the per-surface `widget_rects`
/// slice for a `Vec` + a `HashMap<usize, usize>` index.
pub fn find_widget<'a, Msg: Clone>(
widget_rects: &'a [ LaidOutWidget<Msg> ],
flat_idx: usize,
) -> Option<&'a LaidOutWidget<Msg>>
{
widget_rects.iter().find( |w| w.flat_idx == flat_idx )
}
/// Convenience wrapper around [`find_widget`] that returns just the handler
/// snapshot — what most input dispatch sites actually want.
pub fn find_handlers<'a, Msg: Clone>(
widget_rects: &'a [ LaidOutWidget<Msg> ],
flat_idx: usize,
) -> Option<&'a WidgetHandlers<Msg>>
{
find_widget( widget_rects, flat_idx ).map( |w| &w.handlers )
}
/// Compute the next focusable widget for Tab / Shift+Tab navigation.
/// Returns the `flat_idx` of the next keyboard-focusable entry after the one
/// matching `current` (or wrapping around). `reverse` flips the direction
/// (Shift+Tab). Returns `None` when no entry has
/// [`LaidOutWidget::keyboard_focusable`] set — typical for a surface that only
/// hosts hit-testable chrome (e.g. a row of `WindowButton`s).
///
/// When `current` is `None` (no widget currently focused) the result is the
/// first or last keyboard-focusable widget depending on direction — matching
/// how desktop toolkits behave when Tab is pressed in an unfocused window.
///
/// Non-focusable interactive entries (chrome such as `WindowButton`) live in
/// the same `widget_rects` slice so pointer/touch hit testing finds them, but
/// are skipped here so they don't steal Tab focus from window content.
pub fn next_focusable_index<Msg: Clone>(
widget_rects: &[ LaidOutWidget<Msg> ],
current: Option<usize>,
reverse: bool,
) -> Option<usize>
{
// Project the slice down to just the keyboard-focusable entries. Tab
// cycles over this projection only — pointer-only chrome sits in
// `widget_rects` for hit testing but never receives keyboard focus.
let focusables: Vec<usize> = widget_rects
.iter()
.filter( |w| w.keyboard_focusable )
.map( |w| w.flat_idx )
.collect();
let n = focusables.len();
if n == 0 { return None; }
let current_pos = current.and_then( |fi| focusables.iter().position( |&i| i == fi ) );
let next = if reverse
{
current_pos
.map( |pos| focusables[ ( pos + n - 1 ) % n ] )
.unwrap_or( focusables[ n - 1 ] )
} else {
current_pos
.map( |pos| focusables[ ( pos + 1 ) % n ] )
.unwrap_or( focusables[ 0 ] )
};
Some( next )
}

432
src/types.rs Normal file
View File

@@ -0,0 +1,432 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Geometry and primitive value types used across the public API.
//!
//! These are the cheap, copy-friendly types that flow through every
//! widget builder, layout method and runtime hook:
//!
//! - [`Color`] — RGBA in `[0.0, 1.0]` floats; `Color::WHITE`,
//! `Color::BLACK`, `Color::TRANSPARENT` constants and a `Color::hex(r, g, b)`
//! constructor for byte literals.
//! - [`Rect`] — axis-aligned `(x, y, width, height)`; the universal
//! layout / hit-test currency.
//! - [`Point`] — a 2D point used by hit testing and gesture progress.
//! - [`Size`] — a `(width, height)` pair without an origin.
//! - [`Corners`] — per-corner radius for the
//! [`Container`](crate::container()) widget and any other rounded
//! surface; coerces from `f32` for the uniform case.
//! - [`WidgetId`] — a stable `&'static str` identifier for focus
//! management, paired with [`crate::App::take_focus_request`].
//!
//! Every type is `Copy` (or `Clone`) so passing them by value is the
//! default. The crate root re-exports them all (`ltk::Color`,
//! `ltk::Rect`, …) so application code rarely needs the `ltk::types::`
//! prefix.
/// An RGBA color with floating-point channels in the range `[0.0, 1.0]`.
#[ derive( Debug, Clone, Copy, PartialEq ) ]
pub struct Color
{
/// Red channel `[0.0, 1.0]`.
pub r: f32,
/// Green channel `[0.0, 1.0]`.
pub g: f32,
/// Blue channel `[0.0, 1.0]`.
pub b: f32,
/// Alpha channel — `0.0` is fully transparent, `1.0` is fully opaque.
pub a: f32,
}
impl Color
{
/// Fully opaque white.
pub const WHITE: Self = Self { r: 1., g: 1., b: 1., a: 1. };
/// Fully opaque black.
pub const BLACK: Self = Self { r: 0., g: 0., b: 0., a: 1. };
/// Fully transparent black.
pub const TRANSPARENT: Self = Self { r: 0., g: 0., b: 0., a: 0. };
/// Create an opaque color from 8-bit `r`, `g`, `b` components.
pub const fn hex( r: u8, g: u8, b: u8 ) -> Self
{
Self { r: r as f32 / 255.0, g: g as f32 / 255.0, b: b as f32 / 255.0, a: 1.0 }
}
/// Create an opaque color from float `r`, `g`, `b` components in `[0.0, 1.0]`.
pub fn rgb( r: f32, g: f32, b: f32 ) -> Self
{
Self { r, g, b, a: 1. }
}
/// Create a color from float `r`, `g`, `b`, `a` components in `[0.0, 1.0]`.
pub fn rgba( r: f32, g: f32, b: f32, a: f32 ) -> Self
{
Self { r, g, b, a }
}
/// Convert to a [`tiny_skia::Color`] for rendering.
pub fn to_tiny_skia( self ) -> tiny_skia::Color
{
tiny_skia::Color::from_rgba( self.r, self.g, self.b, self.a )
.unwrap_or( tiny_skia::Color::BLACK )
}
}
/// A 2-D point in screen coordinates (pixels, top-left origin).
#[ derive( Debug, Clone, Copy, PartialEq, Default ) ]
pub struct Point
{
/// Horizontal position in pixels.
pub x: f32,
/// Vertical position in pixels.
pub y: f32,
}
/// A width/height pair in pixels.
#[ derive( Debug, Clone, Copy, PartialEq, Default ) ]
pub struct Size
{
/// Width in pixels.
pub width: f32,
/// Height in pixels.
pub height: f32,
}
/// An axis-aligned rectangle in screen coordinates.
#[ derive( Debug, Clone, Copy, PartialEq, Default ) ]
pub struct Rect
{
/// Left edge in pixels.
pub x: f32,
/// Top edge in pixels.
pub y: f32,
/// Width in pixels.
pub width: f32,
/// Height in pixels.
pub height: f32,
}
impl Rect
{
/// Returns `true` if `p` lies inside or on the boundary of this rect.
pub fn contains( &self, p: Point ) -> bool
{
p.x >= self.x
&& p.x <= self.x + self.width
&& p.y >= self.y
&& p.y <= self.y + self.height
}
/// Returns a new rect grown by `amount` pixels on every side.
pub fn expand( &self, amount: f32 ) -> Self
{
Self
{
x: self.x - amount,
y: self.y - amount,
width: self.width + amount * 2.0,
height: self.height + amount * 2.0,
}
}
/// Convert to [`tiny_skia::Rect`], returning `None` if dimensions are non-positive.
pub fn to_tiny_skia( &self ) -> Option<tiny_skia::Rect>
{
tiny_skia::Rect::from_xywh( self.x, self.y, self.width, self.height )
}
}
/// Per-corner radii for a rounded rect, ordered top-left → top-right →
/// bottom-right → bottom-left (clockwise from top-left, matching CSS
/// `border-radius`'s long form). All four values are independent
/// pixel radii — set any subset to `0.0` for a square corner, or use
/// the [`top`](Self::top), [`bottom`](Self::bottom),
/// [`left`](Self::left), [`right`](Self::right) shortcuts for the
/// common asymmetric cases.
///
/// The renderer caps each corner against the inscribed-circle limit
/// `min(width, height) / 2`, mirroring tiny-skia / browser behaviour:
/// passing absurdly large values is a "make this side a pill" idiom
/// rather than an error.
///
/// `f32` and `(f32, f32, f32, f32)` both convert via [`From`] so any
/// API taking `impl Into<Corners>` accepts a uniform radius literal
/// (`.radius( 16.0 )`), an explicit set (`.radius( ( 16.0, 16.0,
/// 0.0, 0.0 ) )`), or a constructed value (`.radius( Corners::top(
/// 16.0 ) )`) interchangeably.
#[ derive( Debug, Clone, Copy, PartialEq, Default ) ]
pub struct Corners
{
/// Top-left corner radius in pixels.
pub tl: f32,
/// Top-right corner radius in pixels.
pub tr: f32,
/// Bottom-right corner radius in pixels.
pub br: f32,
/// Bottom-left corner radius in pixels.
pub bl: f32,
}
impl Corners
{
/// All four corners square (radius `0`).
pub const ZERO: Self = Self { tl: 0.0, tr: 0.0, br: 0.0, bl: 0.0 };
/// Uniform radius on every corner — equivalent to `r.into()` and
/// the most common construction.
pub const fn all( r: f32 ) -> Self
{
Self { tl: r, tr: r, br: r, bl: r }
}
/// Rounded top corners, square bottom corners. Matches the CSS
/// shorthand `border-radius: r r 0 0` and the typical "card sits
/// flush against the bottom of the screen" pattern (docks,
/// bottom-anchored modals).
pub const fn top( r: f32 ) -> Self
{
Self { tl: r, tr: r, br: 0.0, bl: 0.0 }
}
/// Rounded bottom corners, square top corners. Mirror of
/// [`top`](Self::top) for top-anchored chrome.
pub const fn bottom( r: f32 ) -> Self
{
Self { tl: 0.0, tr: 0.0, br: r, bl: r }
}
/// Rounded left corners, square right corners.
pub const fn left( r: f32 ) -> Self
{
Self { tl: r, tr: 0.0, br: 0.0, bl: r }
}
/// Rounded right corners, square left corners.
pub const fn right( r: f32 ) -> Self
{
Self { tl: 0.0, tr: r, br: r, bl: 0.0 }
}
/// `true` when every corner is `<= 0` — the renderer can take
/// the fast straight-rect path.
pub fn is_zero( &self ) -> bool
{
self.tl <= 0.0 && self.tr <= 0.0 && self.br <= 0.0 && self.bl <= 0.0
}
/// `true` when every corner has the same radius. Used by the
/// software path to fall back to the single-radius cubic builder
/// when the asymmetric path would produce an identical curve.
pub fn is_uniform( &self ) -> bool
{
self.tl == self.tr && self.tr == self.br && self.br == self.bl
}
/// The largest of the four radii. Useful for sizing the shader
/// quad's anti-alias pad — the worst-case AA band has to cover
/// the steepest curve.
pub fn max( &self ) -> f32
{
self.tl.max( self.tr ).max( self.br ).max( self.bl )
}
/// Cap every corner to `min(width, height) / 2`, the inscribed-
/// circle limit a rounded box can't exceed without degenerating.
/// Mirrors the clamp the GLES shader applies internally; software
/// path callers use it before building the path so the cubic
/// control points stay inside the rect.
pub fn clamp_to_size( &self, width: f32, height: f32 ) -> Self
{
let cap = ( width.min( height ) * 0.5 ).max( 0.0 );
Self
{
tl: self.tl.min( cap ).max( 0.0 ),
tr: self.tr.min( cap ).max( 0.0 ),
br: self.br.min( cap ).max( 0.0 ),
bl: self.bl.min( cap ).max( 0.0 ),
}
}
/// Pack as `[ tl, tr, br, bl ]` for `glUniform4fv`. Order
/// matches the `vec4 u_radii` convention every fragment shader
/// in `gles_render::shaders` reads.
pub fn to_uniform( &self ) -> [ f32; 4 ]
{
[ self.tl, self.tr, self.br, self.bl ]
}
}
impl From<f32> for Corners
{
fn from( r: f32 ) -> Self { Self::all( r ) }
}
impl From<( f32, f32, f32, f32 )> for Corners
{
/// Tuple form, ordered `( tl, tr, br, bl )` — matches CSS shorthand.
fn from( t: ( f32, f32, f32, f32 ) ) -> Self
{
Self { tl: t.0, tr: t.1, br: t.2, bl: t.3 }
}
}
/// A stable widget identifier used for focus management.
///
/// Assign an id to a widget with `.id( WidgetId("my_widget") )`, then request
/// focus via [`App::take_focus_request`](crate::app::App::take_focus_request).
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
pub struct WidgetId( pub &'static str );
/// Pointer cursor shape, sent to the compositor via
/// `wp_cursor_shape_v1` when the pointer enters a widget that
/// declares one. Mirrors `cursor_icon::CursorIcon` 1:1 so the
/// runtime can convert losslessly. Compositors that do not advertise
/// `wp_cursor_shape_v1` ignore these — the user sees their default
/// system cursor.
#[ derive( Debug, Clone, Copy, PartialEq, Eq, Hash ) ]
pub enum CursorShape
{
Default,
ContextMenu,
Help,
/// "Hand" — clickable buttons, links.
Pointer,
/// "Spinning wheel" — work in progress, you can still interact.
Progress,
/// "Hourglass" — UI is busy and unresponsive.
Wait,
Cell,
Crosshair,
/// I-beam — text input fields.
Text,
VerticalText,
Alias,
Copy,
Move,
NoDrop,
NotAllowed,
/// Open hand — draggable but not yet dragging.
Grab,
/// Closed hand — currently dragging.
Grabbing,
EResize,
NResize,
NeResize,
NwResize,
SResize,
SeResize,
SwResize,
WResize,
EwResize,
NsResize,
NeswResize,
NwseResize,
ColResize,
RowResize,
AllScroll,
ZoomIn,
ZoomOut,
}
impl Default for CursorShape
{
fn default() -> Self { CursorShape::Default }
}
#[ cfg( test ) ]
mod tests
{
use super::*;
// ── Color ─────────────────────────────────────────────────────────────────
#[ test ]
fn color_hex_sets_rgb_and_full_alpha()
{
let c = Color::hex( 0xFF, 0x00, 0x80 );
assert!( ( c.r - 1.0 ).abs() < 1e-3 );
assert!( ( c.g - 0.0 ).abs() < 1e-6 );
assert!( ( c.b - 0x80 as f32 / 255.0 ).abs() < 1e-3 );
assert_eq!( c.a, 1.0 );
}
#[ test ]
fn color_rgba_stores_all_channels()
{
let c = Color::rgba( 0.1, 0.2, 0.3, 0.4 );
assert!( ( c.r - 0.1 ).abs() < 1e-6 );
assert!( ( c.g - 0.2 ).abs() < 1e-6 );
assert!( ( c.b - 0.3 ).abs() < 1e-6 );
assert!( ( c.a - 0.4 ).abs() < 1e-6 );
}
#[ test ]
fn color_white_constant_is_all_ones()
{
let c = Color::WHITE;
assert_eq!( c.r, 1. );
assert_eq!( c.g, 1. );
assert_eq!( c.b, 1. );
assert_eq!( c.a, 1. );
}
#[ test ]
fn color_transparent_has_zero_alpha()
{
assert_eq!( Color::TRANSPARENT.a, 0. );
}
#[ test ]
fn color_rgb_sets_full_alpha()
{
let c = Color::rgb( 0.5, 0.5, 0.5 );
assert_eq!( c.a, 1.0 );
}
// ── Rect ──────────────────────────────────────────────────────────────────
#[ test ]
fn rect_contains_interior_point()
{
let r = Rect { x: 10., y: 20., width: 100., height: 50. };
assert!( r.contains( Point { x: 60., y: 45. } ) );
}
#[ test ]
fn rect_contains_boundary_points()
{
let r = Rect { x: 0., y: 0., width: 100., height: 100. };
assert!( r.contains( Point { x: 0., y: 0. } ) );
assert!( r.contains( Point { x: 100., y: 100. } ) );
}
#[ test ]
fn rect_does_not_contain_exterior_points()
{
let r = Rect { x: 10., y: 20., width: 100., height: 50. };
assert!( !r.contains( Point { x: 5., y: 45. } ) );
assert!( !r.contains( Point { x: 60., y: 5. } ) );
assert!( !r.contains( Point { x: 200., y: 45. } ) );
assert!( !r.contains( Point { x: 60., y: 80. } ) );
}
#[ test ]
fn rect_expand_grows_in_all_directions()
{
let r = Rect { x: 10., y: 10., width: 80., height: 40. };
let e = r.expand( 5. );
assert_eq!( e.x, 5. );
assert_eq!( e.y, 5. );
assert_eq!( e.width, 90. );
assert_eq!( e.height, 50. );
}
#[ test ]
fn rect_expand_zero_is_identity()
{
let r = Rect { x: 1., y: 2., width: 3., height: 4. };
let e = r.expand( 0. );
assert_eq!( r, e );
}
}

255
src/wallpaper.rs Normal file
View File

@@ -0,0 +1,255 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Orientation-aware wallpaper helper.
//!
//! Theme assets only ship a single landscape image per variant; portrait
//! surfaces (phones held vertically, the lock screen on a tablet, …) take a
//! left-aligned crop of that image down to the surface aspect ratio. The crop
//! result is cached so panning around different windows on the same display
//! does not redecode anything.
//!
//! Construct with [`WallpaperBundle::from_path`] (or one of the
//! `…_or_fallback` variants) at startup, then call
//! [`WallpaperBundle::for_size`] from each `view()` to get the variant
//! appropriate for the current surface.
use std::path::Path;
use std::sync::{ Arc, Mutex };
/// Decoded RGBA image: (pixel buffer, width, height).
pub type ImageData = ( Arc<Vec<u8>>, u32, u32 );
/// A wallpaper that adapts to landscape vs portrait surfaces.
///
/// Holds a single full-resolution landscape image and lazily produces
/// left-cropped portrait variants on demand, caching the most recently
/// requested crop dimensions.
pub struct WallpaperBundle
{
landscape: ImageData,
cache: Mutex<Option<( ( u32, u32 ), ImageData )>>,
}
impl WallpaperBundle
{
/// Wrap an already-decoded landscape image.
pub fn from_decoded( landscape: ImageData ) -> Self
{
Self
{
landscape,
cache: Mutex::new( None ),
}
}
/// Decode `bytes` (PNG/JPEG) as the landscape image.
pub fn from_bytes( bytes: &[u8] ) -> Result<Self, image::ImageError>
{
Ok( Self::from_decoded( decode_bytes( bytes )? ) )
}
/// Load the landscape image from `path`.
pub fn from_path( path: &Path ) -> Result<Self, image::ImageError>
{
Ok( Self::from_decoded( decode_path( path )? ) )
}
/// Try to load the landscape image from `path`; on failure fall back to
/// decoding `bundled_fallback` (typically a `include_bytes!` asset shipped
/// inside the consumer binary). Errors loading the path are reported on
/// stderr.
pub fn from_path_or_bytes( path: Option<&Path>, bundled_fallback: &[u8] ) -> Self
{
if let Some( p ) = path
{
match decode_path( p )
{
Ok( img ) => return Self::from_decoded( img ),
Err( e ) => eprintln!(
"ltk: failed to load wallpaper {}: {} — falling back to bundled",
p.display(), e,
),
}
}
Self::from_bytes( bundled_fallback ).expect( "bundled wallpaper must decode" )
}
/// Try to load from `path`; on failure produce a 1×1 solid-colour bundle
/// using `(r, g, b)` instead of requiring embedded PNG bytes. Reports path
/// load errors on stderr.
pub fn from_path_or_solid( path: Option<&Path>, r: u8, g: u8, b: u8 ) -> Self
{
if let Some( p ) = path
{
match decode_path( p )
{
Ok( img ) => return Self::from_decoded( img ),
Err( e ) => eprintln!(
"ltk: failed to load wallpaper {}: {} — falling back to solid colour",
p.display(), e,
),
}
}
let rgba = Arc::new( vec![ r, g, b, 255u8 ] );
Self::from_decoded( ( rgba, 1, 1 ) )
}
/// The original landscape image. Cheap clone — only bumps the inner `Arc`.
pub fn landscape( &self ) -> ImageData
{
( Arc::clone( &self.landscape.0 ), self.landscape.1, self.landscape.2 )
}
/// Return the wallpaper variant appropriate for a surface of `(sw, sh)`.
///
/// - Landscape surface (`sw >= sh`): returns the full landscape image.
/// - Portrait surface (`sw < sh`): returns a left-cropped variant whose
/// aspect ratio matches `(sw, sh)`. The crop is computed once per unique
/// `(sw, sh)` pair and reused on subsequent calls.
///
/// `(0, 0)` is treated as landscape (returns the original image) so that
/// callers can use this before the surface has been sized.
pub fn for_size( &self, sw: u32, sh: u32 ) -> ImageData
{
if sw == 0 || sh == 0 || sw >= sh
{
return self.landscape();
}
let key = ( sw, sh );
let mut guard = self.cache.lock().expect( "wallpaper cache poisoned" );
if let Some( ( cached_key, data ) ) = guard.as_ref()
{
if *cached_key == key
{
return ( Arc::clone( &data.0 ), data.1, data.2 );
}
}
let cropped = crop_left_to_aspect( &self.landscape, sw, sh );
let result = ( Arc::clone( &cropped.0 ), cropped.1, cropped.2 );
*guard = Some( ( key, cropped ) );
result
}
}
fn decode_bytes( bytes: &[u8] ) -> Result<ImageData, image::ImageError>
{
use image::GenericImageView as _;
let img = image::load_from_memory( bytes )?;
let ( w, h ) = img.dimensions();
Ok( ( Arc::new( img.to_rgba8().into_raw() ), w, h ) )
}
fn decode_path( path: &Path ) -> Result<ImageData, image::ImageError>
{
use image::GenericImageView as _;
let img = image::open( path )?;
let ( w, h ) = img.dimensions();
Ok( ( Arc::new( img.to_rgba8().into_raw() ), w, h ) )
}
/// Take the left-most slice of `src` whose width matches the `(target_w, target_h)`
/// aspect ratio. If `src` is already narrower than the target, it is returned
/// unchanged (callers will scale it up — letterboxing avoidance is the
/// renderer's job, not this helper's).
fn crop_left_to_aspect( src: &ImageData, target_w: u32, target_h: u32 ) -> ImageData
{
let ( ref rgba, sw, sh ) = *src;
if sw == 0 || sh == 0 || target_w == 0 || target_h == 0
{
return ( Arc::clone( rgba ), sw, sh );
}
let target_aspect = target_w as f32 / target_h as f32;
let new_w_f = ( sh as f32 * target_aspect ).round();
let new_w = ( new_w_f as u32 ).clamp( 1, sw );
if new_w >= sw
{
return ( Arc::clone( rgba ), sw, sh );
}
let mut out = Vec::with_capacity( ( new_w as usize ) * ( sh as usize ) * 4 );
let row_bytes_src = ( sw as usize ) * 4;
let row_bytes_new = ( new_w as usize ) * 4;
for y in 0..( sh as usize )
{
let start = y * row_bytes_src;
out.extend_from_slice( &rgba[ start .. start + row_bytes_new ] );
}
( Arc::new( out ), new_w, sh )
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[ cfg( test ) ]
mod tests
{
use super::*;
/// Build a tiny gradient image where each pixel's red channel encodes its
/// x coordinate. Lets us assert that a left-crop really starts at column 0.
fn xgrad( w: u32, h: u32 ) -> ImageData
{
let mut buf = Vec::with_capacity( ( w * h * 4 ) as usize );
for _y in 0..h
{
for x in 0..w
{
buf.extend_from_slice( &[ x as u8, 0, 0, 255 ] );
}
}
( Arc::new( buf ), w, h )
}
#[ test ]
fn landscape_surface_returns_full_image()
{
let bundle = WallpaperBundle::from_decoded( xgrad( 16, 8 ) );
let ( _, w, h ) = bundle.for_size( 32, 16 );
assert_eq!( ( w, h ), ( 16, 8 ) );
}
#[ test ]
fn portrait_surface_left_crops()
{
// 100x10 source, portrait surface 9:16 → crop width = round(10 * 9/16) = 6.
let bundle = WallpaperBundle::from_decoded( xgrad( 100, 10 ) );
let ( bytes, w, h ) = bundle.for_size( 9, 16 );
assert_eq!( h, 10 );
assert_eq!( w, 6 );
// First pixel of the cropped image must come from column 0 of the source.
assert_eq!( bytes[0], 0 );
// Last pixel of the first row must come from column (w-1) of the crop = 5.
let last = ( ( w as usize - 1 ) * 4 ) as usize;
assert_eq!( bytes[ last ], 5 );
}
#[ test ]
fn portrait_crop_is_cached()
{
let bundle = WallpaperBundle::from_decoded( xgrad( 100, 10 ) );
let a = bundle.for_size( 9, 16 );
let b = bundle.for_size( 9, 16 );
// Same Arc pointer ⇒ second call hit the cache.
assert!( Arc::ptr_eq( &a.0, &b.0 ) );
}
#[ test ]
fn portrait_narrower_than_target_returns_source()
{
// Source already 1:10 (very tall) — taller than any portrait surface,
// so the helper returns it unchanged.
let bundle = WallpaperBundle::from_decoded( xgrad( 1, 10 ) );
let ( _, w, h ) = bundle.for_size( 9, 16 );
assert_eq!( ( w, h ), ( 1, 10 ) );
}
#[ test ]
fn zero_size_returns_landscape()
{
let bundle = WallpaperBundle::from_decoded( xgrad( 4, 4 ) );
let ( _, w, h ) = bundle.for_size( 0, 0 );
assert_eq!( ( w, h ), ( 4, 4 ) );
}
}

View File

@@ -0,0 +1,124 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Position a child element below the on-screen rect of another widget,
//! looked up from the *previous* frame's [`LaidOutWidget`] snapshot.
//!
//! The classic use case is a combo / dropdown popup that should appear
//! flush below its trigger. The trigger's rect is not known in `view()`
//! time — it's assigned during the layout pass — so the popup widget
//! cannot place itself there directly. `AnchoredOverlay` solves this
//! with a one-frame-old anchor: the trigger carries a stable
//! [`WidgetId`], the popup wraps in `AnchoredOverlay` referencing the
//! same id, and at draw time the wrapper looks up the trigger's rect
//! from the runtime's persisted `widget_rects` (frame N 1) and
//! overrides the rect that its parent gave it.
//!
//! When the anchor is not found (first frame after open, missing id,
//! widget went off-screen) the wrapper falls back to drawing the child
//! in the parent-supplied rect — which, for a typical Stack-overlay
//! root in a `view()`, means the full surface, giving a sane modal
//! fallback until the next frame fixes the position.
use crate::types::{ Rect, WidgetId };
use super::Element;
/// A wrapper that re-positions its child relative to an anchor widget
/// found in the previous frame's layout snapshot.
pub struct AnchoredOverlay<Msg: Clone>
{
/// The element to draw at the anchored position.
pub child: Box<Element<Msg>>,
/// Stable identifier of the widget whose rect provides the anchor.
pub anchor_id: WidgetId,
/// Vertical pixel gap between the bottom of the anchor and the top
/// of the child.
pub gap: f32,
}
impl<Msg: Clone> AnchoredOverlay<Msg>
{
/// Wrap `child` so it draws anchored below the widget that carries
/// `anchor_id` in its `.id( … )` builder. `gap` is the vertical
/// space (logical pixels) between the anchor's bottom edge and the
/// child's top edge.
pub fn new( child: impl Into<Element<Msg>>, anchor_id: WidgetId, gap: f32 ) -> Self
{
Self
{
child: Box::new( child.into() ),
anchor_id,
gap,
}
}
/// Compute the draw rect for the child, given the anchor rect (when
/// available) and the parent-supplied fallback.
///
/// Anchor available → place the child flush below the anchor with
/// `gap` spacing, preserving the anchor's width.
/// Anchor missing → return the parent rect verbatim, so the child
/// renders modal-style as a fallback.
pub fn resolve_rect( anchor: Option<Rect>, gap: f32, fallback: Rect ) -> Rect
{
match anchor
{
Some( a ) => Rect
{
x: a.x,
y: a.y + a.height + gap,
width: a.width,
height: fallback.height,
},
None => fallback,
}
}
pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> AnchoredOverlay<U>
where
U: Clone + 'static,
Msg: 'static,
{
AnchoredOverlay
{
child: Box::new( self.child.map_arc( f ) ),
anchor_id: self.anchor_id,
gap: self.gap,
}
}
}
impl<Msg: Clone + 'static> From<AnchoredOverlay<Msg>> for Element<Msg>
{
fn from( a: AnchoredOverlay<Msg> ) -> Self
{
Element::AnchoredOverlay( a )
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ test ]
fn resolve_rect_uses_anchor_when_present()
{
let anchor = Rect { x: 100.0, y: 50.0, width: 200.0, height: 40.0 };
let fallback = Rect { x: 0.0, y: 0.0, width: 800.0, height: 600.0 };
let r = AnchoredOverlay::<()>::resolve_rect( Some( anchor ), 8.0, fallback );
assert_eq!( r.x, 100.0 );
assert_eq!( r.y, 98.0 ); // anchor.y + height + gap
assert_eq!( r.width, 200.0 ); // anchor width
assert_eq!( r.height, 600.0 ); // fallback height
}
#[ test ]
fn resolve_rect_falls_back_when_anchor_missing()
{
let fallback = Rect { x: 5.0, y: 6.0, width: 7.0, height: 8.0 };
let r = AnchoredOverlay::<()>::resolve_rect( None, 8.0, fallback );
assert_eq!( r, fallback );
}
}

553
src/widget/button.rs Normal file
View File

@@ -0,0 +1,553 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::sync::Arc;
use crate::types::{ Color, Rect, WidgetId };
use crate::render::Canvas;
use super::Element;
// Theme colors driven by the process-wide palette (see `ltk::theme`).
// Non-colour geometry (radius, font size, focus width, etc.) is static — only
// palette tokens respond to light/dark mode.
mod theme
{
use crate::types::Color;
/// Primary button background — brand accent.
pub fn p_default_bg() -> Color { crate::theme::palette().accent }
/// Primary button border — a darker tone of the accent so the pill
/// reads as a discrete affordance over surfaces that share the
/// background hue. Computed by darkening the linear-RGB components
/// of the accent rather than carrying yet another palette slot.
pub fn p_default_border() -> Color
{
let a = crate::theme::palette().accent;
Color { r: a.r * 0.55, g: a.g * 0.55, b: a.b * 0.55, a: a.a }
}
/// Primary button label colour. Falls back to the theme's
/// `text_primary`; themes whose accent does not pair well with
/// their text-primary token can override the palette so this
/// reads cleanly.
pub fn p_default_text() -> Color { crate::theme::palette().text_primary }
/// Disabled primary background — uses the theme's divider token,
/// the lowest-contrast neutral available across modes.
pub fn p_disabled_bg() -> Color { crate::theme::palette().divider }
/// Disabled primary / secondary / tertiary text — uses the
/// theme's secondary text token.
pub fn p_disabled_text() -> Color { crate::theme::palette().text_secondary }
/// Secondary button default background — matches the surface_alt surface.
pub fn s_bg() -> Color { crate::theme::palette().surface_alt }
/// Secondary button border.
pub fn s_border() -> Color { crate::theme::palette().text_primary }
/// Disabled secondary background — slightly lighter than the
/// disabled primary so the two states stay visually distinct.
pub fn s_disabled_bg() -> Color { crate::theme::palette().surface_alt }
/// Disabled secondary border — uses the divider token.
pub fn s_disabled_border() -> Color { crate::theme::palette().divider }
/// Tertiary button text colour.
pub fn t_text() -> Color { crate::theme::palette().text_primary }
/// Keyboard focus ring / hover circle.
pub fn focus_color() -> Color { crate::theme::palette().accent }
pub const S_BORDER_W: f32 = 2.0;
pub const P_BORDER_W: f32 = 1.0;
pub const FOCUS_W: f32 = 3.0;
pub const HEIGHT: f32 = 48.0;
pub const RADIUS: f32 = 100.0;
pub const FONT_SIZE: f32 = 16.0;
pub const PAD_H: f32 = 24.0;
}
/// Visual style of a text button.
#[ derive( Clone, Default ) ]
pub enum ButtonVariant
{
/// Filled with the brand color — use for the primary call-to-action.
#[ default ]
Primary,
/// White background with a dark border — use for secondary actions.
Secondary,
/// Text-only, no background — use for low-emphasis actions.
Tertiary,
}
/// Internal content of a button — either a text label or a PNG icon.
pub enum ButtonContent
{
/// A text label rendered with the theme font.
Text( String ),
/// An RGBA image used as the button face (Arc avoids per-frame cloning).
Icon { rgba: Arc<Vec<u8>>, img_w: u32, img_h: u32 },
}
/// A pressable button widget.
///
/// Create text buttons with [`button()`](crate::button()) and icon buttons with
/// [`icon_button()`](crate::icon_button()). Buttons that step a value
/// (date / time pickers, numeric spinners) can opt into press-and-
/// hold repeat via [`Self::repeating`] — the runtime then re-fires
/// `on_press` while the button is held, at the keyboard's repeat
/// cadence.
pub struct Button<Msg: Clone>
{
/// The visual content of this button.
pub content: ButtonContent,
/// Message emitted when the button is pressed, or `None` if disabled.
pub 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>,
/// 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>,
/// 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,
/// Optional stable identifier for focus management.
pub id: Option<WidgetId>,
/// Whether this button participates in keyboard focus (Tab). Default: `true`.
pub 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>,
/// 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
/// repeats every ~120 ms (≈ 8 Hz, deliberately slower than the
/// keyboard's 30 Hz so a stepper does not whip past the
/// 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,
}
impl<Msg: Clone> Button<Msg>
{
/// Create a text button with the given label.
pub fn new( label: String ) -> Self
{
Self
{
content: ButtonContent::Text( label ),
on_press: None,
on_long_press: None,
on_drag_start: None,
variant: ButtonVariant::Primary,
icon_size: theme::HEIGHT,
id: None,
focusable: true,
cursor: None,
repeating: false,
}
}
/// Override the pointer cursor shape shown on hover.
pub fn cursor( mut self, shape: crate::types::CursorShape ) -> Self
{
self.cursor = Some( shape );
self
}
/// Create an icon button from a shared RGBA buffer.
///
/// `img_w` and `img_h` must match the dimensions of `rgba`.
pub fn new_icon( rgba: Arc<Vec<u8>>, img_w: u32, img_h: u32 ) -> Self
{
Self
{
content: ButtonContent::Icon { rgba, img_w, img_h },
on_press: None,
on_long_press: None,
on_drag_start: None,
variant: ButtonVariant::Tertiary,
icon_size: theme::HEIGHT,
id: None,
focusable: true,
cursor: None,
repeating: false,
}
}
/// Set the message emitted when the button is pressed.
pub fn on_press( mut self, msg: Msg ) -> Self
{
self.on_press = Some( msg );
self
}
/// Optionally set the message — `None` leaves the button disabled.
pub fn on_press_maybe( mut self, msg: Option<Msg> ) -> Self
{
self.on_press = msg;
self
}
/// Auto-fire `on_press` while the button is held down. The
/// runtime fires once on press, then re-fires after the
/// keyboard's repeat *delay* (≈ 500 ms) and at a fixed ~120 ms
/// (≈ 8 Hz) interval afterwards — slow enough to release on
/// the value the user wants, fast enough to ramp. Each tick
/// re-reads `on_press` from the live widget tree, so a
/// stepper-style button whose message is `"go to value + 1"`
/// keeps stepping correctly as the value updates.
///
/// Mutually compatible with `on_long_press` only in spirit —
/// once the long-press message fires the gesture machine
/// transitions to drag mode and the repeat timer is cancelled
/// regardless of `repeating`. Default `false`.
pub fn repeating( mut self, on: bool ) -> Self
{
self.repeating = on;
self
}
/// Attach a long-press message. Fires when the press has been held
/// stationary for [`App::long_press_duration`](crate::app::App::long_press_duration),
/// or when the user right-clicks with the mouse. By itself this does
/// NOT put the gesture into drag mode — that is governed by
/// [`Self::on_drag_start`]. The regular `on_press` is suppressed
/// only when the press has been promoted to a drag (drag-arm fired).
pub fn on_long_press( mut self, msg: Msg ) -> Self
{
self.on_long_press = Some( msg );
self
}
/// Attach a drag-arm message. Fires when the press transitions into
/// drag mode — touch on hold-timer expiry (alongside `on_long_press`),
/// mouse on motion past the drag-promotion threshold (without firing
/// `on_long_press`). Independent of the menu so a button can be
/// draggable without showing a menu, or open a menu without becoming
/// draggable.
pub fn on_drag_start( mut self, msg: Msg ) -> Self
{
self.on_drag_start = Some( msg );
self
}
/// Control whether this button receives keyboard focus (Tab navigation).
/// Set to `false` for purely decorative or status-indicator buttons.
pub fn focusable( mut self, yes: bool ) -> Self
{
self.focusable = yes;
self
}
/// Set the visual variant.
pub fn variant( mut self, v: ButtonVariant ) -> Self
{
self.variant = v;
self
}
/// Set the display size (width = height) for icon buttons in pixels.
pub fn icon_size( mut self, size: f32 ) -> Self
{
self.icon_size = size;
self
}
/// Assign a stable identifier for focus management.
pub fn id( mut self, id: WidgetId ) -> Self
{
self.id = Some( id );
self
}
/// Bounding box of everything the button can paint at `rect`, across every
/// interaction state. This is the sum of: icon-button hover/press circle
/// (radius `rect.min_dim / 2 + 8`), focus ring (grows `FOCUS_W + 1` beyond
/// that), stroke half-width (`FOCUS_W / 2`), plus ~1 px of antialiasing
/// bleed. Text buttons only have the focus ring.
///
/// The partial-redraw path uses this to know how much canvas area to
/// invalidate when the button transitions in/out of a state.
pub fn paint_bounds( &self, rect: crate::types::Rect ) -> crate::types::Rect
{
let stroke_bleed = theme::FOCUS_W * 0.5 + 1.0;
match &self.content
{
ButtonContent::Icon { .. } =>
{
// The circle grows 8 px beyond the icon rect, the focus ring grows
// `FOCUS_W + 1` beyond the circle.
let circle_pad = 8.0_f32;
let ring_pad = theme::FOCUS_W + 1.0;
rect.expand( circle_pad + ring_pad + stroke_bleed )
}
ButtonContent::Text( _ ) => match self.variant
{
ButtonVariant::Primary | ButtonVariant::Secondary =>
{
rect.expand( theme::FOCUS_W + 2.0 + stroke_bleed )
}
ButtonVariant::Tertiary => rect.expand( 2.0 + stroke_bleed ),
},
}
}
/// Return the preferred `(width, height)` given available `max_width`.
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{
match &self.content
{
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 )
}
ButtonContent::Icon { .. } =>
{
let s = self.icon_size.min( max_width );
( s, s )
}
}
}
/// Draw the button into `canvas` at `rect`.
///
/// `focused` draws a keyboard-focus ring; `hovered` and `pressed` apply
/// pointer/touch state overlays (icon buttons only).
pub fn draw( &self, canvas: &mut Canvas, rect: Rect, focused: bool, hovered: bool, pressed: bool )
{
match &self.content
{
ButtonContent::Text( label ) =>
{
self.draw_text_button( canvas, rect, focused, label );
}
ButtonContent::Icon { rgba, img_w, img_h } =>
{
self.draw_icon_button( canvas, rect, focused, hovered, pressed, rgba, *img_w, *img_h );
}
}
}
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;
match self.variant
{
ButtonVariant::Primary =>
{
let bg = if is_disabled { theme::p_disabled_bg() } else { theme::p_default_bg() };
let text_c = if is_disabled { theme::p_disabled_text() } else { theme::p_default_text() };
let border_c = theme::p_default_border();
canvas.fill_rect( rect, bg, theme::RADIUS );
if !is_disabled
{
canvas.stroke_rect( rect, border_c, theme::P_BORDER_W, theme::RADIUS );
}
if focused
{
let ring = rect.expand( theme::FOCUS_W + 2.0 );
canvas.stroke_rect(
ring,
theme::focus_color(),
theme::FOCUS_W,
theme::RADIUS + theme::FOCUS_W + 2.0,
);
}
let text_w = canvas.measure_text( label, theme::FONT_SIZE );
canvas.draw_text(
label,
rect.x + (rect.width - text_w) / 2.0,
text_y,
theme::FONT_SIZE,
text_c,
);
}
ButtonVariant::Secondary =>
{
let bg = if is_disabled { theme::s_disabled_bg() } else { theme::s_bg() };
let text_c = if is_disabled { theme::p_disabled_text() } else { theme::t_text() };
let border_c = if is_disabled { theme::s_disabled_border() } else { theme::s_border() };
canvas.fill_rect( rect, bg, theme::RADIUS );
canvas.stroke_rect( rect, border_c, theme::S_BORDER_W, theme::RADIUS );
if focused
{
let ring = rect.expand( theme::FOCUS_W + 2.0 );
canvas.stroke_rect(
ring,
theme::focus_color(),
theme::FOCUS_W,
theme::RADIUS + theme::FOCUS_W + 2.0,
);
}
let text_w = canvas.measure_text( label, theme::FONT_SIZE );
canvas.draw_text(
label,
rect.x + (rect.width - text_w) / 2.0,
text_y,
theme::FONT_SIZE,
text_c,
);
}
ButtonVariant::Tertiary =>
{
let text_c = if is_disabled { theme::p_disabled_text() } else { theme::t_text() };
if focused
{
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 );
canvas.draw_text(
label,
rect.x + (rect.width - text_w) / 2.0,
text_y,
theme::FONT_SIZE,
text_c,
);
}
}
}
fn draw_icon_button(
&self,
canvas: &mut Canvas,
rect: Rect,
focused: bool,
hovered: bool,
pressed: bool,
rgba: &[u8],
img_w: u32,
img_h: u32,
)
{
// Semi-transparent circular overlay behind the icon for hover / press feedback
let circle_pad = 8.0_f32;
let r = rect.width.min( rect.height ) / 2.0 + circle_pad;
let cx = rect.x + rect.width / 2.0;
let cy = rect.y + rect.height / 2.0;
let circle = Rect
{
x: cx - r,
y: cy - r,
width: r * 2.0,
height: r * 2.0,
};
// Hover / press feedback is the theme's primary text colour
// at low alpha — works as a "lighten" in light mode (where
// text_primary tends to be dark and the underlying icon is
// dark) and as a subtle wash in dark mode without baking in
// a fixed white.
let fp = crate::theme::palette().text_primary;
if pressed
{
canvas.fill_rect( circle, Color::rgba( fp.r, fp.g, fp.b, 0.18 ), r );
} else if hovered {
canvas.fill_rect( circle, Color::rgba( fp.r, fp.g, fp.b, 0.10 ), r );
}
if focused
{
let ring = circle.expand( theme::FOCUS_W + 1.0 );
canvas.stroke_rect( ring, theme::focus_color(), theme::FOCUS_W, r + theme::FOCUS_W + 1.0 );
}
canvas.draw_image_data( rgba, img_w, img_h, rect, 1.0 );
}
/// Wrap this button in an [`Element`].
pub fn into_element( self ) -> Element<Msg>
{
Element::Button( self )
}
/// Re-tag this button's three message slots through `f`. Called by
/// [`Element::map`] while walking a sub-tree.
pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> Button<U>
where
U: Clone + 'static,
Msg: 'static,
{
Button
{
content: self.content,
on_press: self.on_press.map( |m| ( *f )( m ) ),
on_long_press: self.on_long_press.map( |m| ( *f )( m ) ),
on_drag_start: self.on_drag_start.map( |m| ( *f )( m ) ),
variant: self.variant,
icon_size: self.icon_size,
id: self.id,
focusable: self.focusable,
cursor: self.cursor,
repeating: self.repeating,
}
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ test ]
fn text_button_focusable_by_default()
{
let b = Button::<()>::new( "ok".into() );
assert!( b.focusable );
}
#[ test ]
fn icon_button_focusable_by_default()
{
let b = Button::<()>::new_icon( Arc::new( vec![] ), 0, 0 );
assert!( b.focusable );
}
#[ test ]
fn focusable_builder_disables_focus()
{
let b = Button::<()>::new( "ok".into() ).focusable( false );
assert!( !b.focusable );
}
#[ test ]
fn focusable_builder_re_enables_focus()
{
let b = Button::<()>::new( "ok".into() ).focusable( false ).focusable( true );
assert!( b.focusable );
}
#[ test ]
fn on_press_none_by_default()
{
let b = Button::<()>::new( "ok".into() );
assert!( b.on_press.is_none() );
}
#[ test ]
fn on_press_maybe_none_leaves_disabled()
{
let b = Button::<()>::new( "ok".into() ).on_press_maybe( None );
assert!( b.on_press.is_none() );
}
#[ test ]
fn repeating_off_by_default()
{
let b = Button::<()>::new( "ok".into() );
assert!( !b.repeating );
}
#[ test ]
fn repeating_builder_sets_flag()
{
let b = Button::<()>::new( "ok".into() ).repeating( true );
assert!( b.repeating );
let b = b.repeating( false );
assert!( !b.repeating );
}
}

217
src/widget/checkbox.rs Normal file
View File

@@ -0,0 +1,217 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::{ Rect, WidgetId };
use crate::render::Canvas;
use super::Element;
mod theme
{
use crate::types::Color;
pub fn box_border() -> Color { crate::theme::palette().divider }
pub fn box_checked() -> Color { crate::theme::palette().accent }
/// Tick mark — uses the page-background colour so it reads as
/// the inverse of the accent fill regardless of mode.
pub fn check_color() -> Color { crate::theme::palette().bg }
pub fn focus_color() -> Color { crate::theme::palette().accent }
pub fn label_color() -> Color { crate::theme::palette().text_primary }
pub const BOX_SIZE: f32 = 24.0;
pub const RADIUS: f32 = 4.0;
pub const BORDER_W: f32 = 2.0;
pub const GAP: f32 = 12.0;
pub const HEIGHT: f32 = 48.0;
pub const FOCUS_W: f32 = 3.0;
pub const CHECK_W: f32 = 2.5;
pub const FONT_SIZE: f32 = 16.0;
}
/// A two-state opt-in control with a square box and a check glyph.
///
/// Use for individual binary choices inside a form (terms acceptance,
/// multi-select lists, "remember me"). The widget is stateless — the
/// application owns `checked` and rebuilds the checkbox from current state
/// on every frame.
///
/// ```rust,no_run
/// # use ltk::{ checkbox, Checkbox };
/// # #[ derive( Clone ) ] enum Msg { ToggleTerms }
/// # struct App { accept_terms: bool }
/// # impl App { fn _ex( &self ) -> Checkbox<Msg> {
/// // In view():
/// checkbox( self.accept_terms )
/// .label( "I accept the terms" )
/// .on_toggle( Msg::ToggleTerms )
/// # }}
/// ```
///
/// See also [`Toggle`](super::toggle::Toggle) for prominent on / off
/// switches (settings panels, system toggles) and
/// [`Radio`](super::radio::Radio) for mutually-exclusive selection in a
/// group.
pub struct Checkbox<Msg: Clone>
{
/// Current checked state. Drawn from this field every frame; the
/// runtime never mutates it.
pub checked: bool,
/// Message emitted on activation. `None` leaves the checkbox inert.
pub on_toggle: Option<Msg>,
/// Optional label drawn to the right of the box.
pub label: Option<String>,
/// Optional stable identifier for focus management.
pub id: Option<WidgetId>,
}
impl<Msg: Clone> Checkbox<Msg>
{
/// Create a checkbox in the given state, with no label and no
/// callback. Wire activation through [`Self::on_toggle`] before adding
/// it to a widget tree.
pub fn new( checked: bool ) -> Self
{
Self { checked, on_toggle: None, label: None, id: None }
}
/// Set the message emitted when the checkbox is activated. The
/// application's `update` is responsible for flipping `checked` in
/// response.
pub fn on_toggle( mut self, msg: Msg ) -> Self
{
self.on_toggle = Some( msg );
self
}
/// Set a text label rendered to the right of the box. The checkbox's
/// preferred width grows to fit `box_size + gap + label_width`,
/// clamped to `max_width`.
pub fn label( mut self, label: impl Into<String> ) -> Self
{
self.label = Some( label.into() );
self
}
/// Assign a stable identifier for focus management.
pub fn id( mut self, id: WidgetId ) -> Self
{
self.id = Some( id );
self
}
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{
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 )
} else {
theme::BOX_SIZE.min( max_width )
};
( w, theme::HEIGHT )
}
/// Focus ring on the box extends `FOCUS_W + 2 + FOCUS_W/2 ≈ 6.5 px` beyond
/// the box edge (which sits flush with the widget's left edge).
pub fn paint_bounds( &self, rect: Rect ) -> Rect
{
rect.expand( theme::FOCUS_W + 2.0 + theme::FOCUS_W * 0.5 + 1.0 )
}
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_rect = Rect
{
x: rect.x,
y: box_y,
width: theme::BOX_SIZE,
height: theme::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;
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 {
canvas.stroke_rect( box_rect, theme::box_border(), theme::BORDER_W, theme::RADIUS );
}
if focused
{
let ring = box_rect.expand( theme::FOCUS_W + 2.0 );
canvas.stroke_rect( ring, theme::focus_color(), theme::FOCUS_W, theme::RADIUS + theme::FOCUS_W + 2.0 );
}
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() );
}
}
pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> Checkbox<U>
where
U: Clone + 'static,
Msg: 'static,
{
Checkbox
{
checked: self.checked,
on_toggle: self.on_toggle.map( |m| ( *f )( m ) ),
label: self.label,
id: self.id,
}
}
}
/// Create a [`Checkbox`] in the given state.
///
/// Shorthand for [`Checkbox::new`]. Wire activation with
/// [`Checkbox::on_toggle`] and add a label with [`Checkbox::label`]:
///
/// ```rust,no_run
/// # use ltk::{ checkbox, Checkbox };
/// # #[ derive( Clone ) ] enum Msg { ToggleAccept }
/// # struct App { accept: bool }
/// # impl App { fn _ex( &self ) -> Checkbox<Msg> {
/// checkbox( self.accept )
/// .label( "I accept the terms" )
/// .on_toggle( Msg::ToggleAccept )
/// # }}
/// ```
pub fn checkbox<Msg: Clone>( checked: bool ) -> Checkbox<Msg>
{
Checkbox::new( checked )
}
impl<Msg: Clone + 'static> From<Checkbox<Msg>> for Element<Msg>
{
fn from( c: Checkbox<Msg> ) -> Self
{
Element::Checkbox( c )
}
}
#[cfg(test)]
mod tests
{
use super::*;
#[test]
fn checkbox_default_state()
{
let c = checkbox::<()>( true );
assert!( c.checked );
assert!( c.on_toggle.is_none() );
}
#[test]
fn checkbox_unchecked()
{
let c = checkbox::<()>( false );
assert!( !c.checked );
}
}

545
src/widget/color_picker.rs Normal file
View File

@@ -0,0 +1,545 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! ColorPicker — RGBA sliders + hex input + preview swatch + a
//! continuous hue strip for picking arbitrary colours.
//!
//! Stateless — the application owns the current [`Color`] and updates
//! it from [`ColorPicker::on_change`]. Four sliders cover R / G / B
//! and (when [`ColorPicker::show_alpha`]) A; a hex input lets the user
//! type or paste `#RRGGBB` / `#RRGGBBAA`; a hue slider with a rainbow
//! track lets the user grab any pure hue at full saturation / value
//! in one drag, and the RGB sliders then fine-tune it.
//!
//! ```rust,no_run
//! # use ltk::{ color_picker, Color, ColorPicker };
//! # #[ derive( Clone ) ] enum Msg { AccentChanged( Color ) }
//! # struct App { accent: Color }
//! # impl App { fn _ex( &self ) -> ColorPicker<Msg> {
//! color_picker( self.accent )
//! .show_alpha( false )
//! .on_change( Msg::AccentChanged )
//! # }}
//! ```
use std::sync::Arc;
use crate::types::Color;
use crate::layout::column::column;
use crate::layout::row::row;
use crate::layout::spacer::spacer;
use crate::theme::{ ColorStop, GradientSpace, LinearGradient, Paint };
use super::Element;
mod theme
{
use crate::types::Color;
pub fn surface_alt() -> Color { crate::theme::palette().surface_alt }
pub fn divider() -> Color { crate::theme::palette().divider }
pub fn text_muted() -> Color { crate::theme::palette().text_secondary }
pub const PADDING: f32 = 16.0;
pub const RADIUS: f32 = 16.0;
pub const SWATCH_SZ: f32 = 40.0;
pub const LABEL_FS: f32 = 12.0;
pub const SPACING: f32 = 8.0;
}
/// Format a [`Color`] as `#RRGGBB` (or `#RRGGBBAA` when `with_alpha`
/// is true and the colour is not fully opaque). Bytes are clamped to
/// `0..=255`.
pub fn color_to_hex( c: Color, with_alpha: bool ) -> String
{
let to_byte = | f: f32 | ( f.clamp( 0.0, 1.0 ) * 255.0 ).round() as u8;
let r = to_byte( c.r );
let g = to_byte( c.g );
let b = to_byte( c.b );
let a = to_byte( c.a );
if with_alpha && a != 255
{
format!( "#{:02X}{:02X}{:02X}{:02X}", r, g, b, a )
} else {
format!( "#{:02X}{:02X}{:02X}", r, g, b )
}
}
/// Parse a hex colour string (`"#RGB"` / `"#RGBA"` / `"#RRGGBB"` /
/// `"#RRGGBBAA"`, with or without the leading `#`, case-insensitive)
/// into a [`Color`]. Returns `None` for malformed input.
pub fn parse_hex( s: &str ) -> Option<Color>
{
let s = s.trim();
let s = s.strip_prefix( '#' ).unwrap_or( s );
let parse_byte = | hi: char, lo: char | -> Option<u8>
{
let h = hi.to_digit( 16 )?;
let l = lo.to_digit( 16 )?;
Some( ( ( h << 4 ) | l ) as u8 )
};
let chars: Vec<char> = s.chars().collect();
let to_color = | r: u8, g: u8, b: u8, a: u8 | Color::rgba(
r as f32 / 255.0,
g as f32 / 255.0,
b as f32 / 255.0,
a as f32 / 255.0,
);
match chars.len()
{
3 =>
{
let r = parse_byte( chars[0], chars[0] )?;
let g = parse_byte( chars[1], chars[1] )?;
let b = parse_byte( chars[2], chars[2] )?;
Some( to_color( r, g, b, 255 ) )
}
4 =>
{
let r = parse_byte( chars[0], chars[0] )?;
let g = parse_byte( chars[1], chars[1] )?;
let b = parse_byte( chars[2], chars[2] )?;
let a = parse_byte( chars[3], chars[3] )?;
Some( to_color( r, g, b, a ) )
}
6 =>
{
let r = parse_byte( chars[0], chars[1] )?;
let g = parse_byte( chars[2], chars[3] )?;
let b = parse_byte( chars[4], chars[5] )?;
Some( to_color( r, g, b, 255 ) )
}
8 =>
{
let r = parse_byte( chars[0], chars[1] )?;
let g = parse_byte( chars[2], chars[3] )?;
let b = parse_byte( chars[4], chars[5] )?;
let a = parse_byte( chars[6], chars[7] )?;
Some( to_color( r, g, b, a ) )
}
_ => None,
}
}
/// RGBA colour selector with sliders, hex input, preview swatch and
/// a continuous hue strip.
pub struct ColorPicker<Msg: Clone>
{
pub value: Color,
pub 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,
}
impl<Msg: Clone + 'static> ColorPicker<Msg>
{
pub fn new( value: Color ) -> Self
{
Self
{
value,
on_change: None,
show_alpha: false,
}
}
pub fn on_change( mut self, f: impl Fn( Color ) -> Msg + 'static ) -> Self
{
self.on_change = Some( Arc::new( f ) );
self
}
pub fn show_alpha( mut self, on: bool ) -> Self
{
self.show_alpha = on;
self
}
/// Build the `Element` tree representing this color picker.
pub fn build( self ) -> Element<Msg>
{
use super::{ container, text, text_edit };
use super::slider::slider;
let value = self.value;
let on_chg = self.on_change.clone();
let show_alpha = self.show_alpha;
// Preview swatch.
let swatch: Element<Msg> = container::<Msg>( spacer() )
.background( value )
.border( theme::divider(), 1.0 )
.radius( 12.0 )
.padding( 0.0 )
.into();
let mut preview_row = row::<Msg>().spacing( theme::SPACING ).push(
container::<Msg>( swatch )
.padding( 0.0 )
.radius( 12.0 ),
);
// We size the swatch via an explicit container holding the
// preview rect — the parent row will negotiate the space.
let _ = theme::SWATCH_SZ;
// Hex input — the `on_change` parses the typed string and
// only fires the picker's callback on a successful parse, so
// in-progress typing does not blank the preview every key.
let hex_value = color_to_hex( value, show_alpha );
let mut hex_edit = text_edit::<Msg>( "#RRGGBB", hex_value );
if let Some( ref cb ) = on_chg
{
let cb = cb.clone();
hex_edit = hex_edit.on_change( move |s|
{
match parse_hex( &s )
{
Some( c ) => cb( c ),
None => cb( value ), // hold the previous value
}
} );
}
preview_row = preview_row.push( hex_edit );
// One slider per channel. Each slider's on_change rebuilds
// the colour by replacing only its channel — the others
// snapshot at view-build time, which is correct because
// every change goes through `on_change` and re-renders.
let chan_slider = | label: &str, current: f32, build_color: Arc<dyn Fn( f32 ) -> Color> | -> Element<Msg>
{
let mut s = slider::<Msg>( current );
if let Some( ref cb ) = on_chg
{
let cb_outer = cb.clone();
s = s.on_change( move |v|
{
let c = build_color( v );
cb_outer( c )
} );
}
column::<Msg>().spacing( 4.0 )
.push( text( label ).size( theme::LABEL_FS ).color( theme::text_muted() ) )
.push( s )
.into()
};
let r_build: Arc<dyn Fn( f32 ) -> Color> = Arc::new( move |v| Color::rgba( v, value.g, value.b, value.a ) );
let g_build: Arc<dyn Fn( f32 ) -> Color> = Arc::new( move |v| Color::rgba( value.r, v, value.b, value.a ) );
let b_build: Arc<dyn Fn( f32 ) -> Color> = Arc::new( move |v| Color::rgba( value.r, value.g, v, value.a ) );
let a_build: Arc<dyn Fn( f32 ) -> Color> = Arc::new( move |v| Color::rgba( value.r, value.g, value.b, v ) );
let mut sliders = column::<Msg>().spacing( 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 ) );
if show_alpha
{
sliders = sliders.push( chan_slider( "A", value.a, a_build ) );
}
// Hue strip: a slider whose track is a multi-stop rainbow
// gradient. Position 0 maps to red, position 1 to a hue
// just *short* of the full wheel (see `HUE_RANGE` below)
// so that dragging to the right edge does not snap the
// thumb back to the left on the next render. Moving the
// thumb fires `on_change` with the picked hue at full
// saturation / value, preserving the current alpha — RGB
// sliders fine-tune brightness afterwards.
//
// `HUE_RANGE = 359.0` instead of 360 closes the
// round-trip: at position 1.0 we pick hue 359°, the colour
// is almost-red (one degree off pure red), `rgb_to_hue`
// returns ~359°, and `position = 359 / 359 = 1.0` lands
// back where the user was. Mapping to 360° instead would
// snap to hue 0 (pure red, same as position 0) and the
// slider thumb would teleport to the left — the wheel
// closes on itself, but a linear slider cannot represent
// both endpoints. The 1° colour gap between the two ends
// is imperceptible.
//
// Software backend: `fill_paint_rect` falls back to the
// gradient's first stop (a flat red), so the slider still
// works functionally; only the rainbow visual is missing
// off the GLES path. Acceptable trade-off given the
// software path is the fallback for compositors without GL.
const HUE_RANGE: f32 = 359.0;
let current_hue = rgb_to_hue( value.r, value.g, value.b );
let hue_alpha = value.a;
let mut hue_slider = slider::<Msg>( ( current_hue / HUE_RANGE ).clamp( 0.0, 1.0 ) )
.track_paint( rainbow_gradient() );
if let Some( ref cb ) = on_chg
{
let cb = cb.clone();
hue_slider = hue_slider.on_change( move |v|
{
let ( r, g, b ) = hue_to_rgb( v.clamp( 0.0, 1.0 ) * HUE_RANGE );
cb( Color::rgba( r, g, b, hue_alpha ) )
} );
}
let hue_row: Element<Msg> = column::<Msg>().spacing( 4.0 )
.push( text( "Hue" ).size( theme::LABEL_FS ).color( theme::text_muted() ) )
.push( hue_slider )
.into();
let body = column::<Msg>().spacing( theme::SPACING * 2.0 )
.push( preview_row )
.push( sliders )
.push( hue_row );
container::<Msg>( body )
.background( theme::surface_alt() )
.padding( theme::PADDING )
.radius( theme::RADIUS )
.into()
}
}
/// Build the rainbow [`Paint::Linear`] used as the hue strip's track.
/// Seven stops across the wheel with a final repeat of red so the
/// gradient closes cleanly. CSS angle convention: `90deg` sweeps
/// left-to-right, matching the slider's value axis.
fn rainbow_gradient() -> Paint
{
let stop = | pos: f32, c: Color | ColorStop { position: pos, color: c };
Paint::Linear( LinearGradient
{
angle_deg: 90.0,
stops: vec!
[
stop( 0.000, Color::rgba( 1.0, 0.0, 0.0, 1.0 ) ), // red
stop( 1.0 / 6.0, Color::rgba( 1.0, 1.0, 0.0, 1.0 ) ), // yellow
stop( 2.0 / 6.0, Color::rgba( 0.0, 1.0, 0.0, 1.0 ) ), // green
stop( 3.0 / 6.0, Color::rgba( 0.0, 1.0, 1.0, 1.0 ) ), // cyan
stop( 4.0 / 6.0, Color::rgba( 0.0, 0.0, 1.0, 1.0 ) ), // blue
stop( 5.0 / 6.0, Color::rgba( 1.0, 0.0, 1.0, 1.0 ) ), // magenta
stop( 1.000, Color::rgba( 1.0, 0.0, 0.0, 1.0 ) ), // red (closes the wheel)
],
space: GradientSpace::Srgb,
} )
}
/// Compute the HSV hue (in degrees, `0..360`) of an RGB triple. Returns
/// `0.0` for greys (where hue is undefined) so the slider reads at a
/// stable position when the user dials saturation down to zero via the
/// RGB sliders.
pub fn rgb_to_hue( r: f32, g: f32, b: f32 ) -> f32
{
let max = r.max( g ).max( b );
let min = r.min( g ).min( b );
let delta = max - min;
if delta <= f32::EPSILON { return 0.0; }
let h = if max == r
{
60.0 * ( ( ( g - b ) / delta ).rem_euclid( 6.0 ) )
}
else if max == g
{
60.0 * ( ( b - r ) / delta + 2.0 )
}
else
{
60.0 * ( ( r - g ) / delta + 4.0 )
};
if h < 0.0 { h + 360.0 } else { h }
}
/// Build an RGB triple at full saturation and value from a hue in
/// degrees (`0..360`). Returns each channel in `0.0..=1.0`.
pub fn hue_to_rgb( hue_deg: f32 ) -> ( f32, f32, f32 )
{
let h = hue_deg.rem_euclid( 360.0 ) / 60.0;
let c = 1.0_f32;
let x = c * ( 1.0 - ( ( h.rem_euclid( 2.0 ) ) - 1.0 ).abs() );
let ( r, g, b ) = match h as u32
{
0 => ( c, x, 0.0 ),
1 => ( x, c, 0.0 ),
2 => ( 0.0, c, x ),
3 => ( 0.0, x, c ),
4 => ( x, 0.0, c ),
_ => ( c, 0.0, x ),
};
( r, g, b )
}
impl<Msg: Clone + 'static> From<ColorPicker<Msg>> for Element<Msg>
{
fn from( c: ColorPicker<Msg> ) -> Self { c.build() }
}
/// Create a [`ColorPicker`] starting from the given colour.
///
/// ```rust,no_run
/// # use ltk::{ color_picker, Color, ColorPicker };
/// # #[ derive( Clone ) ] enum Msg { AccentChanged( Color ) }
/// # struct App { accent: Color }
/// # impl App { fn _ex( &self ) -> ColorPicker<Msg> {
/// color_picker( self.accent )
/// .show_alpha( true )
/// .on_change( Msg::AccentChanged )
/// # }}
/// ```
pub fn color_picker<Msg: Clone + 'static>( value: Color ) -> ColorPicker<Msg>
{
ColorPicker::new( value )
}
#[ cfg( test ) ]
mod tests
{
use super::*;
// ── hex serialization ─────────────────────────────────────────────────────
#[ test ]
fn color_to_hex_uppercase_six_digits_for_opaque()
{
let c = Color::rgba( 1.0, 0.0, 0.5, 1.0 );
assert_eq!( color_to_hex( c, false ), "#FF0080" );
assert_eq!( color_to_hex( c, true ), "#FF0080" ); // alpha=255 stripped
}
#[ test ]
fn color_to_hex_emits_eight_digits_when_alpha_kept_and_present()
{
let c = Color::rgba( 1.0, 0.0, 0.5, 0.5 );
// 0.5 × 255 = 127.5 → rounds to 128 = 0x80.
assert_eq!( color_to_hex( c, true ), "#FF008080" );
}
#[ test ]
fn color_to_hex_clamps_out_of_range()
{
let c = Color::rgba( 1.5, -0.1, 0.0, 1.0 );
assert_eq!( color_to_hex( c, false ), "#FF0000" );
}
// ── hex parsing ───────────────────────────────────────────────────────────
#[ test ]
fn parse_hex_six_digits()
{
let c = parse_hex( "#FF0080" ).expect( "ok" );
assert!( ( c.r - 1.0 ).abs() < 1e-3 );
assert_eq!( c.g, 0.0 );
assert!( ( c.b - 0.502 ).abs() < 1e-2 );
assert_eq!( c.a, 1.0 );
}
#[ test ]
fn parse_hex_three_digit_shorthand()
{
let c = parse_hex( "#F08" ).expect( "ok" );
// "F" → 0xFF. "0" → 0x00. "8" → 0x88.
assert_eq!( ( c.r * 255.0 ).round() as u8, 0xFF );
assert_eq!( ( c.g * 255.0 ).round() as u8, 0x00 );
assert_eq!( ( c.b * 255.0 ).round() as u8, 0x88 );
}
#[ test ]
fn parse_hex_eight_digit_includes_alpha()
{
let c = parse_hex( "#FF008080" ).expect( "ok" );
assert!( ( c.a * 255.0 ).round() as u8 == 0x80 );
}
#[ test ]
fn parse_hex_optional_leading_hash()
{
assert!( parse_hex( "FF0080" ).is_some() );
assert!( parse_hex( "#FF0080" ).is_some() );
}
#[ test ]
fn parse_hex_case_insensitive()
{
let upper = parse_hex( "#A1B2C3" ).unwrap();
let lower = parse_hex( "#a1b2c3" ).unwrap();
assert_eq!( upper, lower );
}
#[ test ]
fn parse_hex_rejects_garbage()
{
assert!( parse_hex( "" ).is_none() );
assert!( parse_hex( "#" ).is_none() );
assert!( parse_hex( "#XYZ" ).is_none() );
// 5 / 7 / 9 digits — not one of the valid widths (3/4/6/8).
assert!( parse_hex( "#12345" ).is_none() );
assert!( parse_hex( "#1234567" ).is_none() );
// Non-hex character inside a valid-length string.
assert!( parse_hex( "#GG0000" ).is_none() );
}
#[ test ]
fn parse_hex_round_trips_through_color_to_hex()
{
let original = Color::rgba( 0.2, 0.4, 0.6, 1.0 );
let s = color_to_hex( original, false );
let parsed = parse_hex( &s ).expect( "ok" );
// Allow ±1/255 quantisation slack.
assert!( ( parsed.r - original.r ).abs() < 1.0 / 255.0 + 1e-6 );
assert!( ( parsed.g - original.g ).abs() < 1.0 / 255.0 + 1e-6 );
assert!( ( parsed.b - original.b ).abs() < 1.0 / 255.0 + 1e-6 );
}
// ── builders ──────────────────────────────────────────────────────────────
#[ derive( Clone, Debug, PartialEq ) ]
enum Msg { Pick( Color ) }
#[ test ]
fn defaults()
{
let p: ColorPicker<Msg> = color_picker( Color::WHITE );
assert_eq!( p.value, Color::WHITE );
assert!( !p.show_alpha );
assert!( p.on_change.is_none() );
}
#[ test ]
fn build_does_not_panic_minimal()
{
let _: Element<Msg> = color_picker::<Msg>( Color::WHITE ).build();
}
#[ test ]
fn build_does_not_panic_with_alpha_and_callback()
{
let _: Element<Msg> = color_picker::<Msg>( Color::rgba( 0.2, 0.4, 0.6, 0.8 ) )
.show_alpha( true )
.on_change( Msg::Pick )
.build();
}
// ── Hue arithmetic ────────────────────────────────────────────────────────
#[ test ]
fn rgb_to_hue_pure_primaries()
{
assert!( ( rgb_to_hue( 1.0, 0.0, 0.0 ) - 0.0 ).abs() < 1e-3 );
assert!( ( rgb_to_hue( 1.0, 1.0, 0.0 ) - 60.0 ).abs() < 1e-3 );
assert!( ( rgb_to_hue( 0.0, 1.0, 0.0 ) - 120.0 ).abs() < 1e-3 );
assert!( ( rgb_to_hue( 0.0, 1.0, 1.0 ) - 180.0 ).abs() < 1e-3 );
assert!( ( rgb_to_hue( 0.0, 0.0, 1.0 ) - 240.0 ).abs() < 1e-3 );
assert!( ( rgb_to_hue( 1.0, 0.0, 1.0 ) - 300.0 ).abs() < 1e-3 );
}
#[ test ]
fn rgb_to_hue_grey_returns_zero()
{
assert_eq!( rgb_to_hue( 0.5, 0.5, 0.5 ), 0.0 );
assert_eq!( rgb_to_hue( 0.0, 0.0, 0.0 ), 0.0 );
}
#[ test ]
fn hue_to_rgb_round_trips_at_primaries()
{
for deg in [ 0.0, 60.0, 120.0, 180.0, 240.0, 300.0 ]
{
let ( r, g, b ) = hue_to_rgb( deg );
let h = rgb_to_hue( r, g, b );
let diff = ( ( h - deg + 540.0 ) % 360.0 - 180.0 ).abs();
assert!( diff < 1e-3, "hue {deg} degrees did not round-trip (got {h})" );
}
}
}

977
src/widget/combo.rs Normal file
View File

@@ -0,0 +1,977 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Combo / select / dropdown widget.
//!
//! Editable, searchable, multi-select, scrollable. Built as a pure
//! composition over existing widgets ([`text_edit`](super::text_edit),
//! [`list_item`](super::list_item), [`pressable`](super::pressable),
//! [`scroll`](super::scroll), [`viewport`](super::viewport),
//! [`container`](super::container), [`stack`](crate::layout::stack)),
//! so the runtime needs no special layout / dispatch path **and the
//! widget works in plain `xdg-shell` application windows** — the popup
//! is a `Stack` overlay layered into the same surface as the trigger,
//! not a separate Wayland surface.
//!
//! ## Mental model
//!
//! A `Combo` is a *projection* over a [`ComboState`] that the
//! application owns and mutates from `update()`. The widget itself is
//! stateless: every frame the app rebuilds it from current state and
//! consumes the messages the user produces.
//!
//! Two pieces flow into the app's view tree:
//!
//! 1. The **trigger** (returned by [`Combo::trigger`]) lives wherever the
//! app puts a normal widget: a column, a row, inside a container. It
//! paints the labelled pill, the optional chips for multi-select
//! selections, the query text edit, the down-arrow toggle, and the
//! helper / error rows.
//! 2. The **popup** (returned by [`Combo::popup`]) is `None` when the
//! combo is closed and `Some(Element)` when open. The application
//! layers it on top of the rest of the view via [`stack`](crate::stack).
//! The returned element already contains a full-surface dismiss
//! layer behind the panel so a tap outside fires
//! [`Combo::on_dismiss`].
//!
//! ## Wiring
//!
//! ```rust,no_run
//! # #[ derive( Clone ) ] enum Msg {
//! # FruitQuery( String ), FruitToggle,
//! # FruitSelect( usize ), FruitUnselect( usize ), FruitDismiss,
//! # }
//! use ltk::{ App, Combo, ComboState, Element, combo, column, stack };
//!
//! struct AppState
//! {
//! fruits: ComboState,
//! }
//!
//! impl AppState
//! {
//! fn build_combo( &self ) -> Combo<Msg>
//! {
//! combo( self.fruits.clone(), [ "Apple", "Banana", "Cherry", "Date" ]
//! .iter().map( |s| s.to_string() ).collect() )
//! .label( "Fruits" )
//! .placeholder( "Pick one or more…" )
//! .multi_select( true )
//! .searchable( true )
//! .on_query_change( Msg::FruitQuery )
//! .on_toggle_open( Msg::FruitToggle )
//! .on_select_idx( Msg::FruitSelect )
//! .on_unselect_idx( Msg::FruitUnselect )
//! .on_dismiss( Msg::FruitDismiss )
//! }
//! }
//!
//! impl App for AppState
//! {
//! type Message = Msg;
//! fn view( &self ) -> Element<Msg>
//! {
//! let combo = self.build_combo();
//! let mut s = stack::<Msg>().push( column().push( combo.trigger() ) );
//! if let Some( p ) = combo.popup() { s = s.push( p ); }
//! s.into()
//! }
//! # fn update( &mut self, _msg: Msg ) {}
//! }
//! ```
//!
//! `update()` then handles the messages: append to / remove from
//! `fruits.selected` on `FruitSelect` / `FruitUnselect`, flip
//! `fruits.is_open` on `FruitToggle` / `FruitDismiss`, and copy the new
//! query string into `fruits.query` on `FruitQuery`.
//!
//! ## Dismiss behaviour
//!
//! The runtime fires [`Combo::on_dismiss`] in three situations: the
//! compositor sends `xdg_popup.popup_done`; the user taps the main
//! surface outside the trigger pill while the popup is mapped; or the
//! user presses Escape. The app's only job is to flip `is_open` to
//! `false` in `update()` — the runtime is idempotent if the message
//! arrives more than once for the same open / close cycle. See
//! [`crate::app::OverlaySpec::on_dismiss`] for the full contract.
use std::sync::Arc;
use std::collections::hash_map::DefaultHasher;
use std::hash::{ Hash, Hasher };
use crate::app::{ Anchor, Layer, OverlayId, OverlaySpec };
use crate::types::WidgetId;
use super::Element;
/// Stable identifier of the trigger pill used as the popup's anchor.
/// Read by [`crate::widget::anchored_overlay::AnchoredOverlay`] in the
/// draw pass to position the popup flush below the trigger. A single
/// constant is enough as long as only one combo is open at a time —
/// the popup of a closed combo contributes nothing to the view tree,
/// so two combos with the same anchor id coexist fine when they don't
/// open simultaneously. Apps that need overlapping open combos should
/// switch to [`Combo::anchor_id`] with distinct ids.
const COMBO_ANCHOR_DEFAULT: WidgetId = WidgetId( "ltk-combo-trigger" );
/// Application-owned state for a [`Combo`].
///
/// Lives on the app struct and is mutated from `update()`. The widget
/// reads it once per frame to render the trigger and popup; nothing
/// inside ltk keeps a copy.
#[ derive( Debug, Clone, Default ) ]
pub struct ComboState
{
/// Current text in the trigger's search field. Drives item filtering.
/// Empty string means "no filter — show every item".
pub query: String,
/// `true` when the popup is visible.
pub is_open: bool,
/// Indices into the `items` slice currently selected. In single-select
/// mode this is empty or has a single entry; in multi-select mode it
/// can hold any subset.
pub selected: Vec<usize>,
}
impl ComboState
{
/// Empty state: no query, popup closed, nothing selected.
pub fn new() -> Self { Self::default() }
/// Convenience: toggle `is_open`.
pub fn toggle_open( &mut self ) { self.is_open = !self.is_open; }
/// Convenience: add `idx` to `selected` if not present.
pub fn select( &mut self, idx: usize )
{
if !self.selected.contains( &idx ) { self.selected.push( idx ); }
}
/// Convenience: remove `idx` from `selected` if present.
pub fn unselect( &mut self, idx: usize )
{
self.selected.retain( |&i| i != idx );
}
}
/// A combo / select / dropdown widget.
///
/// See the module-level documentation for the full wiring pattern.
/// Build via [`combo`] and configure with the chained builders.
pub struct Combo<Msg: Clone>
{
state: ComboState,
items: Vec<String>,
label: Option<String>,
description: Option<String>,
placeholder: Option<String>,
helper: Option<String>,
error: Option<String>,
disabled: bool,
multi_select: bool,
searchable: bool,
max_chips_visible: usize,
anchor_id: WidgetId,
popup_gap: f32,
popup_width: f32,
popup_max_height: f32,
on_query_change: Option<Arc<dyn Fn( String ) -> Msg>>,
on_toggle_open: Option<Msg>,
on_select_idx: Option<Arc<dyn Fn( usize ) -> Msg>>,
on_unselect_idx: Option<Arc<dyn Fn( usize ) -> Msg>>,
on_dismiss: Option<Msg>,
}
impl<Msg: Clone> Combo<Msg>
{
/// Construct a combo over `items` driven by `state`. Both arguments
/// are taken by value because the widget tree is rebuilt every
/// frame; clone the app's state slice into here.
pub fn new( state: ComboState, items: Vec<String> ) -> Self
{
Self
{
state,
items,
label: None,
description: None,
placeholder: None,
helper: None,
error: None,
disabled: false,
multi_select: false,
searchable: false,
max_chips_visible: 4,
anchor_id: COMBO_ANCHOR_DEFAULT,
popup_gap: 4.0,
popup_width: 320.0,
popup_max_height: 280.0,
on_query_change: None,
on_toggle_open: None,
on_select_idx: None,
on_unselect_idx: None,
on_dismiss: None,
}
}
/// Bold label drawn above the trigger.
pub fn label( mut self, s: impl Into<String> ) -> Self
{
self.label = Some( s.into() );
self
}
/// Optional descriptive paragraph drawn below the label.
pub fn description( mut self, s: impl Into<String> ) -> Self
{
self.description = Some( s.into() );
self
}
/// Placeholder for the trigger's text edit when the query is empty.
pub fn placeholder( mut self, s: impl Into<String> ) -> Self
{
self.placeholder = Some( s.into() );
self
}
/// Helper / informative text below the trigger. Hidden when an
/// error message is set.
pub fn helper( mut self, s: impl Into<String> ) -> Self
{
self.helper = Some( s.into() );
self
}
/// Error message below the trigger. Replaces the helper text and
/// applies the destructive theme tokens to the trigger surface.
pub fn error( mut self, s: impl Into<String> ) -> Self
{
self.error = Some( s.into() );
self
}
/// Render in the disabled style. Activations / selections still emit
/// messages — the consumer is responsible for ignoring them in
/// `update()`.
pub fn disabled( mut self, yes: bool ) -> Self
{
self.disabled = yes;
self
}
/// Allow multiple items to be selected at once. Selected items are
/// rendered as chips above the trigger.
pub fn multi_select( mut self, yes: bool ) -> Self
{
self.multi_select = yes;
self
}
/// Make the trigger an editable text field that filters the popup
/// list as the user types. Without `searchable`, the trigger is a
/// pressable button that displays the current selection.
pub fn searchable( mut self, yes: bool ) -> Self
{
self.searchable = yes;
self
}
/// Cap on the number of selection chips drawn above the trigger
/// before falling back to a "+N more" indicator. Default `4`.
pub fn max_chips_visible( mut self, n: usize ) -> Self
{
self.max_chips_visible = n;
self
}
/// Stable identifier of the trigger pill. The popup looks the rect
/// of this widget up in the previous frame's layout snapshot to
/// place itself flush below the trigger. Apps with several combos
/// that may open simultaneously must give each a distinct id.
pub fn anchor_id( mut self, id: WidgetId ) -> Self
{
self.anchor_id = id;
self
}
/// Vertical gap (logical pixels) between the bottom of the trigger
/// and the top of the popup panel. Default `4`.
pub fn popup_gap( mut self, px: f32 ) -> Self
{
self.popup_gap = px.max( 0.0 );
self
}
/// Width of the popup in logical pixels. Default `320`.
pub fn popup_width( mut self, px: f32 ) -> Self
{
self.popup_width = px.max( 80.0 );
self
}
/// Maximum height of the popup before it scrolls internally. Default
/// `280`. The popup never grows past this height; it shrinks to fit
/// the filtered item list when shorter.
pub fn popup_max_height( mut self, px: f32 ) -> Self
{
self.popup_max_height = px.max( 80.0 );
self
}
/// Callback fired with the new query string on every keystroke
/// inside the trigger's search field. Required when `searchable( true )`.
pub fn on_query_change( mut self, f: impl Fn( String ) -> Msg + 'static ) -> Self
{
self.on_query_change = Some( Arc::new( f ) );
self
}
/// Message emitted when the user activates the trigger to toggle the
/// popup open / closed (tap on the trigger pill or press the
/// down-arrow icon button).
pub fn on_toggle_open( mut self, msg: Msg ) -> Self
{
self.on_toggle_open = Some( msg );
self
}
/// Callback fired with the index of the item the user selects from
/// the popup. The application's `update()` should add that index to
/// `state.selected` (multi-select) or replace it (single-select).
pub fn on_select_idx( mut self, f: impl Fn( usize ) -> Msg + 'static ) -> Self
{
self.on_select_idx = Some( Arc::new( f ) );
self
}
/// Callback fired with the index of a chip the user dismisses
/// (tapping its `×`). Multi-select only.
pub fn on_unselect_idx( mut self, f: impl Fn( usize ) -> Msg + 'static ) -> Self
{
self.on_unselect_idx = Some( Arc::new( f ) );
self
}
/// Message emitted when the user taps outside the popup. Typically
/// flips `state.is_open` back to `false` in `update()`.
pub fn on_dismiss( mut self, msg: Msg ) -> Self
{
self.on_dismiss = Some( msg );
self
}
// ── filtering / item lookup ──────────────────────────────────────────
/// Return the indices of items that match the current query (case
/// insensitive `contains` match). Empty query passes every item.
pub fn filtered_indices( &self ) -> Vec<usize>
{
let q = self.state.query.to_lowercase();
if q.is_empty()
{
return ( 0..self.items.len() ).collect();
}
self.items.iter().enumerate()
.filter( |( _, item )| item.to_lowercase().contains( &q ) )
.map( |( i, _ )| i )
.collect()
}
fn first_selected_label( &self ) -> Option<&str>
{
self.state.selected.first()
.and_then( |&i| self.items.get( i ) )
.map( |s| s.as_str() )
}
}
// Methods that build widget trees. Split into a `'static` impl block so
// `From<Combo<Msg>>` and the constructor functions don't fight over
// generic bounds.
impl<Msg: Clone + 'static> Combo<Msg>
{
/// Build the trigger: label / description / chips (multi-select) /
/// pill with text-edit + arrow / helper or error row. Returns an
/// [`Element`] ready to drop into [`App::view`](crate::App::view).
pub fn trigger( &self ) -> Element<Msg>
{
use super::{ container, text, text_edit };
use super::flex::flex;
use super::pressable::pressable;
use crate::layout::column::column;
use crate::layout::row::row;
use crate::layout::spacer::spacer;
let palette = crate::theme::palette();
let mut col = column::<Msg>().padding( 0.0 ).spacing( 8.0 ).align_center_x( false );
// Label.
if let Some( ref s ) = self.label
{
let c = if self.disabled { palette.text_secondary } else { palette.text_primary };
col = col.push( text( s.clone() ).size( 16.0 ).color( c ) );
}
// Descriptive text.
if let Some( ref s ) = self.description
{
col = col.push( text( s.clone() ).size( 16.0 ).color( palette.text_secondary ) );
}
// Chip strip (multi-select with at least one selection).
if self.multi_select && !self.state.selected.is_empty()
{
const CHIP_X_PX: u32 = 14;
let chip_x = crate::theme::icon_rgba( "window/close", CHIP_X_PX )
.map( | ( rgba, w, h ) |
{
let tinted = std::sync::Arc::new(
crate::theme::tint_symbolic( &rgba, palette.text_primary ),
);
( tinted, w, h )
} );
let mut chips = row::<Msg>().padding( 0.0 ).spacing( 6.0 );
let visible = self.state.selected.iter().take( self.max_chips_visible );
for &idx in visible
{
if let Some( label ) = self.items.get( idx ).cloned()
{
let label_color = palette.text_primary;
let mut chip_row = row::<Msg>().padding( 0.0 ).spacing( 10.0 )
.push( text( label ).size( 14.0 ).color( label_color ) );
if let Some( cb ) = self.on_unselect_idx.as_ref()
{
let msg = cb( idx );
if let Some( ( ref rgba, w, h ) ) = chip_x
{
let btn = crate::widget::icon_button::<Msg>( std::sync::Arc::clone( rgba ), w, h )
.icon_size( CHIP_X_PX as f32 )
.on_press( msg );
chip_row = chip_row.push( btn );
}
}
let chip: Element<Msg> = container::<Msg>( chip_row )
.background( palette.surface_alt )
.padding_h( 10.0 )
.padding_v( 4.0 )
.radius( 16.0 )
.into();
chips = chips.push( chip );
}
}
let extra = self.state.selected.len().saturating_sub( self.max_chips_visible );
if extra > 0
{
chips = chips.push(
text( format!( "+{extra}" ) ).size( 14.0 ).color( palette.text_secondary ),
);
}
col = col.push( chips );
}
// Trigger pill: text-edit (if searchable) + arrow toggle.
let trigger_inner: Element<Msg> = {
let mut r = row::<Msg>().padding( 0.0 ).spacing( 8.0 );
if self.searchable
{
let placeholder = self.placeholder.clone().unwrap_or_default();
let value = self.state.query.clone();
let mut te = text_edit::<Msg>( placeholder, value );
if let Some( cb ) = self.on_query_change.as_ref()
{
let cb = Arc::clone( cb );
te = te.on_change( move |s| cb( s ) );
}
// Wrap in `flex` so the text edit consumes the leftover
// width inside the row instead of claiming `max_width` for
// itself and pushing the down-arrow off-screen.
r = r.push( flex( te ) );
}
else
{
let display = self.first_selected_label()
.map( |s| s.to_string() )
.or_else( || self.placeholder.clone() )
.unwrap_or_default();
let c = if self.first_selected_label().is_some()
{
palette.text_primary
}
else
{
palette.text_secondary
};
r = r.push( text( display ).size( 16.0 ).color( c ) );
// Push the down-arrow to the right edge.
r = r.push( spacer() );
}
const CHEVRON_PX: u32 = 18;
let icon_name = if self.state.is_open
{
"general/up-simple"
} else {
"general/down-simple"
};
if let Some( ( rgba, w, h ) ) = crate::theme::icon_rgba( icon_name, CHEVRON_PX )
{
let tinted = std::sync::Arc::new(
crate::theme::tint_symbolic( &rgba, palette.text_primary ),
);
let toggle_button = crate::widget::icon_button::<Msg>( tinted, w, h )
.icon_size( CHEVRON_PX as f32 );
if let Some( ref msg ) = self.on_toggle_open
{
r = r.push( toggle_button.on_press( msg.clone() ) );
}
else
{
r = r.push( toggle_button );
}
}
r.into()
};
let ( pill_bg, pill_border ) = if self.error.is_some()
{
( palette.danger_bg, palette.danger )
}
else
{
( palette.surface_alt, palette.divider )
};
let pill: Element<Msg> = container::<Msg>( trigger_inner )
.background( pill_bg )
.border( pill_border, 1.0 )
.padding_h( 16.0 )
.padding_v( 12.0 )
.radius( 32.0 )
.into();
// Always wrap the pill in a pressable so the popup toggles when
// the user taps anywhere on the pill chrome. Inner interactive
// widgets (text_edit, button) keep priority because the layout
// pass pushes the pressable's hit rect *before* recursing into
// the children — `find_widget_at` iterates the rect list in
// reverse, so deeper widgets win on overlap. Without this
// fallback, clicks that hit the gap between the text edit and
// the chevron button (or the rounded corners outside any child
// rect) silently land on nothing.
//
// The pressable also carries the anchor id so the popup can
// look the trigger pill's rect up at draw time and place
// itself flush below.
let pill: Element<Msg> = {
let p = pressable::<Msg>( pill ).id( self.anchor_id );
let p = if let Some( ref msg ) = self.on_toggle_open
{
p.on_press( msg.clone() )
} else { p };
p.into()
};
col = col.push( pill );
// Helper / error row.
if let Some( ref err ) = self.error
{
col = col.push( text( err.clone() ).size( 14.0 ).color( palette.danger ) );
}
else if let Some( ref h ) = self.helper
{
col = col.push( text( h.clone() ).size( 14.0 ).color( palette.text_secondary ) );
}
col.into()
}
/// Build the popup as a [`Stack`](crate::Stack)-overlayable element.
/// Returns `None` when the combo is closed.
///
/// The returned element is meant to be layered **on top of the rest
/// of the application's view tree** in the same surface — typically
/// by wrapping the app's `view()` output in a [`stack`](crate::stack)
/// and pushing this element when it is `Some`. It already contains:
///
/// 1. A full-surface dismiss layer behind the panel — a transparent
/// [`pressable`](super::pressable::Pressable) that fires
/// [`Combo::on_dismiss`] when the user taps anywhere outside the
/// panel.
/// 2. The panel itself, centred horizontally and anchored 80 px from
/// the top of the available rect, with the filtered item list
/// inside a scrolling viewport bounded by [`Combo::popup_width`]
/// and [`Combo::popup_max_height`].
///
/// The popup centres modal-style — it does not anchor to the trigger
/// position because the trigger's screen-space rect is not known at
/// `view()` time. For a trigger-anchored popup, drive the popup's
/// position from a runtime hint the application stores after the
/// first hit-test.
pub fn popup( &self ) -> Option<Element<Msg>>
{
if !self.state.is_open { return None; }
use super::container;
use super::list_item::list_item;
use super::pressable::pressable;
use super::scroll::scroll;
use super::text;
use super::viewport::viewport;
use crate::layout::column::column;
use crate::layout::spacer::spacer;
use crate::layout::stack::{ stack, HAlign, VAlign };
let palette = crate::theme::palette();
// Build the filtered list of items. The list_item widget paints
// its own hover / pressed surfaces; `selected( true )` overrides
// both with the dark surface + white text variant the design
// system specifies for the picked option.
let mut list = column::<Msg>().padding( 4.0 ).spacing( 0.0 ).align_center_x( false );
let filtered = self.filtered_indices();
for idx in &filtered
{
let label = self.items[ *idx ].clone();
let is_selected = self.state.selected.contains( idx );
let mut li = list_item::<Msg>( label ).selected( is_selected );
if let Some( cb ) = self.on_select_idx.as_ref()
{
li = li.on_press( cb( *idx ) );
}
list = list.push( li );
}
// Empty-list hint when no item matches the query.
if filtered.is_empty()
{
list = list.push(
text( "No matches" ).size( 14.0 ).color( palette.text_secondary ),
);
}
// Wrap the list in a viewport that pins the popup's max height
// and lets the inner scroll do its thing when the content
// overflows.
let scroller: Element<Msg> = scroll::<Msg>( list ).into();
let bounded: Element<Msg> = viewport::<Msg>( scroller )
.width( self.popup_width )
.height( self.popup_max_height )
.into();
let panel: Element<Msg> = container::<Msg>( bounded )
.background( palette.surface_alt )
.border( palette.divider, 1.0 )
.padding( 8.0 )
.radius( 32.0 )
.into();
// Build the popup as a Stack with two layers:
// - layer 0 (Fill, Fill): full-surface dismiss layer. Built
// as `pressable( spacer() )` because Spacer reports zero
// intrinsic size and Stack's `Fill` alignment grows it to
// the full Stack rect (which the user installs at the root
// of `view()`, so it spans the whole surface).
// - layer 1 (Start, Top): the actual panel. The outer
// `AnchoredOverlay` below relocates this layer to flush
// below the trigger; without an anchor (first frame after
// open) the layer renders at the surface's top-left, which
// is acceptable as a one-frame artefact.
let mut s = stack::<Msg>();
if let Some( ref dismiss ) = self.on_dismiss
{
let backdrop: Element<Msg> = pressable::<Msg>( spacer() )
.on_press( dismiss.clone() )
.into();
s = s.push( backdrop );
}
// The dismiss layer wants the FULL surface; the panel wants the
// anchored rect under the trigger. They cannot share a single
// `AnchoredOverlay` — wrapping the Stack root would relocate
// both layers and break the dismiss layer's full-coverage
// requirement.
//
// Solution: keep the dismiss layer at root level, and wrap
// only the panel in `AnchoredOverlay` so it (alone) reads
// the trigger's anchor.
let anchored_panel: Element<Msg> = super::anchored_overlay::AnchoredOverlay::new(
panel,
self.anchor_id,
self.popup_gap,
).into();
s = s.push_aligned( anchored_panel, HAlign::Start, VAlign::Top );
Some( s.into() )
}
/// Build the [`OverlaySpec`] that the application should return from
/// [`App::overlays`](crate::App::overlays) when this combo is open.
///
/// Returns `None` when the combo is closed.
///
/// Unlike [`Combo::popup`], the overlay is rendered as a real Wayland
/// **xdg-popup** child of the application's main window — it can extend
/// outside the parent surface (the canonical select / dropdown
/// behaviour) and is positioned by the compositor relative to the
/// trigger pill rect from the previous frame's layout. The
/// [`OverlayId`] is derived from [`Combo::anchor_id`] so two combos
/// with distinct anchor ids get distinct overlay ids automatically.
///
/// The spec's `view` is the panel itself (background, border, padding,
/// rounded corners and the bounded scrolling item list). No extra
/// dismiss layer is needed: tap-outside dismissal is handled by the
/// usual ltk mechanism — wire [`App::on_tap`](crate::App::on_tap) to
/// close the combo, or rely on the spec's `on_dismiss` which fires
/// when the compositor sends `popup_done`.
pub fn overlay( &self ) -> Option<OverlaySpec<Msg>>
{
if !self.state.is_open { return None; }
use super::container;
use super::list_item::list_item;
use super::scroll::scroll;
use super::text;
use super::viewport::viewport;
use crate::layout::column::column;
let palette = crate::theme::palette();
let mut list = column::<Msg>().padding( 4.0 ).spacing( 0.0 ).align_center_x( false );
let filtered = self.filtered_indices();
for idx in &filtered
{
let label = self.items[ *idx ].clone();
let is_selected = self.state.selected.contains( idx );
let mut li = list_item::<Msg>( label ).selected( is_selected );
if let Some( cb ) = self.on_select_idx.as_ref()
{
li = li.on_press( cb( *idx ) );
}
list = list.push( li );
}
if filtered.is_empty()
{
list = list.push(
text( "No matches" ).size( 14.0 ).color( palette.text_secondary ),
);
}
let scroller: Element<Msg> = scroll::<Msg>( list ).into();
let bounded: Element<Msg> = viewport::<Msg>( scroller )
.width( self.popup_width )
.height( self.popup_max_height )
.into();
let panel: Element<Msg> = container::<Msg>( bounded )
.background( palette.surface_alt )
.border( palette.divider, 1.0 )
.padding( 8.0 )
.radius( 32.0 )
.into();
// Derive the OverlayId from the anchor_id's static-str so apps
// don't have to manually pick non-colliding ids for each combo.
let mut hasher = DefaultHasher::new();
self.anchor_id.0.hash( &mut hasher );
let overlay_id = OverlayId( hasher.finish() as u32 );
Some( OverlaySpec
{
id: overlay_id,
// `layer` / `anchor` / `exclusive_zone` / `keyboard_exclusive`
// are ignored on the xdg-popup path; the values below are the
// neutral defaults a layer-shell fallback would expect.
layer: Layer::Overlay,
anchor: Anchor::ALL,
// `size.0 == 0` asks the runtime to size the popup to the
// trigger pill width (the canonical select / dropdown
// behaviour). Height stays capped at `popup_max_height`.
size: ( 0, self.popup_max_height as u32 ),
exclusive_zone: 0,
keyboard_exclusive: false,
input_region: None,
view: panel,
on_dismiss: self.on_dismiss.clone(),
anchor_widget_id: Some( self.anchor_id ),
} )
}
}
/// Create a [`Combo`] over `items` driven by `state`.
pub fn combo<Msg: Clone>( state: ComboState, items: Vec<String> ) -> Combo<Msg>
{
Combo::new( state, items )
}
#[ cfg( test ) ]
mod tests
{
use super::*;
fn st() -> ComboState { ComboState::new() }
#[ test ]
fn defaults_match_documented_values()
{
let c: Combo<()> = combo( st(), vec![] );
assert!( c.label.is_none() );
assert!( !c.disabled );
assert!( !c.multi_select );
assert!( !c.searchable );
assert_eq!( c.max_chips_visible, 4 );
assert_eq!( c.popup_width, 320.0 );
assert_eq!( c.popup_max_height, 280.0 );
}
#[ test ]
fn builders_round_trip_through_struct_fields()
{
let c: Combo<()> = combo( st(), vec![] )
.label( "Fruit" )
.description( "Choose" )
.placeholder( "Pick…" )
.helper( "Type to search" )
.disabled( true )
.multi_select( true )
.searchable( true )
.popup_width( 400.0 )
.popup_max_height( 320.0 )
.max_chips_visible( 6 );
assert_eq!( c.label.as_deref(), Some( "Fruit" ) );
assert_eq!( c.description.as_deref(), Some( "Choose" ) );
assert_eq!( c.placeholder.as_deref(), Some( "Pick…" ) );
assert_eq!( c.helper.as_deref(), Some( "Type to search" ) );
assert!( c.disabled );
assert!( c.multi_select );
assert!( c.searchable );
assert_eq!( c.popup_width, 400.0 );
assert_eq!( c.popup_max_height, 320.0 );
assert_eq!( c.max_chips_visible, 6 );
}
#[ test ]
fn popup_width_is_clamped_to_eighty_minimum()
{
let c: Combo<()> = combo( st(), vec![] ).popup_width( 10.0 );
assert_eq!( c.popup_width, 80.0 );
}
#[ test ]
fn popup_max_height_is_clamped_to_eighty_minimum()
{
let c: Combo<()> = combo( st(), vec![] ).popup_max_height( 10.0 );
assert_eq!( c.popup_max_height, 80.0 );
}
#[ test ]
fn empty_query_returns_every_index()
{
let items = vec![ "Apple".into(), "Banana".into(), "Cherry".into() ];
let c: Combo<()> = combo( st(), items );
assert_eq!( c.filtered_indices(), vec![ 0, 1, 2 ] );
}
#[ test ]
fn case_insensitive_contains_filter()
{
let items = vec![ "Apple".into(), "Banana".into(), "Avocado".into(), "Cherry".into() ];
let mut state = st();
state.query = "a".into();
let c: Combo<()> = combo( state, items );
// "Apple", "Banana", "Avocado" all contain `a` in some case;
// "Cherry" does not.
assert_eq!( c.filtered_indices(), vec![ 0, 1, 2 ] );
}
#[ test ]
fn filter_match_is_case_insensitive_for_query_uppercase()
{
let items = vec![ "apple".into(), "BANANA".into(), "Cherry".into() ];
let mut state = st();
state.query = "RY".into();
let c: Combo<()> = combo( state, items );
assert_eq!( c.filtered_indices(), vec![ 2 ] );
}
#[ test ]
fn filter_with_no_matches_returns_empty()
{
let items = vec![ "Apple".into(), "Banana".into() ];
let mut state = st();
state.query = "zzz".into();
let c: Combo<()> = combo( state, items );
assert!( c.filtered_indices().is_empty() );
}
#[ test ]
fn closed_state_yields_no_popup()
{
let c: Combo<()> = combo( st(), vec![ "x".into() ] );
assert!( c.popup().is_none() );
}
#[ test ]
fn open_state_yields_popup_element()
{
let mut state = st();
state.is_open = true;
let c: Combo<()> = combo( state, vec![ "x".into() ] );
assert!( c.popup().is_some(), "open combo must produce a popup element" );
}
#[ test ]
fn combo_state_helpers_select_and_unselect()
{
let mut s = ComboState::new();
s.select( 1 );
s.select( 3 );
s.select( 1 ); // duplicate should be a no-op
assert_eq!( s.selected, vec![ 1, 3 ] );
s.unselect( 1 );
assert_eq!( s.selected, vec![ 3 ] );
s.unselect( 99 ); // missing index is a no-op
assert_eq!( s.selected, vec![ 3 ] );
}
#[ test ]
fn combo_state_toggle_flips_open_flag()
{
let mut s = ComboState::new();
assert!( !s.is_open );
s.toggle_open();
assert!( s.is_open );
s.toggle_open();
assert!( !s.is_open );
}
#[ test ]
fn first_selected_label_returns_none_when_empty()
{
let c: Combo<()> = combo( st(), vec![ "Apple".into() ] );
assert!( c.first_selected_label().is_none() );
}
#[ test ]
fn first_selected_label_returns_first_selected_item()
{
let mut state = st();
state.selected = vec![ 1 ];
let c: Combo<()> = combo( state, vec![ "Apple".into(), "Banana".into() ] );
assert_eq!( c.first_selected_label(), Some( "Banana" ) );
}
#[ test ]
fn first_selected_label_handles_stale_index()
{
let mut state = st();
state.selected = vec![ 99 ]; // stale index — no panic
let c: Combo<()> = combo( state, vec![ "Apple".into() ] );
assert!( c.first_selected_label().is_none() );
}
}

374
src/widget/container.rs Normal file
View File

@@ -0,0 +1,374 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::{ Color, Corners };
use crate::render::Canvas;
use super::Element;
/// A transparent wrapper that adds a background color or a themed
/// surface and padding around any child [`Element`].
///
/// Does not consume a flat index — it is invisible to focus/hit-testing.
///
/// Two background styles. [`Container::background`] paints a flat
/// colour rounded rect. [`Container::surface`] names a theme slot (a
/// `"type": "surface"` entry in the active `ThemeDocument`) which
/// resolves at paint time to a full Glass stack: gradient / solid
/// fill, outer drop shadow, inset shadows, backdrop blur. `surface`
/// takes precedence when both are set, and degrades to `background`
/// (or to no background at all, when neither is set) if the slot is
/// absent from the active theme — third-party themes that do not
/// ship the named surface still render the content, just without
/// the Glass chrome.
///
/// ```rust,no_run
/// # use ltk::{ column, container, row, text, Color, Element };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex(
/// # icon: Element<Msg>,
/// # title: Element<Msg>,
/// # subtitle: Element<Msg>,
/// # ) -> ( Element<Msg>, Element<Msg> ) {
/// // Flat colour
/// let flat = container( text( "Hello" ) )
/// .background( Color::rgb( 0.2, 0.2, 0.25 ) )
/// .padding( 12.0 );
///
/// // Glass card backed by a named theme surface
/// let card = container(
/// row()
/// .push( icon )
/// .push( column().push( title ).push( subtitle ) )
/// )
/// .surface( "surface-card" )
/// .radius( 32.0 )
/// .padding_h( 16.5 )
/// .padding_v( 24.0 );
/// # ( flat.into(), card.into() )
/// # }
/// ```
pub struct Container<Msg: Clone>
{
pub child: Box<Element<Msg>>,
pub background: Option<Color>,
/// Slot id of a themed surface (resolved via
/// [`crate::theme::resolve_surface`]). When set, takes precedence
/// over `background` and paints the full Glass stack instead of a
/// flat colour fill.
pub surface: Option<String>,
/// Per-corner radii applied to every painted layer of the
/// container chrome — flat fill, themed surface (gradient + outer
/// shadows + insets + backdrop blur). Stored as [`Corners`] so
/// callers can pin the rounded shape to one or two corners (a
/// panel pinned to the screen bottom, a side panel pinned to the
/// left edge, …) without hitting the renderer with an offset
/// trick.
pub corners: Corners,
/// Padding on the top edge in logical px — gap between the
/// container's top boundary and its child.
pub pad_top: f32,
/// Padding on the right edge in logical px.
pub pad_right: f32,
/// Padding on the bottom edge in logical px.
pub pad_bottom: f32,
/// Padding on the left edge in logical px.
pub pad_left: f32,
pub opacity: f32,
/// Optional `( color, width_px )` border stroke painted around the
/// container's rounded rectangle, after the fill / surface and
/// before the child draws. `None` leaves the chrome flat.
pub border: Option<( Color, f32 )>,
}
impl<Msg: Clone> Container<Msg>
{
pub fn new( child: impl Into<Element<Msg>> ) -> Self
{
Self
{
child: Box::new( child.into() ),
background: None,
surface: None,
corners: Corners::ZERO,
pad_top: 0.0,
pad_right: 0.0,
pad_bottom: 0.0,
pad_left: 0.0,
opacity: 1.0,
border: None,
}
}
/// Paint a rounded-rect stroke around the container with the given
/// colour and pixel width. Useful for input fields, popovers and
/// any chrome the design system specifies as outlined rather than
/// filled.
pub fn border( mut self, color: Color, width: f32 ) -> Self
{
self.border = Some( ( color, width.max( 0.0 ) ) );
self
}
/// Set the background fill color. Ignored at paint time if a
/// themed [`surface`](Self::surface) is set and resolves against
/// the active theme.
pub fn background( mut self, color: Color ) -> Self
{
self.background = Some( color );
self
}
/// Back the container with a themed surface slot. The slot id is
/// resolved against the active `ThemeDocument` at paint time via
/// [`crate::theme::resolve_surface`]; missing slots fall through
/// to [`background`](Self::background) or to no background at all.
///
/// Slot ids are documented by the theme. The default theme ships
/// `surface-card` (generic Glass container) and the slider-specific
/// slots; downstream themes are free to add their own.
pub fn surface( mut self, slot: impl Into<String> ) -> Self
{
self.surface = Some( slot.into() );
self
}
/// Set the corner radii for every painted layer of the container
/// chrome. Accepts a single `f32` (uniform radius — the common
/// case, equivalent to `Corners::all( r )`), a tuple `( tl, tr,
/// br, bl )` (CSS shorthand order), or any explicit
/// [`Corners`] value.
///
/// ```rust,no_run
/// # use ltk::{ container, text, Corners, Element };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex() -> ( Element<Msg>, Element<Msg>, Element<Msg> ) {
/// // Uniform 16 px on all corners (single-value form).
/// let a = container( text( "child" ) ).radius( 16.0 );
///
/// // Rounded top corners only — for a panel pinned flush against
/// // the bottom edge of the screen.
/// let b = container( text( "child" ) ).radius( Corners::top( 16.0 ) );
///
/// // Custom four-corner radii.
/// let c = container( text( "child" ) ).radius( ( 16.0, 16.0, 0.0, 0.0 ) );
/// # ( a.into(), b.into(), c.into() )
/// # }
/// ```
pub fn radius( mut self, corners: impl Into<Corners> ) -> Self
{
self.corners = corners.into();
self
}
/// Set uniform padding on all four sides — equivalent to setting
/// `padding_top`, `padding_right`, `padding_bottom`, and
/// `padding_left` to `p`. Asymmetric variants
/// ([`padding_top`](Self::padding_top), …) override individual
/// edges, so calling this first and then a per-edge setter is the
/// idiomatic way to express "uniform padding except for one
/// edge".
pub fn padding( mut self, p: f32 ) -> Self
{
self.pad_top = p;
self.pad_right = p;
self.pad_bottom = p;
self.pad_left = p;
self
}
/// Set horizontal padding (left + right each).
pub fn padding_h( mut self, p: f32 ) -> Self
{
self.pad_left = p;
self.pad_right = p;
self
}
/// Set vertical padding (top + bottom each).
pub fn padding_v( mut self, p: f32 ) -> Self
{
self.pad_top = p;
self.pad_bottom = p;
self
}
/// Set the top edge padding only. Pairs with
/// [`padding_bottom`](Self::padding_bottom) for asymmetric
/// vertical insets.
pub fn padding_top( mut self, p: f32 ) -> Self
{
self.pad_top = p;
self
}
/// Set the right edge padding only.
pub fn padding_right( mut self, p: f32 ) -> Self
{
self.pad_right = p;
self
}
/// Set the bottom edge padding only.
pub fn padding_bottom( mut self, p: f32 ) -> Self
{
self.pad_bottom = p;
self
}
/// Set the left edge padding only.
pub fn padding_left( mut self, p: f32 ) -> Self
{
self.pad_left = p;
self
}
/// Set opacity for the entire container and its contents (0.0 = transparent, 1.0 = opaque).
pub fn opacity( mut self, alpha: f32 ) -> Self
{
self.opacity = alpha.clamp( 0.0, 1.0 );
self
}
/// Return the preferred `(width, height)` accounting for padding.
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 )
{
let pad_x = self.pad_left + self.pad_right;
let pad_y = self.pad_top + self.pad_bottom;
let inner_w = ( max_width - pad_x ).max( 0.0 );
let ( cw, ch ) = self.child.preferred_size( inner_w, canvas );
( cw + pad_x, ch + pad_y )
}
pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> Container<U>
where
U: Clone + 'static,
Msg: 'static,
{
Container
{
child: Box::new( self.child.map_arc( f ) ),
background: self.background,
surface: self.surface,
corners: self.corners,
pad_top: self.pad_top,
pad_right: self.pad_right,
pad_bottom: self.pad_bottom,
pad_left: self.pad_left,
opacity: self.opacity,
border: self.border,
}
}
}
/// Create a [`Container`] that wraps `child`.
pub fn container<Msg: Clone>( child: impl Into<Element<Msg>> ) -> Container<Msg>
{
Container::new( child )
}
#[ cfg( test ) ]
mod tests
{
use super::*;
use crate::layout::spacer::spacer;
#[ test ]
fn default_no_background()
{
let c = container::<()>( spacer() );
assert!( c.background.is_none() );
}
#[ test ]
fn padding_sets_all_four_sides()
{
let c = container::<()>( spacer() ).padding( 10.0 );
assert_eq!( c.pad_top, 10.0 );
assert_eq!( c.pad_right, 10.0 );
assert_eq!( c.pad_bottom, 10.0 );
assert_eq!( c.pad_left, 10.0 );
}
#[ test ]
fn padding_h_only_touches_left_and_right()
{
let c = container::<()>( spacer() ).padding_h( 8.0 );
assert_eq!( c.pad_left, 8.0 );
assert_eq!( c.pad_right, 8.0 );
assert_eq!( c.pad_top, 0.0 );
assert_eq!( c.pad_bottom, 0.0 );
}
#[ test ]
fn padding_v_only_touches_top_and_bottom()
{
let c = container::<()>( spacer() ).padding_v( 6.0 );
assert_eq!( c.pad_top, 6.0 );
assert_eq!( c.pad_bottom, 6.0 );
assert_eq!( c.pad_left, 0.0 );
assert_eq!( c.pad_right, 0.0 );
}
#[ test ]
fn per_edge_overrides_uniform_padding()
{
// Idiomatic "uniform with one override".
let c = container::<()>( spacer() )
.padding( 12.0 )
.padding_bottom( 22.0 );
assert_eq!( c.pad_top, 12.0 );
assert_eq!( c.pad_right, 12.0 );
assert_eq!( c.pad_bottom, 22.0 );
assert_eq!( c.pad_left, 12.0 );
}
#[ test ]
fn background_set()
{
use crate::types::Color;
let c = container::<()>( spacer() ).background( Color::BLACK );
assert!( c.background.is_some() );
}
#[ test ]
fn radius_set_uniform()
{
let c = container::<()>( spacer() ).radius( 12.0 );
assert_eq!( c.corners, Corners::all( 12.0 ) );
}
#[ test ]
fn radius_set_per_corner()
{
let c = container::<()>( spacer() ).radius( Corners::top( 16.0 ) );
assert_eq!( c.corners.tl, 16.0 );
assert_eq!( c.corners.tr, 16.0 );
assert_eq!( c.corners.br, 0.0 );
assert_eq!( c.corners.bl, 0.0 );
}
#[ test ]
fn radius_set_tuple()
{
let c = container::<()>( spacer() ).radius( ( 8.0, 4.0, 2.0, 1.0 ) );
assert_eq!( c.corners.tl, 8.0 );
assert_eq!( c.corners.tr, 4.0 );
assert_eq!( c.corners.br, 2.0 );
assert_eq!( c.corners.bl, 1.0 );
}
#[ test ]
fn default_no_surface()
{
let c = container::<()>( spacer() );
assert!( c.surface.is_none() );
}
#[ test ]
fn surface_stores_slot_id()
{
let c = container::<()>( spacer() ).surface( "surface-card" );
assert_eq!( c.surface.as_deref(), Some( "surface-card" ) );
}
}

617
src/widget/date_picker.rs Normal file
View File

@@ -0,0 +1,617 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! DatePicker — month-grid calendar widget.
//!
//! Stateless: the application owns the selected date and the
//! currently-visible month. Two callbacks bridge widget → app:
//!
//! - [`DatePicker::on_change`] fires when the user taps a day cell;
//! the message carries the picked [`Date`].
//! - [`DatePicker::on_navigate`] fires when the user taps the
//! previous / next-month arrows; the message carries the new
//! `(year, month)` that the calendar should display.
//!
//! `on_navigate` is optional — when not wired, the navigation arrows
//! render disabled. The application typically stores both `date:
//! Date` and a `view: Date` (or `(view_year, view_month)`) in its
//! state and updates `view` from `on_navigate` to let the user scroll
//! through months without changing the selection.
//!
//! Date arithmetic (leap years, day-of-week, month wraparound) is
//! built in — no `chrono` / `time` dependency. Limited to the
//! Gregorian calendar from year 1 onwards (Zeller's congruence).
//!
//! ```rust,no_run
//! # use ltk::{ date_picker, Date, DatePicker };
//! # #[ derive( Clone ) ] enum Msg { DateChanged( Date ), DateView( i32, u8 ) }
//! # struct App { date: Date, view_year: i32, view_month: u8, today: Date }
//! # impl App { fn _ex( &self ) -> DatePicker<Msg> {
//! date_picker( self.date )
//! .view( self.view_year, self.view_month )
//! .today( self.today )
//! .on_change( Msg::DateChanged )
//! .on_navigate( |y, m| Msg::DateView( y, m ) )
//! # }}
//! ```
use std::sync::Arc;
use crate::layout::column::column;
use crate::layout::spacer::spacer;
use crate::layout::wrap_grid::grid;
use super::Element;
mod theme
{
use crate::types::Color;
pub fn surface() -> Color { crate::theme::palette().surface }
pub fn surface_alt() -> Color { crate::theme::palette().surface_alt }
pub fn text() -> Color { crate::theme::palette().text_primary }
pub fn text_muted() -> Color { crate::theme::palette().text_secondary }
pub fn accent() -> Color { crate::theme::palette().accent }
pub const PADDING: f32 = 16.0;
pub const RADIUS: f32 = 16.0;
pub const HEADER_FS: f32 = 16.0;
pub const DOW_FS: f32 = 12.0;
pub const DAY_FS: f32 = 14.0;
pub const CELL_SIZE: f32 = 36.0;
pub const SPACING: f32 = 4.0;
}
/// A calendar date in the proleptic Gregorian calendar. No time
/// component, no timezone. `month` is 112, `day` is 131 (further
/// constrained by [`days_in_month`]).
#[ derive( Clone, Copy, Debug, PartialEq, Eq, Hash ) ]
pub struct Date
{
pub year: i32,
pub month: u8,
pub day: u8,
}
impl Date
{
/// Construct a [`Date`] without validating bounds. Callers that
/// might pass user-controlled data should run [`Self::is_valid`]
/// first.
pub const fn new( year: i32, month: u8, day: u8 ) -> Self
{
Self { year, month, day }
}
/// `true` when `month` is in `1..=12` and `day` is a real day of
/// that month / year (29-Feb in leap years, etc.).
pub fn is_valid( self ) -> bool
{
( 1..=12 ).contains( &self.month )
&& self.day >= 1
&& self.day <= days_in_month( self.year, self.month )
}
}
/// `true` when `year` is a leap year in the proleptic Gregorian
/// calendar.
pub fn is_leap_year( year: i32 ) -> bool
{
( year % 4 == 0 && year % 100 != 0 ) || year % 400 == 0
}
/// Number of days in `month` of `year` (1-indexed month). Returns 0
/// for invalid month numbers.
pub fn days_in_month( year: i32, month: u8 ) -> u8
{
match month
{
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 => if is_leap_year( year ) { 29 } else { 28 },
_ => 0,
}
}
/// Day of the week for `(year, month, day)`. `0 = Sunday`,
/// `1 = Monday`, …, `6 = Saturday`. Uses Zeller's congruence;
/// undefined for years before AD 1.
pub fn day_of_week( year: i32, month: u8, day: u8 ) -> u8
{
let ( m, y ) = if month < 3 { ( month as i32 + 12, year - 1 ) } else { ( month as i32, year ) };
let k = y.rem_euclid( 100 );
let j = y.div_euclid( 100 );
let h = ( day as i32 + ( 13 * ( m + 1 ) ) / 5 + k + k / 4 + j / 4 + 5 * j ).rem_euclid( 7 );
// h: 0=Saturday, 1=Sunday, 2=Monday, ..., 6=Friday → re-map to 0=Sun..6=Sat
( ( h + 6 ).rem_euclid( 7 ) ) as u8
}
/// Add `delta` months to `(year, month)` with wraparound. Negative
/// deltas walk backwards; year crosses are handled.
pub fn add_months( year: i32, month: u8, delta: i32 ) -> ( i32, u8 )
{
let total = year * 12 + ( month as i32 ) - 1 + delta;
let new_year = total.div_euclid( 12 );
let new_month = ( total.rem_euclid( 12 ) + 1 ) as u8;
( new_year, new_month )
}
/// Locale settings for the date picker. Month names and day-of-week
/// labels are pulled from the i18n registry via
/// [`rust_i18n::t!`] so changing the active locale (e.g. `set_locale("es")`)
/// flips the calendar without recreating the widget. The remaining piece is
/// the **first day of the week**, which varies independently of language
/// (US starts on Sunday, ES / FR / DE on Monday) — that one stays as a
/// builder field.
#[ derive( Clone, Copy, Debug ) ]
pub struct Locale
{
/// First day of the week (0 = Sunday, 1 = Monday). Default 1
/// (Monday) — the convention used across most of Europe.
pub first_dow: u8,
}
impl Locale
{
/// Locale starting the week on Monday. The default.
pub const MONDAY_FIRST: Self = Self { first_dow: 1 };
/// Locale starting the week on Sunday — common in US English.
pub const SUNDAY_FIRST: Self = Self { first_dow: 0 };
}
impl Default for Locale
{
fn default() -> Self { Self::MONDAY_FIRST }
}
/// Translated month name (1-indexed: `month = 1` → January, …,
/// `month = 12` → December). Resolves through the i18n registry, so
/// `set_locale("es")` returns `"Enero"`, etc.
fn month_name( month: u8 ) -> String
{
match month
{
1 => rust_i18n::t!( "date_picker.month_1" ).to_string(),
2 => rust_i18n::t!( "date_picker.month_2" ).to_string(),
3 => rust_i18n::t!( "date_picker.month_3" ).to_string(),
4 => rust_i18n::t!( "date_picker.month_4" ).to_string(),
5 => rust_i18n::t!( "date_picker.month_5" ).to_string(),
6 => rust_i18n::t!( "date_picker.month_6" ).to_string(),
7 => rust_i18n::t!( "date_picker.month_7" ).to_string(),
8 => rust_i18n::t!( "date_picker.month_8" ).to_string(),
9 => rust_i18n::t!( "date_picker.month_9" ).to_string(),
10 => rust_i18n::t!( "date_picker.month_10" ).to_string(),
11 => rust_i18n::t!( "date_picker.month_11" ).to_string(),
12 => rust_i18n::t!( "date_picker.month_12" ).to_string(),
_ => "?".to_string(),
}
}
/// Translated single-letter day-of-week label keyed by `dow` where
/// `0 = Sunday`, …, `6 = Saturday`. The display order in the calendar
/// header is rotated by [`Locale::first_dow`].
fn dow_short( dow: u8 ) -> String
{
match dow
{
0 => rust_i18n::t!( "date_picker.dow_short_0" ).to_string(),
1 => rust_i18n::t!( "date_picker.dow_short_1" ).to_string(),
2 => rust_i18n::t!( "date_picker.dow_short_2" ).to_string(),
3 => rust_i18n::t!( "date_picker.dow_short_3" ).to_string(),
4 => rust_i18n::t!( "date_picker.dow_short_4" ).to_string(),
5 => rust_i18n::t!( "date_picker.dow_short_5" ).to_string(),
6 => rust_i18n::t!( "date_picker.dow_short_6" ).to_string(),
_ => "?".to_string(),
}
}
/// Calendar date selector.
pub struct DatePicker<Msg: Clone>
{
pub value: Date,
pub view_year: i32,
pub view_month: u8,
pub today: Option<Date>,
pub on_change: Option<Arc<dyn Fn( Date ) -> Msg>>,
pub on_navigate: Option<Arc<dyn Fn( i32, u8 ) -> Msg>>,
pub locale: Locale,
}
impl<Msg: Clone + 'static> DatePicker<Msg>
{
/// Create a date picker with the given selected date. The view
/// month defaults to the same month as `value`; override with
/// [`Self::view`] when the user is browsing without selecting.
pub fn new( value: Date ) -> Self
{
Self
{
value,
view_year: value.year,
view_month: value.month,
today: None,
on_change: None,
on_navigate: None,
locale: Locale::default(),
}
}
/// Override the visible month. Call this from your view function
/// with whatever `(year, month)` your application state stores
/// for "the calendar's current page".
pub fn view( mut self, year: i32, month: u8 ) -> Self
{
self.view_year = year;
self.view_month = month;
self
}
/// Mark a specific date as "today" — drawn with a subtle accent
/// ring even if it is not selected.
pub fn today( mut self, today: Date ) -> Self
{
self.today = Some( today );
self
}
/// Day-tap callback. Required for the picker to be interactive.
pub fn on_change( mut self, f: impl Fn( Date ) -> Msg + 'static ) -> Self
{
self.on_change = Some( Arc::new( f ) );
self
}
/// Arrow-tap callback. The runtime calls `f(new_year, new_month)`
/// when the user taps prev / next. Wire to your view-month state.
pub fn on_navigate( mut self, f: impl Fn( i32, u8 ) -> Msg + 'static ) -> Self
{
self.on_navigate = Some( Arc::new( f ) );
self
}
/// Override the locale (month / day-of-week names + week start).
pub fn locale( mut self, l: Locale ) -> Self
{
self.locale = l;
self
}
/// Build the `Element` tree representing this date picker.
pub fn build( self ) -> Element<Msg>
{
use super::{ button, container, icon_button, text };
use super::pressable::pressable;
use super::button::ButtonVariant;
use crate::layout::stack::{ stack, HAlign, VAlign };
let view_y = self.view_year;
let view_m = self.view_month.clamp( 1, 12 );
let today = self.today;
let value = self.value;
let on_chg = self.on_change.clone();
let on_nav = self.on_navigate.clone();
let first = self.locale.first_dow;
// Header chevrons load from the active theme as SVG icons
// (`icons/catalogue/filled/multimedia/{previous,next}.svg`),
// tinted to the primary text colour so they read against
// either light or dark surfaces. Sized small so the buttons
// sit visually inside the grid's first / last column.
// Falls back to the matching Unicode glyph if the icon is
// missing from the theme.
const CHEVRON_PX: u32 = 18;
let nav_button = | name: &str, fallback: &str | -> super::button::Button<Msg>
{
match crate::theme::icon_rgba( name, CHEVRON_PX )
{
Some( ( rgba, w, h ) ) =>
{
let tinted = std::sync::Arc::new(
crate::theme::tint_symbolic( &rgba, theme::text() ),
);
icon_button::<Msg>( tinted, w, h ).icon_size( CHEVRON_PX as f32 )
}
None => button::<Msg>( fallback ).variant( ButtonVariant::Tertiary ),
}
};
let title = format!( "{} {}", month_name( view_m ), view_y );
let mut prev = nav_button( "multimedia/previous", "" );
let mut next = nav_button( "multimedia/next", "" );
if let Some( ref nav ) = on_nav
{
let ( py, pm ) = add_months( view_y, view_m, -1 );
let ( ny, nm ) = add_months( view_y, view_m, 1 );
let nav_p = nav.clone();
let nav_n = nav.clone();
prev = prev.on_press( nav_p( py, pm ) );
next = next.on_press( nav_n( ny, nm ) );
}
// Header: title centred horizontally; prev pinned to the left
// edge, next pinned to the right edge — both at the same
// padding offset as the calendar grid below, so they align
// vertically with the first and last day columns. Built as a
// `Stack` (the only layout that lets independent children
// sit at left / centre / right of the same rect) wrapped in a
// container that fixes the row height.
let header_height = ( CHEVRON_PX as f32 + 16.0 ).max( theme::HEADER_FS + 16.0 );
let header_inner: Element<Msg> = stack::<Msg>()
.push_aligned( prev, HAlign::Start, VAlign::Center )
.push_aligned(
text( title ).size( theme::HEADER_FS ).color( theme::text() ),
HAlign::Center, VAlign::Center,
)
.push_aligned( next, HAlign::End, VAlign::Center )
.into();
let header: Element<Msg> = container::<Msg>( header_inner )
.padding_v( ( header_height - CHEVRON_PX as f32 ) * 0.5 )
.padding_h( 0.0 )
.into();
// Day-of-week row in the locale's display order. Built as a
// 7-column `wrap_grid` so the columns share the same
// equal-width slots that the day-cell grid below will use,
// guaranteeing letter-and-number alignment frame to frame
// regardless of glyph width (`W` is wider than `M`, etc.).
let mut dow_row = grid::<Msg>( 7 ).spacing( theme::SPACING );
for slot in 0..7u8
{
// Convert the column index (display order) back to a
// weekday number (0 = Sunday) honouring `first_dow`.
let dow = ( ( slot + first ) % 7 ) as u8;
let cell: Element<Msg> = container::<Msg>(
text( dow_short( dow ) )
.size( theme::DOW_FS )
.color( theme::text_muted() )
.align_center(),
)
.padding_h( 0.0 )
.padding_v( 4.0 )
.into();
dow_row = dow_row.push( cell );
}
// Day grid: 7 columns × 6 rows of equal-width cells. Off-month
// slots stay empty so the calendar always reserves the same
// height; the `wrap_grid` lays out each cell in its own slot
// so vertical alignment with the DOW row above is automatic.
let dim = days_in_month( view_y, view_m );
let first_dow = day_of_week( view_y, view_m, 1 );
let leading_blanks = ( ( first_dow as i32 - first as i32 ).rem_euclid( 7 ) ) as u8;
let total_cells = 6 * 7;
let mut day_grid = grid::<Msg>( 7 ).spacing( theme::SPACING );
for slot in 0..total_cells
{
let day_num = slot as i32 - leading_blanks as i32 + 1;
let cell: Element<Msg> = if day_num < 1 || day_num > dim as i32
{
container::<Msg>( spacer() ).padding( 0.0 ).into()
} else {
let day = day_num as u8;
let date = Date::new( view_y, view_m, day );
let is_selected = date == value;
let is_today = today == Some( date );
let label_color = if is_selected { theme::surface() } else { theme::text() };
let bg = if is_selected
{
Some( theme::accent() )
} else if is_today {
Some( theme::surface_alt() )
} else {
None
};
let mut card = container::<Msg>(
text( format!( "{}", day ) )
.size( theme::DAY_FS )
.color( label_color )
.align_center(),
)
.padding_h( 4.0 )
.padding_v( 8.0 )
.radius( theme::CELL_SIZE * 0.5 );
if let Some( c ) = bg { card = card.background( c ); }
if let Some( ref cb ) = on_chg
{
let m = cb( date );
pressable( card ).on_press( m ).into()
} else {
card.into()
}
};
day_grid = day_grid.push( cell );
}
container::<Msg>(
column::<Msg>()
.spacing( 0.0 )
.push( header )
// Extra breathing space between the month title and
// the day-of-week header so the row separation reads
// cleanly. Tuned by feedback — the previous shared
// `SPACING * 2.0` was too tight.
.push( spacer().height( theme::SPACING * 4.0 ) )
.push( dow_row )
.push( spacer().height( theme::SPACING * 1.5 ) )
.push( day_grid ),
)
.background( theme::surface_alt() )
.padding( theme::PADDING )
.radius( theme::RADIUS )
.into()
}
}
impl<Msg: Clone + 'static> From<DatePicker<Msg>> for Element<Msg>
{
fn from( d: DatePicker<Msg> ) -> Self { d.build() }
}
/// Create a [`DatePicker`] with the given selected date.
///
/// ```rust,no_run
/// # use ltk::{ date_picker, Date, DatePicker };
/// # #[ derive( Clone ) ] enum Msg { DateChanged( Date ), DateView( i32, u8 ) }
/// # struct App { date: Date, today: Date }
/// # impl App { fn _ex( &self ) -> DatePicker<Msg> {
/// date_picker( self.date )
/// .today( self.today )
/// .on_change( Msg::DateChanged )
/// .on_navigate( Msg::DateView )
/// # }}
/// ```
pub fn date_picker<Msg: Clone + 'static>( value: Date ) -> DatePicker<Msg>
{
DatePicker::new( value )
}
#[ cfg( test ) ]
mod tests
{
use super::*;
// ── leap years ────────────────────────────────────────────────────────────
#[ test ]
fn leap_year_rules()
{
assert!( is_leap_year( 2024 ) );
assert!( is_leap_year( 2000 ) ); // div by 400
assert!( !is_leap_year( 1900 ) ); // div by 100 but not 400
assert!( !is_leap_year( 2023 ) );
assert!( is_leap_year( -4 ) ); // proleptic
}
#[ test ]
fn days_in_month_handles_leap_february()
{
assert_eq!( days_in_month( 2024, 2 ), 29 );
assert_eq!( days_in_month( 2023, 2 ), 28 );
assert_eq!( days_in_month( 2024, 4 ), 30 );
assert_eq!( days_in_month( 2024, 7 ), 31 );
}
#[ test ]
fn days_in_month_zero_for_invalid_month()
{
assert_eq!( days_in_month( 2024, 0 ), 0 );
assert_eq!( days_in_month( 2024, 13 ), 0 );
}
// ── day of week ───────────────────────────────────────────────────────────
#[ test ]
fn day_of_week_known_dates()
{
// 1970-01-01 was a Thursday → 4.
assert_eq!( day_of_week( 1970, 1, 1 ), 4 );
// 2000-01-01 was a Saturday → 6.
assert_eq!( day_of_week( 2000, 1, 1 ), 6 );
// 2024-02-29 was a Thursday → 4 (leap day).
assert_eq!( day_of_week( 2024, 2, 29 ), 4 );
// 2026-05-02 (today, per user clock) was a Saturday → 6.
assert_eq!( day_of_week( 2026, 5, 2 ), 6 );
}
// ── month arithmetic ──────────────────────────────────────────────────────
#[ test ]
fn add_months_within_year()
{
assert_eq!( add_months( 2024, 3, 1 ), ( 2024, 4 ) );
assert_eq!( add_months( 2024, 3, -1 ), ( 2024, 2 ) );
}
#[ test ]
fn add_months_wraps_into_next_year()
{
assert_eq!( add_months( 2024, 12, 1 ), ( 2025, 1 ) );
}
#[ test ]
fn add_months_wraps_into_previous_year()
{
assert_eq!( add_months( 2024, 1, -1 ), ( 2023, 12 ) );
}
#[ test ]
fn add_months_handles_large_deltas()
{
assert_eq!( add_months( 2024, 1, 25 ), ( 2026, 2 ) );
assert_eq!( add_months( 2024, 1, -25 ), ( 2021, 12 ) );
}
// ── Date validity ─────────────────────────────────────────────────────────
#[ test ]
fn date_is_valid_on_realistic_inputs()
{
assert!( Date::new( 2024, 2, 29 ).is_valid() );
assert!( !Date::new( 2023, 2, 29 ).is_valid() ); // not leap
assert!( !Date::new( 2024, 4, 31 ).is_valid() ); // april has 30
assert!( !Date::new( 2024, 0, 1 ).is_valid() );
assert!( !Date::new( 2024, 13, 1 ).is_valid() );
assert!( !Date::new( 2024, 1, 0 ).is_valid() );
}
// ── builders ──────────────────────────────────────────────────────────────
#[ derive( Clone, Debug, PartialEq, Eq ) ]
enum Msg
{
Pick( Date ),
Nav( i32, u8 ),
}
#[ test ]
fn defaults_view_to_value_month()
{
let d: DatePicker<Msg> = date_picker( Date::new( 2024, 7, 15 ) );
assert_eq!( d.view_year, 2024 );
assert_eq!( d.view_month, 7 );
}
#[ test ]
fn view_builder_overrides()
{
let d: DatePicker<Msg> = date_picker( Date::new( 2024, 7, 15 ) ).view( 2025, 1 );
assert_eq!( d.view_year, 2025 );
assert_eq!( d.view_month, 1 );
}
#[ test ]
fn on_change_callback_invokes_with_date()
{
let d: DatePicker<Msg> = date_picker( Date::new( 2024, 7, 15 ) )
.on_change( Msg::Pick );
let cb = d.on_change.as_ref().expect( "set" );
assert_eq!( cb( Date::new( 2024, 7, 16 ) ), Msg::Pick( Date::new( 2024, 7, 16 ) ) );
}
#[ test ]
fn on_navigate_callback_invokes_with_year_month()
{
let d: DatePicker<Msg> = date_picker( Date::new( 2024, 7, 15 ) )
.on_navigate( Msg::Nav );
let cb = d.on_navigate.as_ref().expect( "set" );
assert_eq!( cb( 2025, 1 ), Msg::Nav( 2025, 1 ) );
}
#[ test ]
fn build_does_not_panic_on_minimal_config()
{
let _: Element<Msg> = date_picker::<Msg>( Date::new( 2024, 7, 15 ) ).build();
}
#[ test ]
fn build_does_not_panic_when_view_month_is_clamped()
{
// `view_month = 0` is invalid — build clamps to 1 instead of
// indexing month_names at -1.
let mut d: DatePicker<Msg> = date_picker( Date::new( 2024, 7, 15 ) );
d.view_month = 0;
let _: Element<Msg> = d.build();
}
}

451
src/widget/dialog.rs Normal file
View File

@@ -0,0 +1,451 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Modal / non-modal centered dialog: a darkened scrim with a card
//! holding a title, an optional subtitle, an optional body, and a row
//! of action buttons.
//!
//! The widget is implemented as a thin builder over existing
//! primitives: at conversion time
//! ([`From<Dialog<Msg>> for Element<Msg>`](Dialog#impl-From%3CDialog%3CMsg%3E%3E-for-Element%3CMsg%3E))
//! it lowers itself to a [`Stack`](crate::layout::stack::Stack) of
//!
//! 1. a full-surface [`Pressable`](crate::widget::pressable::Pressable)
//! scrim — `swallow=true` so it absorbs every pointer event that
//! misses the card (so widgets behind the dialog cannot be clicked),
//! `on_escape=cancel_msg` so the keyboard ESC handler can find it,
//! and `on_press=dismiss_msg` only when the dialog is non-modal and
//! a `dismiss_on_scrim` was configured;
//! 2. a centered card backed by a flat opaque fill (`palette.surface`
//! with alpha forced to 1.0 — themed `surface-card` / similar
//! Glass surfaces ship as translucent for the rest of the toolkit,
//! but a confirmation dialog must read against any background, so
//! the dialog opts out of the Glass chrome). The card wraps a
//! *card-area* `Pressable( swallow=true )` so the body itself
//! silently absorbs taps and only clicks strictly outside the card
//! can dismiss the dialog. Inside the card sits a column with the
//! title (700-weight, wraps), the subtitle
//! (`text_secondary`, wraps), the user-supplied body, and a
//! right-aligned actions row.
//!
//! ## Example
//!
//! ```rust,no_run
//! # use ltk::{ button, dialog, ButtonVariant, Element };
//! # #[ derive( Clone ) ] enum Msg { Cancel, Confirm }
//! # fn _ex() -> Element<Msg> {
//! dialog()
//! .title( "Delete partition?" )
//! .subtitle( "This will erase every file on /dev/sda2." )
//! .cancel( Msg::Cancel )
//! .action( button::<Msg>( "Cancel" )
//! .variant( ButtonVariant::Tertiary )
//! .on_press( Msg::Cancel ) )
//! .action( button::<Msg>( "Delete" )
//! .variant( ButtonVariant::Primary )
//! .on_press( Msg::Confirm ) )
//! .into()
//! # }
//! ```
//!
//! ## Modality and dismissal
//!
//! `modal` is `true` by default — every pointer event outside the card
//! is silently swallowed and underlying widgets cannot be reached. Set
//! `modal( false )` to let pointer events pass through to the
//! application **except** that you can still wire a
//! [`Dialog::dismiss_on_scrim`] message that fires only when the user
//! taps strictly outside the card. `dismiss_on_scrim` is rejected at
//! build time for a modal dialog (the contract is "modality means no
//! escape", so an "escape via tap-outside" message is contradictory).
//!
//! `Esc` always fires the [`Dialog::cancel`] message — independent of
//! modality. Wire `cancel` to the same message your "Cancel" /
//! "Dismiss" action button uses so keyboard ESC matches the click
//! behaviour.
use crate::layout::column::column;
use crate::layout::row::row;
use crate::layout::spacer::spacer;
use crate::layout::stack::stack;
use crate::types::{ Color, Corners };
use super::container::container;
use super::pressable::pressable;
use super::text;
use super::Element;
/// Default scrim opacity over the underlying surface.
pub const SCRIM_ALPHA: f32 = 0.45;
/// Default card max-width (logical pixels). Override with
/// [`Dialog::max_width`].
pub const DEFAULT_MAX_WIDTH: f32 = 480.0;
/// Default card corner radius.
pub const CARD_RADIUS: f32 = 16.0;
/// Default card padding (uniform).
pub const CARD_PADDING: f32 = 24.0;
/// Default vertical gap between the title, subtitle, body, and
/// actions row.
pub const SECTION_GAP: f32 = 12.0;
/// Default horizontal gap between action buttons.
pub const ACTION_GAP: f32 = 8.0;
/// Default title font size.
pub const TITLE_SIZE: f32 = 22.0;
/// Default title weight.
pub const TITLE_WEIGHT: u16 = 700;
/// Default subtitle font size.
pub const SUBTITLE_SIZE: f32 = 14.0;
/// A centered confirmation dialog with optional title, subtitle, body
/// and action buttons.
pub struct Dialog<Msg: Clone>
{
pub title: Option<String>,
pub subtitle: Option<String>,
/// User-supplied body element rendered between the subtitle and
/// the action row. Use this for sliders, lists, spinners or any
/// other custom content.
pub body: Option<Box<Element<Msg>>>,
pub actions: Vec<Element<Msg>>,
pub modal: bool,
/// Optional message dispatched when the user taps the scrim
/// (strictly outside the card). Always `None` when [`Self::modal`]
/// is `true`. Construction panics if both are set together.
pub dismiss_msg: Option<Msg>,
/// Optional message dispatched when the user presses `Escape`
/// while the dialog is on screen. Wire this to the same message
/// your "Cancel" action button uses.
pub cancel_msg: Option<Msg>,
pub max_width: f32,
}
impl<Msg: Clone> Default for Dialog<Msg>
{
fn default() -> Self
{
Self::new()
}
}
impl<Msg: Clone> Dialog<Msg>
{
/// Construct a default modal dialog with no title, subtitle,
/// body, or actions. Build it up with the chained setters.
pub fn new() -> Self
{
Self
{
title: None,
subtitle: None,
body: None,
actions: Vec::new(),
modal: true,
dismiss_msg: None,
cancel_msg: None,
max_width: DEFAULT_MAX_WIDTH,
}
}
/// Set the dialog title. Wraps across multiple lines if it does
/// not fit on a single one.
pub fn title( mut self, t: impl Into<String> ) -> Self
{
self.title = Some( t.into() );
self
}
/// Set the dialog subtitle. Wraps across multiple lines if it
/// does not fit on a single one.
pub fn subtitle( mut self, s: impl Into<String> ) -> Self
{
self.subtitle = Some( s.into() );
self
}
/// Replace the dialog body with a custom element — slider,
/// progress indicator, list, anything. Rendered between the
/// subtitle and the action row.
pub fn body( mut self, e: impl Into<Element<Msg>> ) -> Self
{
self.body = Some( Box::new( e.into() ) );
self
}
/// Append an action element to the right-aligned action row.
/// Typically a [`Button`](crate::widget::button::Button); any
/// `Element` is accepted.
pub fn action( mut self, e: impl Into<Element<Msg>> ) -> Self
{
self.actions.push( e.into() );
self
}
/// Toggle modality. Default: `true` (every pointer event outside
/// the card is silently absorbed).
pub fn modal( mut self, on: bool ) -> Self
{
self.modal = on;
self
}
/// Bind a message dispatched when the user taps the scrim
/// (outside the card). Only valid for non-modal dialogs;
/// construction panics if combined with `modal( true )`.
pub fn dismiss_on_scrim( mut self, msg: Msg ) -> Self
{
self.dismiss_msg = Some( msg );
self
}
/// Bind a message dispatched when the user presses `Escape`.
/// Wire the same message your "Cancel" / "Dismiss" action button
/// uses so keyboard and pointer behaviour match.
pub fn cancel( mut self, msg: Msg ) -> Self
{
self.cancel_msg = Some( msg );
self
}
/// Override the card's maximum width in logical pixels. Default
/// is `480.0`.
pub fn max_width( mut self, w: f32 ) -> Self
{
self.max_width = w;
self
}
}
impl<Msg: Clone + 'static> From<Dialog<Msg>> for Element<Msg>
{
fn from( d: Dialog<Msg> ) -> Element<Msg>
{
assert!(
!( d.modal && d.dismiss_msg.is_some() ),
"dialog: dismiss_on_scrim is not valid when modal=true",
);
let palette = crate::theme::palette();
// 1. Inner card column: title, subtitle, body, actions.
let mut card_col = column::<Msg>().spacing( SECTION_GAP );
if let Some( title ) = d.title
{
card_col = card_col.push(
text( title )
.size( TITLE_SIZE )
.weight( TITLE_WEIGHT )
.color( palette.text_primary )
.wrap( true ),
);
}
if let Some( subtitle ) = d.subtitle
{
card_col = card_col.push(
text( subtitle )
.size( SUBTITLE_SIZE )
.color( palette.text_secondary )
.wrap( true ),
);
}
if let Some( body ) = d.body
{
card_col = card_col.push( *body );
}
if !d.actions.is_empty()
{
let mut actions_row = row::<Msg>().spacing( ACTION_GAP ).push( spacer() );
for a in d.actions
{
actions_row = actions_row.push( a );
}
card_col = card_col.push( actions_row );
}
// 2. Card surface — flat opaque fill, no themed surface stack.
// Themed surfaces (`surface-card` / `surface-dialog`) ship as
// translucent Glass for the rest of the toolkit; for a
// confirmation dialog the body must read against any
// background, so we force `palette.surface` to full opacity
// and skip the Glass chrome.
let card_bg = Color { a: 1.0, ..palette.surface };
let card = container::<Msg>( card_col )
.background( card_bg )
.radius( Corners::all( CARD_RADIUS ) )
.padding( CARD_PADDING );
// 3. Card-area swallow: a Pressable wrapping the card so
// taps on the body silently absorb without firing the
// scrim's `dismiss_msg`. Always armed (modal or not) — the
// card never dismisses.
let card_swallow = pressable::<Msg>( card ).swallow( true );
// 4. Center the card on the screen. The outer column claims
// the full surface; `center_y` + `align_center_x` keep the
// card vertically and horizontally centered, and `max_width`
// caps it at `d.max_width` even on ultra-wide layouts.
let centered = column::<Msg>()
.center_y( true )
.align_center_x( true )
.max_width( d.max_width )
.push( card_swallow );
// 5. Scrim — a full-bleed Pressable with the dim layer
// rendered behind it. `swallow=true` means it always shows
// up in `widget_rects` (modal dialogs need this so missing
// hits do not fall through to the underlying app); when
// non-modal AND a `dismiss_msg` was configured, taps fire
// the message. The cancel-on-ESC binding lives here too.
let scrim_bg = container::<Msg>( spacer() )
.background( Color { r: 0.0, g: 0.0, b: 0.0, a: SCRIM_ALPHA } )
.radius( Corners::ZERO );
let mut scrim_press = pressable::<Msg>( scrim_bg ).swallow( true );
if let Some( msg ) = d.dismiss_msg
{
scrim_press = scrim_press.on_press( msg );
}
if let Some( msg ) = d.cancel_msg
{
scrim_press = scrim_press.on_escape( msg );
}
// 6. Stack scrim + centered card. Layout walker pushes
// children in order, so the scrim's `flat_idx` < the card
// area's < each action button's; `iter().rev()` hit-testing
// therefore finds buttons first, then the card-area
// swallow, and finally the scrim outside the card.
stack::<Msg>()
.push( scrim_press )
.push( centered )
.into()
}
}
/// Construct a [`Dialog`]. See the type's documentation for the full
/// builder API and the lowering details.
pub fn dialog<Msg: Clone>() -> Dialog<Msg>
{
Dialog::new()
}
#[ cfg( test ) ]
mod tests
{
use super::*;
use crate::layout::spacer::spacer;
#[ derive( Clone, Debug, PartialEq, Eq ) ]
enum Msg { Cancel, Dismiss }
#[ test ]
fn new_defaults_are_modal_with_no_content()
{
let d = Dialog::<Msg>::new();
assert!( d.modal );
assert!( d.title.is_none() );
assert!( d.subtitle.is_none() );
assert!( d.body.is_none() );
assert!( d.actions.is_empty() );
assert!( d.dismiss_msg.is_none() );
assert!( d.cancel_msg.is_none() );
assert_eq!( d.max_width, DEFAULT_MAX_WIDTH );
}
#[ test ]
fn title_and_subtitle_builders_set_strings()
{
let d = Dialog::<Msg>::new()
.title( "Hello" )
.subtitle( "World" );
assert_eq!( d.title.as_deref(), Some( "Hello" ) );
assert_eq!( d.subtitle.as_deref(), Some( "World" ) );
}
#[ test ]
fn body_builder_replaces_existing_body()
{
let d = Dialog::<Msg>::new()
.body( spacer() )
.body( spacer() );
assert!( d.body.is_some() );
}
#[ test ]
fn action_builder_appends_in_order()
{
let d = Dialog::<Msg>::new()
.action( spacer() )
.action( spacer() )
.action( spacer() );
assert_eq!( d.actions.len(), 3 );
}
#[ test ]
fn modal_builder_toggles_flag()
{
assert!( !Dialog::<Msg>::new().modal( false ).modal );
assert!( Dialog::<Msg>::new().modal( true ).modal );
}
#[ test ]
fn dismiss_on_scrim_builder_records_message()
{
let d = Dialog::<Msg>::new()
.modal( false )
.dismiss_on_scrim( Msg::Dismiss );
assert_eq!( d.dismiss_msg, Some( Msg::Dismiss ) );
}
#[ test ]
fn cancel_builder_records_escape_message()
{
let d = Dialog::<Msg>::new().cancel( Msg::Cancel );
assert_eq!( d.cancel_msg, Some( Msg::Cancel ) );
}
#[ test ]
fn max_width_builder_overrides_default()
{
let d = Dialog::<Msg>::new().max_width( 720.0 );
assert_eq!( d.max_width, 720.0 );
}
#[ test ]
#[ should_panic( expected = "dismiss_on_scrim is not valid when modal=true" ) ]
fn modal_with_dismiss_panics_at_lower()
{
// Construction allows the combination; only the lowering
// to `Element` enforces the contract — that is where the
// invariant matters because that is where input dispatch
// would have to honour two contradictory rules.
let d = Dialog::<Msg>::new()
.modal( true )
.dismiss_on_scrim( Msg::Dismiss );
let _: Element<Msg> = d.into();
}
#[ test ]
fn non_modal_with_dismiss_lowers_cleanly()
{
let d = Dialog::<Msg>::new()
.modal( false )
.dismiss_on_scrim( Msg::Dismiss )
.cancel( Msg::Cancel )
.title( "Title" );
// Lowering must not panic.
let _: Element<Msg> = d.into();
}
#[ test ]
fn modal_with_cancel_only_lowers_cleanly()
{
// modal=true + cancel(...) (no dismiss_on_scrim) is the
// canonical confirm-dialog shape.
let d = Dialog::<Msg>::new()
.title( "Confirm" )
.subtitle( "Are you sure?" )
.cancel( Msg::Cancel );
let _: Element<Msg> = d.into();
}
}

182
src/widget/external.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>
//! Widget that hosts content rendered by an external GL producer.
//!
//! Reserves layout space and, at draw time, samples a caller-provided GL
//! texture into the LTK canvas via [`Canvas::draw_external_texture`]. The
//! producer (a web engine, a video decoder, …) keeps ownership of the
//! texture and updates it on its own cadence; LTK only composites.
//!
//! # Source closure contract
//!
//! [`ExternalSource::Texture`] carries an
//! `Arc<dyn Fn(&glow::Context, Rect) -> Option<glow::Texture>>` that LTK
//! invokes once per frame, with its GLES context current. The producer:
//!
//! 1. Reads `Rect` (in physical pixels of the host surface) — useful for
//! sizing the inner viewport (e.g. resizing a `WPEToplevel` to match)
//! and for translating input coordinates by `rect.origin`.
//! 2. May allocate / update GL textures against the supplied
//! `glow::Context`.
//! 3. May bind extension-imported EGLImages onto a persistent texture.
//! 4. Returns the `glow::Texture` to sample, or `None` to paint
//! transparent (e.g. while the first frame is still being produced).
//!
//! Returning `None` for one frame and `Some` for the next is fine; LTK
//! re-invokes on every redraw.
//!
//! # Use cases
//!
//! * `ltk-webkit` hosting a `WPEView`-rendered page.
//! * Video / media playback widgets that decode into a GL texture.
//! * Any embedding that already has a GLES producer and wants its output
//! in-line with the rest of the LTK widget tree.
//!
//! # Example
//!
//! ```rust,no_run
//! # use std::sync::{ Arc, Mutex };
//! # use ltk::{ External, ExternalSource, Element };
//! # #[ derive( Clone ) ] enum Msg {}
//! # fn _ex() -> Element<Msg> {
//! let cached_texture: Arc<Mutex<Option<glow::Texture>>> = Arc::new( Mutex::new( None ) );
//! let cached = Arc::clone( &cached_texture );
//! External::new(
//! 800.0, 600.0,
//! ExternalSource::Texture( Arc::new( move | _gl, _rect | -> Option<glow::Texture>
//! {
//! *cached.lock().ok()?
//! } ) ),
//! ).into()
//! # }
//! ```
use std::sync::Arc;
use crate::render::Canvas;
use crate::types::Rect;
/// A widget that defers its pixels to an external GL texture producer.
///
/// The widget itself is non-interactive and produces no messages. Wrap it
/// in a [`crate::widget::pressable::Pressable`] (or similar) when input
/// capture is needed.
pub struct External
{
/// Reserved width in logical pixels.
pub width: f32,
/// Reserved height in logical pixels.
pub height: f32,
/// Source of the GL texture sampled at draw time.
pub source: ExternalSource,
/// Opacity multiplier in `[0.0, 1.0]`.
pub opacity: f32,
}
/// Backends an [`External`] widget can pull pixels from.
///
/// The only variant today is [`ExternalSource::Texture`], a closure
/// returning the current GL texture name. Future variants (DMA-BUF
/// imports, software pixmaps for the CPU backend, …) can be added
/// without changing call sites.
#[ derive( Clone ) ]
pub enum ExternalSource
{
/// Closure invoked once per frame with LTK's [`glow::Context`] current
/// and the widget's laid-out rect (in physical pixels of the host
/// surface). The producer can allocate textures, bind extension-
/// imported EGLImages, etc., and returns the texture name to sample.
/// Returning `None` paints transparent — useful while the producer
/// is still warming up its first frame. The rect is exposed so
/// embedders can size their inner viewport to match LTK's actual
/// allocation (e.g. resize a WPEToplevel on layout change) and
/// translate input coordinates by `rect.origin`.
Texture( Arc<dyn Fn( &glow::Context, Rect ) -> Option<glow::Texture> + Send + Sync> ),
}
impl External
{
/// Build a new external-content widget reserving `width × height`
/// logical pixels.
pub fn new( width: f32, height: f32, source: ExternalSource ) -> Self
{
Self { width, height, source, opacity: 1.0 }
}
/// Override the opacity multiplier. Default: `1.0`.
pub fn opacity( mut self, opacity: f32 ) -> Self
{
self.opacity = opacity.clamp( 0.0, 1.0 );
self
}
pub fn preferred_size( &self, _max_width: f32 ) -> ( f32, f32 )
{
( self.width, self.height )
}
pub fn draw( &self, canvas: &mut Canvas, rect: Rect )
{
// SAFETY-ish: this is the only call site of the source closure;
// LTK's draw pass guarantees the GLES context is current and
// the canvas is bound to its FBO. The rect we pass is the
// widget's laid-out rect in physical pixels — the same
// coordinate space pointer events arrive in, so the producer
// can use `rect.origin` to translate input.
// External content only renders against the GLES backend — the
// software backend has no GL texture to sample. Skip silently.
let gl = match canvas
{
Canvas::Software( _ ) => return,
Canvas::Gles( c ) => c.gl.clone(),
};
match &self.source
{
ExternalSource::Texture( get ) =>
{
if let Some( tex ) = get( &gl, rect )
{
canvas.draw_external_texture( tex, rect, self.opacity );
}
}
}
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
use std::sync::Arc;
fn dummy_source() -> ExternalSource
{
ExternalSource::Texture( Arc::new( | _gl, _rect | None ) )
}
#[ test ]
fn preferred_size_returns_constructed_dimensions()
{
let w = External::new( 320.0, 200.0, dummy_source() );
assert_eq!( w.preferred_size( 9999.0 ), ( 320.0, 200.0 ) );
}
#[ test ]
fn opacity_default_is_one()
{
let w = External::new( 1.0, 1.0, dummy_source() );
assert_eq!( w.opacity, 1.0 );
}
#[ test ]
fn opacity_setter_clamps_to_unit_range()
{
let above = External::new( 1.0, 1.0, dummy_source() ).opacity( 1.7 );
let below = External::new( 1.0, 1.0, dummy_source() ).opacity( -0.4 );
let mid = External::new( 1.0, 1.0, dummy_source() ).opacity( 0.5 );
assert_eq!( above.opacity, 1.0 );
assert_eq!( below.opacity, 0.0 );
assert_eq!( mid.opacity, 0.5 );
}
}

138
src/widget/flex.rs Normal file
View File

@@ -0,0 +1,138 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::render::Canvas;
use super::Element;
/// Wraps an [`Element`] so that a [`Row`](crate::layout::row::Row) treats it
/// like a [`Spacer`](crate::layout::spacer::Spacer) for leftover-width
/// distribution, but draws the child inside the allocated rect.
///
/// Use this to make a non-trivial child fill remaining width without resorting
/// to a hard-coded `max_width`. The row computes how much width is left after
/// the fixed-size siblings, splits it across all flex / spacer children
/// proportionally to their `weight`, and gives the flex its share. The wrapped
/// child sees that share as its layout rect — text inside it triggers its own
/// elide path on overflow, columns adjust their inner width, etc.
///
/// ```rust,no_run
/// # use ltk::{ column, flex, row, Element };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex(
/// # icon: Element<Msg>,
/// # title: Element<Msg>,
/// # subtitle: Element<Msg>,
/// # ) -> Element<Msg> {
/// row()
/// .push( icon )
/// .push( flex( column().push( title ).push( subtitle ) ) )
/// .into()
/// # }
/// ```
///
/// Currently only [`Row`](crate::layout::row::Row) honours flex distribution.
/// Inside a [`Column`](crate::layout::column::Column) a flex child behaves
/// like a regular zero-width child along the main axis — vertical flex is not
/// yet implemented.
pub struct Flex<Msg: Clone>
{
pub child: Box<Element<Msg>>,
pub weight: u32,
}
impl<Msg: Clone> Flex<Msg>
{
pub fn new( child: impl Into<Element<Msg>> ) -> Self
{
Self
{
child: Box::new( child.into() ),
weight: 1,
}
}
/// Relative weight when sharing leftover width with other flex / spacer
/// children in the same row (default `1`). A flex with `weight = 2`
/// claims twice the space of a sibling with `weight = 1`.
pub fn weight( mut self, w: u32 ) -> Self
{
self.weight = w;
self
}
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 )
{
// Width contribution to the parent row is zero — the actual width
// comes from the flex distribution. Height comes from the child so
// the row's row-height calculation still picks us up.
let ( _, h ) = self.child.preferred_size( max_width, canvas );
( 0.0, h )
}
pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> Flex<U>
where
U: Clone + 'static,
Msg: 'static,
{
Flex
{
child: Box::new( self.child.map_arc( f ) ),
weight: self.weight,
}
}
}
pub fn flex<Msg: Clone>( child: impl Into<Element<Msg>> ) -> Flex<Msg>
{
Flex::new( child )
}
impl<Msg: Clone + 'static> From<Flex<Msg>> for Element<Msg>
{
fn from( f: Flex<Msg> ) -> Self
{
Element::Flex( f )
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
use crate::layout::spacer::spacer;
use crate::render::Canvas;
fn make_canvas() -> Canvas { Canvas::new( 800, 600 ) }
#[ test ]
fn new_defaults_to_weight_one()
{
let f = Flex::<()>::new( spacer() );
assert_eq!( f.weight, 1 );
}
#[ test ]
fn weight_builder_sets_relative_weight()
{
let f = Flex::<()>::new( spacer() ).weight( 5 );
assert_eq!( f.weight, 5 );
}
#[ test ]
fn preferred_size_reports_zero_width_so_row_treats_it_as_filler()
{
let canvas = make_canvas();
let f = Flex::<()>::new( spacer().width( 100.0 ).height( 40.0 ) );
let ( w, _ ) = f.preferred_size( 600.0, &canvas );
assert_eq!( w, 0.0, "flex must contribute zero width to the row's intrinsic-width sum" );
}
#[ test ]
fn preferred_size_passes_child_height_through()
{
let canvas = make_canvas();
let f = Flex::<()>::new( spacer().width( 100.0 ).height( 40.0 ) );
let ( _, h ) = f.preferred_size( 600.0, &canvas );
assert_eq!( h, 40.0 );
}
}

217
src/widget/image.rs Normal file
View File

@@ -0,0 +1,217 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::sync::Arc;
use crate::types::Rect;
use crate::render::Canvas;
/// A static image widget that renders RGBA pixel data.
///
/// Images are scaled to fill their allocated rect. Alpha blending against the
/// background is handled automatically (straight → premultiplied conversion).
///
/// The pixel buffer is shared via `Arc` so reusing the same image across
/// frames (e.g. a background decoded once at startup) is a cheap pointer
/// copy instead of a full `Vec<u8>` clone.
///
/// ```rust,no_run
/// # use std::sync::Arc;
/// # use ltk::{ img_widget, Element };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex( rgba_bytes: Arc<Vec<u8>>, width: u32, height: u32 ) -> Element<Msg> {
/// img_widget( rgba_bytes, width, height )
/// .opacity( 0.8 )
/// .into()
/// # }
/// ```
pub struct Image
{
/// Raw RGBA pixel data (4 bytes per pixel, straight alpha).
pub rgba: Arc<Vec<u8>>,
/// Pixel width of the source image.
pub width: u32,
/// Pixel height of the source image.
pub height: u32,
/// When `true` the image scales to fill the available width (cover mode).
pub cover: bool,
/// Optional explicit display size in logical pixels.
pub display_size: Option<( f32, f32 )>,
/// Opacity multiplier in `[0.0, 1.0]`. Default: `1.0`.
pub opacity: f32,
}
impl Image
{
/// Create an image from a shared RGBA buffer.
///
/// `width` and `height` must match the dimensions of `rgba`.
pub fn new( rgba: Arc<Vec<u8>>, width: u32, height: u32 ) -> Self
{
Self { rgba, width, height, cover: false, display_size: None, opacity: 1.0 }
}
/// Load an image from a file path. Supports PNG, JPEG, and other formats
/// supported by the [`image`](https://crates.io/crates/image) crate.
pub fn from_path( path: &str ) -> Result<Self, Box<dyn std::error::Error>>
{
let img = image::open( path )?.into_rgba8();
let ( width, height ) = img.dimensions();
Ok( Self { rgba: Arc::new( img.into_raw() ), width, height, cover: false, display_size: None, opacity: 1.0 } )
}
/// Scale the image to fill the available width, preserving aspect ratio (cover mode).
pub fn cover( mut self ) -> Self
{
self.cover = true;
self
}
/// Set an explicit display size in logical pixels.
pub fn size( mut self, width: f32, height: f32 ) -> Self
{
self.display_size = Some( ( width.max( 0.0 ), height.max( 0.0 ) ) );
self
}
/// Set the opacity multiplier. Clamped to `[0.0, 1.0]`.
pub fn opacity( mut self, o: f32 ) -> Self
{
self.opacity = o.clamp( 0.0, 1.0 );
self
}
/// Return the preferred `(width, height)` given available `max_width`.
pub fn preferred_size( &self, max_width: f32 ) -> (f32, f32)
{
if let Some( size ) = self.display_size
{
return size;
}
if self.cover
{
( max_width, max_width * self.height as f32 / self.width as f32 )
} else {
let scale = max_width / self.width as f32;
( max_width, self.height as f32 * scale )
}
}
/// Draw the image into `canvas` at `rect`.
pub fn draw( &self, canvas: &mut Canvas, rect: Rect )
{
canvas.draw_image_data( &self.rgba[..], self.width, self.height, rect, self.opacity );
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
fn solid_rgba( w: u32, h: u32 ) -> Arc<Vec<u8>>
{
Arc::new( vec![ 255u8; ( w * h * 4 ) as usize ] )
}
// ── builders / defaults ───────────────────────────────────────────────────
#[ test ]
fn new_uses_documented_defaults()
{
let img = Image::new( solid_rgba( 4, 4 ), 4, 4 );
assert_eq!( img.width, 4 );
assert_eq!( img.height, 4 );
assert!( !img.cover );
assert!( img.display_size.is_none() );
assert_eq!( img.opacity, 1.0 );
}
#[ test ]
fn cover_builder_sets_cover_flag()
{
let img = Image::new( solid_rgba( 4, 4 ), 4, 4 ).cover();
assert!( img.cover );
}
#[ test ]
fn size_builder_sets_explicit_display_size()
{
let img = Image::new( solid_rgba( 4, 4 ), 4, 4 ).size( 120.0, 80.0 );
assert_eq!( img.display_size, Some( ( 120.0, 80.0 ) ) );
}
#[ test ]
fn size_builder_clamps_negative_dimensions_to_zero()
{
let img = Image::new( solid_rgba( 4, 4 ), 4, 4 ).size( -10.0, -20.0 );
assert_eq!( img.display_size, Some( ( 0.0, 0.0 ) ) );
}
#[ test ]
fn opacity_clamps_to_unit_interval()
{
assert_eq!( Image::new( solid_rgba( 1, 1 ), 1, 1 ).opacity( -0.5 ).opacity, 0.0 );
assert_eq!( Image::new( solid_rgba( 1, 1 ), 1, 1 ).opacity( 1.5 ).opacity, 1.0 );
assert_eq!( Image::new( solid_rgba( 1, 1 ), 1, 1 ).opacity( 0.5 ).opacity, 0.5 );
}
// ── preferred_size ────────────────────────────────────────────────────────
#[ test ]
fn preferred_size_default_scales_height_to_match_width_aspect()
{
// 200×100 source image at max_width = 400 → scale = 2 → height 200.
let img = Image::new( solid_rgba( 200, 100 ), 200, 100 );
let ( w, h ) = img.preferred_size( 400.0 );
assert_eq!( w, 400.0 );
assert_eq!( h, 200.0 );
}
#[ test ]
fn preferred_size_with_explicit_size_wins_over_aspect_logic()
{
let img = Image::new( solid_rgba( 200, 100 ), 200, 100 ).size( 50.0, 25.0 );
assert_eq!( img.preferred_size( 1000.0 ), ( 50.0, 25.0 ) );
}
#[ test ]
fn preferred_size_cover_mode_keeps_aspect_ratio()
{
// 100×50 source in cover mode at max_width = 300 → height = 300 * (50/100) = 150.
let img = Image::new( solid_rgba( 100, 50 ), 100, 50 ).cover();
let ( w, h ) = img.preferred_size( 300.0 );
assert_eq!( w, 300.0 );
assert_eq!( h, 150.0 );
}
#[ test ]
fn preferred_size_cover_and_default_match_for_uniform_scaling()
{
// When the source aspect matches the requested width, cover and the
// default fit-width path produce identical sizes — they only diverge
// when the source is taller than wide.
let normal = Image::new( solid_rgba( 100, 50 ), 100, 50 );
let covered = Image::new( solid_rgba( 100, 50 ), 100, 50 ).cover();
assert_eq!( normal.preferred_size( 200.0 ), covered.preferred_size( 200.0 ) );
}
// ── Arc sharing ───────────────────────────────────────────────────────────
#[ test ]
fn rgba_buffer_is_shared_via_arc_not_cloned_per_image()
{
let bytes = solid_rgba( 8, 8 );
let strong_before = Arc::strong_count( &bytes );
let img = Image::new( bytes.clone(), 8, 8 );
assert_eq!( Arc::strong_count( &bytes ), strong_before + 1 );
// Same buffer goes into a second image — strong count rises again.
let img2 = Image::new( bytes.clone(), 8, 8 );
assert_eq!( Arc::strong_count( &bytes ), strong_before + 2 );
drop( img );
drop( img2 );
assert_eq!( Arc::strong_count( &bytes ), strong_before );
}
}

268
src/widget/list_item.rs Normal file
View File

@@ -0,0 +1,268 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::{ Rect, WidgetId };
use crate::render::Canvas;
use super::Element;
mod theme
{
use crate::types::Color;
pub fn label_color() -> Color { crate::theme::palette().text_primary }
pub fn subtitle_color() -> Color { crate::theme::palette().text_secondary }
pub fn trailing_color() -> Color { crate::theme::palette().text_secondary }
/// Alpha-only overlay tuned for the active mode (white on dark, navy on light).
pub fn hover_bg() -> Color
{
let p = crate::theme::palette();
Color { r: p.text_primary.r, g: p.text_primary.g, b: p.text_primary.b, a: 0.06 }
}
pub fn press_bg() -> Color
{
let p = crate::theme::palette();
Color { r: p.text_primary.r, g: p.text_primary.g, b: p.text_primary.b, a: 0.12 }
}
/// Solid dark surface for the [`ListItem::selected`](super::ListItem::selected) state.
/// `palette.text_primary` happens to be the right colour for both
/// modes (navy on light, white on dark) so the white-on-dark contrast
/// pattern flips into a navy-on-white contrast in dark mode without
/// needing a separate slot.
pub fn selected_bg() -> Color { crate::theme::palette().text_primary }
pub fn focus_color() -> Color { crate::theme::palette().accent }
pub const LABEL_SIZE: f32 = 16.0;
pub const SUBTITLE_SIZE: f32 = 13.0;
pub const TRAILING_SIZE: f32 = 14.0;
pub const HEIGHT: f32 = 56.0;
pub const HEIGHT_SUB: f32 = 68.0;
pub const PAD_H: f32 = 16.0;
pub const RADIUS: f32 = 12.0;
pub const FOCUS_W: f32 = 2.0;
}
/// A row inside a list with a primary label and optional subtitle / trailing
/// text.
///
/// Use to build settings menus, navigation lists, contact rows or any other
/// vertically-stacked tappable content. The widget paints its own hover and
/// pressed surfaces and a rounded focus ring; wrap a column of `ListItem`s
/// inside a [`scroll`](crate::scroll) for scrollable lists.
///
/// ```rust,no_run
/// # use ltk::{ column, list_item, scroll, Element };
/// # #[ derive( Clone ) ] enum Msg { OpenWifi, OpenBluetooth, OpenDisplay }
/// # fn _ex() -> Element<Msg> {
/// // In view():
/// scroll(
/// column()
/// .push( list_item( "Wi-Fi" ).trailing( "Eduroam" ).on_press( Msg::OpenWifi ) )
/// .push( list_item( "Bluetooth" ).subtitle( "AirPods Pro" ).on_press( Msg::OpenBluetooth ) )
/// .push( list_item( "Display" ).trailing( "Light" ).on_press( Msg::OpenDisplay ) ),
/// )
/// .into()
/// # }
/// ```
pub struct ListItem<Msg: Clone>
{
/// Primary label (always visible, top-aligned when a subtitle is
/// present).
pub label: String,
/// Optional secondary line drawn below the label in muted colour.
/// Doubles the row height when set.
pub subtitle: Option<String>,
/// Optional right-aligned text (current setting, badge count, ""
/// disclosure). Drawn in muted colour.
pub trailing: Option<String>,
/// Message emitted on tap. `None` keeps the item visible but inert.
pub on_press: Option<Msg>,
/// Optional stable identifier for focus management.
pub id: Option<WidgetId>,
/// `true` paints the row with the dark selected surface and white
/// text, regardless of hover / press state. Use to indicate the
/// active item in a list of choices (combo dropdown, segmented
/// picker, settings group with a single active value).
pub selected: bool,
}
impl<Msg: Clone> ListItem<Msg>
{
/// Create a list item with the given primary label, no subtitle, no
/// trailing text and no callback.
pub fn new( label: impl Into<String> ) -> Self
{
Self
{
label: label.into(),
subtitle: None,
trailing: None,
on_press: None,
id: None,
selected: false,
}
}
/// Mark this row as the currently-selected option in its list.
/// Selected rows paint with a dark surface and white text and
/// override hover / press visuals.
pub fn selected( mut self, yes: bool ) -> Self
{
self.selected = yes;
self
}
/// Add a secondary line below the label. Doubles the row height to
/// fit both lines comfortably.
pub fn subtitle( mut self, s: impl Into<String> ) -> Self
{
self.subtitle = Some( s.into() );
self
}
/// Add right-aligned text (settings value, badge, disclosure arrow).
pub fn trailing( mut self, s: impl Into<String> ) -> Self
{
self.trailing = Some( s.into() );
self
}
/// Set the message emitted when the row is tapped.
pub fn on_press( mut self, msg: Msg ) -> Self
{
self.on_press = Some( msg );
self
}
/// Assign a stable identifier for focus management.
pub fn id( mut self, id: WidgetId ) -> Self
{
self.id = Some( id );
self
}
pub fn preferred_size( &self, max_width: f32, _canvas: &Canvas ) -> (f32, f32)
{
let h = if self.subtitle.is_some() { theme::HEIGHT_SUB } else { theme::HEIGHT };
( max_width, h )
}
/// Focus stroke is centered on `rect`, so half the stroke width plus ~1 px
/// of antialiasing bleed sits outside.
pub fn paint_bounds( &self, rect: Rect ) -> Rect
{
rect.expand( theme::FOCUS_W * 0.5 + 1.0 )
}
pub fn draw( &self, canvas: &mut Canvas, rect: Rect, focused: bool, hovered: bool, pressed: bool )
{
// Selected wins over hover and press: a chosen item stays
// chosen even when the pointer is over it.
let ( label_color, subtitle_color, trailing_color ) = if self.selected
{
canvas.fill_rect( rect, theme::selected_bg(), theme::RADIUS );
// Selected row is filled with `text_primary` colour;
// label / subtitle / trailing read as the inverse via
// `bg` so they stay legible.
let inverse = crate::theme::palette().bg;
( inverse, inverse, inverse )
}
else
{
if pressed
{
canvas.fill_rect( rect, theme::press_bg(), theme::RADIUS );
}
else if hovered
{
canvas.fill_rect( rect, theme::hover_bg(), theme::RADIUS );
}
( theme::label_color(), theme::subtitle_color(), theme::trailing_color() )
};
if focused
{
canvas.stroke_rect( rect, theme::focus_color(), theme::FOCUS_W, theme::RADIUS );
}
let has_sub = self.subtitle.is_some();
let label_y = if has_sub
{
rect.y + rect.height * 0.35 + theme::LABEL_SIZE * 0.3
} else {
rect.y + ( rect.height + theme::LABEL_SIZE ) / 2.0 - 2.0
};
canvas.draw_text( &self.label, rect.x + theme::PAD_H, label_y, theme::LABEL_SIZE, label_color );
if let Some( ref sub ) = self.subtitle
{
let sub_y = rect.y + rect.height * 0.62 + theme::SUBTITLE_SIZE * 0.3;
canvas.draw_text( sub, rect.x + theme::PAD_H, sub_y, theme::SUBTITLE_SIZE, subtitle_color );
}
if let Some( ref trail ) = self.trailing
{
let tw = canvas.measure_text( trail, theme::TRAILING_SIZE );
let tx = rect.x + rect.width - theme::PAD_H - tw;
let ty = rect.y + ( rect.height + theme::TRAILING_SIZE ) / 2.0 - 2.0;
canvas.draw_text( trail, tx, ty, theme::TRAILING_SIZE, trailing_color );
}
}
pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> ListItem<U>
where
U: Clone + 'static,
Msg: 'static,
{
ListItem
{
label: self.label,
subtitle: self.subtitle,
trailing: self.trailing,
on_press: self.on_press.map( |m| ( *f )( m ) ),
id: self.id,
selected: self.selected,
}
}
}
/// Create a [`ListItem`] with the given primary label.
///
/// Add detail and behaviour through the chained builders:
///
/// ```rust,no_run
/// # use ltk::{ list_item, ListItem };
/// # #[ derive( Clone ) ] enum Msg { OpenDisplay }
/// # fn _ex() -> ListItem<Msg> {
/// list_item( "Display" )
/// .subtitle( "Resolution, brightness, night mode" )
/// .trailing( "" )
/// .on_press( Msg::OpenDisplay )
/// # }
/// ```
pub fn list_item<Msg: Clone>( label: impl Into<String> ) -> ListItem<Msg>
{
ListItem::new( label )
}
impl<Msg: Clone + 'static> From<ListItem<Msg>> for Element<Msg>
{
fn from( l: ListItem<Msg> ) -> Self
{
Element::ListItem( l )
}
}
#[cfg(test)]
mod tests
{
use super::*;
#[test]
fn list_item_default()
{
let l = list_item::<()>( "Test" );
assert_eq!( l.label, "Test" );
assert!( l.subtitle.is_none() );
assert!( l.trailing.is_none() );
assert!( l.on_press.is_none() );
}
}

923
src/widget/mod.rs Executable file
View File

@@ -0,0 +1,923 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Widgets — the interactive and decorative leaves of the [`Element`] tree.
//!
//! Each widget lives in its own submodule and is reached through the
//! crate-root re-exports (`button`, `text`, `text_edit`, `slider`, …) plus
//! the `img_widget` alias for [`image::Image`]. Construct one from its
//! free constructor function, configure it through builder-style methods,
//! and convert it into [`Element<Msg>`] via `.into()` when pushing it
//! into a layout.
//!
//! ```rust,no_run
//! # use ltk::{ button, column, slider, text, Element };
//! # #[ derive( Clone ) ] enum Msg { SetVolume( f32 ), Mute }
//! # struct App { volume: f32 }
//! # impl App { fn _ex( &self ) -> Element<Msg> {
//! column()
//! .push( text( "Volume" ) )
//! .push( slider( self.volume ).on_change( |v| Msg::SetVolume( v ) ) )
//! .push( button( "Mute" ).on_press( Msg::Mute ) )
//! .into()
//! # }}
//! ```
//!
//! ## What lives here
//!
//! * **Buttons / activations**: [`button::Button`],
//! [`pressable::Pressable`], [`window_button::WindowButton`],
//! [`list_item::ListItem`].
//! * **Stateful binary controls**: [`toggle::Toggle`],
//! [`checkbox::Checkbox`], [`radio::Radio`].
//! * **Continuous controls**: [`slider::Slider`], [`vslider::VSlider`],
//! [`progress_bar::ProgressBar`].
//! * **Text**: [`text::Text`], [`text_edit::TextEdit`].
//! * **Images / decoration**: [`image::Image`], [`separator::Separator`],
//! [`container::Container`].
//! * **Clipping wrappers**: [`scroll::Scroll`] (with gesture-driven
//! scrolling), [`viewport::Viewport`] (passive clip / fade), and
//! [`flex::Flex`] (treats a non-spacer child as a row filler).
//! * **Overlays**: [`dialog::Dialog`] (modal / non-modal centered
//! confirmation card with built-in scrim, ESC-to-cancel, and
//! tap-outside-to-dismiss for the non-modal variant).
//!
//! Layouts ([`column`](crate::column), [`row`](crate::row),
//! [`stack`](crate::stack), [`grid`](crate::grid),
//! [`spacer`](crate::spacer)) live in [`crate::layout`]; they share the
//! same [`Element`] tree but are kept separate to make the "what does
//! this paint" / "how is this arranged" distinction explicit.
//!
//! ## Per-leaf handler snapshot
//!
//! [`WidgetHandlers`] is the snapshot the layout pass takes of every
//! interactive widget so the input handlers can dispatch in O(1) without
//! re-walking the [`Element`] tree. It is `pub( crate )` plumbing for the
//! runtime; downstream apps usually never see it. The `test_support`
//! module re-exports it for integration tests that want to assert on the
//! handler shape.
pub mod button;
pub mod container;
pub mod text_edit;
pub mod image;
pub mod text;
pub mod scroll;
pub mod viewport;
pub mod slider;
pub mod vslider;
pub mod toggle;
pub mod separator;
pub mod progress_bar;
pub mod checkbox;
pub mod radio;
pub mod list_item;
pub mod window_button;
pub mod pressable;
pub mod flex;
pub mod combo;
pub mod anchored_overlay;
pub mod spinner;
pub mod tab_bar;
pub mod toast;
pub mod tooltip;
pub mod notebook;
pub mod date_picker;
pub mod time_picker;
pub mod color_picker;
pub mod dialog;
pub mod external;
use std::sync::Arc;
use crate::types::{ Point, Rect, WidgetId };
use crate::render::Canvas;
/// Per-leaf interaction snapshot captured during layout. One variant per
/// interactive widget kind; the layout pass clones the relevant callbacks /
/// values from the [`Element`] tree into here so input handlers can dispatch
/// in O(1) without re-walking the tree.
///
/// `None` is used for focusable widgets that emit no message (e.g. a disabled
/// button or a focusable container) — the entry still appears in
/// `widget_rects` for hit testing and focus traversal.
pub enum WidgetHandlers<Msg: Clone>
{
None,
Button
{
on_press: Option<Msg>,
on_long_press: Option<Msg>,
on_drag_start: Option<Msg>,
/// Keyboard `Escape`-key message — the runtime scans every laid-out
/// `Button` snapshot in reverse and fires the first non-`None`
/// `on_escape` it finds before the default ESC fallthrough chain.
/// Currently sourced from
/// [`crate::widget::pressable::Pressable::on_escape`]; native
/// [`crate::widget::button::Button`] always sets this to `None`.
on_escape: Option<Msg>,
repeating: bool,
},
Toggle { on_toggle: Option<Msg> },
Checkbox { on_toggle: Option<Msg> },
Radio { on_select: Option<Msg> },
ListItem { on_press: Option<Msg> },
WindowButton { on_press: Option<Msg> },
TextEdit
{
value: String,
on_change: Option<Arc<dyn Fn( String ) -> Msg>>,
on_submit: Option<Msg>,
/// `true` when the source `TextEdit` was built with
/// `.secure( true )`. Propagates the wipe-on-drop behaviour to
/// every per-frame handler snapshot so credential text does not
/// linger across multiple cloned `WidgetHandlers` allocations.
secure: bool,
/// `true` when the source `TextEdit` was built with
/// `.multiline( true )`. The keyboard dispatch reads this so
/// pressing Enter inserts a `\n` instead of firing
/// [`Self::submit_msg`]. Mutually exclusive with `secure`.
multiline: bool,
/// Horizontal alignment snapshot — needed by the runtime's
/// hit-testing path so a click on a centred / right-aligned
/// field lands on the correct glyph.
align: text::TextAlign,
/// Font size snapshot — needed by the hit-testing path so
/// the runtime measures glyphs at the same size the renderer
/// drew them. Always the default `theme::FONT_SIZE` for
/// fields that do not call `.font_size( … )`.
font_size: f32,
/// `true` when the source field opted into select-all-on-
/// focus. The runtime reads this in `set_focus` to decide
/// whether the new selection should anchor at `0` (replace
/// on next keystroke) or at the cursor (insert).
select_on_focus: bool,
/// Snapshot of the
/// [`crate::widget::text_edit::TextEdit::password_toggle`]
/// callback. When `Some`, pointer / touch dispatch checks
/// the eye-icon hit zone (via
/// [`crate::widget::text_edit::password_toggle_hit_zone`])
/// before falling through to cursor placement and fires
/// this message instead.
password_toggle_msg: Option<Msg>,
},
Slider
{
on_change: Option<Arc<dyn Fn( f32 ) -> Msg>>,
axis: slider::SliderAxis,
},
}
impl<Msg: Clone> Drop for WidgetHandlers<Msg>
{
/// Mirror the wipe-on-drop behaviour of [`text_edit::TextEdit`] for the
/// per-frame handler snapshots the runtime keeps. When the snapshot
/// was built from a secure text edit we scrub the value bytes here so
/// the heap allocation that backs the cloned `String` is overwritten
/// before it is returned to the allocator. Non-secure variants run an
/// inert path; the field-level drops that fire after this fn handle
/// the rest of the data.
fn drop( &mut self )
{
if let WidgetHandlers::TextEdit { value, secure: true, .. } = self
{
// SAFETY: identical reasoning to `TextEdit::drop` — we only
// write zeros into the underlying byte buffer, leaving valid
// UTF-8 (NUL bytes) in place until the auto-drop frees it.
let bytes = unsafe { value.as_mut_vec() };
crate::secure_mem::secure_zero( bytes );
}
}
}
impl<Msg: Clone> Clone for WidgetHandlers<Msg>
{
fn clone( &self ) -> Self
{
match self
{
WidgetHandlers::None => WidgetHandlers::None,
WidgetHandlers::Button { on_press, on_long_press, on_drag_start, on_escape, repeating } => WidgetHandlers::Button
{
on_press: on_press.clone(),
on_long_press: on_long_press.clone(),
on_drag_start: on_drag_start.clone(),
on_escape: on_escape.clone(),
repeating: *repeating,
},
WidgetHandlers::Toggle { on_toggle } => WidgetHandlers::Toggle { on_toggle: on_toggle.clone() },
WidgetHandlers::Checkbox { on_toggle } => WidgetHandlers::Checkbox { on_toggle: on_toggle.clone() },
WidgetHandlers::Radio { on_select } => WidgetHandlers::Radio { on_select: on_select.clone() },
WidgetHandlers::ListItem { on_press } => WidgetHandlers::ListItem { on_press: on_press.clone() },
WidgetHandlers::WindowButton { on_press } => WidgetHandlers::WindowButton { on_press: on_press.clone() },
WidgetHandlers::TextEdit { value, on_change, on_submit, secure, multiline, align, font_size, select_on_focus, password_toggle_msg } =>
{
WidgetHandlers::TextEdit
{
value: value.clone(),
on_change: on_change.clone(),
on_submit: on_submit.clone(),
secure: *secure,
multiline: *multiline,
align: *align,
font_size: *font_size,
select_on_focus: *select_on_focus,
password_toggle_msg: password_toggle_msg.clone(),
}
}
WidgetHandlers::Slider { on_change, axis } =>
{
WidgetHandlers::Slider { on_change: on_change.clone(), axis: *axis }
}
}
}
}
impl<Msg: Clone> WidgetHandlers<Msg>
{
pub fn is_text_input( &self ) -> bool
{
matches!( self, WidgetHandlers::TextEdit { .. } )
}
/// `true` when this is a [`WidgetHandlers::TextEdit`] whose source
/// widget was built with `.multiline( true )`. The keyboard
/// dispatch reads this so pressing Enter inserts a `\n` instead of
/// submitting.
pub fn is_multiline_text_input( &self ) -> bool
{
matches!( self, WidgetHandlers::TextEdit { multiline: true, .. } )
}
pub fn is_slider( &self ) -> bool
{
matches!( self, WidgetHandlers::Slider { .. } )
}
/// `true` when this widget is a row inside a scrollable list that
/// keyboard arrow navigation should treat as a stepping point.
/// Currently restricted to [`ListItem`](crate::ListItem); buttons,
/// toggles, sliders, etc. are not stepped over by Arrow Up/Down so
/// they do not interfere with the row-by-row navigation pattern in
/// combo popups, settings menus and similar lists.
pub fn is_navigable_list_item( &self ) -> bool
{
matches!( self, WidgetHandlers::ListItem { .. } )
}
/// Convenience: extract the press / activation message for the variants
/// that have one (Button, Toggle, Checkbox, Radio, ListItem). Returns
/// `None` for sliders / text edits / `None` / disabled widgets.
pub fn press_msg( &self ) -> Option<Msg>
{
match self
{
WidgetHandlers::Button { on_press, .. } => on_press.clone(),
WidgetHandlers::Toggle { on_toggle } => on_toggle.clone(),
WidgetHandlers::Checkbox { on_toggle } => on_toggle.clone(),
WidgetHandlers::Radio { on_select } => on_select.clone(),
WidgetHandlers::ListItem { on_press } => on_press.clone(),
WidgetHandlers::WindowButton { on_press } => on_press.clone(),
_ => None,
}
}
/// Submit message (Enter on a focused TextEdit). `None` for every other
/// variant.
pub fn submit_msg( &self ) -> Option<Msg>
{
match self
{
WidgetHandlers::TextEdit { on_submit, .. } => on_submit.clone(),
_ => None,
}
}
/// Build the `on_change` message for a TextEdit given the new value.
pub fn text_change_msg( &self, new_value: &str ) -> Option<Msg>
{
match self
{
WidgetHandlers::TextEdit { on_change: Some( f ), .. } => Some( f( new_value.to_string() ) ),
_ => None,
}
}
/// Build the `on_change` message for a Slider given a value in `[0,1]`.
pub fn slider_change_msg( &self, value: f32 ) -> Option<Msg>
{
match self
{
WidgetHandlers::Slider { on_change: Some( f ), .. } => Some( f( value ) ),
_ => None,
}
}
/// Compute the `[0.0, 1.0]` value for the slider this handler belongs to,
/// given a pointer position inside its layout rect. Dispatches on the
/// stored [`slider::SliderAxis`] so the same call site in `input.rs` drives
/// both horizontal [`Slider`](slider::Slider) and vertical
/// [`VSlider`](vslider::VSlider).
///
/// Returns `0.0` for non-slider variants — callers combine this with
/// [`Self::slider_change_msg`], which also gates on the variant, so the
/// zero is never consumed in practice.
pub fn slider_value_from_pos( &self, rect: Rect, pos: Point ) -> f32
{
match self
{
WidgetHandlers::Slider { axis, .. } =>
{
slider::value_from_pos_in_rect( rect, pos, *axis )
}
_ => 0.0,
}
}
/// `true` when this is a [`WidgetHandlers::Button`] whose source
/// widget opted into press-and-hold repeat. The runtime reads
/// this on press to decide whether to fire `press_msg`
/// immediately + arm a calloop repeat timer, and on release to
/// suppress the regular tap-on-release fire.
pub fn is_repeating( &self ) -> bool
{
matches!( self, WidgetHandlers::Button { repeating: true, .. } )
}
/// Long-press / right-click message for this widget, or `None` if
/// none configured. Currently only [`WidgetHandlers::Button`]
/// carries one. Firing this does not by itself put the press into
/// drag mode — see [`Self::drag_start_msg`] for that.
pub fn long_press_msg( &self ) -> Option<Msg>
{
match self
{
WidgetHandlers::Button { on_long_press, .. } => on_long_press.clone(),
_ => None,
}
}
/// Drag-arm message for this widget, or `None` if none configured.
/// Fired by the touch hold-timer alongside `long_press_msg`, and
/// by mouse left-button motion past the drag-promotion threshold
/// (without firing the menu). Promotes the gesture into drag mode.
pub fn drag_start_msg( &self ) -> Option<Msg>
{
match self
{
WidgetHandlers::Button { on_drag_start, .. } => on_drag_start.clone(),
_ => None,
}
}
/// Keyboard `Escape` message for this widget, or `None` if none
/// configured. Used by the keyboard ESC handler to scan
/// `widget_rects` for a [`crate::widget::dialog::Dialog`] (or other
/// `Pressable::on_escape`-bearing wrapper) and fire its cancel
/// message before the default ESC fallthrough chain.
pub fn escape_msg( &self ) -> Option<Msg>
{
match self
{
WidgetHandlers::Button { on_escape, .. } => on_escape.clone(),
_ => None,
}
}
/// Current text-edit value (for cursor placement on focus, backspace
/// rebuild, etc.). `None` for non-text-edit variants.
pub fn current_value( &self ) -> Option<&str>
{
match self
{
WidgetHandlers::TextEdit { value, .. } => Some( value.as_str() ),
_ => None,
}
}
}
/// Result of laying out one *interactive* widget — i.e. a widget that should
/// receive pointer / touch hit-testing. Captures both the *hit rect* (where
/// input lands) and the *paint rect* (the bounding box of everything the
/// widget actually paints, including hover circles, focus rings, shadows…).
/// The paint rect is used by the partial-redraw path to know how much of the
/// canvas must be invalidated when a widget transitions in/out of a given
/// state — it is always `>= rect`.
///
/// `handlers` carries the snapshot of the widget's callbacks/values at layout
/// time so input dispatch is O(1) instead of re-walking the [`Element`] tree.
///
/// `keyboard_focusable` snapshots whether the widget should participate in the
/// Tab / Shift+Tab cycle. Most interactive widgets are also keyboard-focusable
/// (`Button`, `TextEdit`, `Slider`, …) but window-decoration chrome
/// (`WindowButton`) is interactive without taking keyboard focus by default —
/// matching the convention of macOS / GNOME / Windows title bars.
pub struct LaidOutWidget<Msg: Clone>
{
pub rect: Rect,
pub flat_idx: usize,
pub id: Option<WidgetId>,
pub paint_rect: Rect,
pub handlers: WidgetHandlers<Msg>,
pub keyboard_focusable: bool,
/// Cursor shape this widget wants to show on hover. Snapshotted
/// from [`Element::cursor_shape`] at layout time so the input
/// dispatch can pick the right shape without re-walking the
/// element tree.
pub cursor: crate::types::CursorShape,
}
impl<Msg: Clone> Clone for LaidOutWidget<Msg>
{
fn clone( &self ) -> Self
{
Self
{
rect: self.rect,
flat_idx: self.flat_idx,
id: self.id,
paint_rect: self.paint_rect,
handlers: self.handlers.clone(),
keyboard_focusable: self.keyboard_focusable,
cursor: self.cursor,
}
}
}
pub enum Element<Msg: Clone>
{
Button( button::Button<Msg> ),
Container( container::Container<Msg> ),
TextEdit( text_edit::TextEdit<Msg> ),
Image( image::Image ),
Column( crate::layout::column::Column<Msg> ),
Row( crate::layout::row::Row<Msg> ),
Stack( crate::layout::stack::Stack<Msg> ),
Text( text::Text ),
Spacer( crate::layout::spacer::Spacer ),
Scroll( scroll::Scroll<Msg> ),
Viewport( viewport::Viewport<Msg> ),
WrapGrid( crate::layout::wrap_grid::WrapGrid<Msg> ),
Slider( slider::Slider<Msg> ),
VSlider( vslider::VSlider<Msg> ),
Toggle( toggle::Toggle<Msg> ),
Separator( separator::Separator ),
ProgressBar( progress_bar::ProgressBar ),
Checkbox( checkbox::Checkbox<Msg> ),
Radio( radio::Radio<Msg> ),
ListItem( list_item::ListItem<Msg> ),
WindowButton( window_button::WindowButton<Msg> ),
Pressable( pressable::Pressable<Msg> ),
Flex( flex::Flex<Msg> ),
AnchoredOverlay( anchored_overlay::AnchoredOverlay<Msg> ),
Spinner( spinner::Spinner ),
External( external::External ),
}
impl<Msg: Clone> Element<Msg>
{
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 )
{
match self
{
Element::Button( b ) => b.preferred_size( max_width, canvas ),
Element::TextEdit( t ) => t.preferred_size( max_width, canvas ),
Element::Image( i ) => i.preferred_size( max_width ),
Element::Column( c ) => c.preferred_size( max_width, canvas ),
Element::Row( r ) => r.preferred_size( max_width, canvas ),
Element::Stack( s ) => s.preferred_size( max_width, canvas ),
Element::Text( t ) => t.preferred_size( max_width, canvas ),
Element::Spacer( s ) => s.preferred_size(),
Element::Scroll( s ) => s.preferred_size( max_width, canvas ),
Element::Viewport( v ) => v.preferred_size( max_width, canvas ),
Element::WrapGrid( g ) => g.preferred_size( max_width, canvas ),
Element::Slider( s ) => s.preferred_size( max_width, canvas ),
Element::VSlider( s ) => s.preferred_size( max_width, canvas ),
Element::Container( c ) => c.preferred_size( max_width, canvas ),
Element::Toggle( t ) => t.preferred_size( max_width, canvas ),
Element::Separator( s ) => s.preferred_size( max_width ),
Element::ProgressBar( p ) => p.preferred_size( max_width ),
Element::Checkbox( c ) => c.preferred_size( max_width, canvas ),
Element::Radio( r ) => r.preferred_size( max_width, canvas ),
Element::ListItem( l ) => l.preferred_size( max_width, canvas ),
Element::WindowButton( b ) => b.preferred_size( max_width, canvas ),
Element::Pressable( p ) => p.preferred_size( max_width, canvas ),
Element::Flex( f ) => f.preferred_size( max_width, canvas ),
Element::AnchoredOverlay( a ) => a.child.preferred_size( max_width, canvas ),
Element::Spinner( s ) => s.preferred_size( max_width ),
Element::External( e ) => e.preferred_size( max_width ),
}
}
pub fn draw(
&self,
canvas: &mut Canvas,
rect: Rect,
focused: bool,
hovered: bool,
pressed: bool,
cursor_pos: usize,
selection_anchor: usize,
)
{
match self
{
Element::Button( b ) => b.draw( canvas, rect, focused, hovered, pressed ),
Element::TextEdit( t ) => t.draw( canvas, rect, focused, cursor_pos, selection_anchor ),
Element::Image( i ) => i.draw( canvas, rect ),
Element::Column( c ) => c.draw( canvas, rect, focused ),
Element::Row( r ) => r.draw( canvas, rect, focused ),
Element::Stack( s ) => s.draw( canvas, rect, focused ),
Element::Text( t ) => t.draw( canvas, rect, focused ),
Element::Spacer( _ ) => {}
Element::Scroll( _ ) => {}
Element::Viewport( _ ) => {}
Element::WrapGrid( _ ) => {}
Element::Slider( s ) => s.draw( canvas, rect, focused ),
Element::VSlider( s ) => s.draw( canvas, rect, focused ),
Element::Container( _ ) => {}
Element::Toggle( t ) => t.draw( canvas, rect, focused ),
Element::Separator( s ) => s.draw( canvas, rect ),
Element::ProgressBar( p ) => p.draw( canvas, rect ),
Element::Checkbox( c ) => c.draw( canvas, rect, focused ),
Element::Radio( r ) => r.draw( canvas, rect, focused ),
Element::ListItem( l ) => l.draw( canvas, rect, focused, hovered, pressed ),
Element::WindowButton( b ) => b.draw( canvas, rect, focused, hovered, pressed ),
Element::Pressable( _ ) => {}
Element::Flex( _ ) => {}
Element::AnchoredOverlay( _ ) => {}
Element::Spinner( s ) => s.draw( canvas, rect ),
Element::External( e ) => e.draw( canvas, rect ),
}
}
pub fn hit_test( &self, rect: Rect, pos: Point ) -> bool
{
rect.contains( pos )
}
/// Bounding box of every pixel this widget may paint given `rect` as its
/// layout rect. Must enclose the `draw` output in every possible state
/// (hover, focus, press). Widgets that paint outside their layout rect
/// (hover halos, focus rings, drop shadows…) must override this; the
/// default returns `rect` unchanged.
///
/// Used by the partial-redraw path to invalidate exactly the pixels that
/// might change when a widget transitions in/out of a state.
pub( crate ) fn paint_bounds( &self, rect: Rect ) -> Rect
{
match self
{
Element::Button( b ) => b.paint_bounds( rect ),
Element::Toggle( t ) => t.paint_bounds( rect ),
Element::Radio( r ) => r.paint_bounds( rect ),
Element::Checkbox( c ) => c.paint_bounds( rect ),
Element::TextEdit( e ) => e.paint_bounds( rect ),
Element::ListItem( l ) => l.paint_bounds( rect ),
Element::WindowButton( b ) => b.paint_bounds( rect ),
Element::Slider( s ) => s.paint_bounds( rect ),
Element::VSlider( s ) => s.paint_bounds( rect ),
_ => rect,
}
}
/// Whether the widget should be included in the per-surface
/// `widget_rects` list — i.e. whether pointer / touch hit testing must be
/// able to land on it. This is the predicate that gates the layout pass's
/// push to `DrawCtx::widget_rects` in the draw pass.
///
/// Defaults to [`Self::is_focusable`] for every widget that takes keyboard
/// focus (those are interactive by definition). Widgets that are
/// click/touch-only without taking keyboard focus — currently
/// [`Element::WindowButton`] — opt in here without opting in to the Tab
/// cycle.
pub fn is_interactive( &self ) -> bool
{
match self
{
// Always hit-testable regardless of `focusable`. Lets callers
// opt out of keyboard focus (no Tab target, no lingering focus
// ring after a press) while keeping clicks / taps working.
Element::Button( _ ) | Element::WindowButton( _ ) => true,
// Pressable wrappers participate in hit testing only when they
// carry a handler — a no-op pressable is invisible to input.
Element::Pressable( p ) => p.has_handler(),
_ => self.is_focusable(),
}
}
/// Whether the widget participates in the Tab / Shift+Tab focus cycle.
/// Snapshotted onto [`LaidOutWidget::keyboard_focusable`] at layout time so
/// `next_focusable_index` can iterate without the [`Element`] tree.
///
/// Hit-testable chrome that should *not* steal keyboard focus from window
/// content (e.g. [`Element::WindowButton`]) returns `false` here while
/// still returning `true` from [`Self::is_interactive`].
pub fn is_focusable( &self ) -> bool
{
match self
{
Element::Button( b ) => b.focusable,
Element::TextEdit( _ ) | Element::Slider( _ ) | Element::VSlider( _ ) => true,
Element::Toggle( _ ) | Element::Checkbox( _ ) | Element::Radio( _ ) | Element::ListItem( _ ) => true,
Element::WindowButton( b ) => b.focusable,
_ => false,
}
}
pub fn is_text_input( &self ) -> bool
{
matches!( self, Element::TextEdit( _ ) )
}
/// Cursor shape to display while the pointer is over this widget.
/// Per-widget defaults match desktop conventions:
///
/// * Text inputs → `Text` (I-beam)
/// * Clickables (Button, Pressable, Toggle, Checkbox, Radio,
/// ListItem, WindowButton) → `Pointer` (hand)
/// * Anything else → `Default`
///
/// Widgets that carry a per-instance override (set via the
/// `.cursor( shape )` builder) return the override; otherwise they
/// return their type-based default. The runtime snapshots this onto
/// [`LaidOutWidget::cursor`] at layout time and dispatches the
/// shape via `wp_cursor_shape_v1` on hover transitions.
pub fn cursor_shape( &self ) -> crate::types::CursorShape
{
use crate::types::CursorShape::*;
match self
{
Element::TextEdit( t ) => t.cursor.unwrap_or( Text ),
Element::Button( b ) => b.cursor.unwrap_or( Pointer ),
Element::Pressable( p ) => p.cursor.unwrap_or( Pointer ),
Element::Toggle( _ ) => Pointer,
Element::Checkbox( _ ) => Pointer,
Element::Radio( _ ) => Pointer,
Element::ListItem( _ ) => Pointer,
Element::WindowButton( _ ) => Pointer,
// Sliders read as buttons on hover (`Pointer`) and as
// `Grabbing` during a drag — the runtime swaps to
// `Grabbing` itself when `gesture.dragging_slider` is
// set, so the static value here is just the hover state.
Element::Slider( _ ) => Pointer,
Element::VSlider( _ ) => Pointer,
_ => Default,
}
}
/// Snapshot the widget's callbacks/value into a [`WidgetHandlers`] for
/// O(1) dispatch later. Called once per focusable leaf during the layout
/// pass; the handlers are then stored alongside the rect in
/// [`LaidOutWidget`] so input handlers don't need the [`Element`] tree.
///
/// Containers and layouts that delegate to children return
/// [`WidgetHandlers::None`] — only leaf widgets actually carry payload.
pub( crate ) fn handlers( &self ) -> WidgetHandlers<Msg>
{
match self
{
Element::Button( b ) => WidgetHandlers::Button
{
on_press: b.on_press.clone(),
on_long_press: b.on_long_press.clone(),
on_drag_start: b.on_drag_start.clone(),
on_escape: None,
repeating: b.repeating,
},
Element::Pressable( p ) => WidgetHandlers::Button
{
on_press: p.on_press.clone(),
on_long_press: p.on_long_press.clone(),
on_drag_start: p.on_drag_start.clone(),
on_escape: p.on_escape.clone(),
repeating: false,
},
Element::Toggle( t ) => WidgetHandlers::Toggle { on_toggle: t.on_toggle.clone() },
Element::Checkbox( c ) => WidgetHandlers::Checkbox { on_toggle: c.on_toggle.clone() },
Element::Radio( r ) => WidgetHandlers::Radio { on_select: r.on_select.clone() },
Element::ListItem( l ) => WidgetHandlers::ListItem { on_press: l.on_press.clone() },
Element::WindowButton( b ) => WidgetHandlers::WindowButton { on_press: b.on_press.clone() },
Element::TextEdit( t ) =>
{
WidgetHandlers::TextEdit
{
value: t.value.clone(),
on_change: t.on_change.clone(),
on_submit: t.on_submit.clone(),
// `secure` here drives memory wipe-on-drop and
// the IME bypass — so any password field (with
// or without a toggle) opts in, regardless of
// the user's current visibility choice.
secure: t.secure || t.password_toggle.is_some(),
multiline: t.is_multiline(),
align: t.align,
font_size: t.font_size,
select_on_focus: t.select_on_focus,
password_toggle_msg: t.password_toggle.as_ref().map( |( _, m )| m.clone() ),
}
}
Element::Slider( s ) =>
{
WidgetHandlers::Slider
{
on_change: s.on_change.clone(),
axis: slider::SliderAxis::Horizontal,
}
}
Element::VSlider( s ) =>
{
WidgetHandlers::Slider
{
on_change: s.on_change.clone(),
axis: slider::SliderAxis::Vertical,
}
}
_ => WidgetHandlers::None,
}
}
}
/// Type alias for the message-mapping closure shared across an
/// [`Element::map`] walk. Stored as `Arc<dyn Fn>` so every per-widget
/// `map_msg` can clone and re-share it without copying the closure body
/// — the same closure is invoked once per emitted message, regardless
/// of how many leaves the sub-tree has.
pub( crate ) type MapFn<Msg, U> = Arc<dyn Fn( Msg ) -> U>;
impl<Msg: Clone + 'static> Element<Msg>
{
/// Re-tag every message a sub-view emits.
///
/// Walks the tree once and rewrites every per-leaf message store —
/// `Button::on_press`, `Slider::on_change`, the children of
/// `Column`/`Row`/`Stack`/`WrapGrid`, and so on — so the returned
/// `Element<U>` no longer references `Msg`. The standard Elm /
/// `iced` shape: a sub-view defined as `fn view( …) -> Element<SubMsg>`
/// can be embedded inside a parent that produces `Element<AppMsg>`
/// by calling `.map( AppMsg::Sub )`.
///
/// Cost: `O( leaves )` allocations for the closure-wrapping in the
/// `Arc<dyn Fn(...)>` callbacks (`text_edit`, `slider`, `vslider`),
/// and the closure itself runs an extra indirect call per emitted
/// message — both per-`map`-layer. Trees built fresh every frame
/// (the typical case) absorb this in the same allocator pressure
/// `view()` already produces, so the overhead is in the noise.
///
/// ```rust,no_run
/// # use ltk::{ button, text, Element };
/// # #[ derive( Clone ) ] enum SubMsg { Save }
/// # #[ derive( Clone ) ] enum AppMsg { Sub( SubMsg ) }
/// # fn sub_view() -> Element<SubMsg> {
/// # button( "Save" ).on_press( SubMsg::Save ).into()
/// # }
/// # fn _ex() -> Element<AppMsg> {
/// sub_view().map( AppMsg::Sub )
/// # }
/// ```
pub fn map<U, F>( self, f: F ) -> Element<U>
where
U: Clone + 'static,
F: Fn( Msg ) -> U + 'static,
{
let f: MapFn<Msg, U> = Arc::new( f );
self.map_arc( &f )
}
pub( crate ) fn map_arc<U>( self, f: &MapFn<Msg, U> ) -> Element<U>
where
U: Clone + 'static,
{
match self
{
Element::Button( b ) => Element::Button( b.map_msg( f ) ),
Element::Container( c ) => Element::Container( c.map_msg( f ) ),
Element::TextEdit( t ) => Element::TextEdit( t.map_msg( f ) ),
Element::Image( i ) => Element::Image( i ),
Element::Column( c ) => Element::Column( c.map_msg( f ) ),
Element::Row( r ) => Element::Row( r.map_msg( f ) ),
Element::Stack( s ) => Element::Stack( s.map_msg( f ) ),
Element::Text( t ) => Element::Text( t ),
Element::Spacer( s ) => Element::Spacer( s ),
Element::Scroll( s ) => Element::Scroll( s.map_msg( f ) ),
Element::Viewport( v ) => Element::Viewport( v.map_msg( f ) ),
Element::WrapGrid( g ) => Element::WrapGrid( g.map_msg( f ) ),
Element::Slider( s ) => Element::Slider( s.map_msg( f ) ),
Element::VSlider( s ) => Element::VSlider( s.map_msg( f ) ),
Element::Toggle( t ) => Element::Toggle( t.map_msg( f ) ),
Element::Separator( s ) => Element::Separator( s ),
Element::ProgressBar( p ) => Element::ProgressBar( p ),
Element::Checkbox( c ) => Element::Checkbox( c.map_msg( f ) ),
Element::Radio( r ) => Element::Radio( r.map_msg( f ) ),
Element::ListItem( l ) => Element::ListItem( l.map_msg( f ) ),
Element::WindowButton( b ) => Element::WindowButton( b.map_msg( f ) ),
Element::Pressable( p ) => Element::Pressable( p.map_msg( f ) ),
Element::Flex( fx ) => Element::Flex( fx.map_msg( f ) ),
Element::AnchoredOverlay( a ) => Element::AnchoredOverlay( a.map_msg( f ) ),
Element::Spinner( s ) => Element::Spinner( s ),
Element::External( e ) => Element::External( e ),
}
}
}
impl<Msg: Clone> From<container::Container<Msg>> for Element<Msg>
{
fn from( c: container::Container<Msg> ) -> Self
{
Element::Container( c )
}
}
impl<Msg: Clone> From<button::Button<Msg>> for Element<Msg>
{
fn from( b: button::Button<Msg> ) -> Self
{
Element::Button( b )
}
}
impl<Msg: Clone> From<text_edit::TextEdit<Msg>> for Element<Msg>
{
fn from( t: text_edit::TextEdit<Msg> ) -> Self
{
Element::TextEdit( t )
}
}
impl<Msg: Clone> From<image::Image> for Element<Msg>
{
fn from( i: image::Image ) -> Self
{
Element::Image( i )
}
}
impl<Msg: Clone> From<external::External> for Element<Msg>
{
fn from( e: external::External ) -> Self
{
Element::External( e )
}
}
impl<Msg: Clone> From<crate::layout::column::Column<Msg>> for Element<Msg>
{
fn from( c: crate::layout::column::Column<Msg> ) -> Self
{
Element::Column( c )
}
}
impl<Msg: Clone> From<crate::layout::row::Row<Msg>> for Element<Msg>
{
fn from( r: crate::layout::row::Row<Msg> ) -> Self
{
Element::Row( r )
}
}
impl<Msg: Clone> From<crate::layout::stack::Stack<Msg>> for Element<Msg>
{
fn from( s: crate::layout::stack::Stack<Msg> ) -> Self
{
Element::Stack( s )
}
}
pub fn button<Msg: Clone>( label: impl Into<String> ) -> button::Button<Msg>
{
button::Button::new( label.into() )
}
pub fn icon_button<Msg: Clone>( rgba: Arc<Vec<u8>>, img_w: u32, img_h: u32 ) -> button::Button<Msg>
{
button::Button::new_icon( rgba, img_w, img_h )
}
pub fn text_edit<Msg: Clone>(
placeholder: impl Into<String>,
value: impl Into<String>,
) -> text_edit::TextEdit<Msg>
{
text_edit::TextEdit::new( placeholder.into(), value.into() )
}
pub fn image( rgba: Arc<Vec<u8>>, width: u32, height: u32 ) -> image::Image
{
image::Image::new( rgba, width, height )
}
pub fn text( content: impl Into<String> ) -> text::Text
{
text::Text::new( content )
}
pub fn container<Msg: Clone>( child: impl Into<Element<Msg>> ) -> container::Container<Msg>
{
container::Container::new( child )
}
/// Build an [`external::External`] widget that hosts content rendered by
/// a caller-managed GL texture producer.
pub fn external( width: f32, height: f32, source: external::ExternalSource ) -> external::External
{
external::External::new( width, height, source )
}

249
src/widget/notebook.rs Normal file
View File

@@ -0,0 +1,249 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Notebook — paginated tabs with a content area.
//!
//! Different from `tab_bar` (which is just a segmented selector — the
//! application is responsible for rendering whatever content
//! corresponds to the active tab). A `Notebook` owns the
//! pages: each page bundles a label *and* its content, and only the
//! active page's content is laid out / drawn each frame.
//!
//! The widget itself is stateless: the application owns
//! `self.tab: usize` and updates it from the `on_select` callback. The
//! pages can be built fresh every frame (typical) or memoised on the
//! application side.
//!
//! ```rust,no_run
//! # use ltk::{ notebook, text, Element, Notebook };
//! # #[ derive( Clone ) ] enum Msg { SelectTab( usize ) }
//! # struct App { tab: usize }
//! # impl App {
//! # fn general_view( &self ) -> Element<Msg> { text( "g" ).into() }
//! # fn network_view( &self ) -> Element<Msg> { text( "n" ).into() }
//! # fn audio_view( &self ) -> Element<Msg> { text( "a" ).into() }
//! # fn _ex( &self ) -> Notebook<Msg> {
//! notebook()
//! .page( "General", self.general_view() )
//! .page( "Network", self.network_view() )
//! .page( "Audio", self.audio_view() )
//! .selected( self.tab )
//! .on_select( Msg::SelectTab )
//! # }
//! # }
//! ```
use std::sync::Arc;
use crate::layout::column::column;
use crate::layout::spacer::spacer;
use super::Element;
mod theme
{
pub const SPACING: f32 = 12.0;
}
/// One page of a [`Notebook`] — a label for the tab strip and the
/// element to show when this page is active.
pub struct NotebookPage<Msg: Clone>
{
pub label: String,
pub view: Element<Msg>,
}
/// Paginated tab container.
///
/// Renders a `tab_bar` strip at the top followed by the
/// active page's content. Pages whose index is not [`Self::selected`]
/// are dropped before draw so they do not consume layout time — a
/// notebook with 50 pages costs the same to draw as one with 5.
pub struct Notebook<Msg: Clone>
{
pub pages: Vec<NotebookPage<Msg>>,
pub selected: usize,
pub on_select: Option<Arc<dyn Fn( usize ) -> Msg>>,
}
impl<Msg: Clone + 'static> Notebook<Msg>
{
/// Create an empty notebook with no pages and no selection.
pub fn new() -> Self
{
Self
{
pages: Vec::new(),
selected: 0,
on_select: None,
}
}
/// Append a page. Returns `Self` for chaining.
pub fn page(
mut self,
label: impl Into<String>,
view: impl Into<Element<Msg>>,
) -> Self
{
self.pages.push( NotebookPage
{
label: label.into(),
view: view.into(),
} );
self
}
/// Index of the currently active page. Out-of-range values fall
/// back to page 0; a notebook with no pages renders an empty
/// container.
pub fn selected( mut self, idx: usize ) -> Self
{
self.selected = idx;
self
}
/// Callback invoked with the index of the tab the user tapped.
pub fn on_select( mut self, f: impl Fn( usize ) -> Msg + 'static ) -> Self
{
self.on_select = Some( Arc::new( f ) );
self
}
/// Build the `Element` tree representing this notebook.
pub fn build( self ) -> Element<Msg>
{
use super::tab_bar::tabs;
let labels: Vec<String> = self.pages.iter().map( |p| p.label.clone() ).collect();
let selected = if self.selected < self.pages.len() { self.selected } else { 0 };
let n_pages = self.pages.len();
// Build the tab strip; wire on_select if the caller provided one.
let mut strip = tabs::<Msg, _, _>( labels ).selected( selected );
if let Some( cb ) = self.on_select.clone()
{
strip = strip.on_select( move |i| cb( i ) );
}
// Drain to extract the active page's view by index.
let mut pages = self.pages;
let active_view: Element<Msg> = if pages.is_empty()
{
spacer().into()
} else {
let _ = n_pages;
pages.swap_remove( selected )
.view
};
column()
.spacing( theme::SPACING )
.push( strip )
.push( active_view )
.into()
}
}
impl<Msg: Clone + 'static> Default for Notebook<Msg>
{
fn default() -> Self { Self::new() }
}
impl<Msg: Clone + 'static> From<Notebook<Msg>> for Element<Msg>
{
fn from( n: Notebook<Msg> ) -> Self { n.build() }
}
/// Create an empty [`Notebook`].
///
/// ```rust,no_run
/// # use ltk::{ notebook, text, Element, Notebook };
/// # #[ derive( Clone ) ] enum Msg { SelectTab( usize ) }
/// # struct App { tab: usize }
/// # impl App {
/// # fn inbox_view( &self ) -> Element<Msg> { text( "i" ).into() }
/// # fn sent_view( &self ) -> Element<Msg> { text( "s" ).into() }
/// # fn _ex( &self ) -> Notebook<Msg> {
/// notebook()
/// .page( "Inbox", self.inbox_view() )
/// .page( "Sent", self.sent_view() )
/// .selected( self.tab )
/// .on_select( Msg::SelectTab )
/// # }
/// # }
/// ```
pub fn notebook<Msg: Clone + 'static>() -> Notebook<Msg>
{
Notebook::new()
}
#[ cfg( test ) ]
mod tests
{
use super::*;
use crate::layout::spacer::spacer;
#[ derive( Clone, Debug, PartialEq, Eq ) ]
enum Msg { Pick( usize ) }
#[ test ]
fn defaults_have_no_pages()
{
let n: Notebook<Msg> = notebook();
assert_eq!( n.pages.len(), 0 );
assert_eq!( n.selected, 0 );
assert!( n.on_select.is_none() );
}
#[ test ]
fn page_appends_in_order()
{
let n: Notebook<Msg> = notebook()
.page( "A", spacer() )
.page( "B", spacer() )
.page( "C", spacer() );
assert_eq!( n.pages.len(), 3 );
assert_eq!( n.pages[0].label, "A" );
assert_eq!( n.pages[1].label, "B" );
assert_eq!( n.pages[2].label, "C" );
}
#[ test ]
fn selected_builder_records_index()
{
let n: Notebook<Msg> = notebook()
.page( "A", spacer() )
.page( "B", spacer() )
.selected( 1 );
assert_eq!( n.selected, 1 );
}
#[ test ]
fn on_select_callback_is_invoked_for_index()
{
let n: Notebook<Msg> = notebook()
.page( "A", spacer() )
.on_select( Msg::Pick );
let cb = n.on_select.as_ref().expect( "callback present" );
assert_eq!( cb( 7 ), Msg::Pick( 7 ) );
}
#[ test ]
fn build_does_not_panic_on_empty_pages()
{
let _: Element<Msg> = notebook().build();
}
#[ test ]
fn build_does_not_panic_on_out_of_range_selected()
{
// Out-of-range `selected` must fall back to page 0 instead of
// panicking with an index error in the swap_remove.
let n: Notebook<Msg> = notebook()
.page( "A", spacer() )
.page( "B", spacer() )
.selected( 99 );
let _: Element<Msg> = n.build();
}
}

296
src/widget/pressable.rs Normal file
View File

@@ -0,0 +1,296 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::render::Canvas;
use crate::types::WidgetId;
use super::Element;
/// Wraps any [`Element`] and emits a message on tap. Use when you want
/// click-to-emit on something richer than a [`Button`](super::button::Button)
/// — for example a [`Container`](super::container::Container) styled as a
/// card holding a row of icon + labels.
///
/// The wrapper is invisible to drawing: it delegates `preferred_size` and
/// rendering to the child. It does record a hit rect covering its full
/// allocated rect so taps anywhere inside fire `on_press`. Inner widgets
/// that are themselves interactive (e.g. a button nested inside the
/// pressable) keep priority — the layout pass pushes the wrapper's hit
/// rect *before* recursing into the child, and hit testing iterates in
/// reverse, so deeper widgets win.
///
/// No visual press feedback is applied — for state-driven appearance
/// changes use a [`Button`](super::button::Button) or compose with a
/// container that reacts to focus/press signals.
///
/// ```rust,no_run
/// # use ltk::{ column, container, pressable, row, Element, Pressable };
/// # #[ derive( Clone ) ] enum Msg { OpenWifiPicker }
/// # fn _ex(
/// # icon: Element<Msg>,
/// # title: Element<Msg>,
/// # subtitle: Element<Msg>,
/// # ) -> Pressable<Msg> {
/// pressable(
/// container( row()
/// .push( icon )
/// .push( column().push( title ).push( subtitle ) ) )
/// .surface( "surface-card" )
/// .radius( 32.0 )
/// .padding_h( 16.5 )
/// .padding_v( 24.0 ),
/// )
/// .on_press( Msg::OpenWifiPicker )
/// # }
/// ```
pub struct Pressable<Msg: Clone>
{
pub child: Box<Element<Msg>>,
pub on_press: Option<Msg>,
pub on_long_press: Option<Msg>,
/// Drag-arm message. Fired alongside `on_long_press` when the touch
/// hold timer elapses, AND fired by mouse left-button motion past
/// the drag-promotion threshold without waiting for the timer. The
/// caller uses this to arm any per-app drag state (in crustace,
/// `dragging_item`); a widget that opens a context menu but isn't
/// draggable leaves this `None`.
pub on_drag_start: Option<Msg>,
/// Keyboard `Escape`-key message. The runtime scans every laid-out
/// pressable's snapshot and fires the topmost (highest `flat_idx`)
/// `on_escape` it finds before the default ESC fallthrough chain.
/// Used by [`crate::widget::dialog::Dialog`] to make `Esc` cancel a
/// modal dialog without each app having to wire a global keyboard
/// hook — but available to any composite that needs the same
/// semantics.
pub on_escape: Option<Msg>,
/// Make the pressable hit-testable even when no callback is set.
/// A `swallow=true` pressable consumes pointer events at its hit
/// rect and emits no message — used by
/// [`crate::widget::dialog::Dialog`] for the modal scrim plus the
/// card-area swallow that prevents `dismiss_on_scrim` from firing
/// when the user clicks on the dialog body itself. Has no effect
/// when any handler is also set; in that case the handler determines
/// the message and `swallow` is implicit.
pub swallow: bool,
pub id: Option<WidgetId>,
pub cursor: Option<crate::types::CursorShape>,
}
impl<Msg: Clone> Pressable<Msg>
{
pub fn new( child: impl Into<Element<Msg>> ) -> Self
{
Self
{
child: Box::new( child.into() ),
on_press: None,
on_long_press: None,
on_drag_start: None,
on_escape: None,
swallow: false,
id: None,
cursor: None,
}
}
/// Override the pointer cursor shape shown on hover.
pub fn cursor( mut self, shape: crate::types::CursorShape ) -> Self
{
self.cursor = Some( shape );
self
}
pub fn on_press( mut self, msg: Msg ) -> Self
{
self.on_press = Some( msg );
self
}
pub fn on_long_press( mut self, msg: Msg ) -> Self
{
self.on_long_press = Some( msg );
self
}
/// Attach a drag-arm message. Fires when the press transitions into
/// drag mode — touch on hold-timer expiry, mouse on motion past the
/// drag-promotion threshold. Independent of `on_long_press` so a
/// widget can open a menu without becoming draggable, or be
/// draggable without opening a menu.
pub fn on_drag_start( mut self, msg: Msg ) -> Self
{
self.on_drag_start = Some( msg );
self
}
/// Bind a keyboard-`Escape` message to this pressable. See
/// [`Pressable::on_escape`] for the dispatch order semantics.
pub fn on_escape( mut self, msg: Msg ) -> Self
{
self.on_escape = Some( msg );
self
}
/// Make the pressable hit-testable even when no `on_press` /
/// `on_long_press` / `on_drag_start` is configured. See
/// [`Pressable::swallow`].
pub fn swallow( mut self, on: bool ) -> Self
{
self.swallow = on;
self
}
pub fn id( mut self, id: WidgetId ) -> Self
{
self.id = Some( id );
self
}
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 )
{
self.child.preferred_size( max_width, canvas )
}
/// True when the wrapper participates in hit-testing — has at least
/// one pointer / keyboard handler set, or has been opted in to
/// silent swallow via [`Pressable::swallow`]. Used by the layout
/// pass to skip pushing a hit rect for a no-op pressable.
pub fn has_handler( &self ) -> bool
{
self.on_press.is_some()
|| self.on_long_press.is_some()
|| self.on_drag_start.is_some()
|| self.on_escape.is_some()
|| self.swallow
}
pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> Pressable<U>
where
U: Clone + 'static,
Msg: 'static,
{
Pressable
{
child: Box::new( self.child.map_arc( f ) ),
on_press: self.on_press.map( |m| ( *f )( m ) ),
on_long_press: self.on_long_press.map( |m| ( *f )( m ) ),
on_drag_start: self.on_drag_start.map( |m| ( *f )( m ) ),
on_escape: self.on_escape.map( |m| ( *f )( m ) ),
swallow: self.swallow,
id: self.id,
cursor: self.cursor,
}
}
}
pub fn pressable<Msg: Clone>( child: impl Into<Element<Msg>> ) -> Pressable<Msg>
{
Pressable::new( child )
}
impl<Msg: Clone + 'static> From<Pressable<Msg>> for Element<Msg>
{
fn from( p: Pressable<Msg> ) -> Self
{
Element::Pressable( p )
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
use crate::layout::spacer::spacer;
use crate::render::Canvas;
use crate::types::WidgetId;
#[ derive( Clone, Debug, PartialEq, Eq ) ]
enum Msg { Tap, Hold, Drag, Cancel }
#[ test ]
fn new_defaults_have_no_handlers()
{
let p = Pressable::<Msg>::new( spacer() );
assert!( p.on_press.is_none() );
assert!( p.on_long_press.is_none() );
assert!( p.on_drag_start.is_none() );
assert!( p.on_escape.is_none() );
assert!( !p.swallow );
assert!( p.id.is_none() );
assert!( !p.has_handler() );
}
#[ test ]
fn on_press_builder_arms_tap_message()
{
let p = Pressable::new( spacer() ).on_press( Msg::Tap );
assert_eq!( p.on_press, Some( Msg::Tap ) );
assert!( p.has_handler() );
}
#[ test ]
fn on_long_press_builder_arms_long_press_message()
{
let p = Pressable::new( spacer() ).on_long_press( Msg::Hold );
assert_eq!( p.on_long_press, Some( Msg::Hold ) );
assert!( p.has_handler() );
}
#[ test ]
fn on_drag_start_builder_arms_drag_message()
{
let p = Pressable::new( spacer() ).on_drag_start( Msg::Drag );
assert_eq!( p.on_drag_start, Some( Msg::Drag ) );
assert!( p.has_handler() );
}
#[ test ]
fn has_handler_is_true_when_any_callback_is_set()
{
assert!( Pressable::<Msg>::new( spacer() ).on_press( Msg::Tap ).has_handler() );
assert!( Pressable::<Msg>::new( spacer() ).on_long_press( Msg::Hold ).has_handler() );
assert!( Pressable::<Msg>::new( spacer() ).on_drag_start( Msg::Drag ).has_handler() );
assert!( Pressable::<Msg>::new( spacer() )
.on_press( Msg::Tap ).on_long_press( Msg::Hold ).on_drag_start( Msg::Drag ).has_handler() );
}
#[ test ]
fn on_escape_builder_arms_escape_message()
{
let p = Pressable::new( spacer() ).on_escape( Msg::Cancel );
assert_eq!( p.on_escape, Some( Msg::Cancel ) );
assert!( p.has_handler() );
}
#[ test ]
fn swallow_builder_makes_pressable_hit_testable_without_callbacks()
{
let p = Pressable::<Msg>::new( spacer() ).swallow( true );
assert!( p.swallow );
assert!( p.on_press.is_none() );
assert!( p.has_handler() );
}
#[ test ]
fn swallow_off_with_no_callbacks_is_invisible_to_input()
{
let p = Pressable::<Msg>::new( spacer() ).swallow( false );
assert!( !p.has_handler() );
}
#[ test ]
fn id_builder_assigns_widget_id()
{
let id = WidgetId( "my_card" );
let p = Pressable::<Msg>::new( spacer() ).id( id );
assert_eq!( p.id, Some( id ) );
}
#[ test ]
fn preferred_size_delegates_to_child()
{
let canvas = Canvas::new( 800, 600 );
let child = spacer().width( 50.0 ).height( 30.0 );
let p = Pressable::<Msg>::new( child );
assert_eq!( p.preferred_size( 400.0, &canvas ), ( 50.0, 30.0 ) );
}
}

143
src/widget/progress_bar.rs Normal file
View File

@@ -0,0 +1,143 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::{ Color, Rect };
use crate::render::Canvas;
use super::Element;
mod theme
{
use crate::types::Color;
pub fn track_bg() -> Color { crate::theme::palette().divider }
pub fn fill() -> Color { crate::theme::palette().accent }
pub const TRACK_H: f32 = 6.0;
pub const HEIGHT: f32 = 20.0;
}
/// A linear progress indicator for determinate operations.
///
/// Renders a horizontal track with a coloured fill from the left edge to
/// `value × width`. `value` is clamped to `[0.0, 1.0]` at construction.
/// For indeterminate progress (the operation has no ETA) prefer an
/// animated spinner — `ltk` has no built-in spinner widget yet; build one
/// from a [`Container`](super::container::Container) that rotates a glyph
/// while [`crate::App::is_animating`] returns `true`.
///
/// ```rust,no_run
/// # use ltk::{ column, progress_bar, text, Element };
/// # #[ derive( Clone ) ] enum Msg {}
/// # struct App { progress: f32 }
/// # impl App { fn _ex( &self ) -> Element<Msg> {
/// // In view():
/// column()
/// .push( text( format!( "Downloading… {}%", ( self.progress * 100.0 ) as u32 ) ) )
/// .push( progress_bar( self.progress ) )
/// .into()
/// # }}
/// ```
pub struct ProgressBar
{
/// Current progress in `[0.0, 1.0]`. Always clamped at construction.
pub value: f32,
/// Fill colour. Defaults to the theme's `accent` palette slot.
pub fill: Color,
}
impl ProgressBar
{
/// Create a progress bar at the given fraction. `value` outside
/// `[0.0, 1.0]` is clamped silently.
pub fn new( value: f32 ) -> Self
{
Self { value: value.clamp( 0.0, 1.0 ), fill: theme::fill() }
}
/// Override the fill colour. Useful for "danger" / "success" variants
/// (red for nearly-full disks, green for completed work).
pub fn color( mut self, color: Color ) -> Self
{
self.fill = color;
self
}
/// Return the preferred `(width, height)`. Width fills the available
/// `max_width`; height is the theme-defined row height.
pub fn preferred_size( &self, max_width: f32 ) -> (f32, f32)
{
( max_width, theme::HEIGHT )
}
pub fn draw( &self, canvas: &mut Canvas, rect: Rect )
{
let track_y = rect.y + ( rect.height - theme::TRACK_H ) / 2.0;
let track_r = theme::TRACK_H / 2.0;
let track_rect = Rect
{
x: rect.x,
y: track_y,
width: rect.width,
height: theme::TRACK_H,
};
canvas.fill_rect( track_rect, theme::track_bg(), track_r );
let fill_w = rect.width * self.value;
if fill_w > 0.0
{
let fill_rect = Rect
{
x: rect.x,
y: track_y,
width: fill_w,
height: theme::TRACK_H,
};
canvas.fill_rect( fill_rect, self.fill, track_r );
}
}
}
/// Create a [`ProgressBar`] at the given fraction (clamped to
/// `[0.0, 1.0]`).
///
/// ```rust,no_run
/// # use ltk::{ progress_bar, ProgressBar };
/// # struct App { download_fraction: f32 }
/// # impl App { fn _ex( &self ) -> ProgressBar {
/// progress_bar( self.download_fraction )
/// # }}
/// ```
pub fn progress_bar( value: f32 ) -> ProgressBar
{
ProgressBar::new( value )
}
impl<Msg: Clone> From<ProgressBar> for Element<Msg>
{
fn from( p: ProgressBar ) -> Self
{
Element::ProgressBar( p )
}
}
#[cfg(test)]
mod tests
{
use super::*;
#[test]
fn value_clamped_on_creation()
{
let p = progress_bar( 1.5 );
assert_eq!( p.value, 1.0 );
let p = progress_bar( -0.5 );
assert_eq!( p.value, 0.0 );
}
#[test]
fn preferred_width_fills_available()
{
let p = progress_bar( 0.5 );
let ( w, _ ) = p.preferred_size( 300.0 );
assert_eq!( w, 300.0 );
}
}

212
src/widget/radio.rs Normal file
View File

@@ -0,0 +1,212 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::{ Rect, WidgetId };
use crate::render::Canvas;
use super::Element;
mod theme
{
use crate::types::Color;
pub fn ring_color() -> Color { crate::theme::palette().divider }
pub fn selected() -> Color { crate::theme::palette().accent }
/// Inner dot — uses the page-background colour so it reads as
/// the inverse of the accent fill regardless of mode.
pub fn dot_color() -> Color { crate::theme::palette().bg }
pub fn focus_color() -> Color { crate::theme::palette().accent }
pub fn label_color() -> Color { crate::theme::palette().text_primary }
pub const OUTER_SIZE: f32 = 24.0;
pub const DOT_SIZE: f32 = 12.0;
pub const BORDER_W: f32 = 2.0;
pub const GAP: f32 = 12.0;
pub const HEIGHT: f32 = 48.0;
pub const FOCUS_W: f32 = 3.0;
pub const FONT_SIZE: f32 = 16.0;
}
/// One option inside a mutually-exclusive group.
///
/// Renders a circle with a centred dot when selected. Unlike
/// [`Checkbox`](super::checkbox::Checkbox), a radio is meaningful only as
/// part of a group: the group is the application's responsibility — define
/// an enum for the choices, store the current value, and build one `Radio`
/// per variant with `selected = current == this_variant`.
///
/// ```rust,no_run
/// # use ltk::{ column, radio, Element };
/// #[ derive( Clone, PartialEq ) ]
/// enum Priority { Low, Medium, High }
/// # #[ derive( Clone ) ] enum Msg { SetPriority( Priority ) }
/// # struct App { priority: Priority }
/// # impl App { fn _ex( &self ) -> Element<Msg> {
/// // In view():
/// column()
/// .push( radio( self.priority == Priority::Low ).label( "Low" ).on_select( Msg::SetPriority( Priority::Low ) ) )
/// .push( radio( self.priority == Priority::Medium ).label( "Medium" ).on_select( Msg::SetPriority( Priority::Medium ) ) )
/// .push( radio( self.priority == Priority::High ).label( "High" ).on_select( Msg::SetPriority( Priority::High ) ) )
/// .into()
/// # }}
/// ```
///
/// `ltk` does not enforce mutual exclusion automatically; the application's
/// `update` decides which variant becomes active when a radio is selected.
pub struct Radio<Msg: Clone>
{
/// Whether this option is currently selected. Drawn from this field
/// every frame.
pub selected: bool,
/// Message emitted when the user picks this option. `None` leaves the
/// radio inert.
pub on_select: Option<Msg>,
/// Optional label drawn to the right of the circle.
pub label: Option<String>,
/// Optional stable identifier for focus management.
pub id: Option<WidgetId>,
}
impl<Msg: Clone> Radio<Msg>
{
/// Create a radio in the given state, with no label and no callback.
pub fn new( selected: bool ) -> Self
{
Self { selected, on_select: None, label: None, id: None }
}
/// Set the message emitted when this option is picked. The
/// application's `update` is responsible for flipping the group's
/// current value.
pub fn on_select( mut self, msg: Msg ) -> Self
{
self.on_select = Some( msg );
self
}
/// Set a text label rendered to the right of the circle.
pub fn label( mut self, label: impl Into<String> ) -> Self
{
self.label = Some( label.into() );
self
}
/// Assign a stable identifier for focus management.
pub fn id( mut self, id: WidgetId ) -> Self
{
self.id = Some( id );
self
}
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{
let w = if let Some( ref label ) = self.label
{
let text_w = canvas.measure_text( label, theme::FONT_SIZE );
( theme::OUTER_SIZE + theme::GAP + text_w ).min( max_width )
} else {
theme::OUTER_SIZE.min( max_width )
};
( w, theme::HEIGHT )
}
/// Focus ring on the outer circle extends `FOCUS_W + 2 + FOCUS_W/2 ≈ 6.5 px`
/// beyond the circle (which sits flush with the widget's left edge).
pub fn paint_bounds( &self, rect: Rect ) -> Rect
{
rect.expand( theme::FOCUS_W + 2.0 + theme::FOCUS_W * 0.5 + 1.0 )
}
pub fn draw( &self, canvas: &mut Canvas, rect: Rect, focused: bool )
{
let circle_y = rect.y + ( rect.height - theme::OUTER_SIZE ) / 2.0;
let outer_r = theme::OUTER_SIZE / 2.0;
let outer_rect = Rect
{
x: rect.x,
y: circle_y,
width: theme::OUTER_SIZE,
height: theme::OUTER_SIZE,
};
if self.selected
{
canvas.fill_rect( outer_rect, theme::selected(), outer_r );
let dot_r = theme::DOT_SIZE / 2.0;
let cx = rect.x + outer_r;
let cy = circle_y + outer_r;
let dot_rect = Rect
{
x: cx - dot_r,
y: cy - dot_r,
width: theme::DOT_SIZE,
height: theme::DOT_SIZE,
};
canvas.fill_rect( dot_rect, theme::dot_color(), dot_r );
} else {
canvas.stroke_rect( outer_rect, theme::ring_color(), theme::BORDER_W, outer_r );
}
if focused
{
let ring = outer_rect.expand( theme::FOCUS_W + 2.0 );
canvas.stroke_rect( ring, theme::focus_color(), theme::FOCUS_W, outer_r + theme::FOCUS_W + 2.0 );
}
if let Some( ref label ) = self.label
{
let text_x = rect.x + theme::OUTER_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() );
}
}
pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> Radio<U>
where
U: Clone + 'static,
Msg: 'static,
{
Radio
{
selected: self.selected,
on_select: self.on_select.map( |m| ( *f )( m ) ),
label: self.label,
id: self.id,
}
}
}
/// Create a [`Radio`] option in the given state.
///
/// Shorthand for [`Radio::new`]. See the [`Radio`] type-level docs for
/// the full mutual-exclusion pattern across multiple options.
pub fn radio<Msg: Clone>( selected: bool ) -> Radio<Msg>
{
Radio::new( selected )
}
impl<Msg: Clone + 'static> From<Radio<Msg>> for Element<Msg>
{
fn from( r: Radio<Msg> ) -> Self
{
Element::Radio( r )
}
}
#[cfg(test)]
mod tests
{
use super::*;
#[test]
fn radio_default_state()
{
let r = radio::<()>( true );
assert!( r.selected );
assert!( r.on_select.is_none() );
}
#[test]
fn radio_unselected()
{
let r = radio::<()>( false );
assert!( !r.selected );
}
}

156
src/widget/scroll.rs Normal file
View File

@@ -0,0 +1,156 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::render::Canvas;
use crate::types::WidgetId;
use crate::widget::Element;
/// A vertically scrollable viewport that clips its child to its allocated rect.
///
/// The child can be any element — typically a [`Column`](crate::layout::column::Column)
/// for lists or a [`WrapGrid`](crate::layout::wrap_grid::WrapGrid) for icon grids.
///
/// Scroll offset is driven by touch/pointer drag gestures within the viewport.
/// Dragging inside a `Scroll` does **not** trigger the app-level swipe-up gesture.
///
/// ```rust,no_run
/// # use std::sync::Arc;
/// # use ltk::{ column, grid, icon_button, scroll, text, Element };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex( rgba: Arc<Vec<u8>>, w: u32, h: u32 ) -> ( Element<Msg>, Element<Msg> ) {
/// // Scrollable list
/// let list = scroll( column().spacing( 8.0 ).push( text( "Item 1" ) ).push( text( "Item 2" ) ) );
///
/// // App-drawer style grid
/// let drawer = scroll( grid( 4 ).padding( 16.0 ).spacing( 12.0 ).push( icon_button( rgba, w, h ) ) );
/// # ( list.into(), drawer.into() )
/// # }
/// ```
pub struct Scroll<Msg: Clone>
{
/// The single child element drawn inside the scrollable viewport.
pub child: Box<Element<Msg>>,
/// Optional stable identifier — used as scroll state key.
pub id: Option<WidgetId>,
}
impl<Msg: Clone> Scroll<Msg>
{
/// Assign a stable identifier to this scroll widget.
pub fn id( mut self, id: WidgetId ) -> Self
{
self.id = Some( id );
self
}
/// Returns `(max_width, 0.0)` — the Scroll node claims all remaining space in
/// the parent layout, exactly like a [`Spacer`](crate::layout::spacer::Spacer).
/// The actual viewport height is determined at render time from the rect the
/// parent assigns.
pub fn preferred_size( &self, max_width: f32, _canvas: &Canvas ) -> (f32, f32)
{
( max_width, 0.0 )
}
/// No-op — rendering is handled entirely by `layout_and_draw` in `draw.rs`.
pub fn draw( &self ) {}
pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> Scroll<U>
where
U: Clone + 'static,
Msg: 'static,
{
Scroll
{
child: Box::new( self.child.map_arc( f ) ),
id: self.id,
}
}
}
impl<Msg: Clone + 'static> From<Scroll<Msg>> for Element<Msg>
{
fn from( s: Scroll<Msg> ) -> Self
{
Element::Scroll( s )
}
}
/// Compute the clamped scroll offset given raw offset, content height, and viewport height.
///
/// Extracted as a pure function so it can be unit-tested without a Wayland surface.
pub(crate) fn clamp_offset( offset: f32, content_h: f32, viewport_h: f32 ) -> f32
{
let max = (content_h - viewport_h).max( 0.0 );
offset.clamp( 0.0, max )
}
#[cfg(test)]
mod tests
{
use super::clamp_offset;
#[test]
fn offset_zero_when_content_fits()
{
// Content shorter than viewport — no scrolling possible
assert_eq!( clamp_offset( 50.0, 300.0, 500.0 ), 0.0 );
}
#[test]
fn offset_clamped_to_zero_when_negative()
{
assert_eq!( clamp_offset( -10.0, 600.0, 400.0 ), 0.0 );
}
#[test]
fn offset_clamped_to_max()
{
// max = 600 - 400 = 200; offset 999 → clamped to 200
assert_eq!( clamp_offset( 999.0, 600.0, 400.0 ), 200.0 );
}
#[test]
fn offset_within_range_unchanged()
{
// max = 600 - 400 = 200; offset 100 stays 100
assert_eq!( clamp_offset( 100.0, 600.0, 400.0 ), 100.0 );
}
#[test]
fn zero_offset_stays_zero()
{
assert_eq!( clamp_offset( 0.0, 600.0, 400.0 ), 0.0 );
}
#[test]
fn exact_max_offset_is_valid()
{
assert_eq!( clamp_offset( 200.0, 600.0, 400.0 ), 200.0 );
}
#[test]
fn content_equal_to_viewport_gives_zero_max()
{
// No overflow — max = 0
assert_eq!( clamp_offset( 10.0, 400.0, 400.0 ), 0.0 );
}
}
/// Create a scrollable viewport wrapping `child`.
///
/// The parent layout controls the viewport size by assigning a rect to this widget.
/// Content that overflows vertically is scrolled via drag gesture.
///
/// ```rust,no_run
/// # use ltk::{ column, scroll, text, Element };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex() -> Element<Msg> {
/// scroll( column().push( text( "A" ) ).push( text( "B" ) ) )
/// .into()
/// # }
/// ```
pub fn scroll<Msg: Clone>( child: impl Into<Element<Msg>> ) -> Scroll<Msg>
{
Scroll { child: Box::new( child.into() ), id: None }
}

137
src/widget/separator.rs Normal file
View File

@@ -0,0 +1,137 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::{ Color, Rect };
use crate::render::Canvas;
use super::Element;
mod theme
{
use crate::types::Color;
pub fn color() -> Color { crate::theme::palette().divider }
pub const THICKNESS: f32 = 1.0;
pub const PAD_V: f32 = 8.0;
}
/// A horizontal divider line.
///
/// Renders a 1 px (default) line across the full width of its layout rect,
/// with vertical padding above and below. Use to break a column into
/// visual sections — between settings groups, list categories or content
/// blocks. The line takes the divider colour from the active theme by
/// default; override with [`Self::color`] for custom palettes.
///
/// ```rust,no_run
/// # use ltk::{ column, separator, text, Element };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex() -> Element<Msg> {
/// // In view():
/// column()
/// .push( text( "General" ) )
/// .push( separator() )
/// .push( text( "Network" ) )
/// .into()
/// # }
/// ```
pub struct Separator
{
/// Stroke colour. Defaults to the theme's `divider` palette slot.
pub color: Color,
/// Line thickness in logical pixels.
pub thickness: f32,
/// Vertical padding above and below the line, baked into
/// `preferred_size` so the divider visually centres in the row a
/// parent column allocates.
pub pad_v: f32,
}
impl Separator
{
/// Create a separator with the theme's default divider colour and
/// 1 px thickness.
pub fn new() -> Self
{
Self
{
color: theme::color(),
thickness: theme::THICKNESS,
pad_v: theme::PAD_V,
}
}
/// Override the stroke colour. Useful for emphasised dividers between
/// destructive actions.
pub fn color( mut self, color: Color ) -> Self
{
self.color = color;
self
}
/// Override the line thickness. Defaults to 1 logical pixel.
pub fn thickness( mut self, t: f32 ) -> Self
{
self.thickness = t;
self
}
/// Return the preferred `(width, height)`. Width is `max_width`;
/// height is `thickness + 2 × pad_v`.
pub fn preferred_size( &self, max_width: f32 ) -> (f32, f32)
{
( max_width, self.thickness + self.pad_v * 2.0 )
}
/// Draw the divider line into `canvas` at `rect`.
pub fn draw( &self, canvas: &mut Canvas, rect: Rect )
{
let y = rect.y + rect.height / 2.0;
canvas.draw_line( rect.x, y, rect.x + rect.width, y, self.color, self.thickness );
}
}
/// Create a default [`Separator`] (theme divider colour, 1 px thickness).
///
/// ```rust,no_run
/// # use ltk::{ column, separator, text, Element };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex() -> Element<Msg> {
/// column()
/// .push( text( "Section A" ) )
/// .push( separator() )
/// .push( text( "Section B" ) )
/// .into()
/// # }
/// ```
pub fn separator() -> Separator
{
Separator::new()
}
impl<Msg: Clone> From<Separator> for Element<Msg>
{
fn from( s: Separator ) -> Self
{
Element::Separator( s )
}
}
#[cfg(test)]
mod tests
{
use super::*;
#[test]
fn default_thickness()
{
let s = separator();
assert_eq!( s.thickness, theme::THICKNESS );
}
#[test]
fn preferred_height_includes_padding()
{
let s = separator();
let ( _, h ) = s.preferred_size( 200.0 );
assert_eq!( h, theme::THICKNESS + theme::PAD_V * 2.0 );
}
}

541
src/widget/slider.rs Normal file
View File

@@ -0,0 +1,541 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::sync::Arc;
use crate::types::{ Point, Rect };
use crate::render::Canvas;
use super::Element;
/// Which axis a slider tracks. Used by input dispatch to pick the right
/// `value_from_*_in_rect` formula for a [`Slider`] (horizontal) or
/// [`crate::widget::vslider::VSlider`] (vertical).
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
pub enum SliderAxis
{
Horizontal,
Vertical,
}
/// Compute the slider value `[0.0, 1.0]` from a pointer position within a
/// slider's layout rect, dispatching to the axis-specific formula.
///
/// Exposed alongside [`value_from_x_in_rect`] so `input.rs` can drive both
/// [`Slider`] and [`crate::widget::vslider::VSlider`] through the same call
/// site by consulting the [`SliderAxis`] stored in the widget's handler
/// snapshot.
pub fn value_from_pos_in_rect( rect: Rect, pos: Point, axis: SliderAxis ) -> f32
{
match axis
{
SliderAxis::Horizontal => value_from_x_in_rect( rect, pos.x ),
SliderAxis::Vertical => crate::widget::vslider::value_from_y_in_rect( rect, pos.y ),
}
}
mod theme
{
use crate::types::Color;
pub fn track_bg() -> Color { crate::theme::palette().divider }
pub fn track_fill() -> Color { crate::theme::palette().accent }
/// Thumb — uses the page-background colour so it reads as the
/// inverse of the accent fill regardless of mode.
pub fn thumb() -> Color { crate::theme::palette().bg }
pub fn thumb_border() -> Color { crate::theme::palette().text_primary }
pub const TRACK_H: f32 = 10.0;
pub const THUMB_SIZE: f32 = 24.0;
pub const HEIGHT: f32 = 36.0;
pub const THUMB_BORDER_W: f32 = 2.0;
/// White ring + inner coloured pill of the [`super::Slider::accent_thumb`]
/// variant. Outer diameter is [`ACCENT_THUMB_OUTER`]; the visible
/// pill is smaller than [`THUMB_SIZE`], which is the hit-target /
/// reserved layout footprint shared with the default thumb.
pub const ACCENT_THUMB_BORDER_W: f32 = 4.0;
pub const ACCENT_THUMB_OUTER: f32 = 20.0;
/// Default theme slot id for the [`Slider`](super::Slider) track
/// background. Mirrors the equivalent constant in `vslider::theme`
/// so the same default-theme entries cover both axes — only the
/// rendering geometry differs between the widgets.
pub const SURFACE_TRACK: &str = "surface-slider-track";
/// Default theme slot id for the [`Slider`](super::Slider) fill.
pub const SURFACE_FILL: &str = "surface-slider-fill";
}
/// Intersect `inner` with the `saved` outer clip and return the rect
/// list to install with [`crate::render::Canvas::set_clip_rects`].
///
/// `saved` is the snapshot returned by
/// [`crate::render::Canvas::clip_bounds`]: empty means "no clip is
/// installed" (paint everywhere), non-empty is the outer scissor list
/// — typically the partial-redraw damage rects.
///
/// `Slider` / [`crate::widget::vslider::VSlider`] use this when they
/// need to clip the active-side fill paint to a band tighter than the
/// widget rect. Calling `set_clip_rects( &[ band ] )` directly would
/// REPLACE the outer clip, causing the fill to repaint outside the
/// damage region during partial redraws and clobbering pixels (most
/// visibly the thumb's white interior left from the previous frame).
/// Routing through this helper preserves the outer clip by
/// intersecting with it.
///
/// Returns `Vec::new()` when there is no overlap; callers must skip
/// the paint entirely in that case — installing an empty rect list
/// means "no clip" which would paint outside the damage region.
pub( crate ) fn intersect_clip( saved: &[ Rect ], inner: Rect ) -> Vec<Rect>
{
if saved.is_empty()
{
return vec![ inner ];
}
saved.iter().filter_map( |r|
{
let x0 = inner.x.max( r.x );
let y0 = inner.y.max( r.y );
let x1 = ( inner.x + inner.width ).min( r.x + r.width );
let y1 = ( inner.y + inner.height ).min( r.y + r.height );
if x1 <= x0 || y1 <= y0
{
None
}
else
{
Some( Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } )
}
} ).collect()
}
/// Compute the slider value `[0.0, 1.0]` from a tap/drag x position within a
/// slider's layout rect. Pure — depends only on `theme::THUMB_SIZE`. Lifted
/// out of [`Slider`] so input handlers can call it directly from
/// [`crate::widget::LaidOutWidget`] without needing the [`Element`] tree.
pub fn value_from_x_in_rect( rect: Rect, x: f32 ) -> f32
{
let pad = theme::THUMB_SIZE / 2.0;
let track_start = rect.x + pad;
let track_end = rect.x + rect.width - pad;
let track_w = ( track_end - track_start ).max( 1.0 );
( ( x - track_start ) / track_w ).clamp( 0.0, 1.0 )
}
/// A horizontal slider for selecting a value in a range.
///
/// The track defaults to a theme-resolved surface; pass
/// [`Self::track_paint`] to override with a custom
/// [`Paint`](crate::theme::Paint) — typically a multi-stop linear
/// gradient for a hue / spectrum picker. When `track_paint` is set
/// the active-side fill is suppressed (the spectrum already
/// communicates the position visually through colour).
///
/// ```rust,no_run
/// # use ltk::{ slider, Slider };
/// # #[ derive( Clone ) ] enum Msg { SetBrightness( f32 ) }
/// # struct App { brightness: f32 }
/// # impl App { fn _ex( &self ) -> Slider<Msg> {
/// slider( self.brightness )
/// .on_change( |v| Msg::SetBrightness( v ) )
/// # }}
/// ```
pub struct Slider<Msg: Clone>
{
/// Current value in `[0.0, 1.0]`.
pub value: f32,
/// Callback invoked with the new value when the slider is dragged.
/// `Arc` (not `Box`) so the layout pass can clone it into the per-leaf
/// handler snapshot for O(1) dispatch on input events.
pub on_change: Option<Arc<dyn Fn(f32) -> Msg>>,
/// Theme slot id for the track background pill. Defaults to the
/// generic `surface-slider-track`; override per-instance to opt
/// into the `-flat` (no per-surface backdrop) variant when the
/// slider already lives inside a panel-wide blur, or any other
/// custom slot.
pub track_surface: &'static str,
/// Theme slot id for the rising / leftward fill. Same shape as
/// [`Self::track_surface`] but for the active portion.
pub fill_surface: &'static str,
/// Paint the thumb with the active palette's `accent` colour and
/// a thicker white border (accent inner pill + 4 px white ring)
/// instead of the default white-on-text-primary thumb.
pub accent_thumb: bool,
/// Override the track paint with a custom
/// [`Paint`](crate::theme::Paint) — typically a
/// [`Paint::Linear`](crate::theme::Paint::Linear) for spectrum /
/// hue pickers. When set, the active-side fill is suppressed
/// because a spectrum already conveys position information
/// through colour and the thumb shows where the user is.
pub track_paint: Option<crate::theme::Paint>,
}
impl<Msg: Clone> Slider<Msg>
{
/// Create a slider with the given value (clamped to `[0.0, 1.0]`).
pub fn new( value: f32 ) -> Self
{
Self
{
value: value.clamp( 0.0, 1.0 ),
on_change: None,
track_surface: theme::SURFACE_TRACK,
fill_surface: theme::SURFACE_FILL,
accent_thumb: false,
track_paint: None,
}
}
/// Override the track paint with a custom
/// [`Paint`](crate::theme::Paint) — e.g. a multi-stop linear
/// gradient for a hue / spectrum picker. Disables the active-
/// side fill (the spectrum already encodes the position
/// visually).
pub fn track_paint( mut self, paint: crate::theme::Paint ) -> Self
{
self.track_paint = Some( paint );
self
}
/// Set the callback invoked when the slider value changes.
pub fn on_change( mut self, f: impl Fn(f32) -> Msg + 'static ) -> Self
{
self.on_change = Some( Arc::new( f ) );
self
}
/// Override the theme slot id used to paint the track background
/// — pass `surface-slider-track-flat` (or any other surface) to
/// drop the per-instance backdrop blur when the slider already
/// lives inside a panel-wide blur. See
/// [`crate::widget::vslider::VSlider::track_surface`] for the use
/// case.
pub fn track_surface( mut self, id: &'static str ) -> Self
{
self.track_surface = id;
self
}
/// Override the theme slot id used to paint the fill. Mirrors
/// [`Self::track_surface`].
pub fn fill_surface( mut self, id: &'static str ) -> Self
{
self.fill_surface = id;
self
}
/// Switch the thumb to the accent-pill style: accent-coloured inner
/// + 4 px white border. Default thumb stays white-on-text-primary
/// for shells that haven't opted in.
pub fn accent_thumb( mut self, on: bool ) -> Self
{
self.accent_thumb = on;
self
}
/// Return the preferred `(width, height)`.
pub fn preferred_size( &self, max_width: f32, _canvas: &Canvas ) -> (f32, f32)
{
( max_width, theme::HEIGHT )
}
/// Compute the value `[0.0, 1.0]` from a tap/drag x position within `rect`.
pub fn value_from_x( &self, rect: Rect, x: f32 ) -> f32
{
value_from_x_in_rect( rect, x )
}
/// Thumb border stroke is centered on the thumb edge (which touches the
/// widget's left/right edges at value 0 / 1), so half the stroke width
/// plus ~1 px of antialiasing sits outside `rect`.
pub fn paint_bounds( &self, rect: Rect ) -> Rect
{
let border_w = if self.accent_thumb { theme::ACCENT_THUMB_BORDER_W } else { theme::THUMB_BORDER_W };
rect.expand( ( border_w * 0.5 + 1.0 ).max( surface_shadow_margin( self.track_surface ) ) )
}
/// Draw the slider into `canvas` at `rect`.
///
/// Track + fill paint through theme slots ([`Self::track_surface`]
/// / [`Self::fill_surface`]) when those slots resolve in the
/// active theme — the track pill spans the full inner width and
/// the fill is anchored to the same pill but scissor-clipped to
/// the left band so insets stay positioned against the track rect
/// rather than a shrinking fill rect. Falls back to
/// `theme::track_bg()` / `theme::track_fill()` when the active
/// theme has no entry for either slot — a bare-bones third-party
/// theme still paints a usable slider.
pub fn draw( &self, canvas: &mut Canvas, rect: Rect, _focused: bool )
{
let pad = theme::THUMB_SIZE / 2.0;
let track_y = rect.y + (rect.height - theme::TRACK_H) / 2.0;
let track_start = rect.x + pad;
let track_end = rect.x + rect.width - pad;
let track_w = track_end - track_start;
let radius_bg = theme::TRACK_H / 2.0;
// Background track — full pill across the inner span.
let track_rect = Rect
{
x: track_start,
y: track_y,
width: track_w,
height: theme::TRACK_H,
};
if let Some( paint ) = self.track_paint.as_ref()
{
// Custom paint override (hue spectrum, custom gradient, …)
// short-circuits the theme-slot resolution. The
// active-side fill is suppressed below because a
// spectrum already encodes the position via colour.
canvas.fill_paint_rect( track_rect, paint, radius_bg );
}
else if let Some( ( surf, outer ) ) = crate::theme::resolve_surface( self.track_surface )
{
canvas.fill_surface
(
track_rect,
&surf.fill,
&outer,
&surf.inset_shadows,
radius_bg,
);
}
else
{
canvas.fill_rect( track_rect, theme::track_bg(), radius_bg );
}
// Filled portion. Paint the surface across the FULL track rect
// and clip the visible band to the left-aligned slice. This
// keeps inset shadows / glass rims anchored to the track pill
// instead of to a shrinking fill rect — the highlights don't
// shift as the user drags.
//
// Suppressed entirely when `track_paint` is set: a custom
// spectrum already conveys position through colour, and a
// solid accent band painted over part of it would obscure
// half the spectrum and read as a rendering bug.
let fill_w = track_w * self.value;
if self.track_paint.is_none() && fill_w > 0.5
{
let visible = Rect
{
x: track_start,
y: track_y,
width: fill_w,
height: theme::TRACK_H,
};
let saved_clip = canvas.clip_bounds();
let band = intersect_clip( &saved_clip, visible );
if !band.is_empty()
{
canvas.set_clip_rects( &band );
if let Some( ( surf, _ ) ) = crate::theme::resolve_surface( self.fill_surface )
{
canvas.fill_paint_rect( track_rect, &surf.fill, radius_bg );
// Drop insets with negative-Y offset — they live
// near the top of the track pill and would slice
// the fill at the active band edge. See
// `VSlider::draw` for the full rationale.
for inset in surf.inset_shadows.iter().filter( |s| s.offset[1] >= 0.0 )
{
canvas.fill_shadow_inset( track_rect, inset, radius_bg );
}
}
else
{
canvas.fill_rect( visible, theme::track_fill(), radius_bg );
}
canvas.set_clip_rects( &saved_clip );
}
}
// Thumb.
let thumb_cx = track_start + fill_w;
let thumb_cy = rect.y + rect.height / 2.0;
if self.accent_thumb
{
// Inner pill paints with the track's active fill surface
// so the thumb's centre always matches the line colour.
let outer_r = theme::ACCENT_THUMB_OUTER / 2.0;
let inner_size = ( theme::ACCENT_THUMB_OUTER - 2.0 * theme::ACCENT_THUMB_BORDER_W ).max( 0.0 );
let inner_r = inner_size / 2.0;
let outer_rect = Rect
{
x: thumb_cx - outer_r,
y: thumb_cy - outer_r,
width: theme::ACCENT_THUMB_OUTER,
height: theme::ACCENT_THUMB_OUTER,
};
let inner_rect = Rect
{
x: thumb_cx - inner_r,
y: thumb_cy - inner_r,
width: inner_size,
height: inner_size,
};
canvas.fill_rect( outer_rect, crate::theme::palette().bg, outer_r );
if let Some( ( surf, _ ) ) = crate::theme::resolve_surface( self.fill_surface )
{
canvas.fill_paint_rect( inner_rect, &surf.fill, inner_r );
}
else
{
canvas.fill_rect( inner_rect, crate::theme::palette().accent, inner_r );
}
}
else
{
let thumb_r = theme::THUMB_SIZE / 2.0;
let thumb_rect = Rect
{
x: thumb_cx - thumb_r,
y: thumb_cy - thumb_r,
width: theme::THUMB_SIZE,
height: theme::THUMB_SIZE,
};
canvas.fill_rect( thumb_rect, theme::thumb(), thumb_r );
canvas.stroke_rect( thumb_rect, theme::thumb_border(), theme::THUMB_BORDER_W, thumb_r );
}
}
pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> Slider<U>
where
U: Clone + 'static,
Msg: 'static,
{
// Wrap the existing Arc<dyn Fn(f32) -> Msg> in a fresh closure
// that pipes its result through the user's mapper. The original
// closure is captured by the new one through `Arc::clone` so the
// original Slider's storage stays valid.
let on_change = self.on_change.map( |old| -> Arc<dyn Fn( f32 ) -> U>
{
let mapper = Arc::clone( f );
Arc::new( move |v| ( *mapper )( ( *old )( v ) ) )
} );
Slider
{
value: self.value,
on_change,
track_surface: self.track_surface,
fill_surface: self.fill_surface,
accent_thumb: self.accent_thumb,
track_paint: self.track_paint,
}
}
}
/// Create a [`Slider`] with the given value (clamped to `[0.0, 1.0]`).
pub fn slider<Msg: Clone>( value: f32 ) -> Slider<Msg>
{
Slider::new( value )
}
fn surface_shadow_margin( surface: &str ) -> f32
{
crate::theme::resolve_surface( surface )
.map( |( _, shadows )|
{
shadows.iter()
.map( |s| s.offset[0].abs().max( s.offset[1].abs() ) + s.blur.max( 0.0 ) + s.spread.max( 0.0 ) + 1.0 )
.fold( 0.0, f32::max )
} )
.unwrap_or( 0.0 )
}
impl<Msg: Clone + 'static> From<Slider<Msg>> for Element<Msg>
{
fn from( s: Slider<Msg> ) -> Self
{
Element::Slider( s )
}
}
#[cfg(test)]
mod tests
{
use super::*;
use crate::types::Rect;
#[test]
fn value_clamped_on_creation()
{
let s = slider::<()>( 1.5 );
assert_eq!( s.value, 1.0 );
let s = slider::<()>( -0.5 );
assert_eq!( s.value, 0.0 );
}
#[test]
fn value_from_x_left_edge()
{
let s = slider::<()>( 0.5 );
let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 36.0 };
let v = s.value_from_x( rect, 0.0 );
assert_eq!( v, 0.0 );
}
#[test]
fn value_from_x_right_edge()
{
let s = slider::<()>( 0.5 );
let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 36.0 };
let v = s.value_from_x( rect, 200.0 );
assert_eq!( v, 1.0 );
}
#[test]
fn value_from_x_center()
{
let s = slider::<()>( 0.0 );
let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 36.0 };
let v = s.value_from_x( rect, 100.0 );
assert!( (v - 0.5).abs() < 0.1 );
}
#[test]
fn axis_dispatch_horizontal_uses_x()
{
let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 36.0 };
let v = value_from_pos_in_rect(
rect,
Point { x: 200.0, y: 9999.0 },
SliderAxis::Horizontal,
);
assert_eq!( v, 1.0 );
}
#[test]
fn axis_dispatch_vertical_uses_y()
{
let rect = Rect { x: 0.0, y: 0.0, width: 56.0, height: 160.0 };
// y=0 at the top of a vertical rect is value=1.0, regardless of x.
let v = value_from_pos_in_rect(
rect,
Point { x: 9999.0, y: 0.0 },
SliderAxis::Vertical,
);
assert_eq!( v, 1.0 );
}
#[test]
fn track_paint_default_is_none()
{
let s = slider::<()>( 0.5 );
assert!( s.track_paint.is_none() );
}
#[test]
fn track_paint_builder_stores_paint()
{
use crate::theme::{ ColorStop, GradientSpace, LinearGradient, Paint };
use crate::types::Color;
let g = Paint::Linear( LinearGradient
{
angle_deg: 90.0,
stops: vec![
ColorStop { position: 0.0, color: Color::rgba( 1.0, 0.0, 0.0, 1.0 ) },
ColorStop { position: 1.0, color: Color::rgba( 0.0, 0.0, 1.0, 1.0 ) },
],
space: GradientSpace::Srgb,
} );
let s = slider::<()>( 0.5 ).track_paint( g );
assert!( s.track_paint.is_some() );
}
}

223
src/widget/spinner.rs Normal file
View File

@@ -0,0 +1,223 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Spinner — indeterminate progress indicator.
//!
//! A rotating arc that the renderer draws by stroking a fraction of a
//! circle and offsetting its starting angle by [`Spinner::phase`]. The
//! widget is *stateless*: the application owns the phase variable and
//! advances it from a clock or animation tick. Pair with
//! [`App::is_animating`](crate::App::is_animating) so the run loop keeps
//! requesting redraws while the spinner is on screen.
//!
//! ```rust,no_run
//! # use ltk::{ row, spinner, text, Element };
//! # #[ derive( Clone ) ] enum Msg {}
//! # struct App { tick: f32 }
//! # impl App { fn _ex( &self ) -> Element<Msg> {
//! // In view():
//! row()
//! .push( spinner().phase( self.tick ) )
//! .push( text( "Loading…" ) )
//! .into()
//! # }}
//! ```
use crate::types::{ Color, Rect };
use crate::render::Canvas;
use super::Element;
mod theme
{
use crate::types::Color;
pub fn fill() -> Color { crate::theme::palette().accent }
pub fn track() -> Color
{
// Quarter-opacity copy of the accent so the moving arc reads
// against a faint guide ring instead of empty surface.
let a = crate::theme::palette().accent;
Color::rgba( a.r, a.g, a.b, 0.20 )
}
pub const SIZE: f32 = 32.0;
pub const STROKE_W: f32 = 3.0;
/// Fraction of the full circle that the moving arc covers.
pub const ARC_FRAC: f32 = 0.30;
/// Number of straight segments used to approximate one full circle.
/// 36 is enough for a smooth arc at the default 32 px diameter and
/// keeps the per-frame draw call count low.
pub const SEGMENTS: u32 = 36;
}
/// An indeterminate progress spinner.
///
/// Renders a rotating arc inside a square layout rect. The application
/// drives the rotation by passing a monotonically-increasing `phase`
/// value (any units — only the fractional part of `phase` is used).
pub struct Spinner
{
/// Rotation phase. Only the fractional part is consumed, so any
/// monotonically increasing source works (`elapsed.as_secs_f32()`,
/// frame count divided by FPS, etc.).
pub phase: f32,
/// Arc / ring colour. Defaults to the theme's `accent` palette slot.
pub color: Color,
/// Square diameter in logical pixels. Both width and height of the
/// laid-out rect target this size.
pub size: f32,
/// Stroke width of the arc and the dim guide ring.
pub stroke_w: f32,
}
impl Spinner
{
/// Create a spinner with the theme's accent colour and default size.
pub fn new() -> Self
{
Self
{
phase: 0.0,
color: theme::fill(),
size: theme::SIZE,
stroke_w: theme::STROKE_W,
}
}
/// Set the rotation phase. The widget consumes only the fractional
/// part, so callers can pass an unbounded monotonic clock value.
pub fn phase( mut self, p: f32 ) -> Self
{
self.phase = p;
self
}
/// Override the arc colour.
pub fn color( mut self, c: Color ) -> Self
{
self.color = c;
self
}
/// Set the square diameter in logical pixels.
pub fn size( mut self, s: f32 ) -> Self
{
self.size = s;
self
}
/// Set the arc / ring stroke width in logical pixels.
pub fn stroke_width( mut self, w: f32 ) -> Self
{
self.stroke_w = w;
self
}
/// Return the preferred `(width, height)` — the spinner is square.
pub fn preferred_size( &self, max_width: f32 ) -> ( f32, f32 )
{
let s = self.size.min( max_width );
( s, s )
}
/// Draw the spinner into `canvas` at `rect`. The arc is centred on
/// `rect`'s minor diameter so the widget renders correctly even when
/// laid out into a non-square cell.
pub fn draw( &self, canvas: &mut Canvas, rect: Rect )
{
let cx = rect.x + rect.width * 0.5;
let cy = rect.y + rect.height * 0.5;
let r = ( rect.width.min( rect.height ) - self.stroke_w ) * 0.5;
if r <= 0.0 { return; }
let track = theme::track();
let n = theme::SEGMENTS as i32;
let two_pi = std::f32::consts::TAU;
let dim = two_pi / n as f32;
// Dim guide ring (full circle as polyline).
for i in 0..n
{
let a0 = i as f32 * dim;
let a1 = ( i + 1 ) as f32 * dim;
let x0 = cx + r * a0.cos();
let y0 = cy + r * a0.sin();
let x1 = cx + r * a1.cos();
let y1 = cy + r * a1.sin();
canvas.draw_line( x0, y0, x1, y1, track, self.stroke_w );
}
// Moving arc. `phase` is consumed modulo 1.0.
let frac = self.phase - self.phase.floor();
let start_ang = frac * two_pi;
let end_ang = start_ang + theme::ARC_FRAC * two_pi;
let arc_segs = ( ( theme::ARC_FRAC * n as f32 ).round() as i32 ).max( 1 );
for i in 0..arc_segs
{
let a0 = start_ang + i as f32 * dim;
let a1 = ( start_ang + ( i + 1 ) as f32 * dim ).min( end_ang );
let x0 = cx + r * a0.cos();
let y0 = cy + r * a0.sin();
let x1 = cx + r * a1.cos();
let y1 = cy + r * a1.sin();
canvas.draw_line( x0, y0, x1, y1, self.color, self.stroke_w );
}
}
}
impl Default for Spinner
{
fn default() -> Self { Self::new() }
}
/// Create a [`Spinner`] with default size and theme colour.
pub fn spinner() -> Spinner
{
Spinner::new()
}
impl<Msg: Clone> From<Spinner> for Element<Msg>
{
fn from( s: Spinner ) -> Self
{
Element::Spinner( s )
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ test ]
fn defaults()
{
let s = spinner();
assert_eq!( s.phase, 0.0 );
assert_eq!( s.size, theme::SIZE );
assert_eq!( s.stroke_w, theme::STROKE_W );
}
#[ test ]
fn builders_apply()
{
let s = spinner().phase( 0.42 ).size( 64.0 ).stroke_width( 5.0 );
assert_eq!( s.phase, 0.42 );
assert_eq!( s.size, 64.0 );
assert_eq!( s.stroke_w, 5.0 );
}
#[ test ]
fn preferred_size_clamps_to_max_width()
{
let s = spinner().size( 100.0 );
assert_eq!( s.preferred_size( 40.0 ), ( 40.0, 40.0 ) );
assert_eq!( s.preferred_size( 200.0 ), ( 100.0, 100.0 ) );
}
#[ test ]
fn preferred_size_is_square()
{
let s = spinner();
let ( w, h ) = s.preferred_size( 999.0 );
assert_eq!( w, h );
}
}

208
src/widget/tab_bar.rs Normal file
View File

@@ -0,0 +1,208 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! TabBar — segmented selector built as a composition over existing
//! widgets. The widget itself is stateless: the application owns the
//! current selection (`usize`) and calls back through
//! [`TabBar::on_select`] when the user taps another tab.
//!
//! Returns an [`Element`] directly via [`TabBar::build`] / `Into`, so it
//! drops into any layout that accepts a child widget.
//!
//! ```rust,no_run
//! # use ltk::{ tabs, TabBar };
//! # #[ derive( Clone ) ] enum Msg { SelectTab( usize ) }
//! # struct App { tab: usize }
//! # impl App { fn _ex( &self ) -> TabBar<Msg> {
//! tabs( [ "General", "Network", "Audio" ] )
//! .selected( self.tab )
//! .on_select( Msg::SelectTab )
//! # }}
//! ```
use crate::types::Color;
use super::Element;
mod theme
{
pub const RADIUS: f32 = 100.0;
pub const PADDING: f32 = 4.0;
pub const SPACING: f32 = 4.0;
}
/// Segmented horizontal tab selector. One row of pressable cells with
/// the active cell painted as a filled pill and inactive cells as plain
/// text. Build it from a slice of labels; produce an [`Element`] with
/// [`Self::build`] (or by `.into()`-ing it where an `Element` is
/// expected).
pub struct TabBar<Msg: Clone>
{
pub labels: Vec<String>,
pub selected: usize,
pub on_select: Option<std::sync::Arc<dyn Fn( usize ) -> Msg>>,
/// Background colour of the strip itself. `None` paints no
/// background, letting the parent surface show through.
pub strip_bg: Option<Color>,
}
impl<Msg: Clone + 'static> TabBar<Msg>
{
/// Build a tab bar from an iterable of labels.
pub fn new<I, S>( labels: I ) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self
{
labels: labels.into_iter().map( Into::into ).collect(),
selected: 0,
on_select: None,
strip_bg: Some( crate::theme::palette().surface_alt ),
}
}
/// Index of the currently selected tab. Out-of-range values are
/// drawn as "no tab active".
pub fn selected( mut self, idx: usize ) -> Self
{
self.selected = idx;
self
}
/// Callback invoked with the index of the tapped tab.
pub fn on_select( mut self, f: impl Fn( usize ) -> Msg + 'static ) -> Self
{
self.on_select = Some( std::sync::Arc::new( f ) );
self
}
/// Override the strip background colour. Pass `None` (via
/// [`Self::strip_bg_none`]) to disable the background entirely.
pub fn strip_bg( mut self, c: Color ) -> Self
{
self.strip_bg = Some( c );
self
}
/// Paint no strip background. Useful inside containers that already
/// provide their own surface chrome.
pub fn strip_bg_none( mut self ) -> Self
{
self.strip_bg = None;
self
}
/// Build the [`Element`] tree representing this tab bar. Equivalent
/// to `Element::from( self )`.
pub fn build( self ) -> Element<Msg>
{
use super::{ button, container };
use super::button::ButtonVariant;
use crate::layout::row::row;
use crate::layout::spacer::spacer;
let selected = self.selected;
let cb = self.on_select.clone();
let labels = self.labels;
let mut r = row::<Msg>().padding( theme::PADDING ).spacing( theme::SPACING );
for ( i, label ) in labels.into_iter().enumerate()
{
let is_active = i == selected;
let mut btn = button( label );
if is_active
{
btn = btn.variant( ButtonVariant::Primary );
} else {
btn = btn.variant( ButtonVariant::Tertiary );
}
if let Some( ref f ) = cb
{
let f = f.clone();
let msg = f( i );
btn = btn.on_press( msg );
}
r = r.push( btn );
}
// Trailing spacer so the strip claims the full row width and the
// chips left-align inside it. Callers that want full-width tabs
// can wrap the result in a Row of `flex` children themselves.
r = r.push( spacer() );
match self.strip_bg
{
Some( bg ) => container( r ).background( bg ).radius( theme::RADIUS ).into(),
None => Element::Row( r ),
}
}
}
impl<Msg: Clone + 'static> From<TabBar<Msg>> for Element<Msg>
{
fn from( t: TabBar<Msg> ) -> Self
{
t.build()
}
}
/// Create a [`TabBar`] from any iterable of label-likes.
///
/// ```rust,no_run
/// # use ltk::{ tabs, TabBar };
/// # #[ derive( Clone ) ] enum Msg { SelectTab( usize ) }
/// # struct App { tab: usize }
/// # impl App { fn _ex( &self ) -> TabBar<Msg> {
/// tabs( [ "Inbox", "Sent", "Drafts" ] )
/// .selected( self.tab )
/// .on_select( Msg::SelectTab )
/// # }}
/// ```
pub fn tabs<Msg, I, S>( labels: I ) -> TabBar<Msg>
where
Msg: Clone + 'static,
I: IntoIterator<Item = S>,
S: Into<String>,
{
TabBar::new( labels )
}
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ derive( Clone, Debug, PartialEq, Eq ) ]
enum Msg { Pick( usize ) }
#[ test ]
fn defaults()
{
let t: TabBar<Msg> = TabBar::new( vec![ "a", "b", "c" ] );
assert_eq!( t.labels.len(), 3 );
assert_eq!( t.selected, 0 );
assert!( t.on_select.is_none() );
}
#[ test ]
fn selected_builder()
{
let t: TabBar<Msg> = TabBar::new( vec![ "a", "b" ] ).selected( 1 );
assert_eq!( t.selected, 1 );
}
#[ test ]
fn on_select_invokes_callback()
{
let t: TabBar<Msg> = TabBar::new( vec![ "a", "b" ] ).on_select( Msg::Pick );
let cb = t.on_select.as_ref().expect( "callback set" );
assert_eq!( cb( 1 ), Msg::Pick( 1 ) );
}
#[ test ]
fn strip_bg_none_disables_background()
{
let t: TabBar<Msg> = TabBar::new( vec![ "a" ] ).strip_bg_none();
assert!( t.strip_bg.is_none() );
}
}

384
src/widget/text.rs Normal file
View File

@@ -0,0 +1,384 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::sync::Arc;
use fontdue::Font;
use crate::theme::FontStyle;
use crate::types::{ Color, Rect };
use crate::render::Canvas;
use super::Element;
#[ derive( Debug, Clone, Copy, PartialEq ) ]
pub enum TextAlign
{
Left,
Center,
Right,
}
pub struct Text
{
pub content: String,
pub size: f32,
pub color: Color,
pub align: TextAlign,
pub wrap: bool,
/// Optional `(family, weight, style)` override resolved through
/// the active theme's font registry on every draw. `None` keeps
/// the canvas default font (Sora Regular in `ltk-theme-default`).
pub font: Option<( String, u16, FontStyle )>,
}
impl Text
{
pub fn new( content: impl Into<String> ) -> Self
{
Self
{
content: content.into(),
size: 16.0,
color: Color::WHITE,
align: TextAlign::Left,
wrap: false,
font: None,
}
}
/// Set a `(family, weight, style)` override for this text node.
/// The triple is resolved through [`Canvas::font_for`] at draw
/// time, so missing weights fall through the registry's nearest-
/// match precedence.
pub fn font( mut self, family: impl Into<String>, weight: u16, style: FontStyle ) -> Self
{
self.font = Some( ( family.into(), weight, style ) );
self
}
/// Shorthand for [`Self::font`] when the desired override is just
/// a weight on the default Sora family (the family `ltk-theme-
/// default` declares).
pub fn weight( mut self, weight: u16 ) -> Self
{
self.font = Some( ( "sora".to_string(), weight, FontStyle::Normal ) );
self
}
pub fn size( mut self, s: f32 ) -> Self
{
self.size = s;
self
}
pub fn color( mut self, c: Color ) -> Self
{
self.color = c;
self
}
pub fn align( mut self, a: TextAlign ) -> Self
{
self.align = a;
self
}
pub fn align_center( mut self ) -> Self
{
self.align = TextAlign::Center;
self
}
/// Enable word-wrapping. With `wrap = true` the text breaks on
/// whitespace at `max_width` and the widget reports the natural
/// height of all the resulting lines. With `wrap = false` (the
/// default) the text stays on one line and is truncated with an
/// ellipsis when it overflows.
pub fn wrap( mut self, w: bool ) -> Self
{
self.wrap = w;
self
}
fn resolve_font( &self, canvas: &Canvas ) -> Option<Arc<Font>>
{
self.font.as_ref().map( |( family, weight, style )|
{
canvas.font_for( family, *weight, *style )
} )
}
fn measure( &self, text: &str, canvas: &Canvas, font: Option<&Arc<Font>> ) -> f32
{
match font
{
Some( f ) => canvas.measure_text_with_font( text, self.size, f ),
None => canvas.measure_text( text, self.size ),
}
}
fn measure_char( &self, ch: char, canvas: &Canvas, font: Option<&Arc<Font>> ) -> f32
{
match font
{
Some( f ) => f.metrics( ch, self.size * canvas.dpi_scale() ).advance_width,
None => canvas.font_metrics( ch, self.size ).advance_width,
}
}
fn paint( &self, canvas: &mut Canvas, text: &str, x: f32, y: f32, font: Option<&Arc<Font>> )
{
match font
{
Some( f ) => canvas.draw_text_with_font( text, x, y, self.size, self.color, f ),
None => canvas.draw_text( text, x, y, self.size, self.color ),
}
}
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 )
{
let line_h = canvas.font_line_metrics( self.size )
.map( |m| m.ascent - m.descent )
.unwrap_or( self.size );
let font = self.resolve_font( canvas );
if self.wrap
{
let lines = wrap_lines( &self.content, self.size, max_width, canvas, font.as_ref() );
let h = line_h * lines.len().max( 1 ) as f32;
( max_width, h )
}
else
{
let w = ( self.measure( &self.content, canvas, font.as_ref() ) + 8.0 ).min( max_width );
( w, line_h )
}
}
pub fn draw( &self, canvas: &mut Canvas, rect: Rect, _focused: bool )
{
let ascent = canvas.font_line_metrics( self.size )
.map( |m| m.ascent )
.unwrap_or( self.size * 0.8 );
let line_h = canvas.font_line_metrics( self.size )
.map( |m| m.ascent - m.descent )
.unwrap_or( self.size );
let font = self.resolve_font( canvas );
if self.wrap
{
let lines = wrap_lines( &self.content, self.size, rect.width, canvas, font.as_ref() );
for ( i, line ) in lines.iter().enumerate()
{
let line_w = self.measure( line, canvas, font.as_ref() );
let slack = ( rect.width - line_w ).max( 0.0 );
let pad = 4.0_f32.min( slack );
let tx = match self.align
{
TextAlign::Left => rect.x + pad,
TextAlign::Center => rect.x + slack / 2.0,
TextAlign::Right => rect.x + rect.width - line_w - pad,
};
let ty = rect.y + ascent + line_h * i as f32;
self.paint( canvas, line, tx, ty, font.as_ref() );
}
return;
}
let text_w = self.measure( &self.content, canvas, font.as_ref() );
let display = if text_w > rect.width && rect.width > 0.0
{
let ellipsis = "...";
let ell_w = self.measure( ellipsis, canvas, font.as_ref() );
let budget = rect.width - ell_w;
if budget <= 0.0
{
ellipsis.to_string()
}
else
{
let mut accum = 0.0_f32;
let truncated: String = self.content.chars().take_while( |ch|
{
let cw = self.measure_char( *ch, canvas, font.as_ref() );
accum += cw;
accum <= budget
} ).collect();
format!( "{truncated}{ellipsis}" )
}
}
else
{
self.content.clone()
};
let disp_w = self.measure( &display, canvas, font.as_ref() );
let slack = ( rect.width - disp_w ).max( 0.0 );
let pad = 4.0_f32.min( slack );
let tx = match self.align
{
TextAlign::Left => rect.x + pad,
TextAlign::Center => rect.x + slack / 2.0,
TextAlign::Right => rect.x + rect.width - disp_w - pad,
};
let ty = rect.y + ascent;
self.paint( canvas, &display, tx, ty, font.as_ref() );
}
}
/// Greedy word-wrap: split `text` on whitespace and pack words into
/// lines whose total width stays under `max_width`. A word longer
/// than `max_width` overflows on its own line rather than being
/// hyphenated. Routes through the font override when one is set so
/// measurement and rendering agree on advance widths.
fn wrap_lines( text: &str, size: f32, max_width: f32, canvas: &Canvas, font: Option<&Arc<Font>> ) -> Vec<String>
{
if max_width <= 0.0 || text.is_empty()
{
return vec![ text.to_string() ];
}
let measure = |s: &str| -> f32
{
match font
{
Some( f ) => canvas.measure_text_with_font( s, size, f ),
None => canvas.measure_text( s, size ),
}
};
let space_w = measure( " " );
let mut lines = Vec::new();
let mut current = String::new();
let mut current_w = 0.0_f32;
for word in text.split_whitespace()
{
let word_w = measure( word );
if current.is_empty()
{
current.push_str( word );
current_w = word_w;
}
else if current_w + space_w + word_w <= max_width
{
current.push( ' ' );
current.push_str( word );
current_w += space_w + word_w;
}
else
{
lines.push( std::mem::take( &mut current ) );
current.push_str( word );
current_w = word_w;
}
}
if !current.is_empty() { lines.push( current ); }
if lines.is_empty() { lines.push( String::new() ); }
lines
}
impl<Msg: Clone + 'static> From<Text> for Element<Msg>
{
fn from( t: Text ) -> Self
{
Element::Text( t )
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
use crate::render::Canvas;
fn make_canvas() -> Canvas { Canvas::new( 800, 600 ) }
#[ test ]
fn new_uses_documented_defaults()
{
let t = Text::new( "hello" );
assert_eq!( t.content, "hello" );
assert_eq!( t.size, 16.0 );
assert_eq!( t.color, Color::WHITE );
assert_eq!( t.align, TextAlign::Left );
}
#[ test ]
fn size_builder_overrides_default()
{
let t = Text::new( "" ).size( 32.0 );
assert_eq!( t.size, 32.0 );
}
#[ test ]
fn color_builder_overrides_default()
{
let t = Text::new( "" ).color( Color::rgb( 1.0, 0.0, 0.0 ) );
assert_eq!( t.color, Color::rgb( 1.0, 0.0, 0.0 ) );
}
#[ test ]
fn align_builder_can_target_each_variant()
{
assert_eq!( Text::new( "" ).align( TextAlign::Left ).align, TextAlign::Left );
assert_eq!( Text::new( "" ).align( TextAlign::Center ).align, TextAlign::Center );
assert_eq!( Text::new( "" ).align( TextAlign::Right ).align, TextAlign::Right );
}
#[ test ]
fn align_center_shorthand_matches_explicit_align()
{
let a = Text::new( "" ).align_center();
let b = Text::new( "" ).align( TextAlign::Center );
assert_eq!( a.align, b.align );
}
#[ test ]
fn preferred_size_returns_non_negative_dimensions()
{
let canvas = make_canvas();
let ( w, h ) = Text::new( "ltk" ).preferred_size( 200.0, &canvas );
assert!( w >= 0.0 );
assert!( h > 0.0, "non-empty text reports a positive line height" );
}
#[ test ]
fn preferred_size_caps_width_at_max_width()
{
// A long string must not exceed the parent-supplied bound — text
// elides at draw time, so the layout pass needs to see at most
// `max_width`.
let canvas = make_canvas();
let long = "the quick brown fox jumps over the lazy dog";
let ( w, _ ) = Text::new( long ).size( 24.0 ).preferred_size( 64.0, &canvas );
assert!( w <= 64.0, "preferred width must be clamped to max_width (got {w})" );
}
#[ test ]
fn preferred_size_height_scales_with_font_size()
{
let canvas = make_canvas();
let ( _, h_small ) = Text::new( "x" ).size( 12.0 ).preferred_size( 200.0, &canvas );
let ( _, h_large ) = Text::new( "x" ).size( 48.0 ).preferred_size( 200.0, &canvas );
assert!( h_large > h_small, "larger font must report taller line height" );
}
#[ test ]
fn empty_content_still_reports_a_line_height()
{
let canvas = make_canvas();
let ( _, h ) = Text::new( "" ).preferred_size( 200.0, &canvas );
// Even an empty string keeps the baseline metrics so a hidden field
// keeps its row height inside a column.
assert!( h > 0.0 );
}
#[ test ]
fn text_align_enum_implements_partial_eq()
{
// Compile-time guard: TextAlign must keep deriving PartialEq so
// downstream theming code can compare alignments.
assert_eq!( TextAlign::Left, TextAlign::Left );
assert_ne!( TextAlign::Left, TextAlign::Right );
}
}

2029
src/widget/text_edit.rs Normal file

File diff suppressed because it is too large Load Diff

615
src/widget/time_picker.rs Normal file
View File

@@ -0,0 +1,615 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! TimePicker — HH:MM (and optionally :SS / AM-PM) stepper widget.
//!
//! Stateless — the application owns a [`Time`] and updates it from
//! the [`TimePicker::on_change`] callback. Each unit (hour, minute,
//! second) is a small stepper: an up arrow above the digit cell,
//! a down arrow below. Each digit cell is itself an editable
//! [`crate::widget::text_edit::TextEdit`] with `select_on_focus` —
//! click (or Tab) into it and type to set the value with the
//! keyboard, parser-clamped to the unit's valid range.
//!
//! Stepper buttons opt into press-and-hold repeat
//! ([`crate::widget::button::Button::repeating`]) so a held arrow
//! ramps through values at ~8 Hz. The minute / second steppers
//! honour [`TimePicker::minute_step`] (default 1, common values 5 /
//! 15) and **snap to the next or previous multiple of the step**
//! rather than adding the step verbatim — so `:32` with
//! `minute_step( 5 )` jumps to `:35` (next multiple) rather than
//! `:37`. Typed input bypasses the snap so the user can still type
//! any minute they want.
//!
//! ```rust,no_run
//! # use ltk::{ time_picker, Time, TimePicker };
//! # #[ derive( Clone ) ] enum Msg { TimeChanged( Time ) }
//! # struct App { time: Time }
//! # impl App { fn _ex( &self ) -> TimePicker<Msg> {
//! time_picker( self.time )
//! .minute_step( 5 )
//! .twelve_hour( true )
//! .on_change( Msg::TimeChanged )
//! # }}
//! ```
use std::sync::Arc;
use crate::layout::column::column;
use crate::layout::row::row;
use crate::layout::spacer::spacer;
use super::Element;
mod theme
{
use crate::types::Color;
pub fn text() -> Color { crate::theme::palette().text_primary }
pub fn text_muted() -> Color { crate::theme::palette().text_secondary }
pub fn surface_alt()-> Color { crate::theme::palette().surface_alt }
pub const PADDING: f32 = 16.0;
pub const RADIUS: f32 = 16.0;
pub const VAL_FS: f32 = 28.0;
pub const SEP_FS: f32 = 28.0;
pub const SPACING: f32 = 8.0;
}
/// A wall-clock time, no date / no timezone. `hour` is 023 (24-hour
/// representation regardless of [`TimePicker::twelve_hour`] display
/// mode), `minute` and `second` are 059.
#[ derive( Clone, Copy, Debug, PartialEq, Eq, Hash, Default ) ]
pub struct Time
{
pub hour: u8,
pub minute: u8,
pub second: u8,
}
impl Time
{
pub const fn new( hour: u8, minute: u8 ) -> Self
{
Self { hour, minute, second: 0 }
}
pub const fn with_seconds( hour: u8, minute: u8, second: u8 ) -> Self
{
Self { hour, minute, second }
}
/// Total seconds from 00:00:00. Used internally for arithmetic
/// that needs to wrap correctly across midnight.
pub fn as_seconds( self ) -> i32
{
self.hour as i32 * 3600 + self.minute as i32 * 60 + self.second as i32
}
/// Reconstruct a [`Time`] from a (signed) seconds-of-day count,
/// wrapping on the 24-hour boundary in either direction.
pub fn from_seconds( s: i32 ) -> Self
{
let total = s.rem_euclid( 24 * 3600 );
let hour = ( total / 3600 ) as u8;
let minute = ( ( total % 3600 ) / 60 ) as u8;
let second = ( total % 60 ) as u8;
Self { hour, minute, second }
}
/// `true` when the values are in the canonical ranges (h: 0..24,
/// m / s: 0..60).
pub fn is_valid( self ) -> bool
{
self.hour < 24 && self.minute < 60 && self.second < 60
}
}
/// Compute the signed delta needed to step `value` to the next
/// (`dir = 1`) or previous (`dir = -1`) multiple of `step`. The
/// returned delta carries the right sign already, so the caller just
/// adds it to its working seconds-of-day count.
///
/// Designed for the minute / second steppers in
/// [`TimePicker`](crate::widget::time_picker::TimePicker) — it ignores
/// the wraparound at 60 (the picker always feeds the result through
/// [`Time::from_seconds`], which handles the `:00` → next-hour
/// rollover via `rem_euclid`).
///
/// Examples (`step = 5`):
/// * `value = 30, dir = 1` → `+5` → next aligned at `35`
/// * `value = 32, dir = 1` → `+3` → next aligned at `35`
/// * `value = 32, dir = -1` → `-2` → previous aligned at `30`
/// * `value = 30, dir = -1` → `-5` → previous aligned at `25`
/// Filter `s` down to its ASCII digit characters, parse them as a
/// decimal integer and clamp the result to `0..=max`. Returns `None`
/// only when the filtered string is empty so the caller can detect
/// "the user erased the field" and skip the on-change message rather
/// than committing a spurious zero. Anything else — leading zeros,
/// punctuation, alpha characters, values past `max` — is forgiven and
/// folded into a valid `u8`.
fn parse_clamped_digits( s: &str, max: u8 ) -> Option<u8>
{
let digits: String = s.chars().filter( |c| c.is_ascii_digit() ).collect();
if digits.is_empty() { return None; }
// `u32` instead of `u8::from_str` so a typed "099" or "100" does
// not overflow the parse and bail out — we want to clamp, not
// reject.
let n: u32 = digits.parse().ok()?;
Some( n.min( max as u32 ) as u8 )
}
fn snap_step_delta( value: i32, step: i32, dir: i32 ) -> i32
{
let step = step.max( 1 );
if dir >= 0
{
// Up: smallest multiple of `step` strictly greater than
// `value`. `value % step == 0` ⇒ jump a full step;
// otherwise round up to the next multiple.
let rem = value.rem_euclid( step );
if rem == 0 { step } else { step - rem }
}
else
{
// Down: largest multiple of `step` strictly less than
// `value`. Symmetric to the up case.
let rem = value.rem_euclid( step );
if rem == 0 { -step } else { -rem }
}
}
/// Time-of-day selector with up / down steppers per unit.
pub struct TimePicker<Msg: Clone>
{
pub value: Time,
pub on_change: Option<Arc<dyn Fn( Time ) -> Msg>>,
pub minute_step: u8,
pub second_step: u8,
pub seconds: bool,
pub twelve_hour: bool,
}
impl<Msg: Clone + 'static> TimePicker<Msg>
{
/// Create a time picker with the given current time.
pub fn new( value: Time ) -> Self
{
Self
{
value,
on_change: None,
minute_step: 1,
second_step: 1,
seconds: false,
twelve_hour: false,
}
}
pub fn on_change( mut self, f: impl Fn( Time ) -> Msg + 'static ) -> Self
{
self.on_change = Some( Arc::new( f ) );
self
}
/// Step size for the minute up / down buttons. Common values: 1
/// (default), 5, 15. Clamped to `1..=30`.
pub fn minute_step( mut self, step: u8 ) -> Self
{
self.minute_step = step.clamp( 1, 30 );
self
}
/// Step size for the second up / down buttons. Only meaningful
/// when [`Self::seconds`] is on. Clamped to `1..=30`.
pub fn second_step( mut self, step: u8 ) -> Self
{
self.second_step = step.clamp( 1, 30 );
self
}
/// Show / hide the seconds stepper. Default `false`.
pub fn seconds( mut self, on: bool ) -> Self
{
self.seconds = on;
self
}
/// Display the hour as 12-hour with an AM / PM toggle. Internal
/// storage stays 24-hour. Default `false`.
pub fn twelve_hour( mut self, on: bool ) -> Self
{
self.twelve_hour = on;
self
}
/// Build the `Element` tree representing this time picker.
pub fn build( self ) -> Element<Msg>
{
use super::{ button, container, icon_button, text, text_edit };
use super::button::ButtonVariant;
use super::text::TextAlign;
let on_chg = self.on_change.clone();
let value = self.value;
let twelve = self.twelve_hour;
// Up / down arrows load from the active theme as SVG icons
// (`icons/catalogue/filled/general/{up,down}-simple.svg`)
// tinted with `palette.text_primary` so they read against
// either light or dark surfaces. Falls back to the matching
// Unicode glyph when the icon is missing — the literal
// `▲` / `▼` characters are not present in every system
// font, so without the SVG path the buttons would render
// as missing-glyph boxes (see issue with stock fonts that
// lack U+25B2 / U+25BC).
const ARROW_PX: u32 = 16;
let arrow = | name: &str, fallback: &str | -> super::button::Button<Msg>
{
let btn = match crate::theme::icon_rgba( name, ARROW_PX )
{
Some( ( rgba, w, h ) ) =>
{
let tinted = std::sync::Arc::new(
crate::theme::tint_symbolic( &rgba, theme::text() ),
);
icon_button::<Msg>( tinted, w, h ).icon_size( ARROW_PX as f32 )
}
None => button::<Msg>( fallback ).variant( ButtonVariant::Tertiary ),
};
// Press-and-hold steps through values continuously, like
// holding an arrow key. The runtime fires `on_press`
// immediately on press and then re-fires at the same
// cadence as keyboard repeat while the button is held.
btn.repeating( true )
};
// Build a stepper column for one unit. The middle element is
// supplied by the caller (a static `text` for read-only mode,
// or an editable `text_edit` with the appropriate `on_change`
// parser when a callback is wired) so each unit can carry its
// own typing semantics.
//
// `fit_content()` + `padding(0.0)` are critical: without them
// each column would claim the full row width as its preferred
// size (the `Column` default), pushing the colon and the
// minute / second columns off-screen to the right.
let stepper = | middle: Element<Msg>, up_secs: i32, down_secs: i32 | -> crate::layout::column::Column<Msg>
{
let mut col = column::<Msg>()
.spacing( 4.0 )
.align_center_x( true )
.padding( 0.0 )
.fit_content();
let mut up = arrow( "general/up-simple", "" );
let mut dn = arrow( "general/down-simple", "" );
if let Some( ref cb ) = on_chg
{
let cb_up = cb.clone();
let cb_dn = cb.clone();
let new_up = Time::from_seconds( value.as_seconds() + up_secs );
let new_dn = Time::from_seconds( value.as_seconds() + down_secs );
up = up.on_press( cb_up( new_up ) );
dn = dn.on_press( cb_dn( new_dn ) );
}
col = col.push( up );
col = col.push( middle );
col = col.push( dn );
col
};
// Build the editable digit field for one unit. When no
// `on_change` is wired, falls back to a static `text` so the
// digits still render but the picker is read-only. With
// `on_change` wired, returns a borderless centred
// `text_edit` with `select_on_focus` so the user can click
// (or Tab) to focus and immediately retype the value.
// `parser` translates the typed string into a fresh `Time`,
// returning `None` when the input is invalid (empty / non-
// numeric) so the existing value is preserved.
let digit_field = | display: String, parser: Box<dyn Fn( &str ) -> Option<Time> > | -> Element<Msg>
{
match on_chg.as_ref()
{
None =>
{
text( display )
.size( theme::VAL_FS )
.color( theme::text() )
.align_center()
.into()
}
Some( cb ) =>
{
let cb = cb.clone();
let snapshot = value;
text_edit::<Msg>( "", display )
.borderless( true )
.fixed_width( 72.0 )
.font_size( theme::VAL_FS )
.align( TextAlign::Center )
.select_on_focus( true )
.on_change( move |s: String|
{
let new = parser( &s ).unwrap_or( snapshot );
cb( new )
} )
.into()
}
}
};
// Hour: respect 12-hour toggle for *display only*; storage
// stays 023. 12-hour displays 12 instead of 0.
let display_hour = if twelve
{
let h12 = value.hour % 12;
if h12 == 0 { 12 } else { h12 }
} else {
value.hour
};
let hour_field = digit_field(
format!( "{:02}", display_hour ),
Box::new( move |s: &str| -> Option<Time>
{
let typed = parse_clamped_digits( s, if twelve { 12 } else { 23 } )?;
let hour24 = if twelve
{
// 12-hour input: 12 with PM stays 12, 12 with AM
// becomes 0; 111 keep the AM / PM the user
// already had so retyping a value during a PM
// hour does not silently roll back to AM.
let is_pm = value.hour >= 12;
match ( typed, is_pm )
{
( 12, false ) => 0,
( 12, true ) => 12,
( h, false ) => h,
( h, true ) => h.saturating_add( 12 ).min( 23 ),
}
} else { typed };
Some( Time::with_seconds( hour24, value.minute, value.second ) )
} ),
);
let hour_col = stepper( hour_field, 3600, -3600 );
// Snap minutes / seconds to the next (or previous) multiple
// of the configured step instead of adding or subtracting the
// step verbatim. With `minute_step( 5 )` and a starting value
// of :32, the up arrow takes the user to :35 and the down
// arrow to :30 rather than perpetuating the off-step :37 /
// :27 — so the picker can never produce a value that is not a
// multiple of the step. Same logic for seconds. Typed input
// goes straight into the field — only the steppers enforce
// the snap, the user can still type any valid minute.
let minute_step = self.minute_step as i32;
let minute_up = snap_step_delta( value.minute as i32, minute_step, 1 ) * 60;
let minute_dn = snap_step_delta( value.minute as i32, minute_step, -1 ) * 60;
let minute_field = digit_field(
format!( "{:02}", value.minute ),
Box::new( move |s: &str| -> Option<Time>
{
let m = parse_clamped_digits( s, 59 )?;
Some( Time::with_seconds( value.hour, m, value.second ) )
} ),
);
let minute_col = stepper( minute_field, minute_up, minute_dn );
let mut units = row::<Msg>().spacing( theme::SPACING )
.push( hour_col )
.push( text( ":" ).size( theme::SEP_FS ).color( theme::text_muted() ) )
.push( minute_col );
if self.seconds
{
let second_step = self.second_step as i32;
let second_up = snap_step_delta( value.second as i32, second_step, 1 );
let second_dn = snap_step_delta( value.second as i32, second_step, -1 );
let second_field = digit_field(
format!( "{:02}", value.second ),
Box::new( move |s: &str| -> Option<Time>
{
let sec = parse_clamped_digits( s, 59 )?;
Some( Time::with_seconds( value.hour, value.minute, sec ) )
} ),
);
let second_col = stepper( second_field, second_up, second_dn );
units = units
.push( text( ":" ).size( theme::SEP_FS ).color( theme::text_muted() ) )
.push( second_col );
}
if twelve
{
let is_pm = value.hour >= 12;
let label = if is_pm { "PM" } else { "AM" };
let mut ampm = button::<Msg>( label ).variant( ButtonVariant::Secondary );
if let Some( ref cb ) = on_chg
{
// Toggle PM / AM = ±12 hours, modulo 24.
let toggled = Time::from_seconds( value.as_seconds() + 12 * 3600 );
ampm = ampm.on_press( cb( toggled ) );
}
// Fixed-width gap before AM/PM so the toggle sits next to
// the digits as a single cluster instead of being pushed
// to the right edge — Row's auto-centring then balances
// the whole hh:mm AM block inside the container. A flex
// spacer here would defeat the auto-centre and pin AM/PM
// to the right.
units = units.push( spacer().width( theme::SPACING * 2.0 ) ).push( ampm );
}
// `Row::layout` centres a cluster horizontally when there are
// no flex spacers inside (see `layout/row.rs` — `start_x =
// rect.x + (rect.width - fixed_w - gaps) / 2.0`). All the
// `units` rows we build here only contain fixed-width
// children (steppers, separator text, fixed-width spacer for
// AM/PM), so they centre automatically inside the container.
container::<Msg>( units )
.background( theme::surface_alt() )
.padding( theme::PADDING )
.radius( theme::RADIUS )
.into()
}
}
impl<Msg: Clone + 'static> From<TimePicker<Msg>> for Element<Msg>
{
fn from( t: TimePicker<Msg> ) -> Self { t.build() }
}
/// Create a [`TimePicker`] with the given current time.
///
/// ```rust,no_run
/// # use ltk::{ time_picker, Time, TimePicker };
/// # #[ derive( Clone ) ] enum Msg { TimeChanged( Time ) }
/// # struct App { time: Time }
/// # impl App { fn _ex( &self ) -> TimePicker<Msg> {
/// time_picker( self.time )
/// .minute_step( 5 )
/// .on_change( Msg::TimeChanged )
/// # }}
/// ```
pub fn time_picker<Msg: Clone + 'static>( value: Time ) -> TimePicker<Msg>
{
TimePicker::new( value )
}
#[ cfg( test ) ]
mod tests
{
use super::*;
// ── Time arithmetic ───────────────────────────────────────────────────────
#[ test ]
fn time_round_trips_through_seconds()
{
let t = Time::with_seconds( 13, 24, 56 );
assert_eq!( Time::from_seconds( t.as_seconds() ), t );
}
#[ test ]
fn time_from_seconds_wraps_modulo_24h()
{
// One second past midnight → 00:00:01.
assert_eq!( Time::from_seconds( 24 * 3600 + 1 ), Time::with_seconds( 0, 0, 1 ) );
// One second before midnight (negative) → 23:59:59.
assert_eq!( Time::from_seconds( -1 ), Time::with_seconds( 23, 59, 59 ) );
}
#[ test ]
fn time_validity()
{
assert!( Time::with_seconds( 23, 59, 59 ).is_valid() );
assert!( !Time::with_seconds( 24, 0, 0 ).is_valid() );
assert!( !Time::with_seconds( 0, 60, 0 ).is_valid() );
}
// ── Builders ──────────────────────────────────────────────────────────────
#[ derive( Clone, Debug, PartialEq, Eq ) ]
enum Msg { Pick( Time ) }
#[ test ]
fn defaults()
{
let p: TimePicker<Msg> = time_picker( Time::new( 9, 30 ) );
assert_eq!( p.value, Time::new( 9, 30 ) );
assert_eq!( p.minute_step, 1 );
assert_eq!( p.second_step, 1 );
assert!( !p.seconds );
assert!( !p.twelve_hour );
assert!( p.on_change.is_none() );
}
#[ test ]
fn minute_step_clamps_to_thirty()
{
let p: TimePicker<Msg> = time_picker( Time::new( 0, 0 ) ).minute_step( 99 );
assert_eq!( p.minute_step, 30 );
let p: TimePicker<Msg> = time_picker( Time::new( 0, 0 ) ).minute_step( 0 );
assert_eq!( p.minute_step, 1 );
}
#[ test ]
fn on_change_callback_invokes_with_time()
{
let p: TimePicker<Msg> = time_picker( Time::new( 9, 30 ) ).on_change( Msg::Pick );
let cb = p.on_change.as_ref().expect( "set" );
assert_eq!( cb( Time::new( 10, 0 ) ), Msg::Pick( Time::new( 10, 0 ) ) );
}
#[ test ]
fn build_does_not_panic_minimal()
{
let _: Element<Msg> = time_picker::<Msg>( Time::new( 9, 30 ) ).build();
}
#[ test ]
fn build_does_not_panic_with_twelve_hour_and_seconds()
{
let _: Element<Msg> = time_picker::<Msg>( Time::with_seconds( 13, 24, 56 ) )
.twelve_hour( true )
.seconds( true )
.minute_step( 15 )
.on_change( Msg::Pick )
.build();
}
// ── Step snapping ────────────────────────────────────────────────────────
#[ test ]
fn snap_step_delta_advances_to_next_multiple_when_off_step()
{
// Starting at :32 with step 5: up should land on :35 (+3),
// down should land on :30 (2).
assert_eq!( snap_step_delta( 32, 5, 1 ), 3 );
assert_eq!( snap_step_delta( 32, 5, -1 ), -2 );
}
#[ test ]
fn snap_step_delta_full_step_when_already_aligned()
{
// Starting on a multiple of step takes a full step in the
// requested direction.
assert_eq!( snap_step_delta( 30, 5, 1 ), 5 );
assert_eq!( snap_step_delta( 30, 5, -1 ), -5 );
}
#[ test ]
fn snap_step_delta_clamps_step_to_at_least_one()
{
// A step of 0 is meaningless — clamp to 1 so the picker
// always makes some progress on a click.
assert_eq!( snap_step_delta( 0, 0, 1 ), 1 );
}
// ── Typed-input parser ────────────────────────────────────────────────────
#[ test ]
fn parse_clamped_digits_strips_non_numeric()
{
assert_eq!( parse_clamped_digits( "0a3", 99 ), Some( 3 ) );
assert_eq!( parse_clamped_digits( " 12 ", 59 ), Some( 12 ) );
}
#[ test ]
fn parse_clamped_digits_clamps_to_max()
{
// A 12-hour field caps at 12; typing "23" reads as 23 then
// clamps to 12.
assert_eq!( parse_clamped_digits( "23", 12 ), Some( 12 ) );
// A 24-hour field caps at 23.
assert_eq!( parse_clamped_digits( "099", 23 ), Some( 23 ) );
}
#[ test ]
fn parse_clamped_digits_empty_returns_none()
{
// `None` lets the caller distinguish "user erased the
// field" from "user committed a zero", so the picker can
// hold the previous value instead of jumping to midnight.
assert!( parse_clamped_digits( "", 59 ).is_none() );
assert!( parse_clamped_digits( "abc", 59 ).is_none() );
}
}

305
src/widget/toast.rs Normal file
View File

@@ -0,0 +1,305 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Toast / Snackbar — short-lived bottom-anchored message overlay.
//!
//! [`Toast`] is **not** an [`Element`]; it is a builder that produces
//! an [`OverlaySpec`] for the application to return from
//! [`App::overlays`](crate::App::overlays). The widget itself is
//! stateless: the application owns the visibility / timer state and
//! returns the overlay only while a toast is pending.
//!
//! Auto-dismissal is the application's responsibility. A typical
//! pattern is to spawn a `calloop` timer (or use the channel sender
//! from [`App::set_channel_sender`](crate::App::set_channel_sender))
//! that fires a "toast expired" message after [`Toast::duration`]
//! elapses.
//!
//! ```rust,no_run
//! # use ltk::{ toast, OverlaySpec, WidgetId };
//! # #[ derive( Clone ) ] enum Msg {}
//! # struct ToastState { message: String }
//! # struct App { toast: Option<ToastState> }
//! # impl App {
//! fn overlays( &self ) -> Vec<OverlaySpec<Msg>>
//! {
//! match &self.toast
//! {
//! Some( t ) => vec![ toast( &t.message ).id( WidgetId( "toast/main" ) ).overlay() ],
//! None => vec![],
//! }
//! }
//! # }
//! ```
use std::collections::hash_map::DefaultHasher;
use std::hash::{ Hash, Hasher };
use std::time::Duration;
use crate::app::{ Anchor, Layer, OverlayId, OverlaySpec };
use crate::types::{ Color, WidgetId };
use super::Element;
mod theme
{
use crate::types::Color;
/// Toast pill background — the theme's elevated surface token so
/// the toast reads as a "card" against the page below in both
/// light and dark modes.
pub fn bg() -> Color { crate::theme::palette().surface_alt }
/// Body text colour — primary text token from the theme.
pub fn text() -> Color { crate::theme::palette().text_primary }
pub const HEIGHT: u32 = 56;
pub const BOTTOM_MARGIN: i32 = 32;
pub const PAD_H: f32 = 24.0;
pub const FONT_SIZE: f32 = 16.0;
pub const RADIUS: f32 = 28.0;
}
/// Builder for a transient bottom-anchored notification overlay.
///
/// `Toast` does not own its own timer — the application is responsible
/// for clearing the toast (typically by storing its `message` in an
/// `Option` and resetting it via a delayed message).
pub struct Toast<Msg: Clone>
{
/// Stable id for the overlay. Used to derive the [`OverlayId`] so
/// the same toast persists across frames when the message stays the
/// same.
pub id: WidgetId,
/// Body text rendered inside the pill.
pub message: String,
/// Display duration. The runtime does **not** consume this — it is
/// returned through [`Toast::duration_value`] so the app's timer
/// scheduler can read it back.
pub duration: Duration,
/// Optional message fired when the user taps anywhere outside the
/// pill. Convenient for "tap anywhere to dismiss" behaviour.
pub on_dismiss: Option<Msg>,
/// Optional override for the pill background colour.
pub bg: Option<Color>,
/// Optional override for the body text colour.
pub fg: Option<Color>,
}
impl<Msg: Clone + 'static> Toast<Msg>
{
/// Create a toast with the given message text. The default id is
/// `"toast/default"`; override with [`Self::id`] when more than one
/// toast variant might coexist.
pub fn new( message: impl Into<String> ) -> Self
{
Self
{
id: WidgetId( "toast/default" ),
message: message.into(),
duration: Duration::from_secs( 2 ),
on_dismiss: None,
bg: None,
fg: None,
}
}
/// Override the stable id used to derive the [`OverlayId`].
pub fn id( mut self, id: WidgetId ) -> Self
{
self.id = id;
self
}
/// Set the display duration. The toast itself does not auto-hide;
/// this value is stored so the application's timer scheduler can
/// read it back via [`Self::duration_value`].
pub fn duration( mut self, secs: f32 ) -> Self
{
self.duration = Duration::from_secs_f32( secs.max( 0.0 ) );
self
}
/// Read back the configured duration. Lets the app delegate timer
/// setup to a helper that does not need to know how the toast was
/// configured.
pub fn duration_value( &self ) -> Duration
{
self.duration
}
/// Set the dismiss message fired on a tap outside the pill.
pub fn on_dismiss( mut self, msg: Msg ) -> Self
{
self.on_dismiss = Some( msg );
self
}
/// Override the pill background colour.
pub fn background( mut self, c: Color ) -> Self
{
self.bg = Some( c );
self
}
/// Override the body text colour.
pub fn color( mut self, c: Color ) -> Self
{
self.fg = Some( c );
self
}
/// Build an [`Element`] that paints the toast pill bottom-anchored
/// inside its parent rect. The intended pattern is to push it on
/// top of the main view via a [`Stack`](crate::stack):
///
/// ```text
/// fn view( &self ) -> Element<Msg>
/// {
/// let body = column().push( … );
/// let mut s = stack().push( body );
/// if self.toast_until.is_some()
/// {
/// s = s.push( toast( "Saved" ).view() );
/// }
/// s.into()
/// }
/// ```
///
/// Works on every compositor — no `wlr-layer-shell` needed —
/// because the pill is just a regular widget tree drawn on top of
/// the existing canvas. Use [`Self::overlay`] only when you
/// specifically need the toast to render *above other windows*
/// (notification-style), which requires `wlr-layer-shell`.
pub fn view( self ) -> Element<Msg>
{
use super::{ container, text };
use crate::layout::stack::stack;
use crate::layout::stack::{ HAlign, VAlign };
let bg = self.bg.unwrap_or_else( theme::bg );
let fg = self.fg.unwrap_or_else( theme::text );
let pill: Element<Msg> = container(
text( self.message.clone() )
.size( theme::FONT_SIZE )
.color( fg )
)
.background( bg )
.padding_h( theme::PAD_H )
.padding_v( 12.0 )
.radius( theme::RADIUS )
.into();
stack::<Msg>()
.push_aligned_margin( pill, HAlign::Center, VAlign::Bottom, theme::BOTTOM_MARGIN as f32 )
.into()
}
/// Build the [`OverlaySpec`] the application returns from
/// [`App::overlays`](crate::App::overlays). The overlay is anchored
/// to the bottom edge of the screen with a small breathing margin.
///
/// Requires the compositor to advertise `wlr-layer-shell`. For a
/// portable variant that works everywhere — at the cost of being
/// confined to the application's surface — use [`Self::view`].
pub fn overlay( self ) -> OverlaySpec<Msg>
{
use super::{ container, text };
use crate::layout::stack::stack;
let mut hasher = DefaultHasher::new();
self.id.0.hash( &mut hasher );
let overlay_id = OverlayId( hasher.finish() as u32 );
let bg = self.bg.unwrap_or_else( theme::bg );
let fg = self.fg.unwrap_or_else( theme::text );
let pill: Element<Msg> = container(
text( self.message.clone() )
.size( theme::FONT_SIZE )
.color( fg )
)
.background( bg )
.padding_h( theme::PAD_H )
.padding_v( 12.0 )
.radius( theme::RADIUS )
.into();
// Centre horizontally, anchor to the bottom with a small margin.
let body: Element<Msg> = stack()
.push_aligned_margin(
pill,
crate::layout::stack::HAlign::Center,
crate::layout::stack::VAlign::Bottom,
theme::BOTTOM_MARGIN as f32,
)
.into();
OverlaySpec
{
id: overlay_id,
layer: Layer::Overlay,
anchor: Anchor::BOTTOM,
size: ( 0, theme::HEIGHT + theme::BOTTOM_MARGIN as u32 + 24 ),
exclusive_zone: 0,
keyboard_exclusive: false,
input_region: None,
view: body,
on_dismiss: self.on_dismiss,
anchor_widget_id: None,
}
}
}
/// Create a [`Toast`] with the given message.
pub fn toast<Msg: Clone + 'static>( message: impl Into<String> ) -> Toast<Msg>
{
Toast::new( message )
}
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ derive( Clone, Debug, PartialEq, Eq ) ]
enum Msg { Dismiss }
#[ test ]
fn defaults()
{
let t: Toast<Msg> = toast( "Saved" );
assert_eq!( t.message, "Saved" );
assert_eq!( t.duration, Duration::from_secs( 2 ) );
assert!( t.on_dismiss.is_none() );
}
#[ test ]
fn duration_builder_clamps_negative()
{
let t: Toast<Msg> = toast( "x" ).duration( -1.0 );
assert_eq!( t.duration_value(), Duration::ZERO );
}
#[ test ]
fn on_dismiss_stored()
{
let t = toast( "x" ).on_dismiss( Msg::Dismiss );
assert_eq!( t.on_dismiss, Some( Msg::Dismiss ) );
}
#[ test ]
fn overlay_uses_stable_id()
{
let a: OverlaySpec<Msg> = toast( "x" ).id( WidgetId( "toast/a" ) ).overlay();
let b: OverlaySpec<Msg> = toast( "y" ).id( WidgetId( "toast/a" ) ).overlay();
assert_eq!( a.id, b.id, "same WidgetId must yield same OverlayId" );
let c: OverlaySpec<Msg> = toast( "x" ).id( WidgetId( "toast/b" ) ).overlay();
assert_ne!( a.id, c.id, "different WidgetIds must yield different OverlayIds" );
}
#[ test ]
fn overlay_is_layer_shell_not_xdg_popup()
{
let o: OverlaySpec<Msg> = toast( "x" ).overlay();
assert!( o.anchor_widget_id.is_none() );
assert_eq!( o.layer, Layer::Overlay );
}
}

244
src/widget/toggle.rs Normal file
View File

@@ -0,0 +1,244 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::{ Rect, WidgetId };
use crate::render::Canvas;
use super::Element;
mod theme
{
use crate::types::Color;
pub fn track_off() -> Color { crate::theme::palette().divider }
pub fn track_on() -> Color { crate::theme::palette().accent }
/// Thumb — uses the page-background colour so it reads as the
/// inverse of the track fill regardless of mode.
pub fn thumb() -> Color { crate::theme::palette().bg }
pub fn thumb_border() -> Color { crate::theme::palette().text_primary }
pub fn focus_color() -> Color { crate::theme::palette().accent }
pub fn label_color() -> Color { crate::theme::palette().text_primary }
pub const TRACK_W: f32 = 52.0;
pub const TRACK_H: f32 = 30.0;
pub const THUMB_SIZE: f32 = 24.0;
pub const HEIGHT: f32 = 48.0;
pub const GAP: f32 = 12.0;
pub const FOCUS_W: f32 = 3.0;
pub const THUMB_BORDER_W: f32 = 1.0;
pub const FONT_SIZE: f32 = 16.0;
}
/// A two-state on / off switch.
///
/// Renders as a horizontal pill with a circular thumb that slides between the
/// off (left, divider colour) and on (right, accent colour) positions. The
/// widget is stateless: the application owns `value` and rebuilds the toggle
/// from its current state on every frame. Tapping or pressing Enter / Space
/// while focused emits the message configured with [`Self::on_toggle`] — the
/// app's `update` is then expected to flip the bool and re-render.
///
/// ```rust,no_run
/// # use ltk::{ toggle, Toggle };
/// # #[ derive( Clone ) ] enum Msg { ToggleWifi }
/// # struct App { wifi_enabled: bool }
/// # impl App { fn _ex( &self ) -> Toggle<Msg> {
/// // In view():
/// toggle( self.wifi_enabled )
/// .label( "Wi-Fi" )
/// .on_toggle( Msg::ToggleWifi )
/// # }}
/// ```
///
/// See also [`Checkbox`](super::checkbox::Checkbox) for binary opt-in
/// controls (terms acceptance, multi-select form fields) where the
/// pill-style affordance is too prominent.
pub struct Toggle<Msg: Clone>
{
/// Current on / off state. Drawn from this field every frame; the
/// runtime never mutates it.
pub value: bool,
/// Message emitted on activation. `None` leaves the toggle inert (it
/// still renders and takes focus, but does nothing on press).
pub on_toggle: Option<Msg>,
/// Optional label drawn to the left of the track.
pub label: Option<String>,
/// Optional stable identifier for focus management.
pub id: Option<WidgetId>,
}
impl<Msg: Clone> Toggle<Msg>
{
/// Create a toggle in the given state, with no label and no callback.
///
/// Wire activation through [`Self::on_toggle`] before adding it to a
/// widget tree, otherwise the toggle is decorative.
pub fn new( value: bool ) -> Self
{
Self { value, on_toggle: None, label: None, id: None }
}
/// Set the message emitted when the toggle is activated (tap, Enter or
/// Space while focused). The application's `update` is responsible for
/// flipping `value` in response.
pub fn on_toggle( mut self, msg: Msg ) -> Self
{
self.on_toggle = Some( msg );
self
}
/// Set a text label rendered to the left of the track. The toggle's
/// preferred width grows to fit `label_width + gap + track_width`,
/// capped at the parent-supplied `max_width`.
pub fn label( mut self, label: impl Into<String> ) -> Self
{
self.label = Some( label.into() );
self
}
/// Assign a stable identifier so the application can target this
/// toggle through [`crate::App::take_focus_request`].
pub fn id( mut self, id: WidgetId ) -> Self
{
self.id = Some( id );
self
}
/// Return the preferred `(width, height)` given available `max_width`.
///
/// Width is `track_width` for an unlabelled toggle, or
/// `label_width + gap + track_width` (clamped to `max_width`) when a
/// label is set. Height is the theme-defined row height.
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{
let w = if let Some( ref label ) = self.label
{
let text_w = canvas.measure_text( label, theme::FONT_SIZE );
( text_w + theme::GAP + theme::TRACK_W ).min( max_width )
} else {
theme::TRACK_W.min( max_width )
};
( w, theme::HEIGHT )
}
/// Bounding box of everything painted at `rect` across all states. The
/// focus ring is drawn as `track_rect.expand( FOCUS_W + 2 )` with a stroke
/// of width `FOCUS_W`, so it extends `FOCUS_W + 2 + FOCUS_W/2 ≈ 6.5 px`
/// beyond the track. Since the track sits at the right edge of `rect`,
/// the ring can extend that far outside the widget's layout rect.
pub fn paint_bounds( &self, rect: Rect ) -> Rect
{
rect.expand( theme::FOCUS_W + 2.0 + theme::FOCUS_W * 0.5 + 1.0 )
}
pub fn draw( &self, canvas: &mut Canvas, rect: Rect, focused: bool )
{
let track_x = if let Some( ref label ) = self.label
{
let text_y = rect.y + ( rect.height + theme::FONT_SIZE ) / 2.0 - 2.0;
canvas.draw_text( label, rect.x, text_y, theme::FONT_SIZE, theme::label_color() );
rect.x + rect.width - theme::TRACK_W
} else {
rect.x + ( rect.width - theme::TRACK_W ) / 2.0
};
let track_y = rect.y + ( rect.height - theme::TRACK_H ) / 2.0;
let track_r = theme::TRACK_H / 2.0;
let track_rect = Rect
{
x: track_x,
y: track_y,
width: theme::TRACK_W,
height: theme::TRACK_H,
};
let track_color = if self.value { theme::track_on() } else { theme::track_off() };
canvas.fill_rect( track_rect, track_color, track_r );
let thumb_pad = ( theme::TRACK_H - theme::THUMB_SIZE ) / 2.0;
let thumb_cx = if self.value
{
track_x + theme::TRACK_W - thumb_pad - theme::THUMB_SIZE / 2.0
} else {
track_x + thumb_pad + theme::THUMB_SIZE / 2.0
};
let thumb_cy = track_y + theme::TRACK_H / 2.0;
let thumb_r = theme::THUMB_SIZE / 2.0;
let thumb_rect = Rect
{
x: thumb_cx - thumb_r,
y: thumb_cy - thumb_r,
width: theme::THUMB_SIZE,
height: theme::THUMB_SIZE,
};
canvas.fill_rect( thumb_rect, theme::thumb(), thumb_r );
canvas.stroke_rect( thumb_rect, theme::thumb_border(), theme::THUMB_BORDER_W, thumb_r );
if focused
{
let ring = track_rect.expand( theme::FOCUS_W + 2.0 );
canvas.stroke_rect( ring, theme::focus_color(), theme::FOCUS_W, track_r + theme::FOCUS_W + 2.0 );
}
}
pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> Toggle<U>
where
U: Clone + 'static,
Msg: 'static,
{
Toggle
{
value: self.value,
on_toggle: self.on_toggle.map( |m| ( *f )( m ) ),
label: self.label,
id: self.id,
}
}
}
/// Create a [`Toggle`] in the given state.
///
/// Shorthand for [`Toggle::new`]. Wire activation with [`Toggle::on_toggle`]
/// and an optional label with [`Toggle::label`]:
///
/// ```rust,no_run
/// # use ltk::{ toggle, Toggle };
/// # #[ derive( Clone ) ] enum Msg { ToggleWifi }
/// # struct App { wifi_enabled: bool }
/// # impl App { fn _ex( &self ) -> Toggle<Msg> {
/// toggle( self.wifi_enabled )
/// .label( "Wi-Fi" )
/// .on_toggle( Msg::ToggleWifi )
/// # }}
/// ```
pub fn toggle<Msg: Clone>( value: bool ) -> Toggle<Msg>
{
Toggle::new( value )
}
impl<Msg: Clone + 'static> From<Toggle<Msg>> for Element<Msg>
{
fn from( t: Toggle<Msg> ) -> Self
{
Element::Toggle( t )
}
}
#[cfg(test)]
mod tests
{
use super::*;
#[test]
fn toggle_default_state()
{
let t = toggle::<()>( true );
assert!( t.value );
assert!( t.on_toggle.is_none() );
assert!( t.label.is_none() );
}
#[test]
fn toggle_off_state()
{
let t = toggle::<()>( false );
assert!( !t.value );
}
}

247
src/widget/tooltip.rs Normal file
View File

@@ -0,0 +1,247 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Tooltip — short hint anchored below a widget.
//!
//! Like [`Toast`](super::toast::Toast), `Tooltip` is **not** an
//! [`Element`]; it is a builder that produces an [`OverlaySpec`] for
//! the application to return from [`App::overlays`](crate::App::overlays).
//! The overlay is rendered as an `xdg-popup` anchored to the widget
//! tagged with [`Tooltip::anchor_id`] in the previous frame's layout,
//! so the hint appears flush below the trigger and follows it across
//! resizes and re-layouts.
//!
//! Hover detection and the show / hide delay are the **application's
//! responsibility**. ltk does not currently expose pointer-hover
//! callbacks at the widget level, so the typical pattern is:
//!
//! 1. Track which widget the pointer is over via `App::on_tap` or
//! a custom hover bookkeeping you maintain in `update`.
//! 2. Start a timer (via [`App::set_channel_sender`](crate::App::set_channel_sender))
//! that fires a "show tooltip" message after the desired delay.
//! 3. Return the [`Tooltip::overlay`] from `overlays()` only while the
//! tooltip should be visible.
//!
//! ```rust,no_run
//! # use ltk::{ tooltip, OverlaySpec, WidgetId };
//! # struct App { tooltip_for: Option<WidgetId> }
//! # impl App {
//! fn overlays( &self ) -> Vec<OverlaySpec<()>>
//! {
//! match self.tooltip_for
//! {
//! Some( id ) => vec![ tooltip( "Save the document", id ).overlay() ],
//! None => vec![],
//! }
//! }
//! # }
//! ```
use std::collections::hash_map::DefaultHasher;
use std::hash::{ Hash, Hasher };
use crate::app::{ Anchor, Layer, OverlayId, OverlaySpec };
use crate::types::{ Color, WidgetId };
use super::Element;
mod theme
{
use crate::types::Color;
/// Tooltip background — uses the theme's primary text colour as
/// a high-contrast hint pill (dark in light mode, light in dark
/// mode). Tooltips traditionally invert against the surface to
/// stand out.
pub fn bg() -> Color
{
let p = crate::theme::palette().text_primary;
Color::rgba( p.r, p.g, p.b, 0.95 )
}
/// Tooltip text — `bg-page` so the lettering reads as the
/// inverse of the bg.
pub fn text() -> Color { crate::theme::palette().bg }
pub const PAD_H: f32 = 12.0;
pub const PAD_V: f32 = 6.0;
pub const FONT_SIZE: f32 = 13.0;
pub const RADIUS: f32 = 8.0;
pub const MAX_W: u32 = 320;
pub const HEIGHT: u32 = 28;
}
/// Builder for an `xdg-popup`-backed hint anchored to a widget.
pub struct Tooltip<Msg: Clone>
{
/// Body text rendered inside the tooltip.
pub message: String,
/// Stable identifier of the widget the popup anchors to. The widget
/// must have been built with `.id( anchor_id )` so the runtime can
/// look its rect up in the previous frame's layout snapshot.
pub anchor_id: WidgetId,
/// Maximum width (logical pixels). The tooltip is single-line; long
/// strings are clipped at the surface boundary.
pub max_width: u32,
/// Optional override for the tooltip background colour.
pub bg: Option<Color>,
/// Optional override for the body text colour.
pub fg: Option<Color>,
/// Optional message fired when the user taps outside the popup.
pub on_dismiss: Option<Msg>,
}
impl<Msg: Clone + 'static> Tooltip<Msg>
{
/// Create a tooltip with the given message anchored to `anchor_id`.
pub fn new( message: impl Into<String>, anchor_id: WidgetId ) -> Self
{
Self
{
message: message.into(),
anchor_id,
max_width: theme::MAX_W,
bg: None,
fg: None,
on_dismiss: None,
}
}
/// Override the maximum tooltip width. Defaults to 320 logical px.
pub fn max_width( mut self, w: u32 ) -> Self
{
self.max_width = w;
self
}
/// Override the background colour.
pub fn background( mut self, c: Color ) -> Self
{
self.bg = Some( c );
self
}
/// Override the body text colour.
pub fn color( mut self, c: Color ) -> Self
{
self.fg = Some( c );
self
}
/// Set the dismiss message fired on a tap outside the popup.
pub fn on_dismiss( mut self, msg: Msg ) -> Self
{
self.on_dismiss = Some( msg );
self
}
/// Build the [`OverlaySpec`] the application returns from
/// [`App::overlays`](crate::App::overlays).
pub fn overlay( self ) -> OverlaySpec<Msg>
{
use super::{ container, text };
let mut hasher = DefaultHasher::new();
"tooltip".hash( &mut hasher );
self.anchor_id.0.hash( &mut hasher );
let overlay_id = OverlayId( hasher.finish() as u32 );
let bg = self.bg.unwrap_or_else( theme::bg );
let fg = self.fg.unwrap_or_else( theme::text );
let body: Element<Msg> = container(
text( self.message.clone() )
.size( theme::FONT_SIZE )
.color( fg )
)
.background( bg )
.padding_h( theme::PAD_H )
.padding_v( theme::PAD_V )
.radius( theme::RADIUS )
.into();
OverlaySpec
{
id: overlay_id,
layer: Layer::Overlay,
anchor: Anchor::TOP,
size: ( self.max_width, theme::HEIGHT ),
exclusive_zone: 0,
keyboard_exclusive: false,
input_region: None,
view: body,
on_dismiss: self.on_dismiss,
anchor_widget_id: Some( self.anchor_id ),
}
}
}
/// Create a [`Tooltip`] anchored to the widget tagged with `anchor_id`.
///
/// ```rust,no_run
/// # use ltk::{ tooltip, OverlaySpec, WidgetId };
/// # fn _ex() -> OverlaySpec<()> {
/// tooltip( "Click to save", WidgetId( "btn/save" ) )
/// .max_width( 240 )
/// .overlay()
/// # }
/// ```
pub fn tooltip<Msg: Clone + 'static>(
message: impl Into<String>,
anchor_id: WidgetId,
) -> Tooltip<Msg>
{
Tooltip::new( message, anchor_id )
}
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ test ]
fn defaults()
{
let t: Tooltip<()> = tooltip( "hi", WidgetId( "x" ) );
assert_eq!( t.message, "hi" );
assert_eq!( t.anchor_id.0, "x" );
assert_eq!( t.max_width, theme::MAX_W );
assert!( t.on_dismiss.is_none() );
}
#[ test ]
fn overlay_uses_anchor_widget_id()
{
let id = WidgetId( "btn/save" );
let o: OverlaySpec<()> = tooltip( "Save", id ).overlay();
assert_eq!( o.anchor_widget_id, Some( id ) );
}
#[ test ]
fn overlay_id_includes_tooltip_namespace()
{
// Same WidgetId used for a tooltip and (hypothetically) some
// other overlay must yield distinct OverlayIds. The tooltip's
// hash includes the literal "tooltip" prefix to namespace it
// against e.g. combo overlays that hash only the WidgetId.
let id = WidgetId( "foo" );
let tooltip_overlay_id = {
let mut h = DefaultHasher::new();
"tooltip".hash( &mut h );
id.0.hash( &mut h );
OverlayId( h.finish() as u32 )
};
let combo_style_id = {
let mut h = DefaultHasher::new();
id.0.hash( &mut h );
OverlayId( h.finish() as u32 )
};
assert_ne!( tooltip_overlay_id, combo_style_id );
let o: OverlaySpec<()> = tooltip( "x", id ).overlay();
assert_eq!( o.id, tooltip_overlay_id );
}
#[ test ]
fn max_width_builder_applies()
{
let t: Tooltip<()> = tooltip( "x", WidgetId( "a" ) ).max_width( 120 );
assert_eq!( t.max_width, 120 );
}
}

183
src/widget/viewport.rs Normal file
View File

@@ -0,0 +1,183 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::render::Canvas;
use crate::widget::Element;
/// A non-scrollable clipped viewport for revealing only part of a child tree.
///
/// Unlike `Scroll`, this widget does not own
/// any gesture state. It simply renders its child into an off-screen canvas
/// and blits the result into the allocated rect, clipping anything outside.
pub struct Viewport<Msg: Clone>
{
/// The child element to render inside the viewport.
pub child: Box<Element<Msg>>,
/// Optional fixed width in logical pixels. When omitted the
/// viewport reports `max_width` as its preferred width — i.e. it
/// stretches to fill whatever horizontal slice the parent layout
/// gives it. Set explicitly when the viewport has to coexist with
/// `flex` siblings (the flex would otherwise lose every pixel to
/// the viewport's `max_width` claim) or whenever the inner content
/// has its own intrinsic width and the viewport is just a vertical
/// clip.
pub width: Option<f32>,
/// Optional fixed height in logical pixels. When omitted the viewport
/// reports the child's natural height.
pub height: Option<f32>,
/// Logical pixels at the bottom edge that fade to transparent during the
/// blit. Zero leaves the bottom hard-edged. Useful for slide-in panels so
/// the leading edge of the animation does not knife-cut against the layer
/// below it.
pub fade_bottom: f32,
}
impl<Msg: Clone> Viewport<Msg>
{
pub fn new( child: impl Into<Element<Msg>> ) -> Self
{
Self { child: Box::new( child.into() ), width: None, height: None, fade_bottom: 0.0 }
}
/// Set a fixed viewport width in logical pixels. Mirrors
/// [`Self::height`]: with an explicit value the viewport
/// reports `w` as its preferred width and the child is laid
/// out against `w` rather than the parent-supplied `max_width`.
pub fn width( mut self, w: f32 ) -> Self
{
self.width = Some( w.max( 0.0 ) );
self
}
/// Set a fixed viewport height in logical pixels.
pub fn height( mut self, h: f32 ) -> Self
{
self.height = Some( h.max( 0.0 ) );
self
}
/// Feather the bottom `px` rows of the viewport so its lower edge dissolves
/// to transparent. Drawn by the GLES blit shader as a linear alpha ramp
/// over the bottom band; the software backend currently renders a hard
/// edge regardless.
pub fn fade_bottom( mut self, px: f32 ) -> Self
{
self.fade_bottom = px.max( 0.0 );
self
}
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 )
{
let inner_w = self.width.unwrap_or( max_width );
let child_h = self.child.preferred_size( inner_w, canvas ).1;
( self.width.unwrap_or( max_width ), self.height.unwrap_or( child_h ) )
}
/// No-op — rendering is handled by `layout_and_draw` in `draw.rs`.
pub fn draw( &self ) {}
pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> Viewport<U>
where
U: Clone + 'static,
Msg: 'static,
{
Viewport
{
child: Box::new( self.child.map_arc( f ) ),
width: self.width,
height: self.height,
fade_bottom: self.fade_bottom,
}
}
}
impl<Msg: Clone + 'static> From<Viewport<Msg>> for Element<Msg>
{
fn from( v: Viewport<Msg> ) -> Self
{
Element::Viewport( v )
}
}
/// Create a clipped viewport wrapping `child`.
pub fn viewport<Msg: Clone>( child: impl Into<Element<Msg>> ) -> Viewport<Msg>
{
Viewport::new( child )
}
#[ cfg( test ) ]
mod tests
{
use super::*;
use crate::layout::spacer::spacer;
use crate::render::Canvas;
fn make_canvas() -> Canvas { Canvas::new( 800, 600 ) }
#[ test ]
fn new_defaults_are_unset_dimensions_and_no_fade()
{
let v = Viewport::<()>::new( spacer() );
assert!( v.width.is_none() );
assert!( v.height.is_none() );
assert_eq!( v.fade_bottom, 0.0 );
}
#[ test ]
fn width_builder_clamps_negative_to_zero()
{
let v = Viewport::<()>::new( spacer() ).width( -50.0 );
assert_eq!( v.width, Some( 0.0 ) );
}
#[ test ]
fn height_builder_clamps_negative_to_zero()
{
let v = Viewport::<()>::new( spacer() ).height( -10.0 );
assert_eq!( v.height, Some( 0.0 ) );
}
#[ test ]
fn fade_bottom_clamps_negative_to_zero()
{
let v = Viewport::<()>::new( spacer() ).fade_bottom( -1.0 );
assert_eq!( v.fade_bottom, 0.0 );
}
#[ test ]
fn fade_bottom_accepts_positive_pixel_count()
{
let v = Viewport::<()>::new( spacer() ).fade_bottom( 16.0 );
assert_eq!( v.fade_bottom, 16.0 );
}
#[ test ]
fn explicit_dimensions_override_child_intrinsic_size()
{
let v = Viewport::<()>::new( spacer().width( 99.0 ).height( 99.0 ) )
.width( 200.0 )
.height( 80.0 );
let canvas = make_canvas();
let ( w, h ) = v.preferred_size( 600.0, &canvas );
assert_eq!( w, 200.0 );
assert_eq!( h, 80.0 );
}
#[ test ]
fn unset_width_falls_through_to_max_width()
{
let v = Viewport::<()>::new( spacer().height( 40.0 ) );
let canvas = make_canvas();
let ( w, _ ) = v.preferred_size( 400.0, &canvas );
assert_eq!( w, 400.0 );
}
#[ test ]
fn unset_height_falls_through_to_child_intrinsic_height()
{
let v = Viewport::<()>::new( spacer().height( 75.0 ) );
let canvas = make_canvas();
let ( _, h ) = v.preferred_size( 400.0, &canvas );
assert_eq!( h, 75.0 );
}
}

486
src/widget/vslider.rs Normal file
View File

@@ -0,0 +1,486 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::sync::Arc;
use crate::types::Rect;
use crate::render::Canvas;
use super::Element;
use super::slider::intersect_clip;
mod theme
{
use crate::types::Color;
/// Slot ids for the Glass surfaces that back the VSlider track and
/// fill. The default theme ships them; downstream themes either
/// override them or let the widget fall through to a flat-colour
/// fallback painted from the [`track_bg`] / [`track_fill`] palette
/// tokens below.
pub const SURFACE_TRACK: &str = "surface-slider-track";
pub const SURFACE_FILL: &str = "surface-slider-fill";
/// Flat-colour fallback for the unfilled track — reuses the translucent
/// raised-surface token so the pill reads on both light and dark
/// wallpapers without hardcoding.
pub fn track_bg() -> Color { crate::theme::palette().surface_alt }
/// Flat-colour fallback for the filled portion — brand accent,
/// matching horizontal [`Slider`].
pub fn track_fill() -> Color { crate::theme::palette().accent }
/// Default pill width in pixels.
pub const WIDTH: f32 = 56.0;
/// Default pill height in pixels.
pub const HEIGHT: f32 = 160.0;
}
/// Compute the slider value `[0.0, 1.0]` from a tap/drag y position within a
/// slider's layout rect. `rect.top` maps to `1.0` and `rect.bottom` to `0.0`
/// — so the fill rises from the bottom as the user drags upward. Pure — no
/// theme / canvas dependency. Lifted out of [`VSlider`] so input handlers
/// can call it directly from [`crate::widget::LaidOutWidget`] without
/// needing the [`Element`] tree.
pub fn value_from_y_in_rect( rect: Rect, y: f32 ) -> f32
{
let track_h = rect.height.max( 1.0 );
( 1.0 - ( y - rect.y ) / track_h ).clamp( 0.0, 1.0 )
}
/// A vertical slider — a rounded pill that fills from bottom to top to
/// indicate its value.
///
/// Unlike [`Slider`](crate::Slider), which is horizontal and designed to
/// stretch across whatever width its parent allocates, a [`VSlider`] has
/// fixed pill dimensions (56 × 160 px by default) configurable via
/// [`VSlider::size`]. The widget reports those
/// dimensions as its preferred size and ignores the `max_width` the parent
/// offers — it is intrinsically sized, not filler.
///
/// The widget renders a rounded track in `palette.surface_alt` and, on top,
/// a rising pill in `palette.accent` whose height is proportional to
/// [`VSlider::value`]. No separate thumb is drawn; the top edge of the fill
/// itself acts as the value indicator.
///
/// ```rust,no_run
/// # use std::sync::Arc;
/// # #[ derive( Clone ) ] enum Msg { SetVolume( f32 ) }
/// # struct App { volume: f32 }
/// # impl App { fn _ex( &self, speaker_rgba: Arc<Vec<u8>>, speaker_w: u32, speaker_h: u32 ) -> ltk::Element<Msg> {
/// use ltk::{ stack, vslider, img_widget, HAlign, VAlign };
///
/// // Plain vertical slider.
/// let _: ltk::VSlider<Msg> = vslider( self.volume ).on_change( Msg::SetVolume );
///
/// // With a speaker icon overlaid at the top. Stacked image children are
/// // non-interactive, so drag events still reach the slider underneath.
/// stack::<Msg>()
/// .push( vslider( self.volume ).on_change( Msg::SetVolume ) )
/// .push_aligned(
/// img_widget( speaker_rgba, speaker_w, speaker_h ),
/// HAlign::Center, VAlign::Top,
/// )
/// .into()
/// # }}
/// ```
pub struct VSlider<Msg: Clone>
{
/// Current value in `[0.0, 1.0]`. `0.0` paints no fill; `1.0` fills the
/// whole pill.
pub value: f32,
/// Fixed width of the pill in pixels. Defaults to 56.
pub width: f32,
/// Fixed height of the pill in pixels. Defaults to 160.
pub height: f32,
/// Callback invoked with the new value when the slider is tapped or
/// dragged. `Arc` (not `Box`) so the layout pass can clone it into the
/// per-leaf handler snapshot for O(1) dispatch on input events.
pub on_change: Option<Arc<dyn Fn(f32) -> Msg>>,
/// Theme slot id for the unfilled track. Defaults to
/// `surface-slider-track`. Override with [`VSlider::track_surface`]
/// when the slider lives inside a panel that already provides its
/// own backdrop blur — point the slot at a `*-flat` variant
/// (no `backdrop` field) so the pipeline does not run a redundant
/// backdrop snapshot per slider per frame.
pub track_surface: &'static str,
/// Theme slot id for the filled portion. Same role as
/// [`Self::track_surface`] but for the rising fill.
pub fill_surface: &'static str,
}
impl<Msg: Clone> VSlider<Msg>
{
/// Create a vertical slider at the given value (clamped to `[0.0, 1.0]`).
pub fn new( value: f32 ) -> Self
{
Self
{
value: value.clamp( 0.0, 1.0 ),
width: theme::WIDTH,
height: theme::HEIGHT,
on_change: None,
track_surface: theme::SURFACE_TRACK,
fill_surface: theme::SURFACE_FILL,
}
}
/// Override the theme slot id used for the unfilled track. See
/// [`Self::track_surface`] for the use case.
pub fn track_surface( mut self, id: &'static str ) -> Self
{
self.track_surface = id;
self
}
/// Override the theme slot id used for the rising fill. See
/// [`Self::track_surface`] for the use case.
pub fn fill_surface( mut self, id: &'static str ) -> Self
{
self.fill_surface = id;
self
}
/// Override the fixed pill `(width, height)` in pixels. Both are clamped
/// to a minimum of `2.0` so a rounded pill can always be drawn.
pub fn size( mut self, width: f32, height: f32 ) -> Self
{
self.width = width.max( 2.0 );
self.height = height.max( 2.0 );
self
}
/// Set the callback invoked when the slider value changes.
pub fn on_change( mut self, f: impl Fn(f32) -> Msg + 'static ) -> Self
{
self.on_change = Some( Arc::new( f ) );
self
}
/// Return the preferred `(width, height)`. `max_width` is ignored — see
/// the type-level docs on intrinsic sizing.
pub fn preferred_size( &self, _max_width: f32, _canvas: &Canvas ) -> (f32, f32)
{
( self.width, self.height )
}
/// Compute the value `[0.0, 1.0]` from a tap/drag y position within `rect`.
pub fn value_from_y( &self, rect: Rect, y: f32 ) -> f32
{
value_from_y_in_rect( rect, y )
}
/// VSlider paints strictly inside its layout rect — no hover halo, no
/// thumb overshoot. The partial-redraw path gets a tight bound.
pub fn paint_bounds( &self, rect: Rect ) -> Rect { rect }
/// Draw the slider into `canvas` at `rect`. The track fills `rect` as a
/// rounded pill; the value rises from the bottom edge.
///
/// The track and fill both resolve to Glass surfaces when the active
/// theme ships the `surface-slider-track` / `surface-slider-fill`
/// slots (the default does). When the slots are absent we fall back
/// to a flat pill in `palette.surface_alt` / `palette.accent` — this
/// is how a bare-bones third-party theme still paints a usable
/// slider without having to replicate the full inset-shadow stack.
pub fn draw( &self, canvas: &mut Canvas, rect: Rect, _focused: bool )
{
let radius_bg = ( rect.width.min( rect.height ) ) / 2.0;
// Track outer shadow only — BG fill and insets are deferred to
// above the water line so the fill's silhouette AA doesn't pick
// up the track's translucent white as a 1-px rim.
if let Some( ( _surf, outer ) ) = crate::theme::resolve_surface( self.track_surface )
{
for shadow in &outer
{
canvas.fill_shadow_outer( rect, shadow, radius_bg );
}
}
else
{
canvas.fill_rect( rect, theme::track_bg(), radius_bg );
}
// Fill rises from the bottom as a "liquid level". Sub-pixel
// heights are skipped so we don't draw a hairline at value=0.
//
// The fill is rendered with the TRACK's full geometry (same
// rect, same radius) and scissor-clipped to the visible band
// at the bottom. The visible silhouette is the intersection of
// the track pill with the band, so:
//
// * sides and bottom of the fill follow the track's pill
// curve at all values — no "sticking out" at low fills;
// * top of the fill is a flat horizontal line — the water
// level — at all values, not a droplet cap;
// * inset shadows / backdrop of the fill's Glass surface are
// anchored to the track rect, not to a shrinking fill rect,
// so the rim / highlight geometry stays stable as the user
// drags. Only the clip band changes with value.
//
// The scissor is save/restored via `canvas.clip_bounds()` so
// the tighter clip does not stomp on any outer partial-redraw
// scissor.
let fill_h = ( rect.height * self.value ).clamp( 0.0, rect.height );
if fill_h > 0.5
{
let visible = Rect
{
x: rect.x,
y: rect.y + rect.height - fill_h,
width: rect.width,
height: fill_h,
};
let saved_clip = canvas.clip_bounds();
let band = intersect_clip( &saved_clip, visible );
if !band.is_empty()
{
canvas.set_clip_rects( &band );
// Fill paint + the fill surface's bottom-biased insets.
//
// Any inset with a negative Y offset (the top-left
// Glass highlight, `offset = [-3.6, -3.6]` in the
// default theme) lives near the TOP rim of the full
// track pill. With the surface anchored to the track
// rect and clipped to the water band, that highlight
// would be sliced by the scissor exactly at the water
// line, painting a visibly rectangular bright/dark
// edge across the liquid. Bottom-biased insets
// (offset.y >= 0) live near the track's bottom curve,
// always inside the visible band regardless of level,
// so their rim is continuous.
//
// Outer shadows / backdrop are dropped too: outer
// shadows would only be visible outside the fill
// silhouette (and the scissor kills them anyway);
// re-running the backdrop blur on the track rect
// every time the value changes is expensive and
// produces the same visible result as letting the
// track's own Glass backdrop show through.
if let Some( ( surf, _ ) ) = crate::theme::resolve_surface( self.fill_surface )
{
canvas.fill_paint_rect( rect, &surf.fill, radius_bg );
for inset in surf.inset_shadows.iter().filter( |s| s.offset[1] >= 0.0 )
{
canvas.fill_shadow_inset( rect, inset, radius_bg );
}
}
else
{
canvas.fill_rect( rect, theme::track_fill(), radius_bg );
}
canvas.set_clip_rects( &saved_clip );
}
}
// Track BG + insets, clipped above the water line. Floor the
// height so the scissor doesn't overlap the fill scissor by
// 1 px when `fill_h` is fractional.
let above_h = ( rect.height - fill_h ).floor();
if above_h > 0.5
{
if let Some( ( surf, _ ) ) = crate::theme::resolve_surface( self.track_surface )
{
let above = Rect
{
x: rect.x,
y: rect.y,
width: rect.width,
height: above_h,
};
let saved_clip = canvas.clip_bounds();
let band = intersect_clip( &saved_clip, above );
if !band.is_empty()
{
canvas.set_clip_rects( &band );
canvas.fill_paint_rect( rect, &surf.fill, radius_bg );
for inset in &surf.inset_shadows
{
canvas.fill_shadow_inset( rect, inset, radius_bg );
}
canvas.set_clip_rects( &saved_clip );
}
}
}
}
pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> VSlider<U>
where
U: Clone + 'static,
Msg: 'static,
{
let on_change = self.on_change.map( |old| -> Arc<dyn Fn( f32 ) -> U>
{
let mapper = Arc::clone( f );
Arc::new( move |v| ( *mapper )( ( *old )( v ) ) )
} );
VSlider
{
value: self.value,
width: self.width,
height: self.height,
on_change,
track_surface: self.track_surface,
fill_surface: self.fill_surface,
}
}
}
/// Create a [`VSlider`] at the given value (clamped to `[0.0, 1.0]`).
pub fn vslider<Msg: Clone>( value: f32 ) -> VSlider<Msg>
{
VSlider::new( value )
}
impl<Msg: Clone + 'static> From<VSlider<Msg>> for Element<Msg>
{
fn from( s: VSlider<Msg> ) -> Self { Element::VSlider( s ) }
}
#[ cfg( test ) ]
mod tests
{
use super::*;
use crate::render::Canvas;
use crate::types::Rect;
fn make_canvas() -> Canvas { Canvas::new( 400, 400 ) }
#[ test ]
fn value_clamped_on_creation()
{
let s: VSlider<()> = vslider( 1.5 );
assert_eq!( s.value, 1.0 );
let s: VSlider<()> = vslider( -0.5 );
assert_eq!( s.value, 0.0 );
}
#[ test ]
fn value_from_y_top_is_one()
{
let rect = Rect { x: 0.0, y: 0.0, width: 56.0, height: 160.0 };
assert_eq!( value_from_y_in_rect( rect, 0.0 ), 1.0 );
}
#[ test ]
fn value_from_y_bottom_is_zero()
{
let rect = Rect { x: 0.0, y: 0.0, width: 56.0, height: 160.0 };
assert_eq!( value_from_y_in_rect( rect, 160.0 ), 0.0 );
}
#[ test ]
fn value_from_y_center_is_half()
{
let rect = Rect { x: 0.0, y: 0.0, width: 56.0, height: 160.0 };
let v = value_from_y_in_rect( rect, 80.0 );
assert!( ( v - 0.5 ).abs() < 1e-6 );
}
#[ test ]
fn value_from_y_above_rect_clamps_to_one()
{
let rect = Rect { x: 0.0, y: 10.0, width: 56.0, height: 160.0 };
assert_eq!( value_from_y_in_rect( rect, -50.0 ), 1.0 );
}
#[ test ]
fn value_from_y_below_rect_clamps_to_zero()
{
let rect = Rect { x: 0.0, y: 10.0, width: 56.0, height: 160.0 };
assert_eq!( value_from_y_in_rect( rect, 500.0 ), 0.0 );
}
#[ test ]
fn value_from_y_respects_rect_offset()
{
// A rect starting at y=100 with height=100: y=100 → 1.0, y=200 → 0.0.
let rect = Rect { x: 0.0, y: 100.0, width: 56.0, height: 100.0 };
assert_eq!( value_from_y_in_rect( rect, 100.0 ), 1.0 );
assert_eq!( value_from_y_in_rect( rect, 200.0 ), 0.0 );
let v = value_from_y_in_rect( rect, 150.0 );
assert!( ( v - 0.5 ).abs() < 1e-6 );
}
#[ test ]
fn size_overrides_defaults()
{
let canvas = make_canvas();
let s: VSlider<()> = vslider( 0.5 ).size( 40.0, 200.0 );
let ( w, h ) = s.preferred_size( 500.0, &canvas );
assert_eq!( w, 40.0 );
assert_eq!( h, 200.0 );
}
#[ test ]
fn size_clamps_to_minimum()
{
let canvas = make_canvas();
let s: VSlider<()> = vslider( 0.5 ).size( 0.0, 0.0 );
let ( w, h ) = s.preferred_size( 500.0, &canvas );
assert_eq!( w, 2.0 );
assert_eq!( h, 2.0 );
}
#[ test ]
fn preferred_size_ignores_max_width()
{
// A VSlider is intrinsically sized — the parent's max_width doesn't
// change what we return.
let canvas = make_canvas();
let s: VSlider<()> = vslider( 0.5 );
let ( w_small, _ ) = s.preferred_size( 10.0, &canvas );
let ( w_big, _ ) = s.preferred_size( 9_999.0, &canvas );
assert_eq!( w_small, theme::WIDTH );
assert_eq!( w_big, theme::WIDTH );
}
#[ test ]
fn default_dimensions_are_the_theme_constants()
{
let s: VSlider<()> = vslider( 0.0 );
assert_eq!( s.width, theme::WIDTH );
assert_eq!( s.height, theme::HEIGHT );
}
#[ test ]
fn draw_at_value_zero_does_not_panic()
{
let mut canvas = make_canvas();
let rect = Rect { x: 10.0, y: 10.0, width: 56.0, height: 160.0 };
let s: VSlider<()> = vslider( 0.0 );
s.draw( &mut canvas, rect, false );
}
#[ test ]
fn draw_at_value_one_does_not_panic()
{
let mut canvas = make_canvas();
let rect = Rect { x: 10.0, y: 10.0, width: 56.0, height: 160.0 };
let s: VSlider<()> = vslider( 1.0 );
s.draw( &mut canvas, rect, true );
}
#[ test ]
fn on_change_is_stored()
{
let s: VSlider<u32> = vslider( 0.5 ).on_change( |v| ( v * 100.0 ) as u32 );
let cb = s.on_change.expect( "on_change was set" );
assert_eq!( cb( 0.25 ), 25 );
}
#[ test ]
fn element_from_vslider()
{
let s: VSlider<()> = vslider( 0.5 );
let el: Element<()> = s.into();
assert!( matches!( el, Element::VSlider( _ ) ) );
}
#[ test ]
fn paint_bounds_equals_layout_rect()
{
let rect = Rect { x: 4.0, y: 8.0, width: 56.0, height: 160.0 };
let s: VSlider<()> = vslider( 0.5 );
let pb = s.paint_bounds( rect );
assert_eq!( pb.x, rect.x );
assert_eq!( pb.y, rect.y );
assert_eq!( pb.width, rect.width );
assert_eq!( pb.height, rect.height );
}
}

383
src/widget/window_button.rs Normal file
View File

@@ -0,0 +1,383 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use super::Element;
use crate::layout::row::{ row, Row };
use crate::render::Canvas;
use crate::types::{ Color, Rect, WidgetId };
mod theme
{
use crate::types::Color;
pub fn icon() -> Color { crate::theme::window_controls().icon }
pub fn hover_bg() -> Color { crate::theme::window_controls().hover_bg }
pub fn pressed_bg() -> Color { crate::theme::window_controls().pressed_bg }
pub fn focus_color() -> Color { crate::theme::window_controls().focus_ring }
pub fn close_hover() -> Color { crate::theme::window_controls().close_hover_bg }
pub fn close_icon() -> Color { crate::theme::window_controls().close_icon }
pub const SIZE: f32 = 36.0;
pub const RADIUS: f32 = 10.0;
pub const FOCUS_W: f32 = 2.0;
pub const STROKE_W: f32 = 2.0;
}
/// Semantic role for a window-decoration button.
///
/// Drives both the rendered glyph (a horizontal bar for minimize, a square
/// outline for maximize, etc.) and the close button's special hover
/// treatment (red surface tint instead of the neutral hover wash). Maps
/// 1:1 to the four standard title-bar controls on Windows / GNOME / macOS.
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
pub enum WindowButtonKind
{
/// Hide the window to the dock / taskbar.
Minimize,
/// Maximize the window to fill the available output.
Maximize,
/// Restore a previously-maximized window to its original size.
Restore,
/// Close the window. Renders with a destructive (red) hover surface.
Close,
}
/// Button styled for compositor / window decorations.
///
/// The widget is intentionally policy-free: it paints a standard control and
/// emits the message supplied by the caller. Forge remains responsible for
/// deciding what that message does to the window.
pub struct WindowButton<Msg: Clone>
{
/// Which decoration role this button paints.
pub kind: WindowButtonKind,
/// Message emitted on activation. `None` greys the button and skips
/// the hover / pressed surface — useful for "maximize disabled" on
/// fixed-size windows.
pub on_press: Option<Msg>,
/// Square hit-target size in logical pixels. Clamped to a 20 px floor
/// by [`Self::size`] so the button stays touchable.
pub size: f32,
/// Optional stable identifier for focus management.
pub id: Option<WidgetId>,
/// Whether this button takes part in the Tab / Shift+Tab cycle. Defaults
/// to `false` to match desktop convention — title-bar chrome on macOS,
/// GNOME and Windows is click/touch-only and never steals keyboard focus
/// from window content. Opt in with [`Self::focusable`] for shells where
/// keyboard reachability of decorations matters (accessibility, no-mouse
/// kiosks). Pointer / touch hit testing is unaffected by this flag.
pub focusable: bool,
}
impl<Msg: Clone> WindowButton<Msg>
{
/// Create a window-decoration button of the given kind. The button is
/// inert (no callback) until [`Self::on_press`] is configured.
pub fn new( kind: WindowButtonKind ) -> Self
{
Self
{
kind,
on_press: None,
size: theme::SIZE,
id: None,
focusable: false,
}
}
/// Set the message emitted when the button is activated.
pub fn on_press( mut self, msg: Msg ) -> Self
{
self.on_press = Some( msg );
self
}
/// Like [`Self::on_press`] but keeps the disabled state when `None`
/// is passed — useful when the message depends on a runtime
/// condition (e.g. maximize is disabled for fixed-size windows).
pub fn on_press_maybe( mut self, msg: Option<Msg> ) -> Self
{
self.on_press = msg;
self
}
/// Override the square hit-target size in logical pixels. Clamped to
/// a 20 px floor so the button remains touchable.
pub fn size( mut self, size: f32 ) -> Self
{
self.size = size.max( 20.0 );
self
}
/// Assign a stable identifier for focus management.
pub fn id( mut self, id: WidgetId ) -> Self
{
self.id = Some( id );
self
}
/// Opt into keyboard focus traversal. Defaults to `false` so the
/// button does not steal Tab focus from window content.
pub fn focusable( mut self, yes: bool ) -> Self
{
self.focusable = yes;
self
}
pub fn preferred_size( &self, max_width: f32, _canvas: &Canvas ) -> ( f32, f32 )
{
let s = self.size.min( max_width );
( s, s )
}
pub fn paint_bounds( &self, rect: Rect ) -> Rect
{
rect.expand( theme::FOCUS_W * 1.5 + 2.0 )
}
pub fn draw(
&self,
canvas: &mut Canvas,
rect: Rect,
focused: bool,
hovered: bool,
pressed: bool,
)
{
let disabled = self.on_press.is_none();
let bg = if disabled
{
Color::TRANSPARENT
}
else if pressed
{
theme::pressed_bg()
}
else if hovered && self.kind == WindowButtonKind::Close
{
theme::close_hover()
}
else if hovered
{
theme::hover_bg()
}
else
{
Color::TRANSPARENT
};
if bg.a > 0.0
{
canvas.fill_rect( rect, bg, theme::RADIUS );
}
if focused
{
canvas.stroke_rect(
rect.expand( theme::FOCUS_W + 1.0 ),
theme::focus_color(),
theme::FOCUS_W,
theme::RADIUS + theme::FOCUS_W + 1.0,
);
}
let icon_color = if hovered && self.kind == WindowButtonKind::Close && !disabled
{
theme::close_icon()
}
else
{
let base = theme::icon();
if disabled
{
Color::rgba( base.r, base.g, base.b, base.a * 0.35 )
}
else
{
base
}
};
draw_glyph( canvas, self.kind, rect, icon_color );
}
pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> WindowButton<U>
where
U: Clone + 'static,
Msg: 'static,
{
WindowButton
{
kind: self.kind,
on_press: self.on_press.map( |m| ( *f )( m ) ),
size: self.size,
id: self.id,
focusable: self.focusable,
}
}
}
/// Catalogue path for the symbolic SVG that paints `kind`. Each entry
/// resolves through the theme's `icons/catalogue/filled/window/<kind>.svg`
/// (or the `line/` fallback the catalogue lookup applies automatically).
fn icon_name( kind: WindowButtonKind ) -> &'static str
{
match kind
{
WindowButtonKind::Minimize => "window/minimize",
WindowButtonKind::Maximize => "window/maximize",
WindowButtonKind::Restore => "window/restore",
WindowButtonKind::Close => "window/close",
}
}
/// Render the glyph for `kind` centered inside `rect`, tinted with
/// `color`. Tries the theme catalogue first via [`crate::theme::icon_rgba`]
/// + [`crate::theme::tint_symbolic`]; falls back to the programmatic
/// line / rect drawing in [`draw_symbol`] when the active theme has no
/// catalogue entry for `window/<kind>`.
fn draw_glyph( canvas: &mut Canvas, kind: WindowButtonKind, rect: Rect, color: Color )
{
let s = rect.width.min( rect.height );
let icon_px = ( s * 0.44 ).round().max( 8.0 ) as u32;
if let Some( ( rgba, iw, ih ) ) = crate::theme::icon_rgba( icon_name( kind ), icon_px )
{
let tinted = crate::theme::tint_symbolic( &rgba, color );
// Centre the rasterised glyph inside the button rect. Round to
// integer offsets so the bilinear sampler hits texel centres
// and the glyph stays crisp.
let cx = rect.x + rect.width / 2.0;
let cy = rect.y + rect.height / 2.0;
let dest = Rect
{
x: ( cx - iw as f32 / 2.0 ).round(),
y: ( cy - ih as f32 / 2.0 ).round(),
width: iw as f32,
height: ih as f32,
};
canvas.draw_image_data( &tinted, iw, ih, dest, 1.0 );
}
else
{
// No catalogue entry → fall back to the programmatic glyph so
// chrome stays usable on themes that don't ship
// `icons/catalogue/filled/window/`.
draw_symbol( canvas, kind, rect, color );
}
}
fn draw_symbol( canvas: &mut Canvas, kind: WindowButtonKind, rect: Rect, color: Color )
{
let cx = rect.x + rect.width / 2.0;
let cy = rect.y + rect.height / 2.0;
let s = rect.width.min( rect.height );
let a = s * 0.22;
let w = theme::STROKE_W;
match kind
{
WindowButtonKind::Minimize =>
{
let y = cy + a * 0.65;
canvas.draw_line( cx - a, y, cx + a, y, color, w );
}
WindowButtonKind::Maximize =>
{
let r = Rect { x: cx - a, y: cy - a, width: a * 2.0, height: a * 2.0 };
canvas.stroke_rect( r, color, w, 2.0 );
}
WindowButtonKind::Restore =>
{
let back = Rect { x: cx - a * 0.45, y: cy - a, width: a * 1.55, height: a * 1.55 };
let front = Rect { x: cx - a, y: cy - a * 0.45, width: a * 1.55, height: a * 1.55 };
canvas.stroke_rect( back, color, w, 2.0 );
canvas.stroke_rect( front, color, w, 2.0 );
}
WindowButtonKind::Close =>
{
canvas.draw_line( cx - a, cy - a, cx + a, cy + a, color, w );
canvas.draw_line( cx + a, cy - a, cx - a, cy + a, color, w );
}
}
}
impl<Msg: Clone + 'static> From<WindowButton<Msg>> for Element<Msg>
{
fn from( b: WindowButton<Msg> ) -> Self
{
Element::WindowButton( b )
}
}
/// Create a window-decoration button.
pub fn window_button<Msg: Clone>( kind: WindowButtonKind ) -> WindowButton<Msg>
{
WindowButton::new( kind )
}
/// Create the standard minimize / maximize-or-restore / close control group.
pub fn window_controls<Msg: Clone + 'static>(
minimize: Option<Msg>,
maximize_kind: WindowButtonKind,
maximize: Option<Msg>,
close: Option<Msg>,
) -> Row<Msg>
{
row::<Msg>()
.spacing( 4.0 )
.push( window_button( WindowButtonKind::Minimize ).on_press_maybe( minimize ) )
.push( window_button( maximize_kind ).on_press_maybe( maximize ) )
.push( window_button( WindowButtonKind::Close ).on_press_maybe( close ) )
}
#[ cfg( test ) ]
mod tests
{
use super::*;
use crate::render::Canvas;
#[ test ]
fn default_size_is_decoration_sized()
{
let canvas = Canvas::new( 100, 100 );
let b = window_button::<()>( WindowButtonKind::Close );
assert_eq!( b.preferred_size( 100.0, &canvas ), ( theme::SIZE, theme::SIZE ) );
}
#[ test ]
fn controls_has_three_children()
{
let controls = window_controls::<()>(
Some( () ), WindowButtonKind::Maximize, Some( () ), Some( () ),
);
assert_eq!( controls.children.len(), 3 );
}
#[ test ]
fn icon_names_resolve_each_kind_to_the_window_catalogue()
{
// Lock the catalogue paths so a future rename of the on-disk
// SVG (e.g. close.svg → x.svg) breaks this test instead of
// silently falling back to the programmatic glyph at runtime.
assert_eq!( icon_name( WindowButtonKind::Minimize ), "window/minimize" );
assert_eq!( icon_name( WindowButtonKind::Maximize ), "window/maximize" );
assert_eq!( icon_name( WindowButtonKind::Restore ), "window/restore" );
assert_eq!( icon_name( WindowButtonKind::Close ), "window/close" );
}
#[ test ]
fn paint_bounds_unchanged_after_glyph_refactor()
{
// The SVG glyph paints inside the button rect just like the
// programmatic one did, so paint_bounds must stay equal to
// the previous value (rect expanded by the focus-ring slack).
let b = window_button::<()>( WindowButtonKind::Close );
let rect = Rect { x: 10.0, y: 10.0, width: 36.0, height: 36.0 };
let bounds = b.paint_bounds( rect );
let slack = theme::FOCUS_W * 1.5 + 2.0;
assert!( ( bounds.x - ( rect.x - slack ) ).abs() < 0.01 );
assert!( ( bounds.y - ( rect.y - slack ) ).abs() < 0.01 );
assert!( ( bounds.width - ( rect.width + slack * 2.0 ) ).abs() < 0.01 );
assert!( ( bounds.height - ( rect.height + slack * 2.0 ) ).abs() < 0.01 );
}
}