refactor: split every monolithic module into focused submodules

Each source file that had grown beyond a single concern is replaced by an identically-named directory containing focused submodules. `src/event_loop/mod.rs` (878 lines) becomes a directory with clipboard, context_menu, cursor_shape, drag, focus, handlers, invalidation, overlays_reconcile, repeat, run, surface, text_editing, and tooltip. Every widget, input handler, and theme component follows the same split. Public interfaces are unchanged — only the internal file layout moves.
image bumped from 0.25.2 to 0.25.9.
This commit is contained in:
2026-05-15 23:46:56 +02:00
parent 3d237039c6
commit 4aa3480b64
155 changed files with 13832 additions and 13035 deletions

View File

@@ -29,7 +29,7 @@ calloop = "0.14"
calloop-wayland-source = "0.4"
tiny-skia = "0.12"
fontdue = "0.9"
image = { version = "=0.25.2", default-features = false, features = ["png", "jpeg", "webp"] }
image = { version = "=0.25.9", default-features = false, features = ["png", "jpeg", "webp"] }
resvg = "0.44"
rust-i18n = "3"
wayland-protocols = { version = "0.32", features = ["client", "unstable", "staging"] }

View File

@@ -41,6 +41,8 @@ fn build_widgets( n: usize ) -> Vec<LaidOutWidget<()>>
paint_rect: rect,
handlers,
keyboard_focusable: true,
cursor: ltk::CursorShape::Default,
tooltip: None,
}
} ).collect()
}

View File

@@ -114,7 +114,7 @@ pub( crate ) struct DrawCtx<Msg: Clone>
/// toolkit follows for runtime-internal popups.
pub( crate ) fn draw_context_menu(
canvas: &mut crate::render::Canvas,
menu: &crate::event_loop::app_data::ContextMenu,
menu: &crate::event_loop::context_menu::ContextMenu,
)
{
let palette = crate::theme::palette();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use super::app_data::AppData;
use super::surface::SurfaceFocus;
use crate::app::App;
use crate::tree::find_handlers;
impl<A: App> AppData<A>
{
/// Copy the currently-selected text into the process-local
/// clipboard. No-op when there is no selection.
pub( crate ) fn handle_copy( &mut self, focus: SurfaceFocus )
{
if let Some( text ) = self.focused_selection_text( focus )
{
self.clipboard = text;
}
}
/// Copy + delete: the selected text lands in the clipboard, then
/// the range is removed from the value.
pub( crate ) fn handle_cut( &mut self, focus: SurfaceFocus )
{
let idx = match self.surface( focus ).focused_idx { Some( i ) => i, None => return };
if let Some( text ) = self.focused_selection_text( focus )
{
self.clipboard = text;
}
if let Some( new_value ) = self.delete_selection( focus )
{
let msg = find_handlers( &self.surface( focus ).widget_rects, idx )
.and_then( |h| h.text_change_msg( &new_value ) );
if let Some( m ) = msg { self.pending_msgs.push( m ); }
}
}
/// Insert the clipboard contents at the cursor (replacing the
/// selection if there is one). No-op when the clipboard is empty.
pub( crate ) fn handle_paste( &mut self, focus: SurfaceFocus )
{
if self.clipboard.is_empty() { return; }
// Carbon-copy of the clipboard to break the borrow on `self`
// before `handle_text_insert` runs.
let text = self.clipboard.clone();
self.handle_text_insert( focus, &text );
}
}

View File

@@ -0,0 +1,189 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use super::app_data::AppData;
use super::surface::SurfaceFocus;
use crate::app::App;
use crate::tree::find_handlers;
use crate::types::{ Point, Rect };
/// Built-in context menu for text editing — Copy / Cut / Paste rows
/// drawn on top of the surface content. Shown on a right-click (or
/// long-press) in a [`crate::widget::text_edit::TextEdit`]; dismissed
/// by clicking outside or selecting an action. Lives on
/// [`super::surface::SurfaceState`] so a press inside an overlay shows
/// its own menu in overlay-local coordinates.
#[ derive( Clone, Debug ) ]
pub( crate ) struct ContextMenu
{
/// `widget_rects` index of the TextEdit the menu targets. Used by
/// the action dispatch so we operate on the right field even
/// after focus has moved.
pub widget_idx: usize,
/// Menu rect in surface-local physical pixels.
pub rect: Rect,
pub has_selection: bool,
pub can_paste: bool,
/// Byte offset within the target TextEdit's value that
/// corresponds to the original press point that opened the menu.
/// Paste uses this so the clipboard contents land exactly where
/// the user right-clicked / long-pressed, regardless of where
/// the cursor was beforehand. `None` only when the open path
/// could not snapshot the position (e.g. canvas missing).
pub paste_offset: Option<usize>,
}
/// Number of rows in the built-in clipboard context menu — Copy /
/// Cut / Paste / Delete. Bumped from three when Delete was added so
/// the user has a parallel UI affordance to the Backspace / Delete
/// keys for clearing a selection without touching the clipboard.
pub( crate ) const CONTEXT_MENU_ROWS: usize = 4;
impl ContextMenu
{
/// Vertical band offsets inside [`Self::rect`] for each row. The
/// returned vector always has [`CONTEXT_MENU_ROWS`] entries; the
/// last element is the per-row height. Used by both hit-testing
/// and the renderer so the two stay in sync.
pub fn row_ys( &self ) -> ( Vec<f32>, f32 )
{
let row_h = self.rect.height / CONTEXT_MENU_ROWS as f32;
let ys = ( 0..CONTEXT_MENU_ROWS )
.map( |i| self.rect.y + i as f32 * row_h )
.collect();
( ys, row_h )
}
/// Which row, if any, contains `pos`. Returns `0..CONTEXT_MENU_ROWS`
/// (Copy / Cut / Paste / Delete) or `None` if `pos` is outside the
/// menu rect.
pub fn row_at( &self, pos: Point ) -> Option<usize>
{
if !self.rect.contains( pos ) { return None; }
let row_h = self.rect.height / CONTEXT_MENU_ROWS as f32;
let i = ( ( pos.y - self.rect.y ) / row_h ).floor() as i32;
if ( 0..CONTEXT_MENU_ROWS as i32 ).contains( &i ) { Some( i as usize ) } else { None }
}
}
impl<A: App> AppData<A>
{
/// Open the built-in Copy / Cut / Paste context menu near `pos`,
/// targeting the TextEdit at `widget_idx`. Clamps the menu rect
/// to the surface so it never goes off-screen on a near-edge
/// click. Idempotent — replaces any existing menu.
pub( crate ) fn show_context_menu( &mut self, focus: SurfaceFocus, widget_idx: usize, pos: Point )
{
const W: f32 = 180.0;
const H: f32 = 44.0 * CONTEXT_MENU_ROWS as f32;
let has_selection = self.focused_selection_text( focus ).is_some();
let can_paste = !self.clipboard.is_empty();
// Snapshot the byte offset the press fell on so a later
// "Paste" lands exactly at the right-click point, even though
// the menu deliberately preserves the existing cursor /
// selection (so Copy / Cut still operate on the prior
// selection).
let paste_offset = self.text_input_geometry( focus, widget_idx )
.and_then( |( rect, value, multiline, secure, align, font_size )|
{
let cursor_pos = self.surface( focus ).cursor_state.get( &widget_idx ).copied().unwrap_or( 0 );
let canvas = self.surface( focus ).canvas.as_ref()?;
Some( crate::widget::text_edit::byte_offset_at(
canvas, rect, pos, &value, multiline, secure, cursor_pos, align, font_size,
) )
} );
let ss = self.surface( focus );
let sw = ss.width as f32 * ss.scale_factor.max( 1 ) as f32;
let sh = ss.height as f32 * ss.scale_factor.max( 1 ) as f32;
let mut x = pos.x;
let mut y = pos.y;
// Clamp inside the surface with a small breathing margin.
x = x.min( sw - W - 4.0 ).max( 4.0 );
y = y.min( sh - H - 4.0 ).max( 4.0 );
let rect = Rect { x, y, width: W, height: H };
let menu = ContextMenu { widget_idx, rect, has_selection, can_paste, paste_offset };
let ss = self.surface_mut( focus );
ss.context_menu = Some( menu );
ss.request_redraw();
}
/// Dismiss the context menu on the given surface (no-op when
/// none is shown).
pub( crate ) fn hide_context_menu( &mut self, focus: SurfaceFocus )
{
let ss = self.surface_mut( focus );
if ss.context_menu.is_some()
{
ss.context_menu = None;
ss.request_redraw();
}
}
/// Handle a primary-button press while the context menu is open.
/// Returns `true` when the click was consumed by the menu (either
/// a row activation or an outside-click dismissal); the pointer
/// dispatch then skips the regular gesture path for this press.
pub( crate ) fn handle_context_menu_press( &mut self, focus: SurfaceFocus, pos: Point ) -> bool
{
let menu = match self.surface( focus ).context_menu.clone()
{
Some( m ) => m,
None => return false,
};
// Click outside the menu rect → dismiss only.
let row = match menu.row_at( pos )
{
Some( r ) => r,
None =>
{
self.hide_context_menu( focus );
return true;
}
};
// Action gating: rows are clickable only when their
// underlying operation makes sense.
match row
{
0 => if menu.has_selection { self.handle_copy( focus ); } else { return true; }
1 => if menu.has_selection { self.handle_cut( focus ); } else { return true; }
2 =>
{
if !menu.can_paste { return true; }
// Paste lands at the click position the menu was
// opened from, not at whatever the cursor happens to
// be (which may sit at the end of the value because
// the user just focused the field). Move cursor +
// anchor to the snapshotted offset (collapsing any
// prior selection there) and then run the regular
// paste path.
if let Some( ofs ) = menu.paste_offset
{
let ss = self.surface_mut( focus );
ss.cursor_state.insert( menu.widget_idx, ofs );
ss.selection_anchor.insert( menu.widget_idx, ofs );
}
self.handle_paste( focus );
}
3 =>
{
// Delete row — clears the current selection without
// touching the clipboard (the difference vs. Cut).
// Disabled with no selection: nothing to delete.
if !menu.has_selection { return true; }
if let Some( new_value ) = self.delete_selection( focus )
{
let msg = find_handlers( &self.surface( focus ).widget_rects, menu.widget_idx )
.and_then( |h| h.text_change_msg( &new_value ) );
if let Some( m ) = msg { self.pending_msgs.push( m ); }
}
}
_ => return true,
}
self.hide_context_menu( focus );
true
}
}

View File

@@ -0,0 +1,221 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel::ResizeEdge;
use super::app_data::AppData;
use super::surface::{ SurfaceFocus, SurfaceKind };
use crate::app::App;
use crate::types::{ CursorShape, Point };
/// Classify a pointer position against a surface of size `w × h` (in
/// physical pixels). Returns the resize edge if the pointer is inside
/// the `border`-thick grab band along any edge or corner.
pub( crate ) fn resize_edge_for_pos( pos: Point, w: f32, h: f32, border: f32 ) -> Option<ResizeEdge>
{
let on_left = pos.x < border;
let on_right = pos.x > w - border;
let on_top = pos.y < border;
let on_bottom = pos.y > h - border;
Some( match ( on_top, on_bottom, on_left, on_right )
{
( true, _, true, _ ) => ResizeEdge::TopLeft,
( true, _, _, true ) => ResizeEdge::TopRight,
( _, true, true, _ ) => ResizeEdge::BottomLeft,
( _, true, _, true ) => ResizeEdge::BottomRight,
( true, _, _, _ ) => ResizeEdge::Top,
( _, true, _, _ ) => ResizeEdge::Bottom,
( _, _, true, _ ) => ResizeEdge::Left,
( _, _, _, true ) => ResizeEdge::Right,
_ => return None,
} )
}
/// Map an [`ResizeEdge`] to the [`CursorShape`](crate::types::CursorShape)
/// to display while hovering / dragging it.
pub( crate ) fn resize_edge_to_cursor( edge: ResizeEdge ) -> CursorShape
{
use CursorShape::*;
match edge
{
ResizeEdge::Top => NResize,
ResizeEdge::Bottom => SResize,
ResizeEdge::Left => WResize,
ResizeEdge::Right => EResize,
ResizeEdge::TopLeft => NwResize,
ResizeEdge::TopRight => NeResize,
ResizeEdge::BottomLeft => SwResize,
ResizeEdge::BottomRight => SeResize,
_ => Default,
}
}
impl<A: App> AppData<A>
{
/// Convert a public [`CursorShape`](crate::types::CursorShape) into
/// the wire-protocol [`Shape`] enum the compositor expects.
fn cursor_shape_to_wp( shape: CursorShape )
-> smithay_client_toolkit::reexports::protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::Shape
{
use CursorShape as C;
use smithay_client_toolkit::reexports::protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::Shape as S;
match shape
{
C::Default => S::Default,
C::ContextMenu => S::ContextMenu,
C::Help => S::Help,
C::Pointer => S::Pointer,
C::Progress => S::Progress,
C::Wait => S::Wait,
C::Cell => S::Cell,
C::Crosshair => S::Crosshair,
C::Text => S::Text,
C::VerticalText => S::VerticalText,
C::Alias => S::Alias,
C::Copy => S::Copy,
C::Move => S::Move,
C::NoDrop => S::NoDrop,
C::NotAllowed => S::NotAllowed,
C::Grab => S::Grab,
C::Grabbing => S::Grabbing,
C::EResize => S::EResize,
C::NResize => S::NResize,
C::NeResize => S::NeResize,
C::NwResize => S::NwResize,
C::SResize => S::SResize,
C::SeResize => S::SeResize,
C::SwResize => S::SwResize,
C::WResize => S::WResize,
C::EwResize => S::EwResize,
C::NsResize => S::NsResize,
C::NeswResize => S::NeswResize,
C::NwseResize => S::NwseResize,
C::ColResize => S::ColResize,
C::RowResize => S::RowResize,
C::AllScroll => S::AllScroll,
C::ZoomIn => S::ZoomIn,
C::ZoomOut => S::ZoomOut,
}
}
/// Compute the cursor shape that should be active right now and
/// push it to the compositor if it changed since the last
/// dispatch. Precedence:
///
/// 1. [`crate::App::cursor_override`] — application-driven busy
/// state, wins over everything.
/// 2. Active slider drag — `Grabbing` while the gesture machine
/// holds a `dragging_slider`.
/// 3. Cursor declared by the widget under the pointer
/// ([`crate::widget::LaidOutWidget::cursor`]).
/// 4. `Default` — pointer is over surface chrome / empty space.
pub( crate ) fn dispatch_cursor_shape( &mut self, focus: SurfaceFocus )
{
let target = {
let app_override = self.app.cursor_override();
let dragging = self.surface( focus ).gesture.dragging_slider.is_some();
let hover_cursor = self.surface( focus ).hovered_idx
.and_then( |idx| crate::tree::find_widget( &self.surface( focus ).widget_rects, idx ) )
.map( |w| w.cursor )
.unwrap_or( CursorShape::Default );
let resize_cursor = self.resize_edge_under_pointer( focus ).map( resize_edge_to_cursor );
// Resize-edge hover wins over the widget cursor and over a
// `Default` app override, but loses to a non-default app
// override (so a "busy" / "wait" state still trumps chrome).
let allow_chrome = app_override.is_none()
|| app_override == Some( CursorShape::Default );
if let Some( c ) = resize_cursor.filter( |_| allow_chrome )
{
c
} else if let Some( o ) = app_override {
o
} else if dragging {
CursorShape::Grabbing
} else {
hover_cursor
}
};
// Skip when the compositor already shows the right shape.
// `current_cursor_shape == None` means "we have not pushed
// anything since the last `Enter`" — e.g. the pointer just
// arrived from another client's surface, where the prior
// client may have asked for a text I-beam. We MUST push
// unconditionally in that case so the user does not see the
// stranger cursor lingering until they hit a widget.
if self.current_cursor_shape == Some( target ) { return; }
if let Some( device ) = self.cursor_shape_device.as_ref()
{
device.set_shape( self.last_pointer_enter_serial, Self::cursor_shape_to_wp( target ) );
self.current_cursor_shape = Some( target );
}
}
/// Width (logical pixels) of the resize-grab band along each
/// edge of an xdg-toplevel surface. 6 px is the GTK / KWin
/// convention.
pub( crate ) const RESIZE_BORDER_LOGICAL: f32 = 6.0;
/// Return the [`ResizeEdge`] the pointer currently sits on, or
/// `None` if it is not in a resize-grab band. Only fires for the
/// main surface when it is an xdg-toplevel — layer-shell surfaces
/// and overlays cannot be resized by the user.
pub( crate ) fn resize_edge_under_pointer( &self, focus: SurfaceFocus ) -> Option<ResizeEdge>
{
if !matches!( focus, SurfaceFocus::Main ) { return None; }
if !matches!( self.main.surface, SurfaceKind::Window( _ ) ) { return None; }
let sf = self.surface( focus ).scale_factor.max( 1 ) as f32;
let border = Self::RESIZE_BORDER_LOGICAL * sf;
let w = self.surface( focus ).physical_width() as f32;
let h = self.surface( focus ).physical_height() as f32;
resize_edge_for_pos( self.pointer_pos, w, h, border )
}
}
#[ cfg( test ) ]
mod resize_edge_tests
{
use super::{ resize_edge_for_pos, ResizeEdge };
use crate::types::Point;
const W: f32 = 800.0;
const H: f32 = 600.0;
const B: f32 = 6.0;
fn at( x: f32, y: f32 ) -> Option<ResizeEdge>
{
resize_edge_for_pos( Point { x, y }, W, H, B )
}
#[ test ]
fn middle_is_none()
{
assert!( at( 400.0, 300.0 ).is_none() );
}
#[ test ]
fn just_inside_border_is_none()
{
// 6 px border; (6, 6) is one pixel inside on both axes.
assert!( at( 6.0, 6.0 ).is_none() );
}
#[ test ]
fn corners_take_precedence_over_single_edges()
{
assert_eq!( at( 1.0, 1.0 ), Some( ResizeEdge::TopLeft ) );
assert_eq!( at( W - 1.0, 1.0 ), Some( ResizeEdge::TopRight ) );
assert_eq!( at( 1.0, H - 1.0 ), Some( ResizeEdge::BottomLeft ) );
assert_eq!( at( W - 1.0, H - 1.0 ), Some( ResizeEdge::BottomRight ) );
}
#[ test ]
fn straight_edges()
{
assert_eq!( at( 400.0, 1.0 ), Some( ResizeEdge::Top ) );
assert_eq!( at( 400.0, H - 1.0 ), Some( ResizeEdge::Bottom ) );
assert_eq!( at( 1.0, 300.0 ), Some( ResizeEdge::Left ) );
assert_eq!( at( W - 1.0, 300.0 ), Some( ResizeEdge::Right ) );
}
}

119
src/event_loop/drag.rs Normal file
View File

@@ -0,0 +1,119 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use super::app_data::AppData;
use super::surface::{ SurfaceFocus, SurfaceState };
use crate::app::App;
use crate::types::Point;
impl<A: App> AppData<A>
{
/// Fire long-press messages for any surface whose deadline has
/// elapsed. Idempotent — if a press is in-flight but not yet due this
/// does nothing. On fire, the message is pushed to `pending_msgs`,
/// `long_press_fired` is set, and the per-surface long-press slot is
/// cleared so the same press can't fire twice.
pub( crate ) fn check_long_press_deadlines( &mut self )
{
let dur = self.app.long_press_duration();
let now = std::time::Instant::now();
// Returns `(lp_msg_opt, ds_msg_opt, origin, text_idx_opt)`:
// - `lp_msg_opt = Some` → user-set on_long_press fires; push msg
// (opens the context menu in the app).
// - `ds_msg_opt = Some` → user-set on_drag_start fires; push
// msg + seed `on_drag_move(origin)` so the drag arms with
// the right anchor point. Also flips `long_press_fired` so
// subsequent motion / release route through drag / drop.
// - `text_idx_opt = Some` → press was on a TextEdit with
// neither user msg; the runtime opens the built-in Copy /
// Cut / Paste menu instead.
// Any combination can be present: an icon with both menu and
// drag fires both messages in order (menu first, then drag).
let fire = |ss: &mut SurfaceState<A::Message>|
-> Option<( Option<A::Message>, Option<A::Message>, Point, Option<usize> )>
{
let start = ss.gesture.long_press_start?;
if now.duration_since( start ) < dur { return None; }
ss.gesture.long_press_start = None;
let origin = ss.gesture.long_press_origin.unwrap_or_default();
let lp_msg = ss.gesture.long_press_msg.take();
let ds_msg = ss.gesture.drag_start_msg.take();
let text_idx = ss.gesture.long_press_text_idx.take();
if lp_msg.is_none() && ds_msg.is_none() && text_idx.is_none() { return None; }
// Only flip "fired" when the gesture actually transitions
// into drag mode. A menu-only widget (no on_drag_start)
// stays in tap mode, so a release after the menu fires
// still goes through `on_release` cleanly. The built-in
// text menu likewise does not switch to drag, so a
// subsequent motion can still extend a selection.
if ds_msg.is_some() { ss.gesture.long_press_fired = true; }
ss.request_redraw();
Some( ( lp_msg, ds_msg, origin, text_idx ) )
};
// Capture per-surface fires, then post-process so the borrow
// of `self.main` / `self.overlays` is released before we
// call menu helpers (which take &mut self again).
let main_fire = fire( &mut self.main ).map( |x| ( SurfaceFocus::Main, x ) );
let overlay_fires: Vec<_> = self.overlays.iter_mut()
.filter_map( |( id, ss )| fire( ss ).map( |x| ( SurfaceFocus::Overlay( *id ), x ) ) )
.collect();
let mut consumed_anything = false;
for ( focus, ( lp_msg, ds_msg, origin, text_idx ) ) in main_fire.into_iter().chain( overlay_fires )
{
// Push menu first so the app sets up `edit_menu` before
// the drag-arm message lands and starts consuming
// `on_drag_move` callbacks.
if let Some( m ) = lp_msg
{
self.pending_msgs.push( m );
consumed_anything = true;
}
if let Some( m ) = ds_msg
{
self.pending_msgs.push( m );
let ( ox, oy ) = self.surface_offset_for( focus );
let global = Point { x: origin.x + ox, y: origin.y + oy };
self.pending_drag_inits.push( global );
// Drag promotion cancels any held-button repeat — the
// gesture has switched semantics and the timer has
// nothing to fire against any more.
self.stop_button_repeat();
consumed_anything = true;
}
if let Some( idx ) = text_idx
{
// Built-in Copy / Cut / Paste menu near the press.
self.show_context_menu( focus, idx, origin );
}
}
if consumed_anything { self.dirty_caches(); }
}
pub( crate ) fn has_active_long_press_drag( &self ) -> bool
{
self.main.gesture.long_press_fired
|| self.overlays.values().any( |ss| ss.gesture.long_press_fired )
}
pub( crate ) fn clear_long_press_drag( &mut self )
{
let clear = |ss: &mut SurfaceState<A::Message>|
{
ss.gesture.long_press_start = None;
ss.gesture.long_press_origin = None;
ss.gesture.long_press_msg = None;
ss.gesture.drag_start_msg = None;
ss.gesture.long_press_text_idx = None;
ss.gesture.long_press_fired = false;
ss.gesture.mouse_press = false;
};
clear( &mut self.main );
for ss in self.overlays.values_mut()
{
clear( ss );
}
}
}

61
src/event_loop/error.rs Normal file
View File

@@ -0,0 +1,61 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
/// 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, the compositor disconnecting
/// (`BrokenPipe` / `ConnectionReset`) is treated as a clean exit; any
/// other runtime error during the dispatch loop still panics, since the
/// surface is already on screen and the state machine cannot be unwound
/// from this entry point.
///
/// 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 {}

157
src/event_loop/focus.rs Normal file
View File

@@ -0,0 +1,157 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use smithay_client_toolkit::reexports::client::QueueHandle;
use super::app_data::AppData;
use super::surface::SurfaceFocus;
use crate::app::{ App, OverlayId };
use crate::tree::find_handlers;
use crate::types::Point;
use crate::widget::WidgetHandlers;
impl<A: App> AppData<A>
{
/// Push `on_dismiss` for every active xdg-popup overlay whose
/// anchor widget was not hit by a press at `pos` (logical pixels
/// in main-surface space). Used to compensate for compositors
/// (notably Mutter) that route pointer button events to the main
/// surface while a popup grab is technically active, instead of
/// breaking the grab. The check skips presses on the trigger pill
/// itself so the trigger's own `toggle` message keeps owning that
/// transition.
pub( crate ) fn dismiss_main_outside_popups( &mut self, pos: Point )
{
if self.overlays.is_empty() { return; }
let specs = self.app.overlays();
for spec in specs
{
// Only xdg-popups need the main-side fallback: they grab
// the seat and rely on the compositor to break the grab on
// outside input, which Mutter does not do reliably. Layer-
// shell overlays own their own `input_region` and dispatch
// dismissal from the overlay tap path, so firing it again
// from a press on the main surface produces a duplicate
// dismiss that races other intents (e.g. forge's topbar
// taps that route a separate IPC into the same overlay).
let Some( anchor_id ) = spec.anchor_widget_id else { continue; };
let anchor_rect = self.main.widget_rects.iter()
.find( | w | w.id == Some( anchor_id ) )
.map( | w | w.rect );
let on_anchor = anchor_rect
.map( | r | pos.x >= r.x && pos.x < r.x + r.width
&& pos.y >= r.y && pos.y < r.y + r.height )
.unwrap_or( false );
if !on_anchor
{
if let Some( msg ) = spec.on_dismiss
{
self.pending_msgs.push( msg );
}
}
}
}
/// Push `on_dismiss` for every active xdg-popup overlay
/// unconditionally (used by keyboard Escape handling — there is
/// no spatial test to do).
pub( crate ) fn dismiss_all_popups( &mut self )
{
if self.overlays.is_empty() { return; }
let specs = self.app.overlays();
for spec in specs
{
if spec.anchor_widget_id.is_some()
{
if let Some( msg ) = spec.on_dismiss
{
self.pending_msgs.push( msg );
}
}
}
}
/// Look up the dismiss message for an overlay (tap on empty area).
/// Returns `None` if the overlay has no `on_dismiss` set or was removed.
pub( crate ) fn overlay_dismiss_msg( &self, id: OverlayId ) -> Option<A::Message>
{
self.app.overlays().into_iter()
.find( |s| s.id == id )
.and_then( |s| s.on_dismiss )
}
pub( crate ) fn set_focus( &mut self, focus: SurfaceFocus, idx: Option<usize>, qh: &QueueHandle<Self> )
{
let was_text_input;
let is_text_input;
{
let ss = self.surface_mut( focus );
is_text_input = idx
.and_then( |i| find_handlers( &ss.widget_rects, i ) )
.map( |h| h.is_text_input() )
.unwrap_or( false );
was_text_input = ss.focused_idx
.and_then( |i| find_handlers( &ss.widget_rects, i ) )
.map( |h| h.is_text_input() )
.unwrap_or( false );
// Clear pending text value when losing focus
if let Some( prev_idx ) = ss.focused_idx
{
if idx != Some( prev_idx )
{
ss.pending_text_values.remove( &prev_idx );
}
}
ss.focused_idx = idx;
ss.focused_id = idx.and_then( |i|
{
ss.widget_rects.iter()
.find( |w| w.flat_idx == i )
.and_then( |w| w.id )
} );
ss.request_redraw();
// Sync cursor to end of current value when focusing a text
// input. Default behaviour collapses the selection to the
// cursor — focus changes always discard any prior
// selection state. Fields built with `.select_on_focus(
// true )` instead anchor the selection at `0` so the
// whole value is highlighted, ready to be replaced by the
// next keystroke (typical for numeric pickers).
if is_text_input
{
if let Some( i ) = idx
{
let handler = find_handlers( &ss.widget_rects, i );
let cursor = handler
.and_then( |h| h.current_value() )
.map( |v| v.len() )
.unwrap_or( 0 );
let anchor = match handler
{
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ) => 0,
_ => cursor,
};
ss.cursor_state.insert( i, cursor );
ss.selection_anchor.insert( i, anchor );
}
}
}
if was_text_input && !is_text_input
{
self.deactivate_text_input();
self.app.on_text_input_focused( false );
self.dirty_caches();
}
if is_text_input && !was_text_input
{
self.activate_text_input( qh );
self.app.on_text_input_focused( true );
self.dirty_caches();
}
}
}

View File

@@ -17,6 +17,7 @@ use smithay_client_toolkit::
{
WaylandSurface,
wlr_layer::{ LayerShellHandler, LayerSurface, LayerSurfaceConfigure },
xdg::XdgSurface,
xdg::popup::{ Popup, PopupConfigure, PopupHandler },
xdg::window::{ Window, WindowConfigure, WindowHandler },
},
@@ -218,6 +219,7 @@ impl<A: App> WindowHandler for AppData<A>
let ( hint_w, hint_h ) = self.app.window_size_hint().unwrap_or( ( 800, 600 ) );
let w = configure.new_size.0.map( |v| v.get() ).unwrap_or( hint_w );
let h = configure.new_size.1.map( |v| v.get() ).unwrap_or( hint_h );
window.xdg_surface().set_window_geometry( 0, 0, w as i32, h as i32 );
self.on_configure( w, h );
self.app.on_resize( w, h );
}

View File

@@ -0,0 +1,76 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::collections::HashSet;
use crate::app::{ App, InvalidationScope, SurfaceTarget };
use super::AppData;
/// 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.
pub( super ) 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 )
}

View File

@@ -2,878 +2,24 @@
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
pub( crate ) mod app_data;
pub( crate ) mod clipboard;
pub( crate ) mod context_menu;
pub( crate ) mod cursor_shape;
pub( crate ) mod drag;
pub( crate ) mod focus;
mod handlers;
pub( crate ) mod repeat;
pub( crate ) mod surface;
pub( crate ) mod text_editing;
pub( crate ) mod tooltip;
pub( crate ) use app_data::{ AppData, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState };
pub( crate ) mod error;
pub( crate ) mod run;
pub( crate ) mod invalidation;
pub( crate ) mod overlays_reconcile;
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, the compositor disconnecting
/// (`BrokenPipe` / `ConnectionReset`) is treated as a clean exit; any
/// other runtime error during the dispatch loop still panics, since the
/// surface is already on screen and the state machine cannot be unwound
/// from this entry point.
///
/// 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:?}" ) } )
};
// 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 ) ) =>
{
window.set_min_size( Some( ( w, h ) ) );
window.set_max_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();
// `ext-foreign-toplevel-list-v1`. SCTK's `ForeignToplevelList` handles the
// bind + dispatch routing; absence of the global is fine, the inner
// `GlobalProxy` then yields no toplevels and `App::on_toplevel_event` never
// fires. The list is built unconditionally so apps that override the
// callback always see a consistent stream when the compositor does carry
// the protocol.
let foreign_toplevel_list = smithay_client_toolkit::foreign_toplevel_list::ForeignToplevelList::new( &globals, &qh );
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 pending_size_hint_unpin = !app.start_fullscreen() && app.window_size_hint().is_some();
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,
foreign_toplevel_list,
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(),
tooltip_pending: None,
tooltip_visible: None,
qh: qh.clone(),
last_pointer_serial: 0,
last_input_serial: 0,
exit_requested: false,
pending_fullscreen,
pending_size_hint_unpin,
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 = match ( data.next_long_press_wakeup(), data.next_tooltip_wakeup() )
{
( Some( a ), Some( b ) ) => Some( a.min( b ) ),
( a, b ) => a.or( b ),
};
match event_loop.dispatch( timeout, &mut data )
{
Ok( () ) => {},
Err( calloop::Error::IoError( ref e ) )
if matches!( e.kind(), std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::ConnectionReset ) =>
{
// On a fatal `wl_display.error` the compositor closes the
// socket; the backend has the typed error stashed but the
// dispatch result only carries the resulting `BrokenPipe`.
if let Some( pe ) = conn.protocol_error()
{
eprintln!(
"ltk: wayland protocol error — interface={} object_id={} code={}: {}",
pe.object_interface, pe.object_id, pe.code, pe.message
);
}
else
{
eprintln!( "ltk: wayland connection lost ({e}); exiting" );
}
data.exit_requested = true;
continue;
}
Err( e ) => panic!( "dispatch: {e:?}" ),
}
// 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();
data.check_tooltip_deadline();
// 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
{
let mut specs = data.app.overlays();
if let Some( ts ) = data.tooltip_overlay() { specs.push( ts ); }
data.cached_overlays = Some( specs );
data.overlays_dirty = false;
}
draw_frame( &mut data );
}
// Focus the widget with the requested WidgetId if the app requests it.
// Walks main + every overlay because the targeted widget can live
// on any of them (a search field on a launcher overlay, a text
// edit on a dialog modal, …).
if let Some( id ) = data.app.take_focus_request()
{
let mut hit: Option<( SurfaceFocus, usize )> = data.main.widget_rects.iter()
.find( |w| w.id == Some( id ) )
.map( |w| ( SurfaceFocus::Main, w.flat_idx ) );
if hit.is_none()
{
for ( ov_id, surf ) in &data.overlays
{
if let Some( w ) = surf.widget_rects.iter().find( |w| w.id == Some( id ) )
{
hit = Some( ( SurfaceFocus::Overlay( *ov_id ), w.flat_idx ) );
break;
}
}
}
if let Some( ( focus, flat_idx ) ) = hit
{
let qh = data.qh.clone();
data.set_focus( focus, Some( flat_idx ), &qh );
match focus
{
SurfaceFocus::Main => data.main.request_redraw(),
SurfaceFocus::Overlay( id ) =>
{
if let Some( s ) = data.overlays.get_mut( &id )
{
s.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 mut specs = data.app.overlays();
if let Some( ts ) = data.tooltip_overlay() { specs.push( ts ); }
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;
ss.layer_anchor = Some( spec.anchor );
overlays_m.insert( spec.id, ss );
}
}
pub( crate ) use app_data::AppData;
pub( crate ) use surface::{ LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState };
pub use error::RunError;
pub( crate ) use run::{ run, try_run };
pub use invalidation::diff_overlay_ids;

View File

@@ -0,0 +1,289 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::collections::HashSet;
use smithay_client_toolkit::
{
compositor::Surface,
shell::
{
WaylandSurface,
xdg::
{
XdgPositioner, XdgSurface,
popup::Popup,
},
},
};
use wayland_protocols::xdg::shell::client::xdg_positioner::
{
Anchor as PositionerAnchor,
ConstraintAdjustment,
Gravity,
};
use crate::app::App;
use crate::types::Rect;
use super::{ AppData, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState };
/// 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.
pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
{
let mut specs = data.app.overlays();
if let Some( ts ) = data.tooltip_overlay() { specs.push( ts ); }
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;
ss.layer_anchor = Some( spec.anchor );
overlays_m.insert( spec.id, ss );
}
}

50
src/event_loop/repeat.rs Normal file
View File

@@ -0,0 +1,50 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use calloop::RegistrationToken;
use smithay_client_toolkit::seat::keyboard::KeyEvent;
/// Snapshot of the key whose repeat timer is currently armed.
///
/// Stored on [`super::app_data::AppData::key_repeat`] between press and
/// release of a held-down key. The timer's calloop callback reads this
/// back to re-dispatch the same `KeyEvent` against the same focus, so a
/// held arrow key keeps moving the cursor in the originally-focused text
/// input even if the focus migrates mid-repeat (which it should not —
/// release cancels the timer, and focus changes only happen via Tab /
/// pointer events that arrive after `release_key` is processed).
pub( crate ) struct KeyRepeatState
{
pub event: KeyEvent,
pub token: RegistrationToken,
}
/// Snapshot of the button whose press-and-hold repeat timer is
/// currently armed. Mirrors [`KeyRepeatState`] for pointer / touch
/// presses on a [`crate::widget::button::Button`] built with
/// `.repeating( true )`. The timer's callback re-runs the press
/// against the *current* widget tree: it looks the pressed widget
/// up by `flat_idx` on the focus surface, reads its `on_press`
/// fresh, and pushes that into the pending queue. Re-resolving each
/// tick is what makes a stepper button work — the message a stepper
/// builds is `"go to value + step"` computed at view-build time, so
/// replaying the captured message would re-issue the same target
/// after the first fire and the user would see the value freeze
/// after one step. Reading the live `on_press` instead picks up the
/// new target the time picker just rebuilt with the updated value.
///
/// Cancelled on release, pointer leave, gesture cancel, focus loss
/// and long-press promotion. Also self-cancels on the first tick
/// where the widget at `idx` no longer exists or no longer carries
/// an `on_press` message — view restructuring (e.g. tab switch)
/// invalidates the snapshot and we stop rather than firing the
/// wrong widget's message.
pub( crate ) struct ButtonRepeatState
{
/// calloop registration handle so [`super::app_data::AppData::stop_button_repeat`]
/// can remove the timer from the event loop without leaking the
/// source. The surface focus and pressed `flat_idx` live in
/// the timer's closure capture instead of here — the only
/// caller that needs to act on them is the closure itself.
pub token: RegistrationToken,
}

482
src/event_loop/run.rs Normal file
View File

@@ -0,0 +1,482 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use smithay_client_toolkit::
{
compositor::CompositorState,
output::OutputState,
registry::RegistryState,
seat::SeatState,
shell::
{
WaylandSurface,
wlr_layer::LayerShell,
xdg::
{
XdgShell,
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 crate::app::{ App, InvalidationScope };
use crate::draw::draw_frame;
use crate::types::Point;
use super::{ AppData, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState };
use super::error::RunError;
use super::invalidation::apply_invalidation;
use super::overlays_reconcile::reconcile_overlays;
/// 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:?}" ) } )
};
// 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 ) ) =>
{
window.set_min_size( Some( ( w, h ) ) );
window.set_max_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();
// `ext-foreign-toplevel-list-v1`. SCTK's `ForeignToplevelList` handles the
// bind + dispatch routing; absence of the global is fine, the inner
// `GlobalProxy` then yields no toplevels and `App::on_toplevel_event` never
// fires. The list is built unconditionally so apps that override the
// callback always see a consistent stream when the compositor does carry
// the protocol.
let foreign_toplevel_list = smithay_client_toolkit::foreign_toplevel_list::ForeignToplevelList::new( &globals, &qh );
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 pending_size_hint_unpin = !app.start_fullscreen() && app.window_size_hint().is_some();
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,
foreign_toplevel_list,
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(),
tooltip_pending: None,
tooltip_visible: None,
qh: qh.clone(),
last_pointer_serial: 0,
last_input_serial: 0,
exit_requested: false,
pending_fullscreen,
pending_size_hint_unpin,
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 = match ( data.next_long_press_wakeup(), data.next_tooltip_wakeup() )
{
( Some( a ), Some( b ) ) => Some( a.min( b ) ),
( a, b ) => a.or( b ),
};
match event_loop.dispatch( timeout, &mut data )
{
Ok( () ) => {},
Err( calloop::Error::IoError( ref e ) )
if matches!( e.kind(), std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::ConnectionReset ) =>
{
// On a fatal `wl_display.error` the compositor closes the
// socket; the backend has the typed error stashed but the
// dispatch result only carries the resulting `BrokenPipe`.
if let Some( pe ) = conn.protocol_error()
{
eprintln!(
"ltk: wayland protocol error — interface={} object_id={} code={}: {}",
pe.object_interface, pe.object_id, pe.code, pe.message
);
}
else
{
eprintln!( "ltk: wayland connection lost ({e}); exiting" );
}
data.exit_requested = true;
continue;
}
Err( e ) => panic!( "dispatch: {e:?}" ),
}
// 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();
data.check_tooltip_deadline();
// 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
{
let mut specs = data.app.overlays();
if let Some( ts ) = data.tooltip_overlay() { specs.push( ts ); }
data.cached_overlays = Some( specs );
data.overlays_dirty = false;
}
draw_frame( &mut data );
}
// Focus the widget with the requested WidgetId if the app requests it.
// Walks main + every overlay because the targeted widget can live
// on any of them (a search field on a launcher overlay, a text
// edit on a dialog modal, …).
if let Some( id ) = data.app.take_focus_request()
{
let mut hit: Option<( SurfaceFocus, usize )> = data.main.widget_rects.iter()
.find( |w| w.id == Some( id ) )
.map( |w| ( SurfaceFocus::Main, w.flat_idx ) );
if hit.is_none()
{
for ( ov_id, surf ) in &data.overlays
{
if let Some( w ) = surf.widget_rects.iter().find( |w| w.id == Some( id ) )
{
hit = Some( ( SurfaceFocus::Overlay( *ov_id ), w.flat_idx ) );
break;
}
}
}
if let Some( ( focus, flat_idx ) ) = hit
{
let qh = data.qh.clone();
data.set_focus( focus, Some( flat_idx ), &qh );
match focus
{
SurfaceFocus::Main => data.main.request_redraw(),
SurfaceFocus::Overlay( id ) =>
{
if let Some( s ) = data.overlays.get_mut( &id )
{
s.request_redraw();
}
}
}
}
}
}
Ok( () )
}

380
src/event_loop/surface.rs Normal file
View File

@@ -0,0 +1,380 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use smithay_client_toolkit::
{
compositor::CompositorState,
shell::
{
WaylandSurface,
wlr_layer::{ KeyboardInteractivity, Layer, LayerShell, LayerSurface },
xdg::{ popup::Popup, window::Window },
},
shm::{ Shm, slot::SlotPool },
};
use smithay_client_toolkit::reexports::client::
{
protocol::wl_surface::WlSurface,
QueueHandle,
};
use std::collections::HashMap;
use std::sync::Arc;
use super::app_data::AppData;
use crate::app::OverlayId;
use crate::egl_context::{ EglContext, EglSurface };
use crate::input::GestureState;
use crate::render::Canvas;
use crate::types::{ Point, Rect, WidgetId };
use crate::widget::LaidOutWidget;
/// Identifies which surface an event, focus, or draw call belongs to.
///
/// `Main` refers to the application's main surface (xdg window or layer
/// shell). `Overlay( id )` refers to an auxiliary layer-shell surface created
/// from an entry in [`crate::app::App::overlays`].
#[derive( Debug, Clone, Copy, PartialEq, Eq, Hash )]
pub( crate ) enum SurfaceFocus
{
Main,
#[allow( dead_code )]
Overlay( OverlayId ),
}
/// Configuration for a layer-shell surface, used both for the main surface
/// (when the app uses [`crate::app::ShellMode::Layer`]) and for each overlay
/// returned by [`crate::app::App::overlays`].
#[derive( Clone )]
pub( crate ) struct LayerConfig
{
pub layer: Layer,
pub exclusive_zone: i32,
pub anchor: crate::app::Anchor,
pub size: ( u32, u32 ),
pub keyboard_exclusive: bool,
/// Wayland surface role namespace, sent to the compositor for debugging.
pub namespace: &'static str,
}
pub( crate ) enum SurfaceKind
{
/// Layer shell surface, fully created and committed.
Layer( LayerSurface ),
/// XDG window fallback (no layer shell compositor support).
Window( Window ),
/// Layer shell is available but we are waiting for an output to be
/// advertised before creating the Wayland surface.
Pending( LayerConfig ),
/// XDG popup, child of the main window. Used for combo dropdowns,
/// context menus, tooltips. The compositor positions it relative
/// to an anchor rect specified at creation time and may flip it
/// (drop-up vs drop-down) when constrained.
Popup( Popup ),
}
impl SurfaceKind
{
pub( crate ) fn wl_surface( &self ) -> &WlSurface
{
match self
{
SurfaceKind::Layer( l ) => l.wl_surface(),
SurfaceKind::Window( w ) => w.wl_surface(),
SurfaceKind::Popup( p ) => p.wl_surface(),
// Unreachable: draw_frame is only called when configured == true,
// which only becomes true after on_configure, which requires a real surface.
SurfaceKind::Pending( .. ) => unreachable!( "surface not yet created" ),
}
}
/// Same as [`wl_surface`] but returns `None` for a [`SurfaceKind::Pending`]
/// surface instead of panicking. Used by event dispatch to look up which
/// [`SurfaceState`] an incoming Wayland event belongs to without risk of
/// crashing during startup.
pub( crate ) fn try_wl_surface( &self ) -> Option<&WlSurface>
{
match self
{
SurfaceKind::Layer( l ) => Some( l.wl_surface() ),
SurfaceKind::Window( w ) => Some( w.wl_surface() ),
SurfaceKind::Popup( p ) => Some( p.wl_surface() ),
SurfaceKind::Pending( .. ) => None,
}
}
/// Create the actual layer surface on `output` using `layer_shell`. Returns
/// true if the surface was created (i.e., was `Pending` before).
pub( crate ) fn materialize(
&mut self,
compositor: &CompositorState,
layer_shell: &LayerShell,
qh: &QueueHandle<AppData<impl crate::app::App>>,
output: &smithay_client_toolkit::reexports::client::protocol::wl_output::WlOutput,
) -> bool
{
if let SurfaceKind::Pending( ref cfg ) = *self
{
let surface = compositor.create_surface( qh );
let layer_surface = layer_shell.create_layer_surface(
qh,
surface,
cfg.layer,
Some( cfg.namespace ),
Some( output ),
);
layer_surface.set_exclusive_zone( cfg.exclusive_zone );
layer_surface.set_anchor( cfg.anchor.to_wlr_anchor() );
let interactivity = if cfg.keyboard_exclusive
{
KeyboardInteractivity::Exclusive
} else {
KeyboardInteractivity::OnDemand
};
layer_surface.set_keyboard_interactivity( interactivity );
layer_surface.set_size( cfg.size.0, cfg.size.1 );
layer_surface.commit();
*self = SurfaceKind::Layer( layer_surface );
true
} else {
false
}
}
}
/// Per-surface render + interaction state.
///
/// Each Wayland surface managed by the event loop (main surface and any
/// active overlays) owns an instance of this struct, so multiple surfaces
/// can coexist without interfering.
pub( crate ) struct SurfaceState<Msg: Clone>
{
pub pool: Option<SlotPool>,
/// EGL window surface for the GPU path. Populated on the first configure
/// when an [`EglContext`] is available; mutually exclusive with `pool`
/// (the path is locked after the first configure).
pub egl_surface: Option<EglSurface>,
pub surface: SurfaceKind,
pub canvas: Option<Canvas>,
pub width: u32,
pub height: u32,
pub configured: bool,
pub needs_redraw: bool,
/// Set to `true` when the app's view may have changed in a way that rects
/// don't capture (text content, progress value, slider thumb position, etc).
/// Interaction-only transitions (hover in/out, pressed in/out) leave this
/// `false` so the draw path can take the partial-redraw fast path.
/// Always reset to `false` at the end of a successful draw.
pub content_dirty: bool,
pub last_draw: std::time::Instant,
pub focused_idx: Option<usize>,
pub focused_id: Option<WidgetId>,
pub hovered_idx: Option<usize>,
pub widget_rects: Vec<LaidOutWidget<Msg>>,
pub cursor_state: HashMap<usize, usize>,
/// Selection anchor (byte offset). When equal to the cursor, there
/// is no active selection. Mutated jointly with `cursor_state`:
/// plain arrow keys collapse the selection by setting anchor =
/// cursor; Shift+arrow extends by leaving anchor put while moving
/// cursor; pointer drags set both ends.
pub selection_anchor: HashMap<usize, usize>,
pub pending_text_values: HashMap<usize, String>,
pub scroll_offsets: HashMap<usize, f32>,
pub scroll_rects: Vec<( Rect, usize )>,
pub scroll_canvases: HashMap<usize, Canvas>,
/// Per-scroll list of `(flat_idx, content_y, height)` entries for
/// every interactive item the scroll's child laid out, in document
/// order. Includes items currently scrolled off-screen so keyboard
/// arrow handlers can step `hovered_idx` item-by-item without
/// regenerating the layout pass. Y is in pre-translation,
/// pre-offset content coordinates.
pub scroll_navigable_items: HashMap<usize, Vec<( usize, f32, f32 )>>,
/// Built-in Copy / Cut / Paste context menu when shown on this
/// surface. `None` means no menu is up.
pub context_menu: Option<super::context_menu::ContextMenu>,
/// Per-surface gesture state machine. Owns the press →
/// (long-press? / drag? / scroll? / swipe?) → release lifecycle,
/// driven by both pointer and touch handlers. The input layer
/// (`src/input/`) calls the lifecycle methods; the long-press
/// deadline poller reaches into `gesture.long_press_*` directly.
pub gesture: GestureState<Msg>,
/// Previous frame interaction state for damage tracking
pub prev_focused: Option<usize>,
pub prev_hovered: Option<usize>,
pub prev_pressed: Option<usize>,
/// Height of the client-side title bar (0 for layer-shell surfaces).
pub titlebar_height: f32,
/// Title text shown in the client-side title bar.
pub titlebar_title: String,
/// Rect of the close button in the title bar (for hit testing).
pub titlebar_close_rect: Rect,
/// Compositor scale factor (integer, e.g. 1 or 2). Used to create
/// high-DPI buffers and set `dpi_scale` on the canvas.
pub scale_factor: i32,
/// Last size requested from the compositor via `layer_surface.set_size`.
/// Compared against the app's current [`crate::app::OverlaySpec::size`]
/// each reconcile pass so that overlays can resize after creation (used
/// for slide-down / grow animations). `(0, 0)` for non-layer surfaces
/// and for layer surfaces before their first configure.
pub last_requested_size: ( u32, u32 ),
pub layer_anchor: Option<crate::app::Anchor>,
/// Anchor rect the xdg-popup positioner was last configured with.
/// `None` for non-popup surfaces.
pub last_popup_anchor: Option<Rect>,
/// Monotonic counter for `xdg_popup.reposition` tokens.
pub popup_reposition_token: u32,
/// `true` after the surface has been committed and we are waiting for the
/// matching `wl_surface.frame` callback to fire. Drawing is gated on this
/// being `false`: even if `needs_redraw` flips back to `true` we sit on
/// the next frame until the compositor signals it's time to commit again,
/// so the per-surface pacing follows the compositor (and the display
/// refresh / VRR / "screen is off") instead of a fixed-period timer.
pub frame_pending: bool,
}
impl<Msg: Clone> SurfaceState<Msg>
{
pub( crate ) fn new( surface: SurfaceKind, titlebar_height: f32, titlebar_title: String ) -> Self
{
Self {
pool: None,
egl_surface: None,
surface,
canvas: None,
width: 0,
height: 0,
configured: false,
needs_redraw: false,
content_dirty: true,
last_draw: std::time::Instant::now() - std::time::Duration::from_millis( 100 ),
focused_idx: None,
focused_id: None,
hovered_idx: None,
widget_rects: Vec::new(),
cursor_state: HashMap::new(),
selection_anchor: HashMap::new(),
pending_text_values: HashMap::new(),
scroll_offsets: HashMap::new(),
scroll_rects: Vec::new(),
scroll_navigable_items: HashMap::new(),
context_menu: None,
scroll_canvases: HashMap::new(),
gesture: GestureState::new(),
prev_focused: None,
prev_hovered: None,
prev_pressed: None,
titlebar_height,
titlebar_title,
titlebar_close_rect: Rect::default(),
scale_factor: 1,
last_requested_size: ( 0, 0 ),
layer_anchor: None,
last_popup_anchor: None,
popup_reposition_token: 0,
frame_pending: false,
}
}
/// Convert a logical (surface-local) position to physical pixel coordinates.
pub( crate ) fn to_physical( &self, x: f64, y: f64 ) -> Point
{
let sf = self.scale_factor.max( 1 ) as f32;
Point { x: x as f32 * sf, y: y as f32 * sf }
}
/// Surface width in physical pixels (logical × buffer scale). Matches the
/// coordinate space used by pointer/touch callbacks and layout, so swipe
/// thresholds can be compared against physical `dx` / `start.x` without
/// unit mixing.
pub( crate ) fn physical_width( &self ) -> u32
{
self.width * self.scale_factor.max( 1 ) as u32
}
/// Surface height in physical pixels. See [`Self::physical_width`].
pub( crate ) fn physical_height( &self ) -> u32
{
self.height * self.scale_factor.max( 1 ) as u32
}
/// Configure the surface with a new logical size. Picks the rendering path
/// on the very first configure (GPU when `egl_context` is `Some` and the
/// EGL window can be created, SHM otherwise) and just resizes the existing
/// path on subsequent configures. The two paths are mutually exclusive
/// (`pool.is_some()` ⇔ SHM, `egl_surface.is_some()` ⇔ GPU) and locked
/// after the first configure — switching backends mid-life would require
/// tearing down the canvas as well.
pub( crate ) fn on_configure(
&mut self,
shm: &Shm,
egl_context: Option<&Arc<EglContext>>,
w: u32,
h: u32,
)
{
self.width = w;
self.height = h;
let scale = self.scale_factor.max( 1 ) as u32;
let pw = w * scale;
let ph = h * scale;
if self.egl_surface.is_some()
{
// GPU path: resize the wl_egl_window so the next eglSwapBuffers
// publishes a buffer of the new size. The canvas FBO is reallocated
// by `Canvas::resize`, but only after we eglMakeCurrent — the draw
// path will do that, so we just record the pending size here.
if let Some( ref es ) = self.egl_surface
{
es.resize( pw as i32, ph as i32 );
}
} else if self.pool.is_some() {
// SHM path: reallocate the pool to fit the new buffer size.
self.pool = Some(
SlotPool::new( ( pw * ph * 4 ) as usize, shm ).expect( "pool" ),
);
} else {
// First configure — pick the path. Try GPU when the bootstrap
// succeeded; fall back to SHM if surface creation fails.
let mut chose_gpu = false;
if let Some( ctx ) = egl_context
{
match ctx.create_surface( self.surface.wl_surface(), pw as i32, ph as i32 )
{
Ok( es ) =>
{
self.egl_surface = Some( es );
chose_gpu = true;
}
Err( e ) =>
{
eprintln!( "[ltk] eglCreateWindowSurface failed: {e} — using SHM" );
}
}
}
if !chose_gpu
{
self.pool = Some(
SlotPool::new( ( pw * ph * 4 ) as usize, shm ).expect( "pool" ),
);
}
}
self.surface.wl_surface().set_buffer_scale( self.scale_factor.max( 1 ) );
self.configured = true;
self.needs_redraw = true;
self.content_dirty = true;
}
/// Request a redraw and mark the surface's content as dirty.
///
/// Use this from every site that potentially changes what the app's view
/// returns (text edits, scroll offset, slider value, app messages, animation
/// tick, configure). Pure hover/pressed transitions should leave
/// `content_dirty` alone and only set `needs_redraw` directly so the draw
/// pass can take the cheap partial-redraw path.
pub( crate ) fn request_redraw( &mut self )
{
self.needs_redraw = true;
self.content_dirty = true;
}
}

View File

@@ -0,0 +1,193 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::app::App;
use crate::event_loop::app_data::AppData;
use crate::event_loop::surface::SurfaceFocus;
impl<A: App> AppData<A>
{
/// Read the current text value of the focused widget — preferring
/// the pending typed-but-not-yet-applied value when present, falling
/// back to the value snapshot from the per-frame handler list.
pub( crate ) fn focused_text_value( &self, focus: SurfaceFocus ) -> Option<( usize, String )>
{
let idx = self.surface( focus ).focused_idx?;
let value = if let Some( pending ) = self.surface( focus ).pending_text_values.get( &idx )
{
pending.clone()
} else {
crate::tree::find_handlers( &self.surface( focus ).widget_rects, idx )?
.current_value()
.map( |s| s.to_string() )
.unwrap_or_default()
};
Some( ( idx, value ) )
}
/// Move the text cursor one codepoint to the left. With `extend`,
/// the selection anchor stays put (Shift+Left widens the
/// selection); without it, the selection is collapsed to the new
/// cursor position. No-op when no text input is focused.
pub( crate ) fn handle_cursor_left( &mut self, focus: SurfaceFocus, extend: bool )
{
let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return };
let cursor = self.surface( focus ).cursor_state.get( &idx ).copied()
.unwrap_or( value.len() ).min( value.len() );
let anchor = self.surface( focus ).selection_anchor.get( &idx ).copied()
.unwrap_or( cursor ).min( value.len() );
// Without shift: if a selection exists, collapse to its start.
if !extend && anchor != cursor
{
let s = cursor.min( anchor );
let ss = self.surface_mut( focus );
*ss.cursor_state.entry( idx ).or_insert( 0 ) = s;
*ss.selection_anchor.entry( idx ).or_insert( 0 ) = s;
ss.request_redraw();
return;
}
if cursor == 0 { return; }
let prev_char = value[..cursor].chars().last();
let step = prev_char.map( |c| c.len_utf8() ).unwrap_or( 1 );
let new_cursor = cursor.saturating_sub( step );
let ss = self.surface_mut( focus );
*ss.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor;
if !extend { *ss.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; }
ss.request_redraw();
}
/// Move the text cursor one codepoint to the right.
pub( crate ) fn handle_cursor_right( &mut self, focus: SurfaceFocus, extend: bool )
{
let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return };
let cursor = self.surface( focus ).cursor_state.get( &idx ).copied()
.unwrap_or( value.len() ).min( value.len() );
let anchor = self.surface( focus ).selection_anchor.get( &idx ).copied()
.unwrap_or( cursor ).min( value.len() );
if !extend && anchor != cursor
{
let e = cursor.max( anchor );
let ss = self.surface_mut( focus );
*ss.cursor_state.entry( idx ).or_insert( 0 ) = e;
*ss.selection_anchor.entry( idx ).or_insert( 0 ) = e;
ss.request_redraw();
return;
}
if cursor >= value.len() { return; }
let next_char = value[cursor..].chars().next();
let step = next_char.map( |c| c.len_utf8() ).unwrap_or( 1 );
let new_cursor = ( cursor + step ).min( value.len() );
let ss = self.surface_mut( focus );
*ss.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor;
if !extend { *ss.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; }
ss.request_redraw();
}
/// Move the text cursor up one *visual* row. With `extend`, the
/// selection anchor stays put.
///
/// In multiline mode the move follows the soft-wrapped layout — a
/// long logical line that wraps onto N visual rows lets Up step
/// through each of those rows in turn, instead of jumping straight
/// over them to the previous hard `\n`. Single-line inputs and
/// secure inputs both render as one visual row, so Up always
/// returns `false` and the runtime falls through to sibling-widget
/// keyboard navigation.
pub( crate ) fn handle_cursor_up( &mut self, focus: SurfaceFocus, extend: bool ) -> bool
{
let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return false };
let cursor = self.surface( focus ).cursor_state.get( &idx ).copied()
.unwrap_or( value.len() ).min( value.len() );
let ( rect, _, multiline, secure, _align, _font_size ) = match self.text_input_geometry( focus, idx )
{
Some( g ) => g,
None => return false,
};
if !multiline || secure { return false; }
let canvas = match self.surface( focus ).canvas.as_ref() { Some( c ) => c, None => return false };
let new_cursor = match crate::widget::text_edit::cursor_visual_up( canvas, rect, &value, cursor )
{
Some( n ) => n,
None => return false,
};
let ss = self.surface_mut( focus );
*ss.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor;
if !extend { *ss.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; }
ss.request_redraw();
true
}
/// Move the text cursor down one *visual* row. Mirror of
/// [`Self::handle_cursor_up`].
pub( crate ) fn handle_cursor_down( &mut self, focus: SurfaceFocus, extend: bool ) -> bool
{
let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return false };
let cursor = self.surface( focus ).cursor_state.get( &idx ).copied()
.unwrap_or( value.len() ).min( value.len() );
let ( rect, _, multiline, secure, _align, _font_size ) = match self.text_input_geometry( focus, idx )
{
Some( g ) => g,
None => return false,
};
if !multiline || secure { return false; }
let canvas = match self.surface( focus ).canvas.as_ref() { Some( c ) => c, None => return false };
let new_cursor = match crate::widget::text_edit::cursor_visual_down( canvas, rect, &value, cursor )
{
Some( n ) => n,
None => return false,
};
let ss = self.surface_mut( focus );
*ss.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor;
if !extend { *ss.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; }
ss.request_redraw();
true
}
/// Move the cursor to the start of the current *visual* row (Home
/// key). On a soft-wrapped logical line that lands at the wrap
/// point of the row the caret is on — a second Home press from
/// there does not move further, since the visual row already
/// starts at that boundary.
pub( crate ) fn handle_cursor_home( &mut self, focus: SurfaceFocus, extend: bool )
{
let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return };
let cursor = self.surface( focus ).cursor_state.get( &idx ).copied()
.unwrap_or( value.len() ).min( value.len() );
let new_cursor = match self.text_input_geometry( focus, idx )
{
Some( ( rect, _, true, false, _, _ ) ) => match self.surface( focus ).canvas.as_ref()
{
Some( canvas ) => crate::widget::text_edit::cursor_visual_home( canvas, rect, &value, cursor ),
None => value[..cursor].rfind( '\n' ).map( |p| p + 1 ).unwrap_or( 0 ),
},
_ => value[..cursor].rfind( '\n' ).map( |p| p + 1 ).unwrap_or( 0 ),
};
let ss = self.surface_mut( focus );
*ss.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor;
if !extend { *ss.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; }
ss.request_redraw();
}
/// Move the cursor to the end of the current *visual* row (End
/// key). For a soft-wrapped row that's the wrap point; for a row
/// terminated by `\n` it's the byte just before the newline.
pub( crate ) fn handle_cursor_end( &mut self, focus: SurfaceFocus, extend: bool )
{
let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return };
let cursor = self.surface( focus ).cursor_state.get( &idx ).copied()
.unwrap_or( value.len() ).min( value.len() );
let new_cursor = match self.text_input_geometry( focus, idx )
{
Some( ( rect, _, true, false, _, _ ) ) => match self.surface( focus ).canvas.as_ref()
{
Some( canvas ) => crate::widget::text_edit::cursor_visual_end( canvas, rect, &value, cursor ),
None => value[cursor..].find( '\n' ).map( |p| cursor + p ).unwrap_or( value.len() ),
},
_ => value[cursor..].find( '\n' ).map( |p| cursor + p ).unwrap_or( value.len() ),
};
let ss = self.surface_mut( focus );
*ss.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor;
if !extend { *ss.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; }
ss.request_redraw();
}
}

View File

@@ -0,0 +1,71 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use smithay_client_toolkit::reexports::client::QueueHandle;
use wayland_protocols::wp::text_input::zv3::client::zwp_text_input_v3;
use crate::app::App;
use crate::event_loop::app_data::AppData;
use crate::event_loop::surface::SurfaceFocus;
use crate::tree::find_widget;
use crate::types::Rect;
use crate::widget::WidgetHandlers;
impl<A: App> AppData<A>
{
pub( crate ) fn activate_text_input( &mut self, qh: &QueueHandle<Self> )
{
if let ( Some( manager ), None ) = ( &self.text_input_manager, &self.text_input )
{
let seats: Vec<_> = self.seat_state.seats().collect();
if let Some( seat ) = seats.into_iter().next()
{
let ti = manager.get_text_input( &seat, qh, () );
ti.enable();
ti.set_content_type(
zwp_text_input_v3::ContentHint::None,
zwp_text_input_v3::ContentPurpose::Normal,
);
ti.commit();
self.text_input = Some( ti );
}
}
}
pub( crate ) fn deactivate_text_input( &mut self )
{
if let Some( ti ) = self.text_input.take()
{
ti.disable();
ti.commit();
ti.destroy();
}
}
/// Snapshot a focused-widget's geometry needed by the pointer
/// hit-testers for text editing. Returns `None` when the widget
/// isn't a TextEdit or its rect is missing — the helper above
/// short-circuits in that case.
pub( crate ) fn text_input_geometry(
&self,
focus: SurfaceFocus,
idx: usize,
) -> Option<( Rect, String, bool, bool, crate::widget::text::TextAlign, f32 )>
{
let ss = self.surface( focus );
let widget = find_widget( &ss.widget_rects, idx )?;
let ( value_handler, multiline, secure, align, font_size ) = match &widget.handlers
{
WidgetHandlers::TextEdit { value, multiline, secure, align, font_size, .. } =>
( value.clone(), *multiline, *secure, *align, *font_size ),
_ => return None,
};
// Prefer the *pending* value (typed-but-not-yet-applied) when
// it exists — that's what the user sees right now and what
// the cursor measurements should be relative to.
let value = ss.pending_text_values.get( &idx )
.cloned()
.unwrap_or( value_handler );
Some( ( widget.rect, value, multiline, secure, align, font_size ) )
}
}

View File

@@ -0,0 +1,194 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::app::App;
use crate::event_loop::app_data::AppData;
use crate::event_loop::surface::SurfaceFocus;
use crate::tree::find_handlers;
use crate::widget::WidgetHandlers;
impl<A: App> AppData<A>
{
pub( crate ) fn handle_text_insert( &mut self, focus: SurfaceFocus, text: &str )
{
let idx = match self.surface( focus ).focused_idx { Some( i ) => i, None => return };
// If a selection is active, replace it. The deletion path
// folds anchor=cursor and updates `pending_text_values`, so
// the rest of this fn keeps the same shape after.
let _ = self.delete_selection( focus );
let current_value = if let Some( pending ) = self.surface( focus ).pending_text_values.get( &idx )
{
pending.clone()
} else {
find_handlers( &self.surface( focus ).widget_rects, idx )
.and_then( |h| h.current_value() )
.map( |s| s.to_string() )
.unwrap_or_default()
};
// `select_on_focus` fields keep the cursor pinned to the end
// of the *displayed* value. The pending value the user just
// typed (e.g. "2") may render shorter than the post-update
// display value (e.g. "02" after a `format!("{:02}")`
// normalisation in the app's on_change handler), so anchoring
// the cursor at "end of pending" would land it mid-string in
// the next render. `usize::MAX` is a sentinel: every consumer
// (`draw`, arrow-key handlers, `byte_offset_at`) clamps via
// `cursor.min( value.len() )`, which resolves to "end of
// whatever value we eventually render". Without this, typing
// a second digit would insert into the middle of the
// normalised string ("0|2" + "3" → "032" → 32 instead of 23).
let select_on_focus = matches!(
find_handlers( &self.surface( focus ).widget_rects, idx ),
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ),
);
let new_value;
{
let ss = self.surface_mut( focus );
let cursor = ss.cursor_state.entry( idx ).or_insert( current_value.len() );
let safe_cursor = (*cursor).min( current_value.len() );
let mut v = current_value.clone();
v.insert_str( safe_cursor, text );
let next_cursor = if select_on_focus { usize::MAX } else { safe_cursor + text.len() };
*ss.cursor_state.entry( idx ).or_insert( 0 ) = next_cursor;
*ss.selection_anchor.entry( idx ).or_insert( 0 ) = next_cursor;
ss.pending_text_values.insert( idx, v.clone() );
ss.request_redraw();
new_value = v;
}
let msg = find_handlers( &self.surface( focus ).widget_rects, idx )
.and_then( |h| h.text_change_msg( &new_value ) );
if let Some( m ) = msg
{
self.pending_msgs.push( m );
}
}
/// Forward delete — the `Delete` (Supr) key. Mirrors
/// [`Self::handle_backspace`] but removes the character *after*
/// the cursor instead of before. Selection-aware: if a range is
/// active it is removed in one step, exactly like backspace.
pub( crate ) fn handle_delete_forward( &mut self, focus: SurfaceFocus )
{
let idx = match self.surface( focus ).focused_idx { Some( i ) => i, None => return };
if let Some( new_value ) = self.delete_selection( focus )
{
let msg = find_handlers( &self.surface( focus ).widget_rects, idx )
.and_then( |h| h.text_change_msg( &new_value ) );
if let Some( m ) = msg { self.pending_msgs.push( m ); }
return;
}
let current_value = if let Some( pending ) = self.surface( focus ).pending_text_values.get( &idx )
{
pending.clone()
} else {
find_handlers( &self.surface( focus ).widget_rects, idx )
.and_then( |h| h.current_value() )
.map( |s| s.to_string() )
.unwrap_or_default()
};
let cursor_val = self.surface( focus ).cursor_state.get( &idx ).copied()
.unwrap_or( current_value.len() );
let safe_cursor_pre = cursor_val.min( current_value.len() );
if safe_cursor_pre >= current_value.len() { return; }
// Width of the char *starting* at the cursor — UTF-8 aware so
// `é` / `🦀` come out as one keypress.
let next_char = current_value[safe_cursor_pre..].chars().next();
let step = match next_char { Some( c ) => c.len_utf8(), None => return };
let mut new_value = current_value.clone();
new_value.replace_range( safe_cursor_pre..safe_cursor_pre + step, "" );
// `select_on_focus` fields keep the cursor at the end of the
// post-update displayed value via the `usize::MAX` sentinel —
// see `handle_text_insert` for the reasoning.
let select_on_focus = matches!(
find_handlers( &self.surface( focus ).widget_rects, idx ),
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ),
);
let next_cursor = if select_on_focus { usize::MAX } else { safe_cursor_pre };
{
let ss = self.surface_mut( focus );
// Cursor stays put — the char to its right is gone, so the
// remaining tail shifts left under it.
*ss.cursor_state.entry( idx ).or_insert( 0 ) = next_cursor;
*ss.selection_anchor.entry( idx ).or_insert( 0 ) = next_cursor;
ss.pending_text_values.insert( idx, new_value.clone() );
ss.request_redraw();
}
let msg = find_handlers( &self.surface( focus ).widget_rects, idx )
.and_then( |h| h.text_change_msg( &new_value ) );
if let Some( m ) = msg
{
self.pending_msgs.push( m );
}
}
pub( crate ) fn handle_backspace( &mut self, focus: SurfaceFocus )
{
let idx = match self.surface( focus ).focused_idx { Some( i ) => i, None => return };
// Selection-aware: if a selection is active, backspace just
// deletes the range. The on_change message is emitted from
// here so the app sees a single text update.
if let Some( new_value ) = self.delete_selection( focus )
{
let msg = find_handlers( &self.surface( focus ).widget_rects, idx )
.and_then( |h| h.text_change_msg( &new_value ) );
if let Some( m ) = msg { self.pending_msgs.push( m ); }
return;
}
let current_value = if let Some( pending ) = self.surface( focus ).pending_text_values.get( &idx )
{
pending.clone()
} else {
find_handlers( &self.surface( focus ).widget_rects, idx )
.and_then( |h| h.current_value() )
.map( |s| s.to_string() )
.unwrap_or_default()
};
// Scoped block to release the immutable borrow of `self` (via
// `self.surface( focus )`) before taking a mutable one below.
let cursor_val = self.surface( focus ).cursor_state.get( &idx ).copied()
.unwrap_or( current_value.len() );
if cursor_val == 0 { return; }
let safe_cursor = cursor_val.min( current_value.len() );
let chars: Vec<char> = current_value[..safe_cursor].chars().collect();
if chars.is_empty() { return; }
let removed_char = *chars.last().unwrap();
let new_cursor = safe_cursor - removed_char.len_utf8();
let mut new_value = current_value.clone();
new_value.remove( new_cursor );
// `select_on_focus` fields keep the cursor at the end of the
// post-update displayed value via the `usize::MAX` sentinel —
// see `handle_text_insert` for the reasoning.
let select_on_focus = matches!(
find_handlers( &self.surface( focus ).widget_rects, idx ),
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ),
);
let next_cursor = if select_on_focus { usize::MAX } else { new_cursor };
{
let ss = self.surface_mut( focus );
*ss.cursor_state.entry( idx ).or_insert( 0 ) = next_cursor;
*ss.selection_anchor.entry( idx ).or_insert( 0 ) = next_cursor;
ss.pending_text_values.insert( idx, new_value.clone() );
ss.request_redraw();
}
let msg = find_handlers( &self.surface( focus ).widget_rects, idx )
.and_then( |h| h.text_change_msg( &new_value ) );
if let Some( m ) = msg
{
self.pending_msgs.push( m );
}
}
}

View File

@@ -0,0 +1,12 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Text-editing dispatch on [`super::app_data::AppData`]: cursor
//! movement, insert/delete, selection, IME activation and clipboard
//! integration. Lives on the runtime side; the corresponding widget
//! (`crate::widget::text_edit`) only owns the layout / draw path.
pub( crate ) mod cursor;
pub( crate ) mod insert_delete;
pub( crate ) mod selection;
pub( crate ) mod ime;

View File

@@ -0,0 +1,263 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::app::App;
use crate::event_loop::app_data::AppData;
use crate::event_loop::surface::SurfaceFocus;
use crate::tree::find_handlers;
use crate::types::Point;
use crate::widget::WidgetHandlers;
/// Compute the byte range of the "word" surrounding `byte_offset` in
/// `value`. A word is a maximal run of alphanumeric / underscore
/// chars; whitespace and punctuation count as separators. When
/// `byte_offset` falls *between* two non-word chars, both bounds
/// collapse to that offset (zero-width selection — the caller is
/// expected to either accept that or fall back to a click-position
/// cursor).
fn word_bounds_at( value: &str, byte_offset: usize ) -> ( usize, usize )
{
if value.is_empty() { return ( 0, 0 ); }
let bo = byte_offset.min( value.len() );
let is_word = | c: char | c.is_alphanumeric() || c == '_';
// Walk left from `bo` until we cross a non-word boundary.
let mut start = bo;
{
let mut iter = value[..bo].char_indices().rev();
while let Some( ( i, c ) ) = iter.next()
{
if is_word( c ) { start = i; } else { break; }
}
}
// Walk right from `bo` until the same.
let mut end = bo;
for ( i, c ) in value[bo..].char_indices()
{
if is_word( c ) { end = bo + i + c.len_utf8(); } else { break; }
}
( start, end )
}
impl<A: App> AppData<A>
{
/// `true` when this press should be treated as the second click
/// of a double-click pair. Looks at the timestamp + position of
/// the previous press and compares against a 400 ms / 6 px
/// threshold (matching the GTK / Cocoa defaults). Always updates
/// the snapshot so the next press can pair with this one.
pub( crate ) fn note_press_for_double_click( &mut self, pos: Point ) -> bool
{
const WINDOW_MS: u128 = 400;
const RADIUS: f32 = 6.0;
let now = std::time::Instant::now();
let is_double = match ( self.last_press_time, self.last_press_pos )
{
( Some( t ), Some( p ) ) =>
{
let elapsed = now.duration_since( t ).as_millis();
let dx = pos.x - p.x;
let dy = pos.y - p.y;
elapsed <= WINDOW_MS && dx.hypot( dy ) <= RADIUS
}
_ => false,
};
if is_double
{
// Consume the snapshot so a third quick click is not
// re-paired with the same first click.
self.last_press_time = None;
self.last_press_pos = None;
} else {
self.last_press_time = Some( now );
self.last_press_pos = Some( pos );
}
is_double
}
/// Select the word under the press. The "word" is the maximal
/// run of alphanumeric / underscore chars surrounding the byte
/// offset that `pos` falls on; everything else (whitespace,
/// punctuation) counts as a separator. Empty value → no-op.
pub( crate ) fn handle_text_select_word( &mut self, focus: SurfaceFocus, idx: usize, pos: Point )
{
// `select_on_focus` fields are short-form: the whole value is
// already the natural selection unit, so a double-click does
// not need to add a word-bound selection on top.
let select_on_focus = matches!(
find_handlers( &self.surface( focus ).widget_rects, idx ),
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ),
);
if select_on_focus { return; }
let ( rect, value, multiline, secure, align, font_size ) = match self.text_input_geometry( focus, idx )
{
Some( v ) => v,
None => return,
};
if value.is_empty() { return; }
let cursor_pos = self.surface( focus ).cursor_state.get( &idx ).copied().unwrap_or( 0 );
let click_byte = {
let ss = self.surface( focus );
let canvas = match ss.canvas.as_ref() { Some( c ) => c, None => return };
crate::widget::text_edit::byte_offset_at(
canvas, rect, pos, &value, multiline, secure, cursor_pos, align, font_size,
)
};
let ( start, end ) = word_bounds_at( &value, click_byte );
let ss = self.surface_mut( focus );
ss.selection_anchor.insert( idx, start );
ss.cursor_state.insert( idx, end );
ss.request_redraw();
}
/// Position the text cursor at the click and start a fresh
/// selection (anchor = cursor). Called from the pointer / touch
/// press handler after focus has been moved to the TextEdit.
pub( crate ) fn handle_text_pointer_down( &mut self, focus: SurfaceFocus, idx: usize, pos: Point )
{
// Fields with `select_on_focus` (numeric pickers, short-form
// inputs) keep the whole-value selection that `set_focus`
// just installed — clicking is the *focus* gesture and the
// next keystroke is meant to replace, not insert. Without
// this guard the click-to-position below would collapse the
// selection right after `set_focus` produced it.
let select_on_focus = matches!(
find_handlers( &self.surface( focus ).widget_rects, idx ),
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ),
);
if select_on_focus { return; }
let ( rect, value, multiline, secure, align, font_size ) = match self.text_input_geometry( focus, idx )
{
Some( v ) => v,
None => return,
};
let cursor_pos = self.surface( focus ).cursor_state.get( &idx ).copied().unwrap_or( 0 );
let byte_offset =
{
let ss = self.surface( focus );
let canvas = match ss.canvas.as_ref() { Some( c ) => c, None => return };
crate::widget::text_edit::byte_offset_at(
canvas, rect, pos, &value, multiline, secure, cursor_pos, align, font_size,
)
};
let ss = self.surface_mut( focus );
ss.cursor_state.insert( idx, byte_offset );
ss.selection_anchor.insert( idx, byte_offset );
ss.request_redraw();
}
/// Extend the selection by moving the cursor to the new pointer
/// position. Called from the pointer / touch motion handler while
/// a press is active and the original press landed on a TextEdit.
/// The selection anchor (set on press) stays put.
pub( crate ) fn handle_text_pointer_drag( &mut self, focus: SurfaceFocus, idx: usize, pos: Point )
{
// Same `select_on_focus` guard as the press-down path: the
// short-form field stays whole-selected even if the user
// drags the pointer over the digits.
let select_on_focus = matches!(
find_handlers( &self.surface( focus ).widget_rects, idx ),
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ),
);
if select_on_focus { return; }
let ( rect, value, multiline, secure, align, font_size ) = match self.text_input_geometry( focus, idx )
{
Some( v ) => v,
None => return,
};
let cursor_pos = self.surface( focus ).cursor_state.get( &idx ).copied().unwrap_or( 0 );
let byte_offset =
{
let ss = self.surface( focus );
let canvas = match ss.canvas.as_ref() { Some( c ) => c, None => return };
crate::widget::text_edit::byte_offset_at(
canvas, rect, pos, &value, multiline, secure, cursor_pos, align, font_size,
)
};
let ss = self.surface_mut( focus );
ss.cursor_state.insert( idx, byte_offset );
ss.request_redraw();
}
/// Collapse any active selection on the focused text input to its
/// cursor position. Returns `true` when a collapse happened so
/// the caller (Esc handler) can short-circuit other behaviour.
pub( crate ) fn collapse_selection_if_any( &mut self, focus: SurfaceFocus ) -> bool
{
let idx = match self.surface( focus ).focused_idx { Some( i ) => i, None => return false };
let cursor = self.surface( focus ).cursor_state.get( &idx ).copied();
let anchor = self.surface( focus ).selection_anchor.get( &idx ).copied();
let ( c, a ) = match ( cursor, anchor )
{
( Some( c ), Some( a ) ) if c != a => ( c, a ),
_ => return false,
};
let _ = a;
let ss = self.surface_mut( focus );
ss.selection_anchor.insert( idx, c );
ss.request_redraw();
true
}
/// Select the entire value of the focused text input. Used by
/// Ctrl+A.
pub( crate ) fn handle_select_all( &mut self, focus: SurfaceFocus )
{
let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return };
let ss = self.surface_mut( focus );
*ss.cursor_state.entry( idx ).or_insert( 0 ) = value.len();
*ss.selection_anchor.entry( idx ).or_insert( 0 ) = 0;
ss.request_redraw();
}
/// Snapshot the currently-selected text for the focused widget.
/// Returns `None` when no text input is focused or the selection
/// is empty.
pub( crate ) fn focused_selection_text( &self, focus: SurfaceFocus ) -> Option<String>
{
let ( idx, value ) = self.focused_text_value( focus )?;
let cursor = self.surface( focus ).cursor_state.get( &idx ).copied()
.unwrap_or( value.len() ).min( value.len() );
let anchor = self.surface( focus ).selection_anchor.get( &idx ).copied()
.unwrap_or( cursor ).min( value.len() );
if anchor == cursor { return None; }
let s = cursor.min( anchor );
let e = cursor.max( anchor );
Some( value[s..e].to_string() )
}
/// Delete the current selection (if non-empty), updating the
/// pending text value and cursor / anchor. Returns the resulting
/// string when a deletion happened so the caller can dispatch the
/// `on_change` message; returns `None` when there was no selection
/// to delete.
pub( crate ) fn delete_selection( &mut self, focus: SurfaceFocus ) -> Option<String>
{
let ( idx, value ) = self.focused_text_value( focus )?;
let cursor = self.surface( focus ).cursor_state.get( &idx ).copied()
.unwrap_or( value.len() ).min( value.len() );
let anchor = self.surface( focus ).selection_anchor.get( &idx ).copied()
.unwrap_or( cursor ).min( value.len() );
if anchor == cursor { return None; }
let s = cursor.min( anchor );
let e = cursor.max( anchor );
let mut new_value = value;
new_value.replace_range( s..e, "" );
// `select_on_focus` fields keep the cursor at the end of the
// post-update displayed value via the `usize::MAX` sentinel —
// see `handle_text_insert` for the reasoning.
let select_on_focus = matches!(
find_handlers( &self.surface( focus ).widget_rects, idx ),
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ),
);
let next_cursor = if select_on_focus { usize::MAX } else { s };
let ss = self.surface_mut( focus );
*ss.cursor_state.entry( idx ).or_insert( 0 ) = next_cursor;
*ss.selection_anchor.entry( idx ).or_insert( 0 ) = next_cursor;
ss.pending_text_values.insert( idx, new_value.clone() );
ss.request_redraw();
Some( new_value )
}
}

144
src/event_loop/tooltip.rs Normal file
View File

@@ -0,0 +1,144 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use super::app_data::AppData;
use super::surface::SurfaceFocus;
use crate::app::App;
use crate::types::Rect;
pub const TOOLTIP_DELAY: std::time::Duration = std::time::Duration::from_millis( 600 );
#[derive( Clone )]
pub struct TooltipPending
{
pub focus: SurfaceFocus,
pub flat_idx: usize,
pub deadline: std::time::Instant,
pub text: String,
pub anchor: Rect,
}
#[derive( Clone )]
pub struct TooltipVisible
{
pub focus: SurfaceFocus,
pub anchor: Rect,
pub text: String,
}
impl<A: App> AppData<A>
{
pub( crate ) fn arm_tooltip( &mut self, focus: SurfaceFocus, flat_idx: usize )
{
let ss = self.surface( focus );
let Some( w ) = crate::tree::find_widget( &ss.widget_rects, flat_idx ) else { return };
let Some( text ) = w.tooltip.clone() else
{
self.tooltip_pending = None;
return;
};
let anchor = w.rect;
if let Some( ref p ) = self.tooltip_pending
{
if p.focus == focus && p.flat_idx == flat_idx { return; }
}
self.tooltip_pending = Some( TooltipPending
{
focus,
flat_idx,
deadline: std::time::Instant::now() + TOOLTIP_DELAY,
text,
anchor,
} );
if self.tooltip_visible.is_some()
{
self.tooltip_visible = None;
self.overlays_dirty = true;
}
}
pub( crate ) fn cancel_tooltip( &mut self )
{
let was_visible = self.tooltip_visible.take().is_some();
self.tooltip_pending = None;
if was_visible { self.overlays_dirty = true; }
}
pub( crate ) fn next_tooltip_wakeup( &self ) -> Option<std::time::Duration>
{
let p = self.tooltip_pending.as_ref()?;
Some( p.deadline.saturating_duration_since( std::time::Instant::now() ) )
}
pub( crate ) fn check_tooltip_deadline( &mut self )
{
let Some( p ) = self.tooltip_pending.as_ref() else { return };
if std::time::Instant::now() < p.deadline { return; }
let p = self.tooltip_pending.take().unwrap();
self.tooltip_visible = Some( TooltipVisible
{
focus: p.focus,
anchor: p.anchor,
text: p.text,
} );
self.overlays_dirty = true;
self.main.request_redraw();
}
pub( crate ) fn tooltip_overlay( &self ) -> Option<crate::app::OverlaySpec<A::Message>>
{
let v = self.tooltip_visible.as_ref()?;
let ( ox, oy ) = self.surface_offset_for( v.focus );
let scale = self.surface( v.focus ).scale_factor as f32;
let anchor_x = ox + v.anchor.x / scale;
let anchor_y = oy + v.anchor.y / scale;
let anchor_w = v.anchor.width / scale;
let anchor_h = v.anchor.height / scale;
let palette = crate::theme::palette();
let bg_col = crate::types::Color::rgba( palette.text_primary.r, palette.text_primary.g, palette.text_primary.b, 0.95 );
let pill: crate::widget::Element<A::Message> = crate::widget::container(
crate::widget::text( v.text.clone() )
.size( 13.0 )
.color( palette.bg )
)
.background( bg_col )
.padding_h( 12.0 )
.padding_v( 6.0 )
.radius( 8.0 )
.into();
let estimated_w = ( v.text.chars().count() as f32 * 7.5 + 24.0 ).clamp( 40.0, 320.0 );
let estimated_h = 28.0_f32;
let mut x = anchor_x + ( anchor_w - estimated_w ) / 2.0;
let mut y = anchor_y - estimated_h - 6.0;
let sw = self.main.width as f32;
let sh = self.main.height as f32;
x = x.clamp( 4.0, ( sw - estimated_w - 4.0 ).max( 4.0 ) );
if y < 4.0 { y = anchor_y + anchor_h + 6.0; }
if y + estimated_h > sh - 4.0 { y = ( sh - estimated_h - 4.0 ).max( 4.0 ); }
let view: crate::widget::Element<A::Message> = crate::layout::stack::stack()
.push_translated( pill, crate::layout::stack::HAlign::Start, crate::layout::stack::VAlign::Top, x, y )
.into();
use std::hash::{ Hash, Hasher };
let mut hasher = std::collections::hash_map::DefaultHasher::new();
"ltk-tooltip".hash( &mut hasher );
let id = crate::app::OverlayId( hasher.finish() as u32 );
Some( crate::app::OverlaySpec
{
id,
layer: crate::app::Layer::Overlay,
anchor: crate::app::Anchor::ALL,
size: ( 0, 0 ),
exclusive_zone: -1,
keyboard_exclusive: false,
input_region: Some( Vec::new() ),
view,
on_dismiss: None,
anchor_widget_id: None,
} )
}
}

View File

@@ -0,0 +1,28 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
impl<A: App> AppData<A>
{
/// Top-left of `focus` in main-surface (global) coordinates. Used to
/// translate per-surface pointer coords into a single coordinate
/// space the app can reason about during drag-and-drop across
/// surfaces.
pub( crate ) fn surface_offset_for( &self, focus: SurfaceFocus ) -> ( f32, f32 )
{
let SurfaceFocus::Overlay( id ) = focus else { return ( 0.0, 0.0 ); };
let Some( ss ) = self.overlays.get( &id ) else { return ( 0.0, 0.0 ); };
let Some( anchor ) = ss.layer_anchor else { return ( 0.0, 0.0 ); };
let ( sw, sh ) = ( self.main.width as f32, self.main.height as f32 );
let ( w, h ) = ( ss.width as f32, ss.height as f32 );
let x = if anchor.left { 0.0 }
else if anchor.right { sw - w }
else { ( sw - w ) / 2.0 };
let y = if anchor.top { 0.0 }
else if anchor.bottom { sh - h }
else { ( sh - h ) / 2.0 };
( x, y )
}
}

20
src/input/dispatch/mod.rs Normal file
View File

@@ -0,0 +1,20 @@
// 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 module 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 module 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.
pub( super ) mod outcomes;
pub( super ) mod password_toggle;
pub( super ) mod coords;

View File

@@ -1,93 +1,16 @@
// 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 };
use crate::input::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>
{
/// Top-left of `focus` in main-surface (global) coordinates. Used to
/// translate per-surface pointer coords into a single coordinate
/// space the app can reason about during drag-and-drop across
/// surfaces.
pub( crate ) fn surface_offset_for( &self, focus: SurfaceFocus ) -> ( f32, f32 )
{
let SurfaceFocus::Overlay( id ) = focus else { return ( 0.0, 0.0 ); };
let Some( ss ) = self.overlays.get( &id ) else { return ( 0.0, 0.0 ); };
let Some( anchor ) = ss.layer_anchor else { return ( 0.0, 0.0 ); };
let ( sw, sh ) = ( self.main.width as f32, self.main.height as f32 );
let ( w, h ) = ( ss.width as f32, ss.height as f32 );
let x = if anchor.left { 0.0 }
else if anchor.right { sw - w }
else { ( sw - w ) / 2.0 };
let y = if anchor.top { 0.0 }
else if anchor.bottom { sh - h }
else { ( sh - h ) / 2.0 };
( x, y )
}
/// 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
pub( crate ) fn apply_move_outcome
(
&mut self,
focus: SurfaceFocus,
@@ -142,7 +65,7 @@ impl<A: App> AppData<A>
/// 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
pub( crate ) fn apply_release_events
(
&mut self,
focus: SurfaceFocus,

View File

@@ -0,0 +1,48 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
use crate::types::Point;
use crate::widget::WidgetHandlers;
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( crate ) 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
}
}
}

File diff suppressed because it is too large Load Diff

524
src/input/gesture/mod.rs Normal file
View File

@@ -0,0 +1,524 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Per-surface gesture state machine. Source-agnostic — both
//! `PointerHandler` (Wayland `wl_pointer`) and `TouchHandler`
//! (`wl_touch`) feed into the same machine and apply the decisions it
//! emits.
//!
//! ## Lifecycle
//!
//! 1. [`GestureState::on_press`] — records the gesture origin, snapshots
//! the hit widget's long-press AND drag-start handlers, identifies
//! slider / scroll targets. Returns a [`PressOutcome`] with the hit
//! index and any initial slider message.
//! 2. [`GestureState::on_move`] — cancels the long-press candidate if
//! the finger strays past 6 px, updates slider values, mutates
//! scroll offsets, computes swipe progress. Returns a
//! [`MoveOutcome`] telling the handler which app callbacks to fire.
//! 3. [`GestureState::on_release`] — decides between drop (long-press
//! was already fired), slider / scroll done, horizontal / vertical
//! swipe commit or fall-through, tap or button press. Returns an
//! ordered [`Vec<ReleaseEvent>`] — a single release can emit more
//! than one event when a horizontal swipe falls through and the
//! handler still has to check the vertical branch.
//! 4. [`GestureState::on_cancel`] — resets everything (touch-cancel
//! only; the compositor is stealing the gesture).
//!
//! The long-press DEADLINE itself is not polled here. `AppData`
//! walks every surface's `gesture.long_press_start` against the app's
//! `long_press_duration()` and flips `gesture.long_press_fired` when
//! the deadline elapses — this module only records the start instant
//! and reacts to the flip.
use std::collections::HashMap;
use std::time::Instant;
use crate::tree::{ find_handlers, find_widget, find_widget_at };
use crate::types::{ Point, Rect };
use crate::widget::LaidOutWidget;
mod outcomes;
pub( crate ) use outcomes::*;
#[ cfg( test ) ]
mod tests;
// ─── State ───────────────────────────────────────────────────────────────────
/// Per-surface gesture tracking. Lives on `SurfaceState::gesture`.
///
/// Every field is `pub` because `AppData`'s long-press deadline
/// checker in `event_loop/app_data.rs` needs to read the start instant
/// and set `long_press_fired` from outside the state machine. The
/// lifecycle methods ([`Self::on_press`], [`Self::on_move`],
/// [`Self::on_release`], [`Self::on_cancel`]) are the supported way
/// for input handlers to drive the machine; direct mutation is only
/// for the deadline path.
#[ derive( Debug, Clone ) ]
pub struct GestureState<Msg: Clone>
{
/// Position where the current press / touch-down landed. `None`
/// between gestures.
pub start: Option<Point>,
/// Widget index under the press, if any. Set on press; taken on
/// release.
pub pressed_idx: Option<usize>,
/// Index of the Scroll viewport that owns the current gesture, if
/// the press landed inside one.
pub scrolling_widget: Option<usize>,
/// Scroll-viewport drag exceeded the 8 px start tolerance — the
/// release will be consumed as a scroll instead of a tap.
pub scroll_drag_started: bool,
/// Horizontal drag exceeded the 8 px threshold. Release runs the
/// horizontal-swipe commit check instead of the button path.
pub horizontal_drag_started: bool,
/// Vertical drag exceeded the 8 px threshold. Release runs the
/// swipe-up / swipe-down commit check instead of the button path.
pub vertical_drag_started: bool,
/// Slider widget being dragged for a live-value update.
pub dragging_slider: Option<usize>,
/// Instant when the long-press window opened. `None` if the hit
/// widget has no long-press handler (or the candidate was
/// cancelled by movement).
pub long_press_start: Option<Instant>,
/// Original press position for the long-press candidate — used by
/// [`Self::on_move`] to cancel if the finger strays more than 6 px.
pub long_press_origin: Option<Point>,
/// Message to fire when the deadline elapses (touch hold) or when
/// the user right-clicks. Drained by the deadline checker on touch
/// and by the right-click handler on mouse. Firing this on its own
/// does NOT put the gesture into drag mode — that is governed by
/// [`Self::drag_start_msg`].
pub long_press_msg: Option<Msg>,
/// Drag-arm message captured from the hit widget on press. Fired
/// by the touch deadline alongside `long_press_msg` and by the
/// mouse-motion drag-promotion path on its own. When fired, the
/// gesture transitions into drag mode (`long_press_fired = true`)
/// and subsequent motion / release run through `on_drag_move` /
/// `on_drop`.
pub drag_start_msg: Option<Msg>,
/// `widget_rects` index of a TextEdit the press landed on, when
/// no user `on_long_press` is configured. Lets the runtime show
/// the built-in Copy / Cut / Paste menu after the same delay
/// without forcing every text field to opt in.
pub long_press_text_idx: Option<usize>,
/// `true` once the gesture has transitioned into drag mode —
/// subsequent motion fires `app.on_drag_move`, release fires
/// `app.on_drop`. Set by the touch deadline when `drag_start_msg`
/// is present, or by the mouse-motion promotion path.
pub long_press_fired: bool,
/// `true` when the press came from a mouse (`wl_pointer`) rather
/// than touch. The 6 px stray-cancel in [`Self::on_move`] is gated
/// on this: touch needs the cancel so a hold that turns into a
/// swipe / scroll doesn't keep the long-press candidate armed,
/// but mouse motion is always intentional and never competes with
/// a swipe gesture, so the candidate stays alive until the
/// pointer-side promotion threshold or the deadline fires. Set by
/// the pointer Press handler right after [`Self::on_press`]; left
/// `false` for touch presses.
pub mouse_press: bool,
}
impl<Msg: Clone> Default for GestureState<Msg>
{
fn default() -> Self { Self::new() }
}
impl<Msg: Clone> GestureState<Msg>
{
/// Fresh state — no press in flight.
pub fn new() -> Self
{
Self
{
start: None,
pressed_idx: None,
scrolling_widget: None,
scroll_drag_started: false,
horizontal_drag_started: false,
vertical_drag_started: false,
dragging_slider: None,
long_press_start: None,
long_press_origin: None,
long_press_msg: None,
drag_start_msg: None,
long_press_text_idx: None,
long_press_fired: false,
mouse_press: false,
}
}
/// Press / touch-down: record the gesture origin, capture long-press
/// handler if the hit widget has one, identify slider / scroll
/// targets. Returns the hit index (for keyboard focus update) and
/// any initial slider message (the slider applies its value
/// immediately on press so the thumb tracks the finger from pixel
/// one).
pub fn on_press
(
&mut self,
pos: Point,
widget_rects: &[LaidOutWidget<Msg>],
scroll_rects: &[( Rect, usize )],
) -> PressOutcome<Msg>
{
let hit = find_widget_at( widget_rects, pos );
let ( lp_msg, ds_msg ) = hit.map( |idx|
{
let h = find_handlers( widget_rects, idx );
(
h.as_ref().and_then( |h| h.long_press_msg() ),
h.as_ref().and_then( |h| h.drag_start_msg() ),
)
}).unwrap_or( ( None, None ) );
self.start = Some( pos );
self.scrolling_widget = scroll_rects.iter().rev()
.find( |( r, _ )| r.contains( pos ) )
.map( |( _, idx )| *idx );
self.scroll_drag_started = false;
self.horizontal_drag_started = false;
self.vertical_drag_started = false;
self.pressed_idx = hit;
// A fresh press resets any stale long-press / source state
// from a prior gesture that never released cleanly. The
// pointer Press handler flips `mouse_press` back to true
// right after this call when the press came from a mouse;
// touch leaves it `false`.
self.long_press_fired = false;
self.mouse_press = false;
// Built-in long-press for text inputs: arms the same timer
// even when the widget has no user-set `on_long_press`, so
// the runtime can show the Copy / Cut / Paste menu after the
// usual delay.
let text_idx = hit.and_then( |idx|
{
find_handlers( widget_rects, idx )
.and_then( |h| if h.is_text_input() { Some( idx ) } else { None } )
} );
// Arm the long-press timer if anything is pending: the menu
// message, the drag-start message, or the built-in text menu.
// A widget that only carries `on_drag_start` (no menu) still
// needs the timer so a touch hold can promote into a drag.
let arm_long_press = lp_msg.is_some() || ds_msg.is_some() || text_idx.is_some();
self.long_press_start = arm_long_press.then( Instant::now );
self.long_press_origin = arm_long_press.then_some( pos );
self.long_press_msg = lp_msg;
self.drag_start_msg = ds_msg;
self.long_press_text_idx = text_idx;
// Slider drag: if the hit widget is a slider, arm the drag and
// apply the press-point value immediately.
let mut initial_slider_msg = None;
if let Some( idx ) = hit
{
if let Some( w ) = find_widget( widget_rects, idx )
{
if w.handlers.is_slider()
{
self.dragging_slider = Some( idx );
let value = w.handlers.slider_value_from_pos( w.rect, pos );
initial_slider_msg = w.handlers.slider_change_msg( value );
}
}
}
PressOutcome { hit_idx: hit, initial_slider_msg }
}
/// Move / touch-motion: classify the motion and mutate scroll
/// offsets in place. `global_drag` is `true` when another surface
/// has an active long-press drag (cross-surface drags route every
/// motion through `on_drag_move` even on surfaces that didn't
/// start the gesture).
pub fn on_move
(
&mut self,
pos: Point,
widget_rects: &[LaidOutWidget<Msg>],
scroll_offsets: &mut HashMap<usize, f32>,
swipe: &SwipeConfig,
global_drag: bool,
) -> MoveOutcome<Msg>
{
// Drag phase: once the long-press has fired, every motion
// goes to the app's drag handler and bypasses slider / scroll /
// swipe logic.
if self.long_press_fired || global_drag
{
return MoveOutcome::Drag { pos };
}
// Cancel the long-press candidate if the finger strayed > 6 px
// — but ONLY for touch. Touch needs this so a hold that turns
// into a swipe / scroll doesn't keep the menu candidate
// armed and fire mid-swipe; mouse motion is always
// intentional and never competes with a swipe, so the
// candidate stays alive until the pointer-side promotion at
// 24 px (or the hold-timer deadline) consumes it. Without
// this gate, mouse motion in the 624 px band would clear
// `drag_start_msg` before promotion could fire and the user
// would have to wait out the full 500 ms hold timer to drag.
if !self.mouse_press
{
if let Some( origin ) = self.long_press_origin
{
let d = ( pos.x - origin.x ).hypot( pos.y - origin.y );
if d > 6.0
{
self.long_press_start = None;
self.long_press_origin = None;
self.long_press_msg = None;
self.drag_start_msg = None;
self.long_press_text_idx = None;
}
}
}
// Slider drag: continuously update value.
if let Some( slider_idx ) = self.dragging_slider
{
if let Some( w ) = find_widget( widget_rects, slider_idx )
{
let value = w.handlers.slider_value_from_pos( w.rect, pos );
let msg = w.handlers.slider_change_msg( value );
return MoveOutcome::Slider { msg };
}
return MoveOutcome::Idle;
}
// Scroll viewport drag: mutate offset in place and advance the
// gesture origin so the next delta is frame-to-frame, not
// press-to-now (otherwise the first 8 px trip the threshold
// and the entire scroll gets absorbed into one delta).
if let Some( scroll_idx ) = self.scrolling_widget
{
if let Some( start ) = self.start
{
let dy = pos.y - start.y;
let entry = scroll_offsets.entry( scroll_idx ).or_insert( 0.0 );
*entry = ( *entry - dy ).max( 0.0 );
if dy.abs() > 8.0 { self.scroll_drag_started = true; }
self.start = Some( pos );
return MoveOutcome::Scroll;
}
return MoveOutcome::Idle;
}
// Swipe progress — independent on both axes so a vertical-
// dominant gesture that picks up horizontal drift still drives
// the pager.
if let Some( start ) = self.start
{
let dy = start.y - pos.y;
let dx = pos.x - start.x;
let ( up, down ) = if swipe.surface_height > 0
{
let h = swipe.surface_height as f32;
if dy > 0.0
{
let threshold = h * swipe.up_thresh;
if dy.abs() > 8.0 { self.vertical_drag_started = true; }
( Some( ( dy / threshold ).clamp( 0.0, 1.0 ) ), None )
}
else if start.y <= h * swipe.down_edge
{
let threshold = h * swipe.down_thresh;
if dy.abs() > 8.0 { self.vertical_drag_started = true; }
// Unclamped on purpose — lets follow-the-finger
// panels track past the commit threshold.
( None, Some( ( -dy / threshold ).max( 0.0 ) ) )
}
else { ( None, None ) }
}
else { ( None, None ) };
let horizontal = if swipe.surface_width > 0
{
let h_thr = swipe.surface_width as f32 * swipe.horizontal_thresh;
let h_progress = if h_thr > 0.0 { dx / h_thr } else { 0.0 };
if dx.abs() > 8.0 { self.horizontal_drag_started = true; }
Some( h_progress )
}
else { None };
if up.is_some() || down.is_some() || horizontal.is_some()
{
return MoveOutcome::Swipe { up, down, horizontal };
}
}
MoveOutcome::Idle
}
/// Release / touch-up: decide between drop / slider done / scroll
/// done / swipe commit / swipe fall-through / button press / tap.
///
/// A release can emit multiple events: a horizontal swipe that
/// didn't commit still fires `on_swipe_horizontal_progress(0.0)`
/// before falling through to the vertical branch, and a below-
/// threshold vertical swipe pulses the vertical progress
/// callbacks. The handler dispatches the events in order.
///
/// `global_drag` mirrors the parameter of [`Self::on_move`].
pub fn on_release
(
&mut self,
pos: Point,
widget_rects: &[LaidOutWidget<Msg>],
swipe: &SwipeConfig,
global_drag: bool,
) -> Vec<ReleaseEvent<Msg>>
{
let pressed = self.pressed_idx.take();
let was_dragging_slider = self.dragging_slider.is_some();
let long_press_fired = self.long_press_fired;
let horizontal_drag_started = self.horizontal_drag_started;
let vertical_drag_started = self.vertical_drag_started;
// Drain all the drag / long-press slots. A release ALWAYS
// closes the long-press window: if the deadline fired we've
// already consumed the msg; if not, the candidate is cancelled.
self.dragging_slider = None;
self.long_press_start = None;
self.long_press_origin = None;
self.long_press_msg = None;
self.drag_start_msg = None;
self.long_press_text_idx = None;
self.long_press_fired = false;
self.mouse_press = false;
self.horizontal_drag_started = false;
self.vertical_drag_started = false;
let mut events = Vec::new();
// Drop — long-press had fired earlier in the gesture.
if long_press_fired || global_drag
{
self.start = None;
events.push( ReleaseEvent::Drop { pos } );
return events;
}
// Slider drag complete — not a swipe, not a tap.
if was_dragging_slider
{
self.scroll_drag_started = false;
self.start = None;
return events;
}
// Scroll gesture consumed.
let scroll_consumed = self.scrolling_widget.take().is_some()
&& self.scroll_drag_started;
self.scroll_drag_started = false;
if scroll_consumed
{
self.start = None;
return events;
}
// Horizontal swipe commit / fall-through. On commit we return
// immediately; on fall-through we send the 0.0 pulse, keep
// `self.start` for the vertical branch, and continue.
if horizontal_drag_started
{
if let Some( start ) = self.start
{
let dx = pos.x - start.x;
let width = swipe.surface_width;
let committed = width > 0
&& dx.abs() >= width as f32 * swipe.horizontal_thresh;
if committed
{
self.start = None;
events.push( if dx < 0.0 { ReleaseEvent::SwipeLeft } else { ReleaseEvent::SwipeRight } );
return events;
}
events.push( ReleaseEvent::HorizontalFellThrough );
}
}
// Vertical swipe commit / fall-through. Consumes `self.start`.
if vertical_drag_started || pressed.is_none()
{
if let Some( start ) = self.start.take()
{
let dy = start.y - pos.y;
let height = swipe.surface_height as f32;
let up_thr = height * swipe.up_thresh;
let down_thr = height * swipe.down_thresh;
let down_edge = height * swipe.down_edge;
if dy >= up_thr
{
events.push( ReleaseEvent::SwipeUp );
return events;
}
else if -dy >= down_thr && start.y <= down_edge
{
events.push( ReleaseEvent::SwipeDown );
return events;
}
else
{
events.push( ReleaseEvent::VerticalFellThrough );
}
}
}
else
{
self.start = None;
}
// Any drag started — skip the button path so a gesture that
// fell through without committing does not also trigger a tap.
if horizontal_drag_started || vertical_drag_started
{
return events;
}
// Button / slider press on release: the release must land on
// the same widget the press did.
let release_hit = find_widget_at( widget_rects, pos );
if let Some( idx ) = pressed
{
if release_hit == Some( idx )
{
if let Some( w ) = find_widget( widget_rects, idx )
{
let value = w.handlers.slider_value_from_pos( w.rect, pos );
if let Some( msg ) = w.handlers.slider_change_msg( value )
{
events.push( ReleaseEvent::PushMsg( msg ) );
return events;
}
// Repeating buttons fire `press_msg` on *press* (and
// keep firing through the calloop repeat timer
// while held) — suppressing the release-time fire
// avoids a double tap on a quick click.
if !w.handlers.is_repeating()
{
if let Some( msg ) = w.handlers.press_msg()
{
events.push( ReleaseEvent::PushMsg( msg ) );
}
}
}
}
}
else if release_hit.is_none()
{
// Release on empty area — handler decides Tap vs
// overlay-dismiss based on focus.
events.push( ReleaseEvent::EmptyRelease );
}
events
}
/// Touch-cancel: drop all in-flight gesture state.
pub fn on_cancel( &mut self ) { *self = Self::new(); }
}

View File

@@ -0,0 +1,84 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::Point;
/// Result of [`GestureState::on_press`]. Handler uses `hit_idx` to set
/// keyboard focus; pushes `initial_slider_msg` to the pending queue
/// when present.
pub struct PressOutcome<Msg>
{
pub hit_idx: Option<usize>,
pub initial_slider_msg: Option<Msg>,
}
/// Result of [`GestureState::on_move`]. Handler turns this into app
/// callbacks + redraw flags.
pub enum MoveOutcome<Msg>
{
/// Nothing gesture-level fired. Hovered index may still have
/// changed — pointer handler updates it separately before calling
/// on_move.
Idle,
/// Long-press drag in flight — fire `app.on_drag_move(pos)`.
Drag { pos: Point },
/// Slider value changed. `None` when the slider has no change
/// handler for this value (typically unreachable — sliders always
/// carry a change msg — but the gesture machine stays general).
Slider { msg: Option<Msg> },
/// Scroll viewport offset updated. Handler just requests a redraw.
Scroll,
/// Swipe progress. Each axis is `Some(value)` when it has a valid
/// progress reading for this move (not every motion updates all
/// axes). Handler fires the matching app callback.
Swipe { up: Option<f32>, down: Option<f32>, horizontal: Option<f32> },
}
/// Events emitted by [`GestureState::on_release`], in the order the
/// handler should dispatch them. Multiple events per release are
/// possible (see the horizontal fall-through case).
pub enum ReleaseEvent<Msg>
{
/// Long-press drop — `app.on_drop(pos)`, kick main redraw, clear
/// long-press drag.
Drop { pos: Point },
/// Horizontal swipe committed left.
SwipeLeft,
/// Horizontal swipe committed right.
SwipeRight,
/// Vertical swipe committed up.
SwipeUp,
/// Vertical swipe committed down.
SwipeDown,
/// Horizontal swipe did not commit — fire
/// `app.on_swipe_horizontal_progress(0.0)` + main redraw.
HorizontalFellThrough,
/// Vertical swipe did not commit — fire
/// `app.on_swipe_progress(0.0)` + `app.on_swipe_down_progress(0.0)`.
VerticalFellThrough,
/// Push a widget-level message (button press or final slider
/// value on release).
PushMsg( Msg ),
/// Release on empty area — handler calls `app.on_tap()` on Main
/// focus or `overlay_dismiss_msg(id)` on Overlay focus.
EmptyRelease,
}
/// Swipe thresholds + surface dimensions snapshotted from the app +
/// current surface, passed into gesture methods so the machine stays
/// decoupled from `App` and `SurfaceState`.
pub struct SwipeConfig
{
/// Up-swipe commit fraction of surface height (e.g. 0.3 = 30 %).
pub up_thresh: f32,
/// Down-swipe commit fraction of surface height.
pub down_thresh: f32,
/// Down-swipe start zone fraction — the press must land within
/// `surface_height * down_edge` of the top for a down-swipe to
/// arm.
pub down_edge: f32,
/// Horizontal commit fraction of surface width.
pub horizontal_thresh: f32,
pub surface_width: u32,
pub surface_height: u32,
}

470
src/input/gesture/tests.rs Normal file
View File

@@ -0,0 +1,470 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use super::*;
use crate::widget::{ LaidOutWidget, WidgetHandlers };
#[ derive( Clone, Debug, PartialEq, Eq ) ]
enum Msg
{
Pressed,
LongPressed,
}
fn rect( x: f32, y: f32, w: f32, h: f32 ) -> Rect
{
Rect { x, y, width: w, height: h }
}
fn pt( x: f32, y: f32 ) -> Point
{
Point { x, y }
}
fn button( idx: usize, r: Rect, on_press: Option<Msg>, on_long_press: Option<Msg> )
-> LaidOutWidget<Msg>
{
button_full( idx, r, on_press, on_long_press, None )
}
fn button_full(
idx: usize, r: Rect,
on_press: Option<Msg>, on_long_press: Option<Msg>, on_drag_start: Option<Msg>,
) -> LaidOutWidget<Msg>
{
LaidOutWidget
{
rect: r,
flat_idx: idx,
id: None,
paint_rect: r,
handlers: WidgetHandlers::Button { on_press, on_long_press, on_drag_start, on_escape: None, repeating: false },
keyboard_focusable: true,
cursor: crate::types::CursorShape::Default,
tooltip: None,
}
}
fn cfg_full( w: u32, h: u32 ) -> SwipeConfig
{
SwipeConfig
{
up_thresh: 0.30,
down_thresh: 0.30,
down_edge: 0.10,
horizontal_thresh: 0.30,
surface_width: w,
surface_height: h,
}
}
// ── on_press ──────────────────────────────────────────────────────────────
#[ test ]
fn press_records_origin_and_hit_idx()
{
let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), Some( Msg::Pressed ), None ) ];
let mut g = GestureState::<Msg>::new();
let out = g.on_press( pt( 50.0, 50.0 ), &widgets, &[] );
assert_eq!( out.hit_idx, Some( 1 ) );
assert!( out.initial_slider_msg.is_none() );
assert_eq!( g.start, Some( pt( 50.0, 50.0 ) ) );
assert_eq!( g.pressed_idx, Some( 1 ) );
}
#[ test ]
fn press_outside_any_widget_records_origin_with_no_hit()
{
let widgets = vec![ button( 1, rect( 0.0, 0.0, 50.0, 50.0 ), Some( Msg::Pressed ), None ) ];
let mut g = GestureState::<Msg>::new();
let out = g.on_press( pt( 200.0, 200.0 ), &widgets, &[] );
assert_eq!( out.hit_idx, None );
assert_eq!( g.start, Some( pt( 200.0, 200.0 ) ) );
assert!( g.long_press_start.is_none() );
}
#[ test ]
fn press_arms_long_press_when_handler_is_present()
{
let widgets = vec![ button( 7, rect( 0.0, 0.0, 100.0, 100.0 ), None, Some( Msg::LongPressed ) ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] );
assert!( g.long_press_start.is_some() );
assert_eq!( g.long_press_msg, Some( Msg::LongPressed ) );
assert_eq!( g.long_press_origin, Some( pt( 10.0, 10.0 ) ) );
assert!( !g.long_press_fired );
}
#[ test ]
fn press_does_not_arm_long_press_without_handler()
{
let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), Some( Msg::Pressed ), None ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] );
assert!( g.long_press_start.is_none() );
assert!( g.long_press_msg.is_none() );
assert!( g.drag_start_msg.is_none() );
}
#[ test ]
fn press_arms_long_press_when_only_drag_start_is_set()
{
// A draggable-but-menu-less widget (e.g. launcher icons) still
// needs the timer so a touch hold can promote into a drag.
let widgets = vec![ button_full( 3, rect( 0.0, 0.0, 100.0, 100.0 ), None, None, Some( Msg::Pressed ) ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] );
assert!( g.long_press_start.is_some() );
assert!( g.long_press_msg.is_none() );
assert_eq!( g.drag_start_msg, Some( Msg::Pressed ) );
}
#[ test ]
fn move_past_six_pixels_cancels_drag_start_msg_too()
{
let widgets = vec![ button_full( 4, rect( 0.0, 0.0, 200.0, 200.0 ),
None, Some( Msg::LongPressed ), Some( Msg::Pressed ) ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] );
let mut offsets = HashMap::new();
let _ = g.on_move( pt( 30.0, 30.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert!( g.long_press_msg.is_none() );
assert!( g.drag_start_msg.is_none() );
}
#[ test ]
fn mouse_press_skips_six_pixel_cancel_so_promotion_can_fire()
{
// With `mouse_press = true` the gesture state must keep the
// drag-start / long-press slots alive past the 6 px touch
// tolerance — otherwise the pointer-side promotion at 24 px
// would never see them and a mouse drag past the cancel
// distance would do nothing until the hold-timer deadline.
let widgets = vec![ button_full( 5, rect( 0.0, 0.0, 200.0, 200.0 ),
None, Some( Msg::LongPressed ), Some( Msg::Pressed ) ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] );
g.mouse_press = true;
let mut offsets = HashMap::new();
let _ = g.on_move( pt( 30.0, 30.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert_eq!( g.long_press_msg, Some( Msg::LongPressed ) );
assert_eq!( g.drag_start_msg, Some( Msg::Pressed ) );
assert!( g.long_press_origin.is_some() );
}
#[ test ]
fn press_identifies_scroll_target()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 200.0, 200.0 ), 42 ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &scrolls );
assert_eq!( g.scrolling_widget, Some( 42 ) );
}
#[ test ]
fn press_resets_stale_long_press_fired_flag()
{
let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), Some( Msg::Pressed ), None ) ];
let mut g = GestureState::<Msg>::new();
g.long_press_fired = true; // stale from a prior gesture
let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] );
assert!( !g.long_press_fired );
}
// ── on_move: long-press cancellation ──────────────────────────────────────
#[ test ]
fn move_within_six_pixels_keeps_long_press_armed()
{
let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), None, Some( Msg::LongPressed ) ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] );
let mut offsets = HashMap::new();
let _ = g.on_move( pt( 14.0, 12.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert!( g.long_press_start.is_some() );
assert!( g.long_press_msg.is_some() );
}
#[ test ]
fn move_past_six_pixels_cancels_long_press()
{
let widgets = vec![ button( 1, rect( 0.0, 0.0, 200.0, 200.0 ), None, Some( Msg::LongPressed ) ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] );
let mut offsets = HashMap::new();
let _ = g.on_move( pt( 30.0, 30.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert!( g.long_press_start.is_none() );
assert!( g.long_press_msg.is_none() );
assert!( g.long_press_origin.is_none() );
}
// ── on_move: drag fork ────────────────────────────────────────────────────
#[ test ]
fn move_after_long_press_fired_returns_drag()
{
let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), None, Some( Msg::LongPressed ) ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] );
g.long_press_fired = true;
let mut offsets = HashMap::new();
let out = g.on_move( pt( 50.0, 50.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert!( matches!( out, MoveOutcome::Drag { .. } ) );
}
#[ test ]
fn move_with_global_drag_returns_drag()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let mut g = GestureState::<Msg>::new();
g.start = Some( pt( 0.0, 0.0 ) );
let mut offsets = HashMap::new();
let out = g.on_move( pt( 30.0, 30.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), true );
assert!( matches!( out, MoveOutcome::Drag { .. } ) );
}
// ── on_move: scroll ───────────────────────────────────────────────────────
#[ test ]
fn move_inside_scroll_widget_mutates_offset_in_place()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 7 ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls );
let mut offsets = HashMap::new();
// Drag finger upward → content scrolls down → offset increases.
let out = g.on_move( pt( 100.0, 60.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert!( matches!( out, MoveOutcome::Scroll ) );
assert_eq!( offsets.get( &7 ).copied(), Some( 40.0 ) );
assert!( g.scroll_drag_started, "drag past 8 px must arm scroll_drag_started" );
}
#[ test ]
fn move_inside_scroll_widget_clamps_offset_at_zero()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 9 ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls );
let mut offsets = HashMap::new();
// Drag finger downward → content tries to scroll up past origin → clamp at 0.
let _ = g.on_move( pt( 100.0, 200.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert_eq!( offsets.get( &9 ).copied(), Some( 0.0 ) );
}
#[ test ]
fn move_below_eight_pixels_does_not_arm_scroll_drag()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 1 ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls );
let mut offsets = HashMap::new();
let _ = g.on_move( pt( 100.0, 95.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert!( !g.scroll_drag_started );
}
// ── on_move: swipe progress ───────────────────────────────────────────────
#[ test ]
fn move_below_eight_pixels_emits_swipe_progress_without_arming_drag()
{
// The 8 px threshold only gates `*_drag_started`; the Swipe outcome
// itself fires on every motion that has a non-empty axis reading.
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let mut g = GestureState::<Msg>::new();
g.start = Some( pt( 100.0, 100.0 ) );
let mut offsets = HashMap::new();
let out = g.on_move( pt( 102.0, 99.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert!( matches!( out, MoveOutcome::Swipe { .. } ) );
assert!( !g.horizontal_drag_started );
assert!( !g.vertical_drag_started );
}
#[ test ]
fn move_up_eleven_pixels_arms_vertical_drag()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let mut g = GestureState::<Msg>::new();
g.start = Some( pt( 100.0, 600.0 ) );
let mut offsets = HashMap::new();
let _ = g.on_move( pt( 100.0, 589.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert!( g.vertical_drag_started );
}
#[ test ]
fn move_horizontal_eleven_pixels_arms_horizontal_drag()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let mut g = GestureState::<Msg>::new();
g.start = Some( pt( 100.0, 600.0 ) );
let mut offsets = HashMap::new();
let _ = g.on_move( pt( 111.5, 600.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert!( g.horizontal_drag_started );
}
#[ test ]
fn move_up_progress_clamps_to_unit_interval()
{
// Surface 1000 px tall, threshold 30 % = 300 px. A 600 px upward drag
// must clamp progress to 1.0 instead of overshooting to 2.0.
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let mut g = GestureState::<Msg>::new();
g.start = Some( pt( 100.0, 800.0 ) );
let mut offsets = HashMap::new();
let out = g.on_move( pt( 100.0, 200.0 ), &widgets, &mut offsets, &cfg_full( 800, 1000 ), false );
match out
{
MoveOutcome::Swipe { up: Some( v ), .. } =>
assert!( ( v - 1.0 ).abs() < 1e-6, "expected clamp to 1.0, got {v}" ),
_ => panic!( "expected Swipe with up=Some" ),
}
}
// ── on_release ────────────────────────────────────────────────────────────
#[ test ]
fn release_on_button_emits_press_msg()
{
let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), Some( Msg::Pressed ), None ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 50.0, 50.0 ), &widgets, &[] );
let events = g.on_release( pt( 50.0, 50.0 ), &widgets, &cfg_full( 800, 1200 ), false );
assert_eq!( events.len(), 1 );
assert!( matches!( events[ 0 ], ReleaseEvent::PushMsg( Msg::Pressed ) ) );
}
#[ test ]
fn release_on_different_widget_does_not_emit_press_msg()
{
let widgets = vec![
button( 1, rect( 0.0, 0.0, 50.0, 50.0 ), Some( Msg::Pressed ), None ),
button( 2, rect( 100.0, 0.0, 50.0, 50.0 ), Some( Msg::Pressed ), None ),
];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 25.0, 25.0 ), &widgets, &[] );
let events = g.on_release( pt( 125.0, 25.0 ), &widgets, &cfg_full( 800, 1200 ), false );
assert!(
events.iter().all( |e| !matches!( e, ReleaseEvent::PushMsg( _ ) ) ),
"press_msg must not fire when release lands on a different widget"
);
}
#[ test ]
fn release_on_empty_area_emits_empty_release()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 50.0, 50.0 ), &widgets, &[] );
let events = g.on_release( pt( 50.0, 50.0 ), &widgets, &cfg_full( 800, 1200 ), false );
assert!( events.iter().any( |e| matches!( e, ReleaseEvent::EmptyRelease ) ) );
}
#[ test ]
fn release_after_long_press_fired_emits_drop()
{
let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), None, Some( Msg::LongPressed ) ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 50.0, 50.0 ), &widgets, &[] );
g.long_press_fired = true;
let events = g.on_release( pt( 50.0, 50.0 ), &widgets, &cfg_full( 800, 1200 ), false );
assert!( matches!( events[ 0 ], ReleaseEvent::Drop { .. } ) );
}
#[ test ]
fn release_with_global_drag_emits_drop()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] );
let events = g.on_release( pt( 50.0, 50.0 ), &widgets, &cfg_full( 800, 1200 ), true );
assert!( matches!( events[ 0 ], ReleaseEvent::Drop { .. } ) );
}
#[ test ]
fn release_committing_swipe_up_emits_swipe_up()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let mut g = GestureState::<Msg>::new();
// No press_idx (release on empty area) so the vertical-swipe branch runs.
let _ = g.on_press( pt( 400.0, 900.0 ), &widgets, &[] );
let mut offsets = HashMap::new();
// Drag well past 30 % of 1000 px to trip vertical_drag_started.
let _ = g.on_move( pt( 400.0, 200.0 ), &widgets, &mut offsets, &cfg_full( 800, 1000 ), false );
let events = g.on_release( pt( 400.0, 200.0 ), &widgets, &cfg_full( 800, 1000 ), false );
assert!( events.iter().any( |e| matches!( e, ReleaseEvent::SwipeUp ) ) );
}
#[ test ]
fn release_horizontal_below_threshold_falls_through()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 100.0, 600.0 ), &widgets, &[] );
let mut offsets = HashMap::new();
// 50 px horizontal — below 30 % of 800 = 240 — but past 8 px, so
// horizontal_drag_started flips to true.
let _ = g.on_move( pt( 150.0, 600.0 ), &widgets, &mut offsets, &cfg_full( 800, 1000 ), false );
let events = g.on_release( pt( 150.0, 600.0 ), &widgets, &cfg_full( 800, 1000 ), false );
assert!( events.iter().any( |e| matches!( e, ReleaseEvent::HorizontalFellThrough ) ) );
assert!( events.iter().all( |e| !matches!( e, ReleaseEvent::SwipeLeft | ReleaseEvent::SwipeRight ) ) );
}
#[ test ]
fn release_after_slider_drag_emits_no_events()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let mut g = GestureState::<Msg>::new();
g.start = Some( pt( 50.0, 50.0 ) );
g.dragging_slider = Some( 1 );
let events = g.on_release( pt( 50.0, 50.0 ), &widgets, &cfg_full( 800, 1200 ), false );
assert!( events.is_empty() );
assert!( g.dragging_slider.is_none() );
}
#[ test ]
fn release_after_consumed_scroll_emits_no_events()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let mut g = GestureState::<Msg>::new();
g.start = Some( pt( 50.0, 50.0 ) );
g.scrolling_widget = Some( 1 );
g.scroll_drag_started = true;
let events = g.on_release( pt( 50.0, 50.0 ), &widgets, &cfg_full( 800, 1200 ), false );
assert!( events.is_empty() );
assert!( g.scrolling_widget.is_none() );
assert!( !g.scroll_drag_started );
}
// ── on_cancel ─────────────────────────────────────────────────────────────
#[ test ]
fn cancel_resets_every_field()
{
let mut g = GestureState::<Msg>::new();
g.start = Some( pt( 1.0, 2.0 ) );
g.pressed_idx = Some( 99 );
g.scrolling_widget = Some( 99 );
g.scroll_drag_started = true;
g.horizontal_drag_started = true;
g.vertical_drag_started = true;
g.dragging_slider = Some( 99 );
g.long_press_fired = true;
g.long_press_msg = Some( Msg::LongPressed );
g.drag_start_msg = Some( Msg::Pressed );
g.on_cancel();
assert!( g.start.is_none() );
assert!( g.pressed_idx.is_none() );
assert!( g.scrolling_widget.is_none() );
assert!( !g.scroll_drag_started );
assert!( !g.horizontal_drag_started );
assert!( !g.vertical_drag_started );
assert!( g.dragging_slider.is_none() );
assert!( !g.long_press_fired );
assert!( g.long_press_msg.is_none() );
assert!( g.drag_start_msg.is_none() );
}

View File

@@ -1,645 +0,0 @@
// 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 =>
{
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 intercepted = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed );
if let Some( msg ) = intercepted
{
self.pending_msgs.push( msg );
}
else
{
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 )
{
// already None, no extra fire
}
}
}
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;
let intercepted = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed );
if let Some( msg ) = intercepted
{
self.pending_msgs.push( msg );
}
else if is_text
{
if event.keysym == Keysym::Left
{
self.handle_cursor_left( focus, extend );
} else {
self.handle_cursor_right( focus, extend );
}
}
}
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 intercepted = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed );
if let Some( msg ) = intercepted
{
self.pending_msgs.push( msg );
}
else
{
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
}
}

View File

@@ -0,0 +1,41 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use smithay_client_toolkit::seat::keyboard::{ KeyEvent, Keysym };
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
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.
pub( crate ) 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;
match event.keysym
{
Keysym::BackSpace => self.handle_key_backspace( focus, &event ),
Keysym::Delete => self.handle_key_delete( focus, &event ),
Keysym::Return | Keysym::KP_Enter => self.handle_key_return( focus, &event ),
Keysym::Down | Keysym::Up => self.handle_key_arrow_vertical( focus, &event ),
Keysym::Left | Keysym::Right => self.handle_key_arrow_horizontal( focus, &event ),
Keysym::Home => self.handle_key_home( focus ),
Keysym::End => self.handle_key_end( focus ),
Keysym::a | Keysym::A if self.ctrl_pressed => self.handle_key_ctrl_a( focus ),
Keysym::c | Keysym::C if self.ctrl_pressed => self.handle_key_ctrl_c( focus ),
Keysym::x | Keysym::X if self.ctrl_pressed => self.handle_key_ctrl_x( focus ),
Keysym::v | Keysym::V if self.ctrl_pressed => self.handle_key_ctrl_v( focus ),
Keysym::Tab | Keysym::ISO_Left_Tab => self.handle_key_tab( focus, &event, qh ),
Keysym::Escape => self.handle_key_escape( focus, &event, qh ),
Keysym::space => self.handle_key_space( focus, &event ),
_ => self.handle_key_default( focus, &event ),
}
self.surface_mut( focus ).request_redraw();
}
}

165
src/input/keyboard/mod.rs Normal file
View File

@@ -0,0 +1,165 @@
// 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 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 crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
pub( super ) mod dispatch;
pub( super ) mod text_keys;
pub( super ) mod shortcuts;
pub( super ) mod nav;
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 );
}
}

72
src/input/keyboard/nav.rs Normal file
View File

@@ -0,0 +1,72 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
impl<A: App> AppData<A>
{
/// 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
}
}

View File

@@ -0,0 +1,100 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use smithay_client_toolkit::seat::keyboard::{ KeyEvent, Keysym };
use smithay_client_toolkit::reexports::client::QueueHandle;
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
use crate::tree::{ find_handlers, next_focusable_index };
impl<A: App> AppData<A>
{
pub( super ) fn handle_key_ctrl_a( &mut self, focus: SurfaceFocus )
{
let focused = self.surface( focus ).focused_idx;
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 ); }
}
pub( super ) fn handle_key_ctrl_c( &mut self, focus: SurfaceFocus )
{
let focused = self.surface( focus ).focused_idx;
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 ); }
}
pub( super ) fn handle_key_ctrl_x( &mut self, focus: SurfaceFocus )
{
let focused = self.surface( focus ).focused_idx;
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 ); }
}
pub( super ) fn handle_key_ctrl_v( &mut self, focus: SurfaceFocus )
{
let focused = self.surface( focus ).focused_idx;
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 ); }
}
pub( super ) fn handle_key_tab( &mut self, focus: SurfaceFocus, event: &KeyEvent, qh: &QueueHandle<Self> )
{
let reverse = event.keysym == Keysym::ISO_Left_Tab || self.shift_pressed;
let intercepted = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed );
if let Some( msg ) = intercepted
{
self.pending_msgs.push( msg );
}
else
{
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 );
}
}
}
pub( super ) fn handle_key_escape( &mut self, focus: SurfaceFocus, event: &KeyEvent, qh: &QueueHandle<Self> )
{
// 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 );
}
}
}
}

View File

@@ -0,0 +1,183 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use smithay_client_toolkit::seat::keyboard::{ KeyEvent, Keysym };
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
use crate::tree::find_handlers;
impl<A: App> AppData<A>
{
pub( super ) fn handle_key_backspace( &mut self, focus: SurfaceFocus, event: &KeyEvent )
{
let focused = self.surface( focus ).focused_idx;
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 );
}
}
pub( super ) fn handle_key_delete( &mut self, focus: SurfaceFocus, event: &KeyEvent )
{
let focused = self.surface( focus ).focused_idx;
// 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 );
}
}
pub( super ) fn handle_key_return( &mut self, focus: SurfaceFocus, event: &KeyEvent )
{
let focused = self.surface( focus ).focused_idx;
// 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 );
}
}
}
pub( super ) fn handle_key_arrow_vertical( &mut self, focus: SurfaceFocus, event: &KeyEvent )
{
let focused = self.surface( focus ).focused_idx;
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
let reverse = event.keysym == Keysym::Up;
let extend = self.shift_pressed;
let intercepted = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed );
if let Some( msg ) = intercepted
{
self.pending_msgs.push( msg );
}
else
{
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 )
{
// already None, no extra fire
}
}
}
pub( super ) fn handle_key_arrow_horizontal( &mut self, focus: SurfaceFocus, event: &KeyEvent )
{
let focused = self.surface( focus ).focused_idx;
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
let extend = self.shift_pressed;
let intercepted = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed );
if let Some( msg ) = intercepted
{
self.pending_msgs.push( msg );
}
else if is_text
{
if event.keysym == Keysym::Left
{
self.handle_cursor_left( focus, extend );
} else {
self.handle_cursor_right( focus, extend );
}
}
}
pub( super ) fn handle_key_home( &mut self, focus: SurfaceFocus )
{
let focused = self.surface( focus ).focused_idx;
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 ); }
}
pub( super ) fn handle_key_end( &mut self, focus: SurfaceFocus )
{
let focused = self.surface( focus ).focused_idx;
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 ); }
}
pub( super ) fn handle_key_space( &mut self, focus: SurfaceFocus, event: &KeyEvent )
{
let focused = self.surface( focus ).focused_idx;
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 );
}
}
pub( super ) fn handle_key_default( &mut self, focus: SurfaceFocus, event: &KeyEvent )
{
let focused = self.surface( focus ).focused_idx;
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.clone()
{
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 );
}
}
}

View File

@@ -27,6 +27,7 @@ pub( crate ) mod keyboard;
pub( crate ) mod pointer;
pub( crate ) mod touch;
pub( crate ) mod dispatch;
pub( crate ) mod repeat;
// `GestureState` is the only type consumers outside `input/` need —
// `AppData` stores one per `SurfaceState` and the long-press deadline

View File

@@ -1,504 +0,0 @@
// 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; }
}
match new_hover
{
Some( idx ) => self.arm_tooltip( focus, idx ),
None => self.cancel_tooltip(),
}
}
// 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 );
let ( ox, oy ) = self.surface_offset_for( focus );
self.app.on_drag_move( origin.x + ox, origin.y + oy );
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;
self.cancel_tooltip();
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(),
}
}
}

View File

@@ -0,0 +1,43 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use smithay_client_toolkit::seat::pointer::{ PointerEvent, PointerEventKind };
use smithay_client_toolkit::reexports::client::
{
protocol::wl_pointer::WlPointer,
Connection, QueueHandle,
};
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
impl<A: App> AppData<A>
{
pub( super ) fn on_pointer_enter(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_pointer: &WlPointer,
event: &PointerEvent,
)
{
let focus = self.focus_for_surface( &event.surface )
.unwrap_or( SurfaceFocus::Main );
if let PointerEventKind::Enter { serial } = event.kind
{
// 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 );
}
}
}

View File

@@ -0,0 +1,41 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
use crate::tree::find_handlers;
use super::super::gesture::SwipeConfig;
/// 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( crate ) 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(),
}
}
}
pub( crate ) 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 )
}

69
src/input/pointer/mod.rs Normal file
View File

@@ -0,0 +1,69 @@
// 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::WlPointer,
Connection, QueueHandle,
};
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
pub( crate ) mod enter;
pub( crate ) mod motion;
pub( crate ) mod press;
pub( crate ) mod release;
pub( crate ) mod scroll;
pub( crate ) mod helpers;
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 { .. } => self.on_pointer_enter( conn, qh, pointer, event ),
PointerEventKind::Motion { .. } => self.on_pointer_motion( conn, qh, pointer, event ),
PointerEventKind::Press { button: 0x110, .. } => self.on_pointer_left_press( conn, qh, pointer, event ),
PointerEventKind::Press { button: 0x111, .. } => self.on_pointer_right_click( conn, qh, pointer, event ),
PointerEventKind::Release { button: 0x110, .. } => self.on_pointer_release( conn, qh, pointer, event ),
PointerEventKind::Axis { .. } => self.on_pointer_axis( conn, qh, pointer, event ),
_ => {}
}
}
}
}

145
src/input/pointer/motion.rs Normal file
View File

@@ -0,0 +1,145 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use smithay_client_toolkit::seat::pointer::PointerEvent;
use smithay_client_toolkit::reexports::client::
{
protocol::wl_pointer::WlPointer,
Connection, QueueHandle,
};
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
use crate::tree::{ find_handlers, find_widget_at };
use super::helpers::hover_affects_paint;
impl<A: App> AppData<A>
{
pub( super ) fn on_pointer_motion(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_pointer: &WlPointer,
event: &PointerEvent,
)
{
let focus = self.focus_for_surface( &event.surface )
.unwrap_or( SurfaceFocus::Main );
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; }
}
match new_hover
{
Some( idx ) => self.arm_tooltip( focus, idx ),
None => self.cancel_tooltip(),
}
}
// 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 );
let ( ox, oy ) = self.surface_offset_for( focus );
self.app.on_drag_move( origin.x + ox, origin.y + oy );
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 );
}
}

257
src/input/pointer/press.rs Normal file
View File

@@ -0,0 +1,257 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use smithay_client_toolkit::seat::pointer::{ PointerEvent, PointerEventKind };
use smithay_client_toolkit::reexports::client::
{
protocol::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;
impl<A: App> AppData<A>
{
pub( super ) fn on_pointer_right_click(
&mut self,
_conn: &Connection,
qh: &QueueHandle<Self>,
_pointer: &WlPointer,
event: &PointerEvent,
)
{
let focus = self.focus_for_surface( &event.surface )
.unwrap_or( SurfaceFocus::Main );
// 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 );
}
}
}
pub( super ) fn on_pointer_left_press(
&mut self,
_conn: &Connection,
qh: &QueueHandle<Self>,
_pointer: &WlPointer,
event: &PointerEvent,
)
{
let focus = self.focus_for_surface( &event.surface )
.unwrap_or( SurfaceFocus::Main );
let serial = if let PointerEventKind::Press { serial, .. } = event.kind
{
serial
} else {
return;
};
self.last_pointer_serial = serial;
self.last_input_serial = serial;
self.cancel_tooltip();
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 )
{
return;
}
}
// 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 );
}
}
return;
}
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 );
}
}
}
return;
}
// 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 );
}
}

View File

@@ -0,0 +1,48 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use smithay_client_toolkit::seat::pointer::PointerEvent;
use smithay_client_toolkit::reexports::client::
{
protocol::wl_pointer::WlPointer,
Connection, QueueHandle,
};
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
impl<A: App> AppData<A>
{
pub( super ) fn on_pointer_release(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_pointer: &WlPointer,
event: &PointerEvent,
)
{
let focus = self.focus_for_surface( &event.surface )
.unwrap_or( SurfaceFocus::Main );
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 );
}
}

View File

@@ -0,0 +1,67 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use smithay_client_toolkit::seat::pointer::{ PointerEvent, PointerEventKind };
use smithay_client_toolkit::reexports::client::
{
protocol::{ wl_pointer, wl_pointer::WlPointer },
Connection, QueueHandle,
};
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
impl<A: App> AppData<A>
{
pub( super ) fn on_pointer_axis(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_pointer: &WlPointer,
event: &PointerEvent,
)
{
let focus = self.focus_for_surface( &event.surface )
.unwrap_or( SurfaceFocus::Main );
let ( horizontal, vertical, source ) = if let PointerEventKind::Axis { horizontal, vertical, source, .. } = event.kind
{
( horizontal, vertical, source )
} else {
return;
};
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 );
}
}
}

View File

@@ -0,0 +1,89 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use calloop::timer::{ Timer, TimeoutAction };
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
use crate::event_loop::repeat::ButtonRepeatState;
impl<A: App> AppData<A>
{
/// 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( 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 );
}
}
}

100
src/input/repeat/key.rs Normal file
View File

@@ -0,0 +1,100 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::time::Duration;
use smithay_client_toolkit::seat::keyboard::KeyEvent;
use calloop::timer::{ Timer, TimeoutAction };
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
use crate::event_loop::repeat::KeyRepeatState;
/// 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> AppData<A>
{
/// 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 );
}
}
}

5
src/input/repeat/mod.rs Normal file
View File

@@ -0,0 +1,5 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
pub( crate ) mod key;
pub( crate ) mod button;

116
src/theme/accessors.rs Normal file
View File

@@ -0,0 +1,116 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Slot-typed shorthand accessors against the active theme document.
//!
//! Widgets speak in terms of slot ids. The plumbing for "which document is
//! active" and "which mode" is captured here so widget code stays
//! free of that plumbing.
use crate::types::Color;
use super::active::ensure_active;
use super::paint::Paint;
use super::palette::{ default_window_controls, Palette };
use super::prefs::WindowControlsSpec;
use super::shadow::{ Shadow, ShadowsRef };
use super::surface::Surface;
use super::text_style::TextStyle;
/// Resolve a colour slot in the active mode. `None` when the slot is
/// missing, empty, or not a `Slot::Color` (use [`paint()`] if a gradient
/// should also be acceptable).
pub fn color( id: &str ) -> Option<Color>
{
let state = ensure_active();
state.document.mode( state.mode ).slots.color( id )
}
/// Like [`color`] but returns `fallback` when the slot is missing. The
/// typical pattern for widgets that must always paint something.
pub fn color_or( id: &str, fallback: Color ) -> Color
{
color( id ).unwrap_or( fallback )
}
/// Resolve a paint slot (solid or gradient) in the active mode. A plain
/// `Slot::Color` is promoted to `Paint::Solid`. `None` for missing or
/// non-paintable slots (e.g. a text-style).
pub fn paint( id: &str ) -> Option<Paint>
{
let state = ensure_active();
state.document.mode( state.mode ).slots.paint( id )
}
/// Resolve an elevation (outer-shadow stack) slot in the active mode. The
/// returned `Vec` is a freshly cloned copy so call sites can hold it
/// across frames without borrowing the global state.
pub fn shadows( id: &str ) -> Option<Vec<Shadow>>
{
let state = ensure_active();
state.document.mode( state.mode ).slots.shadows( id ).map( |s| s.to_vec() )
}
/// Resolve a surface slot in the active mode. A colour or paint slot
/// promotes to a surface with that fill and no decorations; a full
/// surface slot returns unchanged.
pub fn surface( id: &str ) -> Option<Surface>
{
let state = ensure_active();
state.document.mode( state.mode ).slots.surface( id )
}
/// Resolve a surface slot together with its outer shadow stack.
///
/// [`Surface::shadows`] may be `ShadowsRef::Named(id)`, pointing at a
/// separate `Slot::Shadows` entry in the same mode. `canvas.fill_surface`
/// expects the stack resolved as a plain `&[Shadow]`, so this helper does
/// the second lookup in one place: widgets call `resolve_surface(id)` and
/// get back the surface plus a `Vec<Shadow>` ready to hand to the canvas.
///
/// Returns `None` only when the surface slot itself is absent. A missing
/// named shadow ref degrades silently to an empty stack — the surface
/// still paints its fill and inset decorations, just without the outer
/// glow.
pub fn resolve_surface( id: &str ) -> Option<( Surface, Vec<Shadow> )>
{
let state = ensure_active();
let mode = state.document.mode( state.mode );
let surface = mode.slots.surface( id )?;
let shadows = match &surface.shadows
{
Some( ShadowsRef::Named( sid ) ) => mode.slots.shadows( sid ).map( |s| s.to_vec() ).unwrap_or_default(),
Some( ShadowsRef::Inline( v ) ) => v.clone(),
None => Vec::new(),
};
Some( ( surface, shadows ) )
}
/// Resolve a typography slot in the active mode. Cloned so the result
/// can outlive the global-state borrow.
pub fn text_style( id: &str ) -> Option<TextStyle>
{
let state = ensure_active();
state.document.mode( state.mode ).slots.text_style( id ).cloned()
}
/// The active mode's window-controls payload, derived from slots if the
/// document did not declare an explicit block.
pub fn window_controls() -> WindowControlsSpec
{
let state = ensure_active();
let mode = state.document.mode( state.mode );
if let Some( wc ) = mode.window_controls { return wc; }
default_window_controls( Palette::from_slots( &mode.slots ) )
}
/// The eight canonical palette slots of the active mode projected as a
/// [`Palette`] struct. This is a one-call shortcut equivalent to
/// `Palette::from_slots(&active_document().mode(active_mode()).slots)`,
/// covering the common case where a widget needs `text_primary` /
/// `surface` / `accent` / etc. without picking specific slot ids.
pub fn palette() -> Palette
{
let state = ensure_active();
Palette::from_slots( &state.document.mode( state.mode ).slots )
}

150
src/theme/active.rs Normal file
View File

@@ -0,0 +1,150 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Process-wide active theme state.
//!
//! The active document is published once and read by every per-slot
//! accessor in [`crate::theme`]. First access triggers a disk load of the
//! `default` theme; if that fails, an embedded B/W document takes over
//! and the [`is_fallback_active`] flag flips on so the draw layer can paint
//! a warning banner.
use std::sync::{ Arc, RwLock };
use super::assets;
use super::document::ThemeDocument;
use super::fallback;
use super::prefs::ThemeMode;
#[ derive( Clone ) ]
pub ( crate ) struct ActiveState
{
/// Currently loaded theme document. The single source of truth —
/// every consumer-facing accessor in this module reads through it
/// against the active [`ThemeMode`].
pub ( crate ) document: Arc<ThemeDocument>,
pub ( crate ) mode: ThemeMode,
/// `true` when the active document was produced by
/// [`fallback::document`] because `ThemeDocument::find("default")`
/// failed. The draw path stamps every frame with a red banner in
/// this state so the user sees the missing-theme signal without
/// the process having to abort.
pub ( crate ) is_fallback: bool,
}
// `RwLock::new` and `Option::None` are both const, so this needs no lazy init.
static ACTIVE: RwLock<Option<ActiveState>> = RwLock::new( None );
/// Read the active state, loading the `default` theme from disk on
/// first access. If the `default` theme cannot be found in any search
/// path, the crate falls back to an in-memory B/W
/// [`fallback::document`], marks the state with `is_fallback = true`
/// (so the draw path can paint the warning banner), and logs a line
/// to stderr pointing at the install instructions. The process never
/// panics on first-access — making ltk embeddable inside programs
/// that want to handle missing theme gracefully.
pub ( crate ) fn ensure_active() -> ActiveState
{
{
let guard = ACTIVE.read().expect( "theme: ACTIVE poisoned" );
if let Some( s ) = guard.as_ref()
{
return s.clone();
}
}
let mut guard = ACTIVE.write().expect( "theme: ACTIVE poisoned" );
if guard.is_none()
{
let ( doc, is_fallback ) = match ThemeDocument::find( "default" )
{
Ok( d ) => ( d, false ),
Err( e ) =>
{
eprintln!
(
"[ltk] default theme not found ({e}); using embedded B/W \
fallback. Install the `ltk-theme-default` Debian package \
(Provides: ltk-theme) or set `LTK_THEMES_DIR` to a \
directory containing `default/theme.json` to get the \
real theme back."
);
( fallback::document(), true )
}
};
*guard = Some( ActiveState
{
document: Arc::new( doc ),
mode: ThemeMode::Light,
is_fallback,
});
}
guard.as_ref().expect( "just installed" ).clone()
}
/// Install `doc` as the active theme. The current mode is preserved
/// (defaulting to [`ThemeMode::Light`] if nothing was set yet). Also
/// clears the fallback flag — an explicit install supersedes the
/// embedded B/W document and the warning banner stops painting from
/// the next frame on.
pub fn set_active_document( doc: ThemeDocument )
{
let mut guard = ACTIVE.write().expect( "theme: ACTIVE poisoned" );
let mode = guard.as_ref().map( |s| s.mode ).unwrap_or( ThemeMode::Light );
*guard = Some( ActiveState
{
document: Arc::new( doc ),
mode,
is_fallback: false,
});
// Drop any rasterised icons cached against the previous theme's
// paths. Cache keys embed absolute paths so old entries are never
// *wrong*, just dead memory; clearing keeps the working set small.
assets::clear_svg_cache();
}
/// Switch the active variant. The document is left untouched — if nothing
/// has been loaded yet, the default theme is loaded first.
pub fn set_active_mode( mode: ThemeMode )
{
// Ensure the default theme is loaded before mutating the mode so we
// never publish an `ActiveState` with a missing document.
let _ = ensure_active();
let mut guard = ACTIVE.write().expect( "theme: ACTIVE poisoned" );
if let Some( s ) = guard.as_mut() { s.mode = mode; }
}
/// The id of the active theme.
pub fn active_theme_id() -> String
{
ensure_active().document.id.clone()
}
/// The active variant (light or dark).
pub fn active_mode() -> ThemeMode
{
ensure_active().mode
}
/// The currently loaded theme document. Use this for slot-typed lookups
/// when the per-slot helpers ([`crate::theme::color`], [`crate::theme::surface`], …) are not
/// expressive enough — e.g. iterating `mode.slots.entries`.
pub fn active_document() -> Arc<ThemeDocument>
{
ensure_active().document
}
/// `true` when the active theme was produced by the embedded B/W
/// fallback because `ThemeDocument::find("default")` failed at
/// first-access. Flipped back to `false` the moment a consumer calls
/// [`set_active_document`] with any document (even another call to
/// the same fallback — the point is "this state came from an
/// explicit install, not from the missing-theme code path").
///
/// The draw layer reads this to decide whether to stamp each surface
/// with the red "install `ltk-theme-default`" banner; apps can read
/// it too, e.g. to log a diagnostic or surface a first-run
/// installation helper.
pub fn is_fallback_active() -> bool
{
ensure_active().is_fallback
}

484
src/theme/assets.rs Normal file
View File

@@ -0,0 +1,484 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Theme asset resolution: wallpapers, lockscreens, branding logos, app
//! icons, launcher icon, and the SVG rasterisation pipeline (with
//! process-wide cache) that the canvas consumes.
use std::collections::HashMap;
use std::path::{ Path, PathBuf };
use std::sync::{ Arc, Mutex };
use crate::types::Color;
use super::active::ensure_active;
use super::prefs::{ ThemeMode, WallpaperFit, WallpaperSpec };
use super::system_fontdb;
// ─── Wallpaper / lockscreen ──────────────────────────────────────────────────
/// The active mode's homescreen / shell wallpaper. Prefers an explicit
/// declaration in `theme.json`; when absent, falls back to the
/// convention path `branding/{mode}/wallpaper.svg` via [`branding_asset`]
/// (with mode → opposite-mode → no-mode fallback). Returns `None` when
/// neither source resolves to an existing file.
///
/// Always returns the SVG. Use [`branding_image( "wallpaper", sw, sh
/// )`](branding_image) when the surface size is known to prefer a
/// pre-rendered raster variant under `branding/{mode}/wallpaper/`.
pub fn wallpaper() -> Option<WallpaperSpec>
{
let state = ensure_active();
if let Some( spec ) = state.document.mode( state.mode ).wallpaper.clone()
{
return Some( spec );
}
branding_asset( "wallpaper", "svg" )
.map( |path| WallpaperSpec { path: Some( path ), fit: WallpaperFit::Cover } )
}
/// The active mode's lockscreen / greeter wallpaper. Same resolution
/// strategy as [`wallpaper`]: explicit `theme.json` declaration first,
/// otherwise `branding/{mode}/lockscreen.svg` via [`branding_asset`].
/// Use [`branding_image( "lockscreen", sw, sh )`](branding_image) for
/// the size-aware raster lookup.
pub fn lockscreen() -> Option<WallpaperSpec>
{
let state = ensure_active();
if let Some( spec ) = state.document.mode( state.mode ).lockscreen.clone()
{
return Some( spec );
}
branding_asset( "lockscreen", "svg" )
.map( |path| WallpaperSpec { path: Some( path ), fit: WallpaperFit::Cover } )
}
// ─── App icons ───────────────────────────────────────────────────────────────
/// Path to the named app icon SVG inside the active theme's `icons/apps/`
/// directory. `name` is the bare stem (e.g. `"firefox"`, `"calculator"`)
/// without the `.svg` extension. Returns `None` when the active document
/// has no on-disk root or the file does not exist.
pub fn app_icon( name: &str ) -> Option<PathBuf>
{
let state = ensure_active();
let root = state.document.root.as_ref()?;
let path = root.join( "icons/apps" ).join( format!( "{}.svg", name ) );
if path.is_file() { Some( path ) } else { None }
}
/// Path to `app-default.svg` inside the active theme's icon directory.
/// Returns `None` when the document has no on-disk root or the file is
/// absent.
pub fn app_default_icon() -> Option<PathBuf>
{
let state = ensure_active();
let root = state.document.root.as_ref()?;
let path = root.join( "icons/app-default.svg" );
if path.is_file() { Some( path ) } else { None }
}
/// Path to the launcher-logo SVG for the currently active mode.
/// Resolves through [`branding_asset`] using the convention
/// `branding/{mode}/launcher.svg`, with the standard mode →
/// opposite-mode → no-mode fallback chain.
pub fn launcher_icon() -> Option<PathBuf>
{
branding_asset( "launcher", "svg" )
}
// ─── Logos ───────────────────────────────────────────────────────────────────
/// Path to the brand logo SVG for the currently active mode.
/// Convention: `branding/{mode}/logo/logo.svg`. The "main" logo —
/// usually the wordmark variant a shell would put in an "About"
/// dialog or a splash. Pair with [`logo_square`] / [`logo_horizontal`]
/// when the surface dictates a different aspect ratio.
///
/// Resolves through [`branding_asset`] with the same three-step
/// fallback (active mode → opposite mode → no-mode), so a theme that
/// only ships one mode still produces a usable path.
pub fn logo() -> Option<PathBuf>
{
branding_asset( "logo/logo", "svg" )
}
/// Path to the square (1:1) brand logo SVG for the currently active
/// mode. Convention: `branding/{mode}/logo/square.svg`. Use when the
/// surface is roughly square — app icons, login avatars, splash
/// screens, lockscreen brand badges. Same fallback chain as [`logo`].
pub fn logo_square() -> Option<PathBuf>
{
branding_asset( "logo/square", "svg" )
}
/// Path to the horizontal (wordmark) brand logo SVG for the currently
/// active mode. Convention: `branding/{mode}/logo/horizontal.svg`.
/// Use when there is meaningful horizontal space — header bars, menu
/// bars, "About" dialogs, sign-in screens. Same fallback chain as
/// [`logo`].
pub fn logo_horizontal() -> Option<PathBuf>
{
branding_asset( "logo/horizontal", "svg" )
}
// ─── Branding asset resolution ───────────────────────────────────────────────
/// Resolve a branding asset (launcher logo, wallpaper, lockscreen, …)
/// against the active theme's `branding/` tree. Tries three candidate
/// paths in order and returns the first one that exists on disk:
///
/// 1. `branding/{active_mode}/{name}.{ext}` — preferred variant.
/// 2. `branding/{opposite_mode}/{name}.{ext}` — graceful degradation
/// when the theme only ships one mode of the asset.
/// 3. `branding/{name}.{ext}` — mode-agnostic asset, for themes that
/// do not bother with light/dark variants.
///
/// Returns `None` when none of the candidates exist or the active
/// document has no on-disk root.
pub fn branding_asset( name: &str, ext: &str ) -> Option<PathBuf>
{
let state = ensure_active();
let root = state.document.root.as_ref()?;
let branding = root.join( "branding" );
let filename = format!( "{name}.{ext}" );
let ( pref_dir, alt_dir ) = match state.mode
{
ThemeMode::Dark => ( "dark", "light" ),
ThemeMode::Light => ( "light", "dark" ),
};
let preferred = branding.join( pref_dir ).join( &filename );
if preferred.is_file() { return Some( preferred ); }
let alternate = branding.join( alt_dir ).join( &filename );
if alternate.is_file() { return Some( alternate ); }
let modeless = branding.join( &filename );
if modeless.is_file() { return Some( modeless ); }
None
}
/// Resolve a raster variant of a branded asset for the given surface
/// dimensions. Looks under `branding/{mode}/{name}/` (with the
/// standard mode → opposite-mode → no-mode fallback chain on the
/// *directory*), parses each filename as `WIDTHxHEIGHT.<ext>` where
/// `<ext>` is `webp`, `png`, `jpg`, or `jpeg`. Returns the best match
/// in the *first existing* directory:
///
/// - If one or more entries cover the surface (`W ≥ sw && H ≥ sh`),
/// returns the smallest such by area — the smallest raster that
/// fits without upscaling.
/// - Otherwise returns the largest entry available — better to
/// upscale a fast-decoding raster than to fall back to the
/// comparatively expensive SVG rasterisation.
///
/// Ties on area are broken by `(width, height)` lexicographic order
/// for determinism.
///
/// `(sw, sh)` of `(0, 0)` means "give me the smallest available
/// raster" — every entry trivially covers a zero-sized surface, so
/// the smallest by area wins. Useful at startup before the
/// surface-configure event has reported the real dimensions.
///
/// Returns `None` only when no directory in the fallback chain
/// contains any parseable raster file.
///
/// Note: only the *first existing* mode-directory in the chain is
/// considered. If `branding/{active_mode}/{name}/` has any raster,
/// the loader uses it; it does not cross over to the opposite-mode
/// directory. Cross-mode fallback happens at the SVG layer through
/// [`branding_asset`], where a colour-wrong raster would be more
/// jarring than a colour-correct vector.
pub fn branding_raster( name: &str, sw: u32, sh: u32 ) -> Option<PathBuf>
{
let state = ensure_active();
let root = state.document.root.as_ref()?;
let branding = root.join( "branding" );
let ( pref_dir, alt_dir ) = match state.mode
{
ThemeMode::Dark => ( "dark", "light" ),
ThemeMode::Light => ( "light", "dark" ),
};
let candidates = [
branding.join( pref_dir ).join( name ),
branding.join( alt_dir ).join( name ),
branding.join( name ),
];
for dir in &candidates
{
if !dir.is_dir() { continue; }
if let Some( best ) = pick_best_raster( dir, sw, sh )
{
return Some( best );
}
}
None
}
/// Resolve a branded image asset, preferring a raster variant (WebP /
/// PNG / JPEG) over the canonical SVG. Tries
/// [`branding_raster(name, sw, sh)`] first; on `None` (no raster files
/// at all under `branding/{mode}/{name}/`) falls back to
/// [`branding_asset(name, "svg")`].
///
/// Pass `(0, 0)` to get the smallest available raster — useful at
/// startup before the surface size is known.
pub fn branding_image( name: &str, sw: u32, sh: u32 ) -> Option<PathBuf>
{
branding_raster( name, sw, sh )
.or_else( || branding_asset( name, "svg" ) )
}
/// Walk `dir` for files named `WIDTHxHEIGHT.<ext>` (case-insensitive
/// `x`, recognised raster extensions: webp, png, jpg, jpeg). Returns
/// the smallest entry whose dimensions cover `( sw, sh )`; if none
/// cover, returns the largest entry available (better to upscale a
/// fast raster than fall through to SVG rasterisation). Ties are
/// broken by `( width, height )` lexicographic order. `None` only
/// when the directory holds no parseable raster files.
fn pick_best_raster( dir: &Path, sw: u32, sh: u32 ) -> Option<PathBuf>
{
let entries = std::fs::read_dir( dir ).ok()?;
let mut covering: Option<( u32, u32, PathBuf )> = None;
let mut fallback: Option<( u32, u32, PathBuf )> = None;
for entry in entries.flatten()
{
let path = entry.path();
let Some( stem ) = path.file_stem().and_then( |s| s.to_str() ) else { continue };
let ext = path.extension().and_then( |e| e.to_str() ).unwrap_or( "" );
if !matches!( ext.to_ascii_lowercase().as_str(), "webp" | "png" | "jpg" | "jpeg" )
{
continue;
}
let Some( ( w_str, h_str ) ) = stem.split_once( |c: char| c == 'x' || c == 'X' ) else { continue };
let ( Ok( w ), Ok( h ) ) = ( w_str.parse::<u32>(), h_str.parse::<u32>() ) else { continue };
let new_area = ( w as u64 ) * ( h as u64 );
if w >= sw && h >= sh
{
let take = match &covering
{
None => true,
Some( ( bw, bh, _ ) ) =>
{
let cur_area = ( *bw as u64 ) * ( *bh as u64 );
new_area < cur_area || ( new_area == cur_area && ( w, h ) < ( *bw, *bh ) )
}
};
if take { covering = Some( ( w, h, path ) ); }
}
else
{
let take = match &fallback
{
None => true,
Some( ( bw, bh, _ ) ) =>
{
let cur_area = ( *bw as u64 ) * ( *bh as u64 );
new_area > cur_area || ( new_area == cur_area && ( w, h ) > ( *bw, *bh ) )
}
};
if take { fallback = Some( ( w, h, path ) ); }
}
}
covering.or( fallback ).map( |( _, _, p )| p )
}
// ─── Symbolic icon tinting ───────────────────────────────────────────────────
/// Re-tint a symbolic RGBA icon: replace every pixel's RGB with `tint` while
/// keeping the source alpha (weighted by `tint.a`).
///
/// Input `rgba` must be straight-alpha RGBA8 with 4 bytes per pixel.
/// Returns a freshly allocated `Vec<u8>` of the same length.
///
/// Useful for flattening Papirus / freedesktop icons to a single theme colour
/// so they stay legible against both light and dark backgrounds.
pub fn tint_symbolic( rgba: &[u8], tint: Color ) -> Vec<u8>
{
let r = (tint.r.clamp( 0.0, 1.0 ) * 255.0) as u8;
let g = (tint.g.clamp( 0.0, 1.0 ) * 255.0) as u8;
let b = (tint.b.clamp( 0.0, 1.0 ) * 255.0) as u8;
let ta = tint.a.clamp( 0.0, 1.0 );
let mut out = Vec::with_capacity( rgba.len() );
for px in rgba.chunks_exact( 4 )
{
let a = (px[3] as f32 / 255.0) * ta;
out.extend_from_slice( &[ r, g, b, (a * 255.0) as u8 ] );
}
out
}
// ─── SVG rasterisation ───────────────────────────────────────────────────────
/// Process-wide cache of rasterised theme icons, keyed by (absolute path on
/// disk, target longest-edge size in physical pixels). Entries are produced
/// by [`icon_rgba`] and never invalidated — the key embeds the absolute path
/// and the icon files are read-only on disk, so a `set_active_document`
/// switch produces fresh keys rather than serving stale data.
static SVG_CACHE: Mutex<Option<HashMap<( PathBuf, u32 ), ( Arc<Vec<u8>>, u32, u32 )>>>
= Mutex::new( None );
/// Drop every entry from the SVG cache. Called when the active document
/// is replaced — keys embed absolute paths so stale entries are never
/// wrong, just dead memory.
pub ( super ) fn clear_svg_cache()
{
if let Ok( mut cache ) = SVG_CACHE.lock()
{
if let Some( ref mut map ) = *cache { map.clear(); }
}
}
/// Decode a UTF-8 SVG document into a premultiplied RGBA8 pixmap of
/// the requested longest-edge `size` in physical pixels. The shorter
/// edge is scaled proportionally so the icon's aspect ratio is
/// preserved; the returned `(width, height)` reflect the final
/// pixmap.
///
/// Returns `None` for malformed SVG input or when the size is too
/// small (≤ 0 px on the longest edge).
///
/// The implementation uses [`resvg`] (which bundles `usvg` and
/// `tiny-skia`) so the rasteriser is the same as the rest of ltk's
/// software-canvas path. Use [`icon_rgba`] when you want
/// path-resolution + caching against the active theme tree.
pub fn decode_svg_bytes( svg_bytes: &[u8], size: u32 ) -> Option<( Arc<Vec<u8>>, u32, u32 )>
{
if size == 0 { return None; }
// SVGs that declare `width="100%"` without an explicit pixel size
// fall back to usvg's `default_size` (100×100). Match it to the
// requested size so percentage-only documents rasterise at the
// dimensions the caller asked for instead of a tiny default.
let mut opts = resvg::usvg::Options::default();
if let Some( ds ) = resvg::usvg::Size::from_wh( size as f32, size as f32 )
{
opts.default_size = ds;
}
opts.fontdb = system_fontdb();
let tree = resvg::usvg::Tree::from_data( svg_bytes, &opts ).ok()?;
let svg_size = tree.size();
let longest = svg_size.width().max( svg_size.height() );
if longest <= 0.0 { return None; }
let scale = size as f32 / longest;
let w = ( svg_size.width() * scale ).ceil() as u32;
let h = ( svg_size.height() * scale ).ceil() as u32;
let mut pixmap = resvg::tiny_skia::Pixmap::new( w.max( 1 ), h.max( 1 ) )?;
let transform = resvg::tiny_skia::Transform::from_scale( scale, scale );
resvg::render( &tree, transform, &mut pixmap.as_mut() );
Some( ( Arc::new( pixmap.take() ), w, h ) )
}
/// Resolve a theme-relative icon name to an absolute path inside the
/// active theme's `icons/catalogue/` tree.
///
/// `name` is the slash-separated path **without** the `.svg`
/// extension (e.g. `"general/right-simple"`,
/// `"system/wifi-signal-full"`). The lookup tries the
/// `catalogue/filled/<name>.svg` variant first and falls back to
/// `catalogue/line/<name>.svg`; returns `None` when neither file
/// exists or the active document has no on-disk root.
pub fn icon_path( name: &str ) -> Option<PathBuf>
{
let state = ensure_active();
let root = state.document.root.as_ref()?;
let filled = root.join( "icons/catalogue/filled" ).join( format!( "{name}.svg" ) );
if filled.is_file() { return Some( filled ); }
let line = root.join( "icons/catalogue/line" ).join( format!( "{name}.svg" ) );
if line.is_file() { return Some( line ); }
None
}
/// Rasterise a theme icon to a premultiplied RGBA8 pixmap.
///
/// Combines [`icon_path`] (path resolution against the active theme)
/// with [`decode_svg_bytes`] (SVG → RGBA), and caches the result by
/// `(absolute path, size)` so a widget redrawn every frame pays the
/// rasterisation cost only on first access.
///
/// Returns `None` when the icon cannot be located, the file cannot be
/// read, or the SVG is malformed.
pub fn icon_rgba( name: &str, size: u32 ) -> Option<( Arc<Vec<u8>>, u32, u32 )>
{
let path = icon_path( name )?;
let key = ( path.clone(), size );
// Fast path: cache hit.
{
let mut guard = SVG_CACHE.lock().ok()?;
let cache = guard.get_or_insert_with( HashMap::new );
if let Some( v ) = cache.get( &key )
{
return Some( ( Arc::clone( &v.0 ), v.1, v.2 ) );
}
}
// Slow path: read + decode + populate cache.
let bytes = std::fs::read( &path ).ok()?;
let result = decode_svg_bytes( &bytes, size )?;
if let Ok( mut guard ) = SVG_CACHE.lock()
{
let cache = guard.get_or_insert_with( HashMap::new );
cache.insert( key, ( Arc::clone( &result.0 ), result.1, result.2 ) );
}
Some( result )
}
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ test ]
fn tint_preserves_source_alpha()
{
let src = [ 255, 0, 0, 255, 0, 255, 0, 128 ];
let out = tint_symbolic( &src, Color::hex( 0x0B, 0x1B, 0x38 ) );
assert_eq!( out[0..3], [ 0x0B, 0x1B, 0x38 ] );
assert_eq!( out[3], 255 );
assert_eq!( out[4..7], [ 0x0B, 0x1B, 0x38 ] );
assert_eq!( out[7], 128 );
}
#[ test ]
fn tint_respects_tint_alpha()
{
let src = [ 255, 255, 255, 255 ];
let out = tint_symbolic( &src, Color { r: 1.0, g: 1.0, b: 1.0, a: 0.5 } );
assert_eq!( out[3], 127 );
}
#[ test ]
fn decode_svg_bytes_returns_pixmap_for_minimal_svg()
{
// 16×16 red square. Double-pound raw byte string so the `#`
// inside `fill="#ff0000"` does not close the literal.
let svg = br##"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><rect width="16" height="16" fill="#ff0000"/></svg>"##;
let ( rgba, w, h ) = decode_svg_bytes( svg, 16 ).expect( "decode" );
assert_eq!( w, 16 );
assert_eq!( h, 16 );
assert_eq!( rgba.len(), ( 16 * 16 * 4 ) as usize );
// Centre pixel should be opaque red (premultiplied → R=255 A=255).
let centre = ( ( 8 * 16 + 8 ) * 4 ) as usize;
assert!( rgba[ centre + 0 ] > 200, "red channel" );
assert!( rgba[ centre + 1 ] < 50, "green channel" );
assert!( rgba[ centre + 2 ] < 50, "blue channel" );
assert_eq!( rgba[ centre + 3 ], 255, "alpha" );
}
#[ test ]
fn decode_svg_bytes_scales_to_requested_size()
{
// 16×16 source rasterised at 32 px → output is 32×32.
let svg = br##"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><rect width="16" height="16" fill="#000"/></svg>"##;
let ( _, w, h ) = decode_svg_bytes( svg, 32 ).expect( "decode" );
assert_eq!( w, 32 );
assert_eq!( h, 32 );
}
#[ test ]
fn decode_svg_bytes_rejects_garbage()
{
assert!( decode_svg_bytes( b"not valid svg", 32 ).is_none() );
assert!( decode_svg_bytes( b"<svg></svg>", 0 ).is_none() ); // size 0
}
}

35
src/theme/error.rs Normal file
View File

@@ -0,0 +1,35 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Error type returned by theme loading and parsing.
use std::fmt;
use std::io;
use std::path::PathBuf;
#[ derive( Debug ) ]
pub enum ThemeError
{
Io( PathBuf, io::Error ),
ParseJson( PathBuf, serde_json::Error ),
NotFound( String ),
InvalidColor( String ),
UnknownColorRef( String ),
}
impl fmt::Display for ThemeError
{
fn fmt( &self, f: &mut fmt::Formatter<'_> ) -> fmt::Result
{
match self
{
ThemeError::Io( p, e ) => write!( f, "reading {}: {}", p.display(), e ),
ThemeError::ParseJson( p, e ) => write!( f, "parsing {}: {}", p.display(), e ),
ThemeError::NotFound( id ) => write!( f, "theme `{}` not found in any search path", id ),
ThemeError::InvalidColor( s ) => write!( f, "invalid colour `{}` (expected #RRGGBB, #RRGGBBAA or rgb[a](…))", s ),
ThemeError::UnknownColorRef( s ) => write!( f, "unknown reference `@{}` (not defined in top-level `colors`, `gradients` or `inset_stacks`)", s ),
}
}
}
impl std::error::Error for ThemeError {}

File diff suppressed because it is too large Load Diff

122
src/theme/palette.rs Normal file
View File

@@ -0,0 +1,122 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! The eight-slot semantic [`Palette`] every widget speaks in terms of, plus
//! the derived [`WindowControlsSpec`] fallback used when a theme document
//! omits the explicit `window_controls` block.
use crate::types::Color;
use super::prefs::WindowControlsSpec;
use super::slots::SlotStore;
// ─── Palette ─────────────────────────────────────────────────────────────────
/// Semantic colour tokens shared by every widget.
#[ derive( Debug, Clone, Copy ) ]
pub struct Palette
{
/// Page / wallpaper background when no image is present.
pub bg: Color,
/// Floating surface over the wallpaper (cards, panels, dock) — usually translucent.
pub surface: Color,
/// Elevated surface (hovered card, pressed toggle, inner pill).
pub surface_alt: Color,
/// Primary foreground text.
pub text_primary: Color,
/// Subdued text (date strip, helper text, disabled labels).
pub text_secondary: Color,
/// Brand accent used for active toggles, sliders, focus rings.
pub accent: Color,
/// Thin separators and low-contrast borders.
pub divider: Color,
/// Tint for symbolic icons (wifi / battery / search / etc).
pub icon: Color,
/// Foreground colour for destructive / error states — text in
/// "delete" buttons, error helper rows under invalid inputs,
/// the border of an errored pill.
pub danger: Color,
/// Soft fill behind error states. Pairs with `danger` as
/// foreground; light enough that body text remains legible on
/// top.
pub danger_bg: Color,
}
// ─── Palette projection ──────────────────────────────────────────────────────
impl Palette
{
/// Project a [`SlotStore`] onto the eight canonical palette fields.
/// Slot ids are the ones declared in the default theme JSON
/// (`bg-page`, `surface`, `surface-alt`, `text-primary`,
/// `text-secondary`, `accent`, `divider`, `icon`). Missing slots
/// fall back to a documented sensible default so downstream widgets
/// never see uninitialised colours. Used by [`crate::theme::palette`] and
/// [`crate::theme::window_controls`].
pub fn from_slots( slots: &SlotStore ) -> Self
{
Self
{
bg: slots.color( "bg-page" ).unwrap_or( Color::WHITE ),
surface: slots.color( "surface" ).unwrap_or( Color::WHITE ),
surface_alt: slots.color( "surface-alt" ).unwrap_or( Color::WHITE ),
text_primary: slots.color( "text-primary" ).unwrap_or( Color::BLACK ),
text_secondary: slots.color( "text-secondary" ).unwrap_or( Color::rgba( 0.0, 0.0, 0.0, 0.6 ) ),
accent: slots.color( "accent" ).unwrap_or( Color::hex( 0x00, 0xCE, 0xB1 ) ),
divider: slots.color( "divider" ).unwrap_or( Color::rgba( 0.0, 0.0, 0.0, 0.08 ) ),
icon: slots.color( "icon" ).unwrap_or( Color::BLACK ),
// Conservative defaults: a rich red for fg, a tinted
// pink wash for bg. Themes can override via the
// `danger` / `danger-bg` slot ids.
danger: slots.color( "danger" ).unwrap_or( Color::hex( 0xA8, 0x00, 0x10 ) ),
danger_bg: slots.color( "danger-bg" ).unwrap_or( Color::rgba( 1.0, 0.85, 0.88, 1.0 ) ),
}
}
}
// ─── Shared helpers ──────────────────────────────────────────────────────────
/// Derive a sensible [`WindowControlsSpec`] from a [`Palette`] when the
/// theme document omits the `window_controls` block. Also used by the
/// JSON loader in `schema.rs` when individual overrides are absent.
pub ( crate ) fn default_window_controls( palette: Palette ) -> WindowControlsSpec
{
WindowControlsSpec
{
bar_bg: Color { a: 1.0, ..palette.surface },
icon: palette.icon,
hover_bg: palette.surface_alt,
pressed_bg: palette.divider,
close_hover_bg: Color::rgba( 0.92, 0.18, 0.18, 0.90 ),
close_icon: Color::WHITE,
focus_ring: palette.accent,
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
use super::super::slots::{ self, Metadata, Slot };
#[ test ]
fn palette_from_slots_uses_canonical_ids()
{
let mut store = slots::SlotStore::new();
store.insert
(
"bg-page",
Slot::Color { value: Color::hex( 0x01, 0x02, 0x03 ), meta: Metadata::default() },
);
store.insert
(
"text-primary",
Slot::Color { value: Color::hex( 0xF0, 0xF1, 0xF2 ), meta: Metadata::default() },
);
let p = Palette::from_slots( &store );
assert_eq!( p.bg, Color::hex( 0x01, 0x02, 0x03 ) );
assert_eq!( p.text_primary, Color::hex( 0xF0, 0xF1, 0xF2 ) );
// Missing slots fall back to documented sensible defaults.
assert_eq!( p.icon, Color::BLACK );
}
}

147
src/theme/prefs.rs Normal file
View File

@@ -0,0 +1,147 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Theme preference / mode types and per-asset specification structs.
use std::path::PathBuf;
use serde::{ Deserialize, Serialize };
use crate::types::Color;
// ─── Wallpaper / launcher ────────────────────────────────────────────────────
/// How a wallpaper image is fitted to its surface.
#[ derive( Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize ) ]
#[ serde( rename_all = "kebab-case" ) ]
pub enum WallpaperFit
{
/// Scale uniformly to fill the surface, cropping the overflow.
Cover,
/// Scale uniformly to fit inside the surface, letterboxing if needed.
Contain,
/// Scale non-uniformly to exactly match the surface.
Stretch,
/// Place at original size centered on the surface.
Center,
/// Repeat the image at original size to cover the surface.
Tile,
}
impl Default for WallpaperFit
{
fn default() -> Self { WallpaperFit::Cover }
}
/// Wallpaper backing for a single theme variant.
#[ derive( Debug, Clone ) ]
pub struct WallpaperSpec
{
/// Absolute path to the image, or `None` for the built-in fallback that
/// just paints [`crate::theme::Palette::bg`].
pub path: Option<PathBuf>,
pub fit: WallpaperFit,
}
/// Styling for the application launcher surface.
#[ derive( Debug, Clone, Copy ) ]
pub struct LauncherSpec
{
/// Background fill of the launcher panel.
pub background: Color,
/// Outer corner radius of the launcher panel, in CSS pixels.
pub border_radius: f32,
}
/// Styling for compositor / window-decoration controls.
#[ derive( Debug, Clone, Copy ) ]
pub struct WindowControlsSpec
{
/// Background fill of the SSD title bar strip the controls sit on.
pub bar_bg: Color,
/// Symbol colour for minimize / maximize / restore / close glyphs.
pub icon: Color,
/// Button background while hovered.
pub hover_bg: Color,
/// Button background while pressed.
pub pressed_bg: Color,
/// Close-button background while hovered.
pub close_hover_bg: Color,
/// Close-button symbol colour while hovered.
pub close_icon: Color,
/// Keyboard focus ring colour.
pub focus_ring: Color,
}
// ─── Mode and preference ─────────────────────────────────────────────────────
/// Which of the two variants a theme family resolves to.
#[ derive( Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize ) ]
#[ serde( rename_all = "kebab-case" ) ]
pub enum ThemeMode
{
Light,
Dark,
}
impl Default for ThemeMode
{
fn default() -> Self { ThemeMode::Light }
}
impl ThemeMode
{
/// Auto-select light or dark from the local hour of day.
///
/// Dark is used between 19:00 and 06:59 local time, light otherwise.
/// `hour` must be in `0..=23`.
pub const fn from_hour( hour: u32 ) -> Self
{
if hour >= 19 || hour < 7 { ThemeMode::Dark } else { ThemeMode::Light }
}
}
/// Shell-side persisted preference. `Auto` is resolved to a [`ThemeMode`]
/// before reaching ltk — the toolkit itself only knows about Light and Dark.
#[ derive( Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize ) ]
#[ serde( rename_all = "kebab-case" ) ]
pub enum ThemePreference
{
Light,
Dark,
Auto,
}
impl Default for ThemePreference
{
fn default() -> Self { ThemePreference::Auto }
}
impl ThemePreference
{
/// Resolve to a concrete mode given the current local hour `[0, 23]`.
pub const fn resolve( self, hour: u32 ) -> ThemeMode
{
match self
{
ThemePreference::Light => ThemeMode::Light,
ThemePreference::Dark => ThemeMode::Dark,
ThemePreference::Auto => ThemeMode::from_hour( hour ),
}
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ test ]
fn preference_resolves_auto_by_hour()
{
assert_eq!( ThemePreference::Auto.resolve( 3 ), ThemeMode::Dark );
assert_eq!( ThemePreference::Auto.resolve( 12 ), ThemeMode::Light );
assert_eq!( ThemePreference::Auto.resolve( 22 ), ThemeMode::Dark );
assert_eq!( ThemePreference::Light.resolve( 22 ), ThemeMode::Light );
}
}

File diff suppressed because it is too large Load Diff

394
src/theme/schema/mod.rs Normal file
View File

@@ -0,0 +1,394 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! JSON schema for the theme document: raw deserialisation types, colour
//! (de)serialiser, and conversion to the public runtime types.
//!
//! This module is the single place where impedance between "JSON as designers
//! and tools write it" and "the Rust types widgets consume" lives. Every
//! public type in `theme::paint`, `theme::shadow`, `theme::surface`,
//! `theme::text_style` and `theme::slots` gets a private `Raw*` counterpart
//! here with the minimum serde annotations to match the JSON shape, and a
//! `From<Raw*> for *` conversion that builds the public value.
//!
//! Keeping the derives off the public types means the public API stays
//! free of serde dependencies and the JSON format can evolve (add fields,
//! accept aliases, reject unknown keys) without touching consumer code.
use std::collections::HashMap;
use std::path::{ Path, PathBuf };
use serde::{ Deserialize, Deserializer, Serializer };
use serde::de::Error as DeError;
use crate::types::Color;
use super::document::{ Mode, ThemeDocument };
use super::fonts::{ FontFamilyDef, FontSource };
use super::palette::default_window_controls;
use super::slots::{ Slot, SlotStore };
use super::
{
LauncherSpec, ThemeError, ThemeMode, WallpaperSpec,
WindowControlsSpec,
};
mod raw;
mod refs;
#[ cfg( test ) ]
mod tests;
use raw::*;
use refs::*;
// ─── Color serialiser ────────────────────────────────────────────────────────
/// Encode / decode [`Color`] as a hex or `rgba(…)` string.
///
/// Accepted input forms:
///
/// * `#RRGGBB` or `RRGGBB` — opaque, 8 bits per channel.
/// * `#RRGGBBAA` or `RRGGBBAA` — with straight alpha, 8 bits per channel.
/// * `rgba(R, G, B, A)` — R/G/B as integers `0..=255` (or floats), A as a
/// float in `0.0..=1.0`. Whitespace around commas is tolerated.
/// * `rgb(R, G, B)` — same as above with implicit A = 1.0.
///
/// Output form (serialisation): `#RRGGBB` when alpha is 1.0, `#RRGGBBAA`
/// otherwise.
pub mod color_serde
{
use super::*;
/// Parse one of the accepted color syntaxes into a [`Color`].
pub fn parse( s: &str ) -> Result<Color, String>
{
let t = s.trim();
if t.starts_with( "rgba(" ) || t.starts_with( "rgb(" )
{
parse_functional( t )
}
else
{
parse_hex( t )
}
}
fn parse_hex( s: &str ) -> Result<Color, String>
{
let h = s.trim_start_matches( '#' );
let ( r, g, b, a ) = match h.len()
{
6 =>
{
let r = u8::from_str_radix( &h[0..2], 16 ).map_err( |_| bad( s ) )?;
let g = u8::from_str_radix( &h[2..4], 16 ).map_err( |_| bad( s ) )?;
let b = u8::from_str_radix( &h[4..6], 16 ).map_err( |_| bad( s ) )?;
( r, g, b, 0xFF )
}
8 =>
{
let r = u8::from_str_radix( &h[0..2], 16 ).map_err( |_| bad( s ) )?;
let g = u8::from_str_radix( &h[2..4], 16 ).map_err( |_| bad( s ) )?;
let b = u8::from_str_radix( &h[4..6], 16 ).map_err( |_| bad( s ) )?;
let a = u8::from_str_radix( &h[6..8], 16 ).map_err( |_| bad( s ) )?;
( r, g, b, a )
}
_ => return Err( bad( s ) ),
};
Ok( Color
{
r: r as f32 / 255.0,
g: g as f32 / 255.0,
b: b as f32 / 255.0,
a: a as f32 / 255.0,
})
}
fn parse_functional( s: &str ) -> Result<Color, String>
{
let ( with_alpha, inner ) = if let Some( rest ) = s.strip_prefix( "rgba(" )
{
( true, rest.strip_suffix( ')' ).ok_or_else( || bad( s ) )? )
}
else if let Some( rest ) = s.strip_prefix( "rgb(" )
{
( false, rest.strip_suffix( ')' ).ok_or_else( || bad( s ) )? )
}
else
{
return Err( bad( s ) );
};
let parts: Vec<&str> = inner.split( ',' ).map( str::trim ).collect();
let expected = if with_alpha { 4 } else { 3 };
if parts.len() != expected { return Err( bad( s ) ); }
let r = parse_channel( parts[0] ).ok_or_else( || bad( s ) )?;
let g = parse_channel( parts[1] ).ok_or_else( || bad( s ) )?;
let b = parse_channel( parts[2] ).ok_or_else( || bad( s ) )?;
let a = if with_alpha
{
parts[3].parse::<f32>().map_err( |_| bad( s ) )?.clamp( 0.0, 1.0 )
}
else
{
1.0
};
Ok( Color { r: r / 255.0, g: g / 255.0, b: b / 255.0, a })
}
fn parse_channel( s: &str ) -> Option<f32>
{
// Integer 0..=255 or float 0..=255.
if let Ok( n ) = s.parse::<u16>()
{
if n <= 255 { return Some( n as f32 ); }
return None;
}
if let Ok( f ) = s.parse::<f32>()
{
if ( 0.0..=255.0 ).contains( &f ) { return Some( f ); }
}
None
}
fn bad( s: &str ) -> String
{
format!
(
"invalid colour `{}` (expected `#RRGGBB`, `#RRGGBBAA` or `rgb[a](…)`)",
s
)
}
/// Canonical string form: `#RRGGBB` when opaque, `#RRGGBBAA` otherwise.
pub fn format( c: Color ) -> String
{
let r = (c.r.clamp( 0.0, 1.0 ) * 255.0).round() as u8;
let g = (c.g.clamp( 0.0, 1.0 ) * 255.0).round() as u8;
let b = (c.b.clamp( 0.0, 1.0 ) * 255.0).round() as u8;
let a = (c.a.clamp( 0.0, 1.0 ) * 255.0).round() as u8;
if a == 0xFF
{
format!( "#{:02X}{:02X}{:02X}", r, g, b )
}
else
{
format!( "#{:02X}{:02X}{:02X}{:02X}", r, g, b, a )
}
}
pub fn serialize<S>( c: &Color, ser: S ) -> Result<S::Ok, S::Error>
where S: Serializer
{
ser.serialize_str( &format( *c ) )
}
pub fn deserialize<'de, D>( de: D ) -> Result<Color, D::Error>
where D: Deserializer<'de>
{
let s = String::deserialize( de )?;
parse( &s ).map_err( D::Error::custom )
}
}
/// Expose the colour parser so error messages and other loaders can share
/// the same syntax understanding.
pub fn parse_color_str( s: &str ) -> Result<Color, String>
{
color_serde::parse( s )
}
// ─── Conversion from raw to runtime types ────────────────────────────────────
fn family_from_raw( root: Option<&Path>, r: RawFontFamily ) -> FontFamilyDef
{
FontFamilyDef
{
name: r.name,
fallbacks: r.fallbacks,
sources: r.sources.into_iter().map( |s| FontSource
{
weight: s.weight,
style: s.style.into(),
path: resolve_relative( root, &s.path ),
}).collect(),
}
}
fn wallpaper_from_raw( root: Option<&Path>, r: RawWallpaper ) -> WallpaperSpec
{
WallpaperSpec { path: Some( resolve_relative( root, &r.path ) ), fit: r.fit }
}
fn window_controls_from_raw
(
fallback_palette: Option<&super::Palette>,
_mode: ThemeMode,
raw: RawWindowControls,
) -> Result<WindowControlsSpec, ThemeError>
{
// When the mode has no palette to derive sensible defaults from, we fall
// back to neutral black-on-white defaults for the fields the author did
// not override.
let fallback = match fallback_palette
{
Some( p ) => default_window_controls( *p ),
None => WindowControlsSpec
{
bar_bg: Color::WHITE,
icon: Color::BLACK,
hover_bg: Color::rgba( 0.0, 0.0, 0.0, 0.08 ),
pressed_bg: Color::rgba( 0.0, 0.0, 0.0, 0.12 ),
close_hover_bg: Color::rgba( 0.92, 0.18, 0.18, 0.90 ),
close_icon: Color::WHITE,
focus_ring: Color::hex( 0x04, 0xD9, 0xFE ),
},
};
Ok( WindowControlsSpec
{
bar_bg: parse_opt( raw.bar_bg.as_deref(), fallback.bar_bg )?,
icon: parse_opt( raw.icon.as_deref(), fallback.icon )?,
hover_bg: parse_opt( raw.hover_bg.as_deref(), fallback.hover_bg )?,
pressed_bg: parse_opt( raw.pressed_bg.as_deref(), fallback.pressed_bg )?,
close_hover_bg: parse_opt( raw.close_hover_bg.as_deref(), fallback.close_hover_bg )?,
close_icon: parse_opt( raw.close_icon.as_deref(), fallback.close_icon )?,
focus_ring: parse_opt( raw.focus_ring.as_deref(), fallback.focus_ring )?,
})
}
fn parse_opt( s: Option<&str>, fallback: Color ) -> Result<Color, ThemeError>
{
match s
{
Some( v ) => parse_color_str( v ).map_err( ThemeError::InvalidColor ),
None => Ok( fallback ),
}
}
fn mode_from_raw( root: Option<&Path>, r: RawMode ) -> Result<Mode, ThemeError>
{
let mut store = SlotStore::new();
for ( id, raw ) in r.slots
{
store.insert( id, Slot::from( raw ) );
}
let wallpaper = r.wallpaper.map( |w| wallpaper_from_raw( root, w ) );
let lockscreen = r.lockscreen.map( |w| wallpaper_from_raw( root, w ) );
let launcher = match r.launcher
{
Some( l ) => Some( LauncherSpec
{
background: parse_color_str( &l.background ).map_err( ThemeError::InvalidColor )?,
border_radius: l.border_radius,
}),
None => None,
};
let window_controls = match r.window_controls
{
Some( raw ) => Some( window_controls_from_raw( None, ThemeMode::Light, raw )? ),
None => None,
};
Ok( Mode { wallpaper, lockscreen, launcher, window_controls, slots: store } )
}
// ─── Public entry points ─────────────────────────────────────────────────────
/// Parse a theme document from its JSON source text.
///
/// `root` is the directory the document was read from, used to resolve
/// relative paths (wallpaper images, font files) to absolute ones. Pass
/// `None` when parsing in-memory fixtures from tests.
///
/// Performs the colour-reference resolution pass before structural
/// deserialisation: any string of the form `@name` or `@name/AA` (where
/// `AA` is a two-digit hex alpha override) is rewritten to its literal
/// hex form by looking `name` up in the top-level `colors` object. The
/// rewritten JSON is then deserialised into [`RawThemeDocument`] and
/// converted to the runtime types as before.
pub fn parse_document_json( text: &str, root: Option<&Path> )
-> Result<ThemeDocument, ThemeError>
{
let mut value: serde_json::Value = serde_json::from_str( text ).map_err( |e|
ThemeError::ParseJson( root.map( Path::to_path_buf ).unwrap_or_default(), e )
)?;
let colors = extract_colors_map( &value )?;
let gradients = extract_gradients_map( &value )?;
let inset_stacks = extract_inset_stacks_map( &value )?;
// Gradients live in objects (`{ "type": "linear", … }`) and inset stacks
// live in arrays (`[ { offset, blur, … }, … ]`); the resolver does not
// care about the shape, so the two sections share a single internal
// lookup. Names must therefore be unique across both — a collision is
// rejected up front rather than letting `@foo` resolve ambiguously.
let mut tokens = gradients;
for ( k, v ) in inset_stacks
{
if tokens.contains_key( &k )
{
return Err( ThemeError::InvalidColor( format!(
"name `{}` is defined in both `gradients` and `inset_stacks`", k
)));
}
tokens.insert( k, v );
}
// Pre-resolve `@color` references that live inside token bodies so
// subsequent substitutions are flat clones with no recursion. Tokens
// cannot reference each other, so we resolve against an empty token
// map here.
let empty_tokens = HashMap::new();
for ( _, t ) in tokens.iter_mut()
{
resolve_refs( t, &colors, &empty_tokens )?;
}
// Drop the top-level palette sections before resolving the rest, so the
// walk does not waste cycles on entries that are about to be discarded
// and `RawThemeDocument`'s `deny_unknown_fields` does not reject them.
if let serde_json::Value::Object( ref mut map ) = value
{
map.remove( "colors" );
map.remove( "gradients" );
map.remove( "inset_stacks" );
}
resolve_refs( &mut value, &colors, &tokens )?;
let raw: RawThemeDocument = serde_json::from_value( value ).map_err( |e|
ThemeError::ParseJson( root.map( Path::to_path_buf ).unwrap_or_default(), e )
)?;
let fonts = raw.fonts
.into_iter()
.map( |( k, v )| ( k, family_from_raw( root, v ) ) )
.collect();
let light = mode_from_raw( root, raw.modes.light )?;
let dark = mode_from_raw( root, raw.modes.dark )?;
Ok( ThemeDocument
{
id: raw.theme.id,
name: raw.theme.name,
root: root.map( Path::to_path_buf ),
fonts,
light,
dark,
})
}
/// Load a theme document from a directory containing a `theme.json`.
pub fn load_document_from_dir( dir: &Path ) -> Result<ThemeDocument, ThemeError>
{
let json_path = dir.join( "theme.json" );
let text = std::fs::read_to_string( &json_path )
.map_err( |e| ThemeError::Io( json_path.clone(), e ) )?;
parse_document_json( &text, Some( dir ) )
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
fn resolve_relative( root: Option<&Path>, rel: &str ) -> PathBuf
{
let p = Path::new( rel );
if p.is_absolute() { return p.to_path_buf(); }
match root
{
Some( r ) => r.join( p ),
None => p.to_path_buf(),
}
}

601
src/theme/schema/raw.rs Normal file
View File

@@ -0,0 +1,601 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::collections::HashMap;
use serde::{ Deserialize, Serialize };
use crate::types::Color;
use super::super::paint::{ ColorStop, GradientSpace, LinearGradient, Paint, RadialGradient };
use super::super::shadow::{ BlendMode, InsetShadow, Shadow, ShadowsRef };
use super::super::slots::{ Metadata, Slot };
use super::super::surface::Surface;
use super::super::text_style::
{
FontRef, FontStyle, LineHeight, TextDecoration, TextStyle, TextTransform,
};
use super::super::WallpaperFit;
use super::color_serde;
// ─── Enum mirrors with defaults ──────────────────────────────────────────────
#[ derive( Debug, Clone, Copy, Serialize, Deserialize ) ]
#[ serde( rename_all = "kebab-case" ) ]
pub( super ) enum RawGradientSpace
{
Srgb,
LinearRgb,
Oklab,
}
impl Default for RawGradientSpace { fn default() -> Self { RawGradientSpace::LinearRgb } }
impl From<RawGradientSpace> for GradientSpace
{
fn from( r: RawGradientSpace ) -> Self
{
match r
{
RawGradientSpace::Srgb => GradientSpace::Srgb,
RawGradientSpace::LinearRgb => GradientSpace::LinearRgb,
RawGradientSpace::Oklab => GradientSpace::Oklab,
}
}
}
#[ derive( Debug, Clone, Copy, Serialize, Deserialize ) ]
#[ serde( rename_all = "kebab-case" ) ]
pub( super ) enum RawBlendMode
{
Normal,
PlusLighter,
Overlay,
Multiply,
Screen,
}
impl Default for RawBlendMode { fn default() -> Self { RawBlendMode::Normal } }
impl From<RawBlendMode> for BlendMode
{
fn from( r: RawBlendMode ) -> Self
{
match r
{
RawBlendMode::Normal => BlendMode::Normal,
RawBlendMode::PlusLighter => BlendMode::PlusLighter,
RawBlendMode::Overlay => BlendMode::Overlay,
RawBlendMode::Multiply => BlendMode::Multiply,
RawBlendMode::Screen => BlendMode::Screen,
}
}
}
#[ derive( Debug, Clone, Copy, Serialize, Deserialize ) ]
#[ serde( rename_all = "kebab-case" ) ]
pub( super ) enum RawFontStyle { Normal, Italic }
impl Default for RawFontStyle { fn default() -> Self { RawFontStyle::Normal } }
impl From<RawFontStyle> for FontStyle
{
fn from( r: RawFontStyle ) -> Self
{
match r
{
RawFontStyle::Normal => FontStyle::Normal,
RawFontStyle::Italic => FontStyle::Italic,
}
}
}
#[ derive( Debug, Clone, Copy, Serialize, Deserialize ) ]
#[ serde( rename_all = "kebab-case" ) ]
pub( super ) enum RawTextTransform { None, Uppercase, Lowercase, Capitalize }
impl Default for RawTextTransform { fn default() -> Self { RawTextTransform::None } }
impl From<RawTextTransform> for TextTransform
{
fn from( r: RawTextTransform ) -> Self
{
match r
{
RawTextTransform::None => TextTransform::None,
RawTextTransform::Uppercase => TextTransform::Uppercase,
RawTextTransform::Lowercase => TextTransform::Lowercase,
RawTextTransform::Capitalize => TextTransform::Capitalize,
}
}
}
#[ derive( Debug, Clone, Copy, Serialize, Deserialize ) ]
#[ serde( rename_all = "kebab-case" ) ]
pub( super ) enum RawTextDecoration { None, Underline, Strikethrough }
impl Default for RawTextDecoration { fn default() -> Self { RawTextDecoration::None } }
impl From<RawTextDecoration> for TextDecoration
{
fn from( r: RawTextDecoration ) -> Self
{
match r
{
RawTextDecoration::None => TextDecoration::None,
RawTextDecoration::Underline => TextDecoration::Underline,
RawTextDecoration::Strikethrough => TextDecoration::Strikethrough,
}
}
}
// ─── Metadata ────────────────────────────────────────────────────────────────
#[ derive( Debug, Clone, Default, Serialize, Deserialize ) ]
pub( super ) struct RawMetadata
{
#[ serde( default, skip_serializing_if = "Option::is_none" ) ]
pub( super ) semantic: Option<String>,
#[ serde( default, skip_serializing_if = "Option::is_none" ) ]
pub( super ) fluent: Option<String>,
#[ serde( default, skip_serializing_if = "Option::is_none" ) ]
pub( super ) usage: Option<String>,
#[ serde( default, skip_serializing_if = "Option::is_none" ) ]
pub( super ) note: Option<String>,
}
impl From<RawMetadata> for Metadata
{
fn from( r: RawMetadata ) -> Self
{
Metadata { semantic: r.semantic, fluent: r.fluent, usage: r.usage, note: r.note }
}
}
// ─── Gradient stops and gradients ────────────────────────────────────────────
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
#[ serde( deny_unknown_fields ) ]
pub( super ) struct RawColorStop
{
#[ serde( alias = "position" ) ]
pub( super ) pos: f32,
#[ serde( with = "color_serde" ) ]
pub( super ) color: Color,
}
impl From<RawColorStop> for ColorStop
{
fn from( r: RawColorStop ) -> Self { ColorStop { position: r.pos, color: r.color } }
}
// ─── Paint (for `surface.fill`) ──────────────────────────────────────────────
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
#[ serde( tag = "type", rename_all = "kebab-case", deny_unknown_fields ) ]
pub( super ) enum RawPaint
{
Solid
{
#[ serde( with = "color_serde" ) ]
color: Color,
},
Linear
{
angle_deg: f32,
#[ serde( default ) ]
space: RawGradientSpace,
stops: Vec<RawColorStop>,
},
Radial
{
center: [f32; 2],
radius: f32,
#[ serde( default ) ]
space: RawGradientSpace,
stops: Vec<RawColorStop>,
},
}
impl From<RawPaint> for Paint
{
fn from( r: RawPaint ) -> Self
{
match r
{
RawPaint::Solid { color } => Paint::Solid( color ),
RawPaint::Linear { angle_deg, space, stops } => Paint::Linear( LinearGradient
{
angle_deg,
space: space.into(),
stops: collect_capped_stops( stops ),
}),
RawPaint::Radial { center, radius, space, stops } => Paint::Radial( RadialGradient
{
center,
radius,
space: space.into(),
stops: collect_capped_stops( stops ),
}),
}
}
}
/// Soft cap on the number of color stops a gradient may carry. Realistic
/// designs use 2 6 stops, the shipped theme tops out at 4, and
/// 64 leaves comfortable headroom for hand-tuned tonemap palettes. The
/// cap exists to bound CPU + memory under a hostile theme document — a
/// 200 000-stop linear gradient would otherwise build a 6 MB
/// `Vec<ColorStop>` per slot at parse time. Exceeding the cap truncates
/// the tail and logs once via stderr.
pub( super ) const MAX_GRADIENT_STOPS: usize = 64;
pub( super ) fn collect_capped_stops( raw: Vec<RawColorStop> ) -> Vec<ColorStop>
{
if raw.len() > MAX_GRADIENT_STOPS
{
eprintln!(
"[ltk theme] gradient declares {} stops, truncating to {}",
raw.len(), MAX_GRADIENT_STOPS,
);
raw.into_iter().take( MAX_GRADIENT_STOPS ).map( Into::into ).collect()
}
else
{
raw.into_iter().map( Into::into ).collect()
}
}
// ─── Shadows ─────────────────────────────────────────────────────────────────
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
#[ serde( deny_unknown_fields ) ]
pub( super ) struct RawShadow
{
pub( super ) offset: [f32; 2],
pub( super ) blur: f32,
#[ serde( default ) ]
pub( super ) spread: f32,
#[ serde( with = "color_serde" ) ]
pub( super ) color: Color,
#[ serde( default ) ]
pub( super ) blend: RawBlendMode,
}
impl From<RawShadow> for Shadow
{
fn from( r: RawShadow ) -> Self
{
Shadow { offset: r.offset, blur: r.blur, spread: r.spread, color: r.color, blend: r.blend.into() }
}
}
impl From<RawShadow> for InsetShadow
{
fn from( r: RawShadow ) -> Self
{
InsetShadow { offset: r.offset, blur: r.blur, spread: r.spread, color: r.color, blend: r.blend.into() }
}
}
/// Reference to a shadow stack inside a surface: either a string id
/// pointing at a `shadows` slot, or a literal list.
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
#[ serde( untagged ) ]
pub( super ) enum RawShadowsRef
{
Named( String ),
Inline( Vec<RawShadow> ),
}
impl From<RawShadowsRef> for ShadowsRef
{
fn from( r: RawShadowsRef ) -> Self
{
match r
{
RawShadowsRef::Named( id ) => ShadowsRef::Named( id ),
RawShadowsRef::Inline( items ) => ShadowsRef::Inline( items.into_iter().map( Into::into ).collect() ),
}
}
}
// ─── Surface ─────────────────────────────────────────────────────────────────
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
#[ serde( deny_unknown_fields ) ]
pub( super ) struct RawSurfaceBody
{
pub( super ) fill: Box<RawPaint>,
#[ serde( default, skip_serializing_if = "Option::is_none" ) ]
pub( super ) shadows: Option<RawShadowsRef>,
#[ serde( default ) ]
pub( super ) inset_shadows: Vec<RawShadow>,
}
impl From<RawSurfaceBody> for Surface
{
fn from( r: RawSurfaceBody ) -> Self
{
Surface
{
fill: Paint::from( *r.fill ),
shadows: r.shadows.map( Into::into ),
inset_shadows: r.inset_shadows.into_iter().map( Into::into ).collect(),
}
}
}
// ─── Text style ──────────────────────────────────────────────────────────────
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
#[ serde( untagged ) ]
pub( super ) enum RawLineHeight
{
Px { px: f32 },
Multiplier { mul: f32 },
}
impl From<RawLineHeight> for LineHeight
{
fn from( r: RawLineHeight ) -> Self
{
match r
{
RawLineHeight::Px { px } => LineHeight::Px( px ),
RawLineHeight::Multiplier { mul } => LineHeight::Multiplier( mul ),
}
}
}
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
#[ serde( deny_unknown_fields ) ]
pub( super ) struct RawFontRef
{
pub( super ) r#ref: String,
}
impl From<RawFontRef> for FontRef
{
fn from( r: RawFontRef ) -> Self { FontRef::Named( r.r#ref ) }
}
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
#[ serde( deny_unknown_fields ) ]
pub( super ) struct RawTextStyleBody
{
pub( super ) family: RawFontRef,
pub( super ) weight: u16,
#[ serde( default ) ]
pub( super ) style: RawFontStyle,
pub( super ) size: f32,
pub( super ) line_height: RawLineHeight,
#[ serde( default ) ]
pub( super ) letter_spacing: f32,
#[ serde( default ) ]
pub( super ) transform: RawTextTransform,
#[ serde( default ) ]
pub( super ) decoration: RawTextDecoration,
#[ serde( default, skip_serializing_if = "Option::is_none" ) ]
pub( super ) color: Option<String>,
}
impl From<RawTextStyleBody> for TextStyle
{
fn from( r: RawTextStyleBody ) -> Self
{
TextStyle
{
family: r.family.into(),
weight: r.weight,
style: r.style.into(),
size: r.size,
line_height: r.line_height.into(),
letter_spacing: r.letter_spacing,
transform: r.transform.into(),
decoration: r.decoration.into(),
color: r.color,
}
}
}
// ─── Slot ────────────────────────────────────────────────────────────────────
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
#[ serde( tag = "type", rename_all = "kebab-case" ) ]
pub( super ) enum RawSlot
{
Color
{
#[ serde( with = "color_serde" ) ]
value: Color,
#[ serde( default ) ]
meta: RawMetadata,
},
Linear
{
angle_deg: f32,
#[ serde( default ) ]
space: RawGradientSpace,
stops: Vec<RawColorStop>,
#[ serde( default ) ]
meta: RawMetadata,
},
Radial
{
center: [f32; 2],
radius: f32,
#[ serde( default ) ]
space: RawGradientSpace,
stops: Vec<RawColorStop>,
#[ serde( default ) ]
meta: RawMetadata,
},
Shadows
{
shadows: Vec<RawShadow>,
#[ serde( default ) ]
meta: RawMetadata,
},
Surface
{
#[ serde( flatten ) ]
body: RawSurfaceBody,
#[ serde( default ) ]
meta: RawMetadata,
},
Typography
{
#[ serde( flatten ) ]
body: RawTextStyleBody,
#[ serde( default ) ]
meta: RawMetadata,
},
}
impl From<RawSlot> for Slot
{
fn from( r: RawSlot ) -> Self
{
match r
{
RawSlot::Color { value, meta } => Slot::Color { value, meta: meta.into() },
RawSlot::Linear { angle_deg, space, stops, meta } => Slot::Paint
{
value: Paint::Linear( LinearGradient
{
angle_deg,
space: space.into(),
stops: collect_capped_stops( stops ),
}),
meta: meta.into(),
},
RawSlot::Radial { center, radius, space, stops, meta } => Slot::Paint
{
value: Paint::Radial( RadialGradient
{
center,
radius,
space: space.into(),
stops: collect_capped_stops( stops ),
}),
meta: meta.into(),
},
RawSlot::Shadows { shadows, meta } => Slot::Shadows
{
value: shadows.into_iter().map( Into::into ).collect(),
meta: meta.into(),
},
RawSlot::Surface { body, meta } => Slot::Surface
{
value: body.into(),
meta: meta.into(),
},
RawSlot::Typography { body, meta } => Slot::TextStyle
{
value: body.into(),
meta: meta.into(),
},
}
}
}
// ─── Fonts block ─────────────────────────────────────────────────────────────
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
#[ serde( deny_unknown_fields ) ]
pub( super ) struct RawFontSource
{
pub( super ) weight: u16,
#[ serde( default ) ]
pub( super ) style: RawFontStyle,
pub( super ) path: String,
}
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
#[ serde( deny_unknown_fields ) ]
pub( super ) struct RawFontFamily
{
pub( super ) name: String,
#[ serde( default ) ]
pub( super ) fallbacks: Vec<String>,
#[ serde( default ) ]
pub( super ) sources: Vec<RawFontSource>,
}
// ─── Wallpaper / window_controls ─────────────────────────────────────────────
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
#[ serde( deny_unknown_fields ) ]
pub( super ) struct RawWallpaper
{
pub( super ) path: String,
#[ serde( default ) ]
pub( super ) fit: WallpaperFit,
}
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
#[ serde( deny_unknown_fields ) ]
pub( super ) struct RawLauncher
{
pub( super ) background: String,
pub( super ) border_radius: f32,
}
#[ derive( Debug, Clone, Serialize, Deserialize, Default ) ]
#[ serde( deny_unknown_fields ) ]
pub( super ) struct RawWindowControls
{
#[ serde( default ) ] pub( super ) bar_bg: Option<String>,
#[ serde( default ) ] pub( super ) icon: Option<String>,
#[ serde( default ) ] pub( super ) hover_bg: Option<String>,
#[ serde( default ) ] pub( super ) pressed_bg: Option<String>,
#[ serde( default ) ] pub( super ) close_hover_bg: Option<String>,
#[ serde( default ) ] pub( super ) close_icon: Option<String>,
#[ serde( default ) ] pub( super ) focus_ring: Option<String>,
}
// ─── Mode / Modes / Document ─────────────────────────────────────────────────
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
#[ serde( deny_unknown_fields ) ]
pub( super ) struct RawMode
{
#[ serde( default, skip_serializing_if = "Option::is_none" ) ]
pub( super ) wallpaper: Option<RawWallpaper>,
#[ serde( default, skip_serializing_if = "Option::is_none" ) ]
pub( super ) lockscreen: Option<RawWallpaper>,
#[ serde( default, skip_serializing_if = "Option::is_none" ) ]
pub( super ) launcher: Option<RawLauncher>,
#[ serde( default, skip_serializing_if = "Option::is_none" ) ]
pub( super ) window_controls: Option<RawWindowControls>,
#[ serde( default ) ]
pub( super ) slots: HashMap<String, RawSlot>,
}
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
#[ serde( deny_unknown_fields ) ]
pub( super ) struct RawModes
{
pub( super ) light: RawMode,
pub( super ) dark: RawMode,
}
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
#[ serde( deny_unknown_fields ) ]
pub( super ) struct RawThemeMeta
{
pub( super ) id: String,
pub( super ) name: String,
}
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
#[ serde( deny_unknown_fields ) ]
pub( super ) struct RawThemeDocument
{
pub( super ) theme: RawThemeMeta,
#[ serde( default ) ]
pub( super ) fonts: HashMap<String, RawFontFamily>,
pub( super ) modes: RawModes,
}

194
src/theme/schema/refs.rs Normal file
View File

@@ -0,0 +1,194 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::collections::HashMap;
use super::super::ThemeError;
/// Read the top-level `colors` object from the raw JSON value and validate
/// each entry is a literal hex string. Returns an empty map if no `colors`
/// section is present.
pub( super ) fn extract_colors_map( value: &serde_json::Value )
-> Result<HashMap<String, String>, ThemeError>
{
let mut out = HashMap::new();
let raw = match value.get( "colors" )
{
Some( c ) => c,
None => return Ok( out ),
};
let map = raw.as_object().ok_or_else( ||
ThemeError::InvalidColor( "`colors` must be an object".to_string() )
)?;
for ( k, v ) in map
{
let s = v.as_str().ok_or_else( ||
ThemeError::InvalidColor( format!( "`colors.{}` must be a hex string", k ) )
)?;
// References inside `colors` itself are not supported — every entry
// must be a literal hex value the rest of the document can point at.
let h = s.trim_start_matches( '#' );
let valid = ( h.len() == 6 || h.len() == 8 ) && h.chars().all( |c| c.is_ascii_hexdigit() );
if !valid
{
return Err( ThemeError::InvalidColor(
format!( "`colors.{}` = `{}` (expected #RRGGBB or #RRGGBBAA)", k, s )
));
}
out.insert( k.clone(), s.to_string() );
}
Ok( out )
}
/// Read the top-level `gradients` object from the raw JSON value. Each
/// entry must be a paint object (`{ "type": "linear", "angle_deg": …,
/// "stops": [ … ] }`); shape correctness beyond "is an object" is left
/// for downstream serde deserialisation to enforce when the gradient is
/// substituted into a paint position.
pub( super ) fn extract_gradients_map( value: &serde_json::Value )
-> Result<HashMap<String, serde_json::Value>, ThemeError>
{
extract_token_map( value, "gradients", "paint object", serde_json::Value::is_object )
}
/// Read the top-level `inset_stacks` object from the raw JSON value. Each
/// entry must be an array of inset-shadow definitions — substituted
/// wholesale into an `inset_shadows` field on a surface.
pub( super ) fn extract_inset_stacks_map( value: &serde_json::Value )
-> Result<HashMap<String, serde_json::Value>, ThemeError>
{
extract_token_map( value, "inset_stacks", "array of inset-shadow definitions", serde_json::Value::is_array )
}
/// Generic helper for the `gradients` / `inset_stacks` extractors above:
/// reads a top-level object, validates each entry against `valid`, and
/// returns the entries cloned into a `HashMap`. Returns an empty map if
/// `section` is absent.
fn extract_token_map(
value: &serde_json::Value,
section: &str,
expected_kind: &str,
valid: impl Fn( &serde_json::Value ) -> bool,
) -> Result<HashMap<String, serde_json::Value>, ThemeError>
{
let mut out = HashMap::new();
let raw = match value.get( section )
{
Some( c ) => c,
None => return Ok( out ),
};
let map = raw.as_object().ok_or_else( ||
ThemeError::InvalidColor( format!( "`{}` must be an object", section ) )
)?;
for ( k, v ) in map
{
if !valid( v )
{
return Err( ThemeError::InvalidColor(
format!( "`{}.{}` must be {}", section, k, expected_kind )
));
}
out.insert( k.clone(), v.clone() );
}
Ok( out )
}
/// Walk `value` and replace `@name` / `@name/AA` references with their
/// resolved form. A reference whose name appears in `tokens` (gradient
/// objects or inset-shadow arrays) is substituted by the cloned token
/// value; a reference whose name appears in `colors` is substituted by
/// its hex literal. After a substitution the new node is recursed into
/// so a gradient that uses `@cyan-soft` in its stops or an inset stack
/// that uses `@glass-hi` is fully expanded.
pub( super ) fn resolve_refs(
value: &mut serde_json::Value,
colors: &HashMap<String, String>,
tokens: &HashMap<String, serde_json::Value>,
) -> Result<(), ThemeError>
{
// Step 1: if this node is a `@ref` string, resolve and overwrite.
let replacement = match value
{
serde_json::Value::String( s ) => match s.strip_prefix( '@' )
{
Some( rest ) => Some( resolve_one_ref( rest, colors, tokens )? ),
None => None,
},
_ => None,
};
if let Some( new_value ) = replacement
{
*value = new_value;
// The replacement may itself contain references (typically a
// gradient with `@color` stops). Recurse into the new node.
resolve_refs( value, colors, tokens )?;
return Ok( () );
}
// Step 2: walk children of objects / arrays.
match value
{
serde_json::Value::Object( map ) =>
{
for ( _, v ) in map.iter_mut()
{
resolve_refs( v, colors, tokens )?;
}
}
serde_json::Value::Array( arr ) =>
{
for v in arr.iter_mut()
{
resolve_refs( v, colors, tokens )?;
}
}
_ => {}
}
Ok( () )
}
/// Resolve a single `@name[/AA]` reference. Looks up `tokens` first
/// (gradient or inset-stack), then `colors`. The `/AA` alpha override
/// only applies to colour references; using it on a non-colour token is
/// rejected.
fn resolve_one_ref(
rest: &str,
colors: &HashMap<String, String>,
tokens: &HashMap<String, serde_json::Value>,
) -> Result<serde_json::Value, ThemeError>
{
let ( name, alpha_hex ) = match rest.split_once( '/' )
{
Some( ( n, a ) ) => ( n, Some( a ) ),
None => ( rest, None ),
};
if let Some( tok ) = tokens.get( name )
{
if let Some( a ) = alpha_hex
{
return Err( ThemeError::InvalidColor( format!(
"@{} is a paint/inset token — alpha override `/{}` is not applicable", name, a
)));
}
return Ok( tok.clone() );
}
let base = colors.get( name )
.ok_or_else( || ThemeError::UnknownColorRef( name.to_string() ) )?;
let h = base.trim_start_matches( '#' );
// `extract_colors_map` already validated the base is 6 or 8 hex digits.
let rgb = &h[0..6];
let s = match alpha_hex
{
Some( a ) =>
{
if a.len() != 2 || u8::from_str_radix( a, 16 ).is_err()
{
return Err( ThemeError::InvalidColor(
format!( "@{}/{} (alpha must be two hex digits)", name, a )
));
}
format!( "#{}{}", rgb.to_uppercase(), a.to_uppercase() )
}
None => format!( "#{}", h.to_uppercase() ),
};
Ok( serde_json::Value::String( s ) )
}

292
src/theme/schema/tests.rs Normal file
View File

@@ -0,0 +1,292 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use super::*;
use super::raw::*;
use super::super::paint::{ GradientSpace, Paint };
use super::super::shadow::{ BlendMode, ShadowsRef };
use super::super::slots::Slot;
use super::super::text_style::LineHeight;
#[ test ]
fn color_parser_accepts_hex_and_functional()
{
assert_eq!( color_serde::parse( "#04D9FE" ).unwrap(), Color::hex( 0x04, 0xD9, 0xFE ) );
assert_eq!( color_serde::parse( "04D9FE" ).unwrap(), Color::hex( 0x04, 0xD9, 0xFE ) );
let with_a = color_serde::parse( "#FFFFFF80" ).unwrap();
assert!( ( with_a.a - 128.0 / 255.0 ).abs() < 1e-6 );
let f = color_serde::parse( "rgba(255, 147, 169, 0.8)" ).unwrap();
assert_eq!( f.r, 1.0 );
assert!( ( f.g - 147.0 / 255.0 ).abs() < 1e-6 );
assert!( ( f.a - 0.8 ).abs() < 1e-6 );
let three = color_serde::parse( "rgb(0, 0, 0)" ).unwrap();
assert_eq!( three, Color::BLACK );
}
#[ test ]
fn color_parser_rejects_bad_input()
{
assert!( color_serde::parse( "" ).is_err() );
assert!( color_serde::parse( "#FFF" ).is_err() );
assert!( color_serde::parse( "rgba(1,2,3)" ).is_err() );
assert!( color_serde::parse( "rgba(1,2,3,4,5)" ).is_err() );
assert!( color_serde::parse( "rgba(300, 0, 0, 1)" ).is_err() );
}
#[ test ]
fn color_formatter_is_canonical()
{
assert_eq!( color_serde::format( Color::hex( 0x04, 0xD9, 0xFE ) ), "#04D9FE" );
let semi = Color { r: 1.0, g: 1.0, b: 1.0, a: 0.5 };
assert_eq!( color_serde::format( semi ), "#FFFFFF80" );
}
#[ test ]
fn slot_color_parses_with_meta()
{
let json = r##"
{
"type": "color",
"value": "#04D9FE",
"meta": { "semantic": "primary/500" }
}
"##;
let raw: RawSlot = serde_json::from_str( json ).unwrap();
let slot = Slot::from( raw );
match slot
{
Slot::Color { value, meta } =>
{
assert_eq!( value, Color::hex( 0x04, 0xD9, 0xFE ) );
assert_eq!( meta.semantic.as_deref(), Some( "primary/500" ) );
}
other => panic!( "expected color slot, got {:?}", other ),
}
}
#[ test ]
fn gradient_under_cap_is_passed_through_verbatim()
{
let raw_stops: Vec<RawColorStop> = ( 0..16 )
.map( |i| serde_json::from_str(
&format!( r##"{{ "pos": {}, "color": "#FFFFFFFF" }}"##, i as f32 / 15.0 ),
).unwrap() )
.collect();
let result = collect_capped_stops( raw_stops );
assert_eq!( result.len(), 16, "stops below the soft cap pass through unchanged" );
}
#[ test ]
fn gradient_at_exact_cap_is_passed_through()
{
let raw_stops: Vec<RawColorStop> = ( 0..MAX_GRADIENT_STOPS )
.map( |i| serde_json::from_str(
&format!( r##"{{ "pos": {}, "color": "#FFFFFFFF" }}"##, i as f32 / 63.0 ),
).unwrap() )
.collect();
let result = collect_capped_stops( raw_stops );
assert_eq!( result.len(), MAX_GRADIENT_STOPS );
}
#[ test ]
fn gradient_over_cap_is_truncated_to_cap()
{
let raw_stops: Vec<RawColorStop> = ( 0..200 )
.map( |i| serde_json::from_str(
&format!( r##"{{ "pos": {}, "color": "#000000FF" }}"##, i as f32 ),
).unwrap() )
.collect();
let result = collect_capped_stops( raw_stops );
assert_eq!( result.len(), MAX_GRADIENT_STOPS, "tail past the cap must be dropped" );
}
#[ test ]
fn slot_linear_gradient_accepts_extrapolated_stops()
{
let json = r##"
{
"type": "linear",
"angle_deg": 152.77,
"space": "linear-rgb",
"stops":
[
{ "pos": -1.1654, "color": "rgba(255, 147, 169, 0.8)" },
{ "pos": 1.2332, "color": "rgba(255, 255, 255, 0.8)" }
]
}
"##;
let raw: RawSlot = serde_json::from_str( json ).unwrap();
match Slot::from( raw )
{
Slot::Paint { value: Paint::Linear( grad ), .. } =>
{
assert_eq!( grad.stops.len(), 2 );
assert!( grad.stops[0].position < 0.0 );
assert!( grad.stops[1].position > 1.0 );
assert_eq!( grad.space, GradientSpace::LinearRgb );
}
_ => panic!( "expected linear paint" ),
}
}
#[ test ]
fn slot_shadows_parses_stack()
{
let json = r##"
{
"type": "shadows",
"shadows":
[
{ "offset": [0, 4], "blur": 10, "color": "#00000014" },
{ "offset": [0, 1], "blur": 4, "color": "#0000000A" }
]
}
"##;
let raw: RawSlot = serde_json::from_str( json ).unwrap();
match Slot::from( raw )
{
Slot::Shadows { value, .. } =>
{
assert_eq!( value.len(), 2 );
assert_eq!( value[0].offset, [ 0.0, 4.0 ] );
assert_eq!( value[0].blur, 10.0 );
assert_eq!( value[0].blend, BlendMode::Normal );
}
_ => panic!( "expected shadows slot" ),
}
}
#[ test ]
fn slot_surface_reproduces_glass_accent()
{
let json = r##"
{
"type": "surface",
"fill": { "type": "solid", "color": "#04D9FE" },
"shadows": "shadows-glass",
"inset_shadows":
[
{ "offset": [-3.6, -3.6], "blur": 13.5, "color": "#555555", "blend": "plus-lighter" },
{ "offset": [ 1.8, 1.8], "blur": 1.8, "color": "#555555", "blend": "plus-lighter" },
{ "offset": [ 0.45, 0.45], "blur": 0.9, "color": "#000000", "blend": "overlay" },
{ "offset": [ 1.8, 1.8], "blur": 7.2, "color": "#00000026", "blend": "normal" }
],
"backdrop": { "blur_px": 22.5 }
}
"##;
let raw: RawSlot = serde_json::from_str( json ).unwrap();
match Slot::from( raw )
{
Slot::Surface { value, .. } =>
{
assert!( matches!( value.fill, Paint::Solid( _ ) ) );
assert_eq!( value.inset_shadows.len(), 4 );
assert_eq!( value.inset_shadows[0].blend, BlendMode::PlusLighter );
assert_eq!( value.inset_shadows[2].blend, BlendMode::Overlay );
match value.shadows.as_ref().unwrap()
{
ShadowsRef::Named( id ) => assert_eq!( id, "shadows-glass" ),
other => panic!( "expected named shadow ref, got {:?}", other ),
}
}
_ => panic!( "expected surface slot" ),
}
}
#[ test ]
fn slot_typography_parses_body_m()
{
let json = r##"
{
"type": "typography",
"family": { "ref": "sora" },
"weight": 400,
"size": 16,
"line_height": { "px": 24 },
"letter_spacing": 0,
"meta": { "semantic": "body/m" }
}
"##;
let raw: RawSlot = serde_json::from_str( json ).unwrap();
match Slot::from( raw )
{
Slot::TextStyle { value, meta } =>
{
assert_eq!( value.weight, 400 );
assert_eq!( value.size, 16.0 );
assert_eq!( value.line_height, LineHeight::Px( 24.0 ) );
assert_eq!( meta.semantic.as_deref(), Some( "body/m" ) );
}
_ => panic!( "expected typography slot" ),
}
}
#[ test ]
fn document_parses_minimal_two_modes()
{
let json = r##"
{
"theme": { "id": "demo", "name": "Demo" },
"fonts":
{
"sora":
{
"name": "Sora",
"fallbacks": ["system-ui", "sans-serif"],
"sources":
[
{ "weight": 400, "path": "fonts/Sora-Regular.ttf" }
]
}
},
"modes":
{
"light":
{
"slots":
{
"primary-500": { "type": "color", "value": "#04D9FE" },
"body-m": { "type": "typography", "family": { "ref": "sora" }, "weight": 400, "size": 16, "line_height": { "px": 24 } }
}
},
"dark":
{
"slots":
{
"primary-500": { "type": "color", "value": "#04D9FE" }
}
}
}
}
"##;
let doc = parse_document_json( json, None ).unwrap();
assert_eq!( doc.id, "demo" );
assert_eq!( doc.name, "Demo" );
assert_eq!( doc.fonts.len(), 1 );
assert_eq!( doc.fonts["sora"].sources[0].weight, 400 );
assert_eq!( doc.light.slots.len(), 2 );
assert_eq!( doc.dark.slots.len(), 1 );
assert!( doc.light.slots.color( "primary-500" ).is_some() );
assert!( doc.light.slots.text_style( "body-m" ).is_some() );
}
#[ test ]
fn unknown_fields_in_stop_are_rejected()
{
// `deny_unknown_fields` doesn't cooperate well with
// `#[ serde( flatten ) ]`, so the slot-level check would be unreliable
// on variants that flatten a body struct. The safety net lives on
// concrete nested types like `RawColorStop`, `RawShadow`, and
// `RawWallpaper`, which reject typos outright.
let bad = r##"
{
"type": "linear",
"angle_deg": 90,
"stops":
[
{ "pos": 0, "color": "#FFFFFF", "extraneous": true }
]
}
"##;
assert!( serde_json::from_str::<RawSlot>( bad ).is_err() );
}

55
src/theme/search.rs Normal file
View File

@@ -0,0 +1,55 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Theme directory search paths and the font-registry builder that walks the
//! active document's font block.
use std::path::PathBuf;
use super::active::ensure_active;
use super::font_registry::FontRegistry;
// ─── Search paths ────────────────────────────────────────────────────────────
/// Returns the ordered list of directories under which theme families are
/// looked up, highest-priority first.
///
/// `LTK_THEMES_DIR` (when set) takes absolute precedence — useful during
/// development to point the shells at an in-tree `themes/` directory without
/// having to install or symlink anything.
pub fn search_paths() -> Vec<PathBuf>
{
let mut out = Vec::new();
if let Some( dev ) = std::env::var_os( "LTK_THEMES_DIR" )
{
out.push( PathBuf::from( dev ) );
}
if let Some( home ) = std::env::var_os( "XDG_DATA_HOME" )
{
out.push( PathBuf::from( home ).join( "ltk/themes" ) );
}
else if let Some( home ) = std::env::var_os( "HOME" )
{
out.push( PathBuf::from( home ).join( ".local/share/ltk/themes" ) );
}
out.push( PathBuf::from( "/usr/share/ltk/themes" ) );
out
}
/// Build a live [`FontRegistry`] from the active document's `fonts`
/// block, loading each declared source from disk. Sources that fail to
/// read or parse are skipped with a warning on stderr — the registry
/// degrades gracefully so one missing TTF does not take down the rest
/// of the family.
///
/// Returns `None` when the active document declares no families at
/// all (in which case the caller keeps the canvas' system-font
/// fallback). Callers should hand the returned registry to the
/// canvas via `Canvas::set_font_registry`; the draw loop already
/// does this at canvas creation time.
pub fn build_font_registry() -> Option<FontRegistry>
{
let doc = ensure_active().document;
if doc.fonts.is_empty() { return None; }
Some( FontRegistry::from_families_lenient( &doc.fonts ) )
}

20
src/theme/typography.rs Normal file
View File

@@ -0,0 +1,20 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Typography scale used by the default theme.
//!
//! Designed around the **Sora** typeface (Google Fonts). If Sora is not
//! installed on the system, ltk falls back to Liberation Sans / DejaVu Sans;
//! glyph metrics will differ slightly but the scale still reads correctly.
pub const H0: f32 = 50.0;
pub const H1: f32 = 34.0;
pub const H2: f32 = 24.0;
pub const H3: f32 = 20.0;
pub const BODY: f32 = 16.0;
pub const BODY_S: f32 = 14.0;
pub const BODY_XS: f32 = 12.0;
/// Interlineado (line-height) multiplier recommended by the kit. Apply as
/// `size * LINE_HEIGHT` when laying out multi-line text blocks.
pub const LINE_HEIGHT: f32 = 1.5;

View File

@@ -24,6 +24,9 @@ use crate::types::{ Rect, WidgetId };
use super::Element;
#[ cfg( test ) ]
mod tests;
/// 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>
@@ -96,29 +99,3 @@ impl<Msg: Clone + 'static> From<AnchoredOverlay<Msg>> for Element<Msg>
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 );
}
}

View File

@@ -0,0 +1,24 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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 );
}

View File

@@ -9,54 +9,10 @@ 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;
mod theme;
/// 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;
}
#[ cfg( test ) ]
mod tests;
/// Visual style of a text button.
#[ derive( Clone, Default ) ]
@@ -498,106 +454,3 @@ impl<Msg: Clone> Button<Msg>
}
}
}
#[ 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 tooltip_none_by_default()
{
let b = Button::<()>::new( "ok".into() );
assert!( b.tooltip.is_none() );
let b = Button::<()>::new_icon( Arc::new( vec![] ), 0, 0 );
assert!( b.tooltip.is_none() );
}
#[ test ]
fn tooltip_builder_sets_text()
{
let b = Button::<()>::new( "ok".into() ).tooltip( "Save document" );
assert_eq!( b.tooltip.as_deref(), Some( "Save document" ) );
}
#[ test ]
fn tooltip_survives_map_msg()
{
let b = Button::<()>::new( "ok".into() ).tooltip( "Hint" );
let f: super::super::MapFn<(), u32> = std::sync::Arc::new( |()| 0u32 );
let mapped = b.map_msg( &f );
assert_eq!( mapped.tooltip.as_deref(), Some( "Hint" ) );
}
#[ test ]
fn element_tooltip_returns_button_tooltip()
{
let e: super::super::Element<()> = Button::<()>::new( "ok".into() ).tooltip( "Hi" ).into_element();
assert_eq!( e.tooltip(), Some( "Hi" ) );
}
#[ test ]
fn element_tooltip_none_for_non_button_widgets()
{
let t: super::super::Element<()> = super::super::text( "label" ).into();
assert!( t.tooltip().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 );
}
}

101
src/widget/button/tests.rs Normal file
View File

@@ -0,0 +1,101 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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 tooltip_none_by_default()
{
let b = Button::<()>::new( "ok".into() );
assert!( b.tooltip.is_none() );
let b = Button::<()>::new_icon( Arc::new( vec![] ), 0, 0 );
assert!( b.tooltip.is_none() );
}
#[ test ]
fn tooltip_builder_sets_text()
{
let b = Button::<()>::new( "ok".into() ).tooltip( "Save document" );
assert_eq!( b.tooltip.as_deref(), Some( "Save document" ) );
}
#[ test ]
fn tooltip_survives_map_msg()
{
let b = Button::<()>::new( "ok".into() ).tooltip( "Hint" );
let f: super::super::MapFn<(), u32> = std::sync::Arc::new( |()| 0u32 );
let mapped = b.map_msg( &f );
assert_eq!( mapped.tooltip.as_deref(), Some( "Hint" ) );
}
#[ test ]
fn element_tooltip_returns_button_tooltip()
{
let e: super::super::Element<()> = Button::<()>::new( "ok".into() ).tooltip( "Hi" ).into_element();
assert_eq!( e.tooltip(), Some( "Hi" ) );
}
#[ test ]
fn element_tooltip_none_for_non_button_widgets()
{
let t: super::super::Element<()> = super::super::text( "label" ).into();
assert!( t.tooltip().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 );
}

View File

@@ -0,0 +1,48 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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;

View File

@@ -5,25 +5,10 @@ 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;
}
mod theme;
#[cfg(test)]
mod tests;
/// A two-state opt-in control with a square box and a check glyph.
///
@@ -194,24 +179,3 @@ impl<Msg: Clone + 'static> From<Checkbox<Msg>> for Element<Msg>
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 );
}
}

View File

@@ -0,0 +1,19 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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 );
}

View File

@@ -0,0 +1,19 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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;

View File

@@ -32,18 +32,9 @@ 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;
}
mod theme;
#[ cfg( test ) ]
mod tests;
/// Format a [`Color`] as `#RRGGBB` (or `#RRGGBBAA` when `with_alpha`
/// is true and the colour is not fully opaque). Bytes are clamped to
@@ -382,164 +373,3 @@ 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})" );
}
}
}

View File

@@ -0,0 +1,159 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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})" );
}
}

View File

@@ -0,0 +1,12 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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;

View File

@@ -104,6 +104,9 @@ use crate::types::WidgetId;
use super::Element;
#[ cfg( test ) ]
mod tests;
/// 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
@@ -805,173 +808,3 @@ 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() );
}
}

168
src/widget/combo/tests.rs Normal file
View File

@@ -0,0 +1,168 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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() );
}

View File

@@ -6,6 +6,9 @@ use crate::types::{ Color, Corners };
use crate::render::Canvas;
use super::Element;
#[ cfg( test ) ]
mod tests;
/// A transparent wrapper that adds a background color or a themed
/// surface and padding around any child [`Element`].
///
@@ -291,120 +294,3 @@ 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" ) );
}
#[ test ]
fn max_width_caps_preferred_width()
{
use crate::render::Canvas;
let canvas = Canvas::new( 800, 600 );
let c = container::<()>( spacer() ).max_width( 200.0 );
let ( w, _ ) = c.preferred_size( 800.0, &canvas );
assert!( w <= 200.0 );
}
}

View File

@@ -0,0 +1,114 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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" ) );
}
#[ test ]
fn max_width_caps_preferred_width()
{
use crate::render::Canvas;
let canvas = Canvas::new( 800, 600 );
let c = container::<()>( spacer() ).max_width( 200.0 );
let ( w, _ ) = c.preferred_size( 800.0, &canvas );
assert!( w <= 200.0 );
}

View File

@@ -43,22 +43,9 @@ 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;
}
mod theme;
#[ cfg( test ) ]
mod tests;
/// A calendar date in the proleptic Gregorian calendar. No time
/// component, no timezone. `month` is 112, `day` is 131 (further
@@ -493,152 +480,3 @@ 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();
}
}

View File

@@ -0,0 +1,147 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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();
}

View File

@@ -0,0 +1,16 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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;

View File

@@ -75,6 +75,9 @@ use super::pressable::pressable;
use super::text;
use super::Element;
#[ cfg( test ) ]
mod tests;
/// Default scrim opacity over the underlying surface.
pub const SCRIM_ALPHA: f32 = 0.45;
/// Default card max-width (logical pixels). Override with
@@ -327,125 +330,3 @@ 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();
}
}

119
src/widget/dialog/tests.rs Normal file
View File

@@ -0,0 +1,119 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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();
}

456
src/widget/element.rs Normal file
View File

@@ -0,0 +1,456 @@
// 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::{
anchored_overlay, button, checkbox, container, external, flex, image,
list_item, pressable, progress_bar, radio, scroll, separator, slider,
spinner, text, text_edit, toggle, viewport, vslider, window_button,
};
use super::handlers::WidgetHandlers;
use super::MapFn;
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 [`super::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
/// [`super::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,
}
}
/// Add an arm here to opt a widget kind into the auto-tooltip flow.
pub fn tooltip( &self ) -> Option<&str>
{
match self
{
Element::Button( b ) => b.tooltip.as_deref(),
_ => None,
}
}
/// 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
/// [`super::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,
}
}
}
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 )
}
}

View File

@@ -145,38 +145,4 @@ impl External
}
#[ 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 );
}
}
mod tests;

35
src/widget/external/tests.rs vendored Normal file
View File

@@ -0,0 +1,35 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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 );
}

52
src/widget/factory.rs Normal file
View File

@@ -0,0 +1,52 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::sync::Arc;
use super::button::Button;
use super::container::Container;
use super::element::Element;
use super::external::{ External, ExternalSource };
use super::image::Image;
use super::text::Text;
use super::text_edit::TextEdit;
pub fn button<Msg: Clone>( label: impl Into<String> ) -> Button<Msg>
{
Button::new( label.into() )
}
pub fn icon_button<Msg: Clone>( rgba: Arc<Vec<u8>>, img_w: u32, img_h: u32 ) -> Button<Msg>
{
Button::new_icon( rgba, img_w, img_h )
}
pub fn text_edit<Msg: Clone>(
placeholder: impl Into<String>,
value: impl Into<String>,
) -> TextEdit<Msg>
{
TextEdit::new( placeholder.into(), value.into() )
}
pub fn image( rgba: Arc<Vec<u8>>, width: u32, height: u32 ) -> Image
{
Image::new( rgba, width, height )
}
pub fn text( content: impl Into<String> ) -> Text
{
Text::new( content )
}
pub fn container<Msg: Clone>( child: impl Into<Element<Msg>> ) -> Container<Msg>
{
Container::new( child )
}
/// Build an [`External`] widget that hosts content rendered by
/// a caller-managed GL texture producer.
pub fn external( width: f32, height: f32, source: ExternalSource ) -> External
{
External::new( width, height, source )
}

View File

@@ -96,43 +96,4 @@ impl<Msg: Clone + 'static> From<Flex<Msg>> for Element<Msg>
}
#[ 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 );
}
}
mod tests;

40
src/widget/flex/tests.rs Normal file
View File

@@ -0,0 +1,40 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use super::*;
use crate::layout::spacer::spacer;
use crate::render::Canvas;
fn 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 );
}

309
src/widget/handlers.rs Normal file
View File

@@ -0,0 +1,309 @@
// 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 super::{ slider, text };
/// Per-leaf interaction snapshot captured during layout. One variant per
/// interactive widget kind; the layout pass clones the relevant callbacks /
/// values from the [`Element`](super::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 [`super::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`](super::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,
}
}
}

View File

@@ -1,217 +0,0 @@
// 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 );
}
}

107
src/widget/image/mod.rs Normal file
View File

@@ -0,0 +1,107 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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;

111
src/widget/image/tests.rs Normal file
View File

@@ -0,0 +1,111 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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 );
}

55
src/widget/laid_out.rs Normal file
View File

@@ -0,0 +1,55 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::{ Rect, WidgetId };
use super::handlers::WidgetHandlers;
/// 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 [`super::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 [`super::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,
pub tooltip: Option<String>,
}
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,
tooltip: self.tooltip.clone(),
}
}
}

View File

@@ -5,39 +5,10 @@ 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;
}
mod theme;
#[cfg(test)]
mod tests;
/// A row inside a list with a primary label and optional subtitle / trailing
/// text.
@@ -250,19 +221,3 @@ impl<Msg: Clone + 'static> From<ListItem<Msg>> for Element<Msg>
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() );
}
}

View File

@@ -0,0 +1,14 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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() );
}

View File

@@ -0,0 +1,33 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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;

View File

@@ -87,849 +87,20 @@ 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,
},
}
pub mod element;
pub mod handlers;
pub mod laid_out;
pub mod factory;
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,
pub tooltip: Option<String>,
}
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,
tooltip: self.tooltip.clone(),
}
}
}
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,
}
}
/// Add an arm here to opt a widget kind into the auto-tooltip flow.
pub fn tooltip( &self ) -> Option<&str>
{
match self
{
Element::Button( b ) => b.tooltip.as_deref(),
_ => None,
}
}
/// 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,
}
}
}
pub use element::Element;
pub use handlers::WidgetHandlers;
pub use laid_out::LaidOutWidget;
pub use factory::{ button, icon_button, text_edit, image, text, container, external };
/// 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 )
}
pub( crate ) type MapFn<Msg, U> = std::sync::Arc<dyn Fn( Msg ) -> U>;

View File

@@ -40,10 +40,10 @@ use crate::layout::spacer::spacer;
use super::Element;
mod theme
{
pub const SPACING: f32 = 12.0;
}
mod theme;
#[ cfg( test ) ]
mod tests;
/// One page of a [`Notebook`] — a label for the tab strip and the
/// element to show when this page is active.
@@ -177,73 +177,3 @@ 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();
}
}

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