First commit. Version 0.1.0
This commit is contained in:
737
src/app.rs
Normal file
737
src/app.rs
Normal 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
485
src/core.rs
Normal 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
102
src/draw/chrome.rs
Normal 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
511
src/draw/damage.rs
Normal 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
253
src/draw/gles.rs
Normal 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
379
src/draw/layout.rs
Normal 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
327
src/draw/mod.rs
Normal 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
283
src/draw/software.rs
Normal 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
515
src/egl_context.rs
Normal 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
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
495
src/event_loop/handlers.rs
Normal 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
814
src/event_loop/mod.rs
Normal 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
158
src/gles_render/clip.rs
Normal 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 1–3 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(),
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
342
src/gles_render/framebuffer.rs
Normal file
342
src/gles_render/framebuffer.rs
Normal 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
293
src/gles_render/helpers.rs
Normal 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
168
src/gles_render/image.rs
Normal 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
387
src/gles_render/mod.rs
Normal 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 1–3
|
||||
//! 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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
545
src/gles_render/primitives.rs
Normal file
545
src/gles_render/primitives.rs
Normal 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
107
src/gles_render/raii.rs
Normal 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
657
src/gles_render/setup.rs
Normal 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
812
src/gles_render/shaders.rs
Normal 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
176
src/gles_render/text.rs
Normal 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
251
src/input/dispatch.rs
Normal 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
1070
src/input/gesture.rs
Normal file
File diff suppressed because it is too large
Load Diff
637
src/input/keyboard.rs
Normal file
637
src/input/keyboard.rs
Normal 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
36
src/input/mod.rs
Normal 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
495
src/input/pointer.rs
Normal 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
223
src/input/touch.rs
Normal 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
359
src/layout/column.rs
Normal 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
47
src/layout/mod.rs
Normal 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
299
src/layout/row.rs
Normal 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
143
src/layout/spacer.rs
Normal 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
182
src/layout/stack.rs
Normal 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
347
src/layout/wrap_grid.rs
Normal 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
429
src/lib.rs
Executable 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
100
src/render/clip.rs
Normal 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
112
src/render/helpers.rs
Normal 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
115
src/render/image.rs
Normal 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
634
src/render/mod.rs
Normal 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
103
src/render/primitives.rs
Normal 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
124
src/render/setup.rs
Normal 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
203
src/render/text.rs
Normal 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
78
src/secure_mem.rs
Normal 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
133
src/system_fonts.rs
Normal 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
111
src/theme/document.rs
Normal 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() ) )
|
||||
}
|
||||
}
|
||||
134
src/theme/fallback/Sora-LICENSE.txt
Normal file
134
src/theme/fallback/Sora-LICENSE.txt
Normal 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".
|
||||
BIN
src/theme/fallback/Sora-Regular.otf
Normal file
BIN
src/theme/fallback/Sora-Regular.otf
Normal file
Binary file not shown.
112
src/theme/fallback/mod.rs
Normal file
112
src/theme/fallback/mod.rs
Normal 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
357
src/theme/font_registry.rs
Normal 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
77
src/theme/fonts.rs
Normal 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
304
src/theme/gradient_lut.rs
Normal 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
1103
src/theme/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
207
src/theme/paint.rs
Normal file
207
src/theme/paint.rs
Normal 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
1449
src/theme/schema.rs
Normal file
File diff suppressed because it is too large
Load Diff
193
src/theme/shadow.rs
Normal file
193
src/theme/shadow.rs
Normal 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
345
src/theme/slots.rs
Normal 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
107
src/theme/surface.rs
Normal 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
225
src/theme/text_style.rs
Normal 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
90
src/tree.rs
Executable 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
432
src/types.rs
Normal 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
255
src/wallpaper.rs
Normal 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 ) );
|
||||
}
|
||||
}
|
||||
124
src/widget/anchored_overlay.rs
Normal file
124
src/widget/anchored_overlay.rs
Normal 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
553
src/widget/button.rs
Normal 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
217
src/widget/checkbox.rs
Normal 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
545
src/widget/color_picker.rs
Normal 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
977
src/widget/combo.rs
Normal 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
374
src/widget/container.rs
Normal 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
617
src/widget/date_picker.rs
Normal 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 1–12, `day` is 1–31 (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
451
src/widget/dialog.rs
Normal 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
182
src/widget/external.rs
Normal 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
138
src/widget/flex.rs
Normal 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
217
src/widget/image.rs
Normal 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
268
src/widget/list_item.rs
Normal 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
923
src/widget/mod.rs
Executable 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
249
src/widget/notebook.rs
Normal 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
296
src/widget/pressable.rs
Normal 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
143
src/widget/progress_bar.rs
Normal 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
212
src/widget/radio.rs
Normal 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
156
src/widget/scroll.rs
Normal 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
137
src/widget/separator.rs
Normal 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
541
src/widget/slider.rs
Normal 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
223
src/widget/spinner.rs
Normal 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
208
src/widget/tab_bar.rs
Normal 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
384
src/widget/text.rs
Normal 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
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
615
src/widget/time_picker.rs
Normal 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 0–23 (24-hour
|
||||
/// representation regardless of [`TimePicker::twelve_hour`] display
|
||||
/// mode), `minute` and `second` are 0–59.
|
||||
#[ 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 0–23. 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; 1–11 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
305
src/widget/toast.rs
Normal 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
244
src/widget/toggle.rs
Normal 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
247
src/widget/tooltip.rs
Normal 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
183
src/widget/viewport.rs
Normal 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
486
src/widget/vslider.rs
Normal 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
383
src/widget/window_button.rs
Normal 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 );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user