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:
@@ -29,7 +29,7 @@ calloop = "0.14"
|
|||||||
calloop-wayland-source = "0.4"
|
calloop-wayland-source = "0.4"
|
||||||
tiny-skia = "0.12"
|
tiny-skia = "0.12"
|
||||||
fontdue = "0.9"
|
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"
|
resvg = "0.44"
|
||||||
rust-i18n = "3"
|
rust-i18n = "3"
|
||||||
wayland-protocols = { version = "0.32", features = ["client", "unstable", "staging"] }
|
wayland-protocols = { version = "0.32", features = ["client", "unstable", "staging"] }
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ fn build_widgets( n: usize ) -> Vec<LaidOutWidget<()>>
|
|||||||
paint_rect: rect,
|
paint_rect: rect,
|
||||||
handlers,
|
handlers,
|
||||||
keyboard_focusable: true,
|
keyboard_focusable: true,
|
||||||
|
cursor: ltk::CursorShape::Default,
|
||||||
|
tooltip: None,
|
||||||
}
|
}
|
||||||
} ).collect()
|
} ).collect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ pub( crate ) struct DrawCtx<Msg: Clone>
|
|||||||
/// toolkit follows for runtime-internal popups.
|
/// toolkit follows for runtime-internal popups.
|
||||||
pub( crate ) fn draw_context_menu(
|
pub( crate ) fn draw_context_menu(
|
||||||
canvas: &mut crate::render::Canvas,
|
canvas: &mut crate::render::Canvas,
|
||||||
menu: &crate::event_loop::app_data::ContextMenu,
|
menu: &crate::event_loop::context_menu::ContextMenu,
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
let palette = crate::theme::palette();
|
let palette = crate::theme::palette();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
48
src/event_loop/clipboard.rs
Normal file
48
src/event_loop/clipboard.rs
Normal 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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
189
src/event_loop/context_menu.rs
Normal file
189
src/event_loop/context_menu.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
221
src/event_loop/cursor_shape.rs
Normal file
221
src/event_loop/cursor_shape.rs
Normal 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
119
src/event_loop/drag.rs
Normal 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
61
src/event_loop/error.rs
Normal 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
157
src/event_loop/focus.rs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ use smithay_client_toolkit::
|
|||||||
{
|
{
|
||||||
WaylandSurface,
|
WaylandSurface,
|
||||||
wlr_layer::{ LayerShellHandler, LayerSurface, LayerSurfaceConfigure },
|
wlr_layer::{ LayerShellHandler, LayerSurface, LayerSurfaceConfigure },
|
||||||
|
xdg::XdgSurface,
|
||||||
xdg::popup::{ Popup, PopupConfigure, PopupHandler },
|
xdg::popup::{ Popup, PopupConfigure, PopupHandler },
|
||||||
xdg::window::{ Window, WindowConfigure, WindowHandler },
|
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 ( 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 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 );
|
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.on_configure( w, h );
|
||||||
self.app.on_resize( w, h );
|
self.app.on_resize( w, h );
|
||||||
}
|
}
|
||||||
|
|||||||
76
src/event_loop/invalidation.rs
Normal file
76
src/event_loop/invalidation.rs
Normal 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 )
|
||||||
|
}
|
||||||
@@ -2,878 +2,24 @@
|
|||||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
pub( crate ) mod app_data;
|
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;
|
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::
|
pub( crate ) use app_data::AppData;
|
||||||
{
|
pub( crate ) use surface::{ LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState };
|
||||||
compositor::{ CompositorState, Surface },
|
pub use error::RunError;
|
||||||
output::OutputState,
|
pub( crate ) use run::{ run, try_run };
|
||||||
registry::RegistryState,
|
pub use invalidation::diff_overlay_ids;
|
||||||
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 );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
289
src/event_loop/overlays_reconcile.rs
Normal file
289
src/event_loop/overlays_reconcile.rs
Normal 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
50
src/event_loop/repeat.rs
Normal 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
482
src/event_loop/run.rs
Normal 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
380
src/event_loop/surface.rs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
193
src/event_loop/text_editing/cursor.rs
Normal file
193
src/event_loop/text_editing/cursor.rs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/event_loop/text_editing/ime.rs
Normal file
71
src/event_loop/text_editing/ime.rs
Normal 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 ) )
|
||||||
|
}
|
||||||
|
}
|
||||||
194
src/event_loop/text_editing/insert_delete.rs
Normal file
194
src/event_loop/text_editing/insert_delete.rs
Normal 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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/event_loop/text_editing/mod.rs
Normal file
12
src/event_loop/text_editing/mod.rs
Normal 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;
|
||||||
263
src/event_loop/text_editing/selection.rs
Normal file
263
src/event_loop/text_editing/selection.rs
Normal 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
144
src/event_loop/tooltip.rs
Normal 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,
|
||||||
|
} )
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/input/dispatch/coords.rs
Normal file
28
src/input/dispatch/coords.rs
Normal 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
20
src/input/dispatch/mod.rs
Normal 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;
|
||||||
@@ -1,93 +1,16 @@
|
|||||||
// SPDX-License-Identifier: LGPL-2.1-only
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
// 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::app::App;
|
||||||
use crate::event_loop::{ AppData, SurfaceFocus };
|
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>
|
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
|
/// Apply the outcome of a motion event. Non-blocking side-effects
|
||||||
/// only: push messages, call app callbacks, set redraw flags.
|
/// only: push messages, call app callbacks, set redraw flags.
|
||||||
pub( super ) fn apply_move_outcome
|
pub( crate ) fn apply_move_outcome
|
||||||
(
|
(
|
||||||
&mut self,
|
&mut self,
|
||||||
focus: SurfaceFocus,
|
focus: SurfaceFocus,
|
||||||
@@ -142,7 +65,7 @@ impl<A: App> AppData<A>
|
|||||||
/// release can emit more than one event (e.g. horizontal
|
/// release can emit more than one event (e.g. horizontal
|
||||||
/// fall-through followed by a vertical commit or fall-through), so
|
/// fall-through followed by a vertical commit or fall-through), so
|
||||||
/// we walk the list and run each event's side-effects in order.
|
/// 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,
|
&mut self,
|
||||||
focus: SurfaceFocus,
|
focus: SurfaceFocus,
|
||||||
48
src/input/dispatch/password_toggle.rs
Normal file
48
src/input/dispatch/password_toggle.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1071
src/input/gesture.rs
1071
src/input/gesture.rs
File diff suppressed because it is too large
Load Diff
524
src/input/gesture/mod.rs
Normal file
524
src/input/gesture/mod.rs
Normal 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 6–24 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(); }
|
||||||
|
}
|
||||||
84
src/input/gesture/outcomes.rs
Normal file
84
src/input/gesture/outcomes.rs
Normal 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
470
src/input/gesture/tests.rs
Normal 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() );
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
41
src/input/keyboard/dispatch.rs
Normal file
41
src/input/keyboard/dispatch.rs
Normal 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
165
src/input/keyboard/mod.rs
Normal 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
72
src/input/keyboard/nav.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
100
src/input/keyboard/shortcuts.rs
Normal file
100
src/input/keyboard/shortcuts.rs
Normal 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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
183
src/input/keyboard/text_keys.rs
Normal file
183
src/input/keyboard/text_keys.rs
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
|
use 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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ pub( crate ) mod keyboard;
|
|||||||
pub( crate ) mod pointer;
|
pub( crate ) mod pointer;
|
||||||
pub( crate ) mod touch;
|
pub( crate ) mod touch;
|
||||||
pub( crate ) mod dispatch;
|
pub( crate ) mod dispatch;
|
||||||
|
pub( crate ) mod repeat;
|
||||||
|
|
||||||
// `GestureState` is the only type consumers outside `input/` need —
|
// `GestureState` is the only type consumers outside `input/` need —
|
||||||
// `AppData` stores one per `SurfaceState` and the long-press deadline
|
// `AppData` stores one per `SurfaceState` and the long-press deadline
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
43
src/input/pointer/enter.rs
Normal file
43
src/input/pointer/enter.rs
Normal 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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/input/pointer/helpers.rs
Normal file
41
src/input/pointer/helpers.rs
Normal 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
69
src/input/pointer/mod.rs
Normal 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
145
src/input/pointer/motion.rs
Normal 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
257
src/input/pointer/press.rs
Normal 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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/input/pointer/release.rs
Normal file
48
src/input/pointer/release.rs
Normal 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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/input/pointer/scroll.rs
Normal file
67
src/input/pointer/scroll.rs
Normal 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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/input/repeat/button.rs
Normal file
89
src/input/repeat/button.rs
Normal 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
100
src/input/repeat/key.rs
Normal 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
5
src/input/repeat/mod.rs
Normal 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
116
src/theme/accessors.rs
Normal 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
150
src/theme/active.rs
Normal 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
484
src/theme/assets.rs
Normal 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
35
src/theme/error.rs
Normal 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 {}
|
||||||
1068
src/theme/mod.rs
1068
src/theme/mod.rs
File diff suppressed because it is too large
Load Diff
122
src/theme/palette.rs
Normal file
122
src/theme/palette.rs
Normal 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
147
src/theme/prefs.rs
Normal 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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
1452
src/theme/schema.rs
1452
src/theme/schema.rs
File diff suppressed because it is too large
Load Diff
394
src/theme/schema/mod.rs
Normal file
394
src/theme/schema/mod.rs
Normal 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
601
src/theme/schema/raw.rs
Normal 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
194
src/theme/schema/refs.rs
Normal 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
292
src/theme/schema/tests.rs
Normal 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
55
src/theme/search.rs
Normal 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
20
src/theme/typography.rs
Normal 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;
|
||||||
@@ -24,6 +24,9 @@ use crate::types::{ Rect, WidgetId };
|
|||||||
|
|
||||||
use super::Element;
|
use super::Element;
|
||||||
|
|
||||||
|
#[ cfg( test ) ]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
/// A wrapper that re-positions its child relative to an anchor widget
|
/// A wrapper that re-positions its child relative to an anchor widget
|
||||||
/// found in the previous frame's layout snapshot.
|
/// found in the previous frame's layout snapshot.
|
||||||
pub struct AnchoredOverlay<Msg: Clone>
|
pub struct AnchoredOverlay<Msg: Clone>
|
||||||
@@ -96,29 +99,3 @@ impl<Msg: Clone + 'static> From<AnchoredOverlay<Msg>> for Element<Msg>
|
|||||||
Element::AnchoredOverlay( a )
|
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 );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
24
src/widget/anchored_overlay/tests.rs
Normal file
24
src/widget/anchored_overlay/tests.rs
Normal 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 );
|
||||||
|
}
|
||||||
@@ -9,54 +9,10 @@ use super::Element;
|
|||||||
// Theme colors driven by the process-wide palette (see `ltk::theme`).
|
// Theme colors driven by the process-wide palette (see `ltk::theme`).
|
||||||
// Non-colour geometry (radius, font size, focus width, etc.) is static — only
|
// Non-colour geometry (radius, font size, focus width, etc.) is static — only
|
||||||
// palette tokens respond to light/dark mode.
|
// palette tokens respond to light/dark mode.
|
||||||
mod theme
|
mod theme;
|
||||||
{
|
|
||||||
use crate::types::Color;
|
|
||||||
|
|
||||||
/// Primary button background — brand accent.
|
#[ cfg( test ) ]
|
||||||
pub fn p_default_bg() -> Color { crate::theme::palette().accent }
|
mod tests;
|
||||||
/// Primary button border — a darker tone of the accent so the pill
|
|
||||||
/// reads as a discrete affordance over surfaces that share the
|
|
||||||
/// background hue. Computed by darkening the linear-RGB components
|
|
||||||
/// of the accent rather than carrying yet another palette slot.
|
|
||||||
pub fn p_default_border() -> Color
|
|
||||||
{
|
|
||||||
let a = crate::theme::palette().accent;
|
|
||||||
Color { r: a.r * 0.55, g: a.g * 0.55, b: a.b * 0.55, a: a.a }
|
|
||||||
}
|
|
||||||
/// Primary button label colour. Falls back to the theme's
|
|
||||||
/// `text_primary`; themes whose accent does not pair well with
|
|
||||||
/// their text-primary token can override the palette so this
|
|
||||||
/// reads cleanly.
|
|
||||||
pub fn p_default_text() -> Color { crate::theme::palette().text_primary }
|
|
||||||
/// Disabled primary background — uses the theme's divider token,
|
|
||||||
/// the lowest-contrast neutral available across modes.
|
|
||||||
pub fn p_disabled_bg() -> Color { crate::theme::palette().divider }
|
|
||||||
/// Disabled primary / secondary / tertiary text — uses the
|
|
||||||
/// theme's secondary text token.
|
|
||||||
pub fn p_disabled_text() -> Color { crate::theme::palette().text_secondary }
|
|
||||||
/// Secondary button default background — matches the surface_alt surface.
|
|
||||||
pub fn s_bg() -> Color { crate::theme::palette().surface_alt }
|
|
||||||
/// Secondary button border.
|
|
||||||
pub fn s_border() -> Color { crate::theme::palette().text_primary }
|
|
||||||
/// Disabled secondary background — slightly lighter than the
|
|
||||||
/// disabled primary so the two states stay visually distinct.
|
|
||||||
pub fn s_disabled_bg() -> Color { crate::theme::palette().surface_alt }
|
|
||||||
/// Disabled secondary border — uses the divider token.
|
|
||||||
pub fn s_disabled_border() -> Color { crate::theme::palette().divider }
|
|
||||||
/// Tertiary button text colour.
|
|
||||||
pub fn t_text() -> Color { crate::theme::palette().text_primary }
|
|
||||||
/// Keyboard focus ring / hover circle.
|
|
||||||
pub fn focus_color() -> Color { crate::theme::palette().accent }
|
|
||||||
|
|
||||||
pub const S_BORDER_W: f32 = 2.0;
|
|
||||||
pub const P_BORDER_W: f32 = 1.0;
|
|
||||||
pub const FOCUS_W: f32 = 3.0;
|
|
||||||
pub const HEIGHT: f32 = 48.0;
|
|
||||||
pub const RADIUS: f32 = 100.0;
|
|
||||||
pub const FONT_SIZE: f32 = 16.0;
|
|
||||||
pub const PAD_H: f32 = 24.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Visual style of a text button.
|
/// Visual style of a text button.
|
||||||
#[ derive( Clone, Default ) ]
|
#[ 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
101
src/widget/button/tests.rs
Normal 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 );
|
||||||
|
}
|
||||||
48
src/widget/button/theme.rs
Normal file
48
src/widget/button/theme.rs
Normal 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;
|
||||||
@@ -5,25 +5,10 @@ use crate::types::{ Rect, WidgetId };
|
|||||||
use crate::render::Canvas;
|
use crate::render::Canvas;
|
||||||
use super::Element;
|
use super::Element;
|
||||||
|
|
||||||
mod theme
|
mod theme;
|
||||||
{
|
|
||||||
use crate::types::Color;
|
#[cfg(test)]
|
||||||
pub fn box_border() -> Color { crate::theme::palette().divider }
|
mod tests;
|
||||||
pub fn box_checked() -> Color { crate::theme::palette().accent }
|
|
||||||
/// Tick mark — uses the page-background colour so it reads as
|
|
||||||
/// the inverse of the accent fill regardless of mode.
|
|
||||||
pub fn check_color() -> Color { crate::theme::palette().bg }
|
|
||||||
pub fn focus_color() -> Color { crate::theme::palette().accent }
|
|
||||||
pub fn label_color() -> Color { crate::theme::palette().text_primary }
|
|
||||||
pub const BOX_SIZE: f32 = 24.0;
|
|
||||||
pub const RADIUS: f32 = 4.0;
|
|
||||||
pub const BORDER_W: f32 = 2.0;
|
|
||||||
pub const GAP: f32 = 12.0;
|
|
||||||
pub const HEIGHT: f32 = 48.0;
|
|
||||||
pub const FOCUS_W: f32 = 3.0;
|
|
||||||
pub const CHECK_W: f32 = 2.5;
|
|
||||||
pub const FONT_SIZE: f32 = 16.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A two-state opt-in control with a square box and a check glyph.
|
/// 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 )
|
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 );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
19
src/widget/checkbox/tests.rs
Normal file
19
src/widget/checkbox/tests.rs
Normal 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 );
|
||||||
|
}
|
||||||
19
src/widget/checkbox/theme.rs
Normal file
19
src/widget/checkbox/theme.rs
Normal 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;
|
||||||
@@ -32,18 +32,9 @@ use crate::theme::{ ColorStop, GradientSpace, LinearGradient, Paint };
|
|||||||
|
|
||||||
use super::Element;
|
use super::Element;
|
||||||
|
|
||||||
mod theme
|
mod theme;
|
||||||
{
|
#[ cfg( test ) ]
|
||||||
use crate::types::Color;
|
mod tests;
|
||||||
pub fn surface_alt() -> Color { crate::theme::palette().surface_alt }
|
|
||||||
pub fn divider() -> Color { crate::theme::palette().divider }
|
|
||||||
pub fn text_muted() -> Color { crate::theme::palette().text_secondary }
|
|
||||||
pub const PADDING: f32 = 16.0;
|
|
||||||
pub const RADIUS: f32 = 16.0;
|
|
||||||
pub const SWATCH_SZ: f32 = 40.0;
|
|
||||||
pub const LABEL_FS: f32 = 12.0;
|
|
||||||
pub const SPACING: f32 = 8.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Format a [`Color`] as `#RRGGBB` (or `#RRGGBBAA` when `with_alpha`
|
/// Format a [`Color`] as `#RRGGBB` (or `#RRGGBBAA` when `with_alpha`
|
||||||
/// is true and the colour is not fully opaque). Bytes are clamped to
|
/// 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 )
|
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})" );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
159
src/widget/color_picker/tests.rs
Normal file
159
src/widget/color_picker/tests.rs
Normal 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})" );
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/widget/color_picker/theme.rs
Normal file
12
src/widget/color_picker/theme.rs
Normal 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;
|
||||||
@@ -104,6 +104,9 @@ use crate::types::WidgetId;
|
|||||||
|
|
||||||
use super::Element;
|
use super::Element;
|
||||||
|
|
||||||
|
#[ cfg( test ) ]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
/// Stable identifier of the trigger pill used as the popup's anchor.
|
/// Stable identifier of the trigger pill used as the popup's anchor.
|
||||||
/// Read by [`crate::widget::anchored_overlay::AnchoredOverlay`] in the
|
/// Read by [`crate::widget::anchored_overlay::AnchoredOverlay`] in the
|
||||||
/// draw pass to position the popup flush below the trigger. A single
|
/// 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 )
|
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
168
src/widget/combo/tests.rs
Normal 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() );
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@ use crate::types::{ Color, Corners };
|
|||||||
use crate::render::Canvas;
|
use crate::render::Canvas;
|
||||||
use super::Element;
|
use super::Element;
|
||||||
|
|
||||||
|
#[ cfg( test ) ]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
/// A transparent wrapper that adds a background color or a themed
|
/// A transparent wrapper that adds a background color or a themed
|
||||||
/// surface and padding around any child [`Element`].
|
/// 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 )
|
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 );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
114
src/widget/container/tests.rs
Normal file
114
src/widget/container/tests.rs
Normal 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 );
|
||||||
|
}
|
||||||
@@ -43,22 +43,9 @@ use crate::layout::wrap_grid::grid;
|
|||||||
|
|
||||||
use super::Element;
|
use super::Element;
|
||||||
|
|
||||||
mod theme
|
mod theme;
|
||||||
{
|
#[ cfg( test ) ]
|
||||||
use crate::types::Color;
|
mod tests;
|
||||||
pub fn surface() -> Color { crate::theme::palette().surface }
|
|
||||||
pub fn surface_alt() -> Color { crate::theme::palette().surface_alt }
|
|
||||||
pub fn text() -> Color { crate::theme::palette().text_primary }
|
|
||||||
pub fn text_muted() -> Color { crate::theme::palette().text_secondary }
|
|
||||||
pub fn accent() -> Color { crate::theme::palette().accent }
|
|
||||||
pub const PADDING: f32 = 16.0;
|
|
||||||
pub const RADIUS: f32 = 16.0;
|
|
||||||
pub const HEADER_FS: f32 = 16.0;
|
|
||||||
pub const DOW_FS: f32 = 12.0;
|
|
||||||
pub const DAY_FS: f32 = 14.0;
|
|
||||||
pub const CELL_SIZE: f32 = 36.0;
|
|
||||||
pub const SPACING: f32 = 4.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A calendar date in the proleptic Gregorian calendar. No time
|
/// A calendar date in the proleptic Gregorian calendar. No time
|
||||||
/// component, no timezone. `month` is 1–12, `day` is 1–31 (further
|
/// component, no timezone. `month` is 1–12, `day` is 1–31 (further
|
||||||
@@ -493,152 +480,3 @@ pub fn date_picker<Msg: Clone + 'static>( value: Date ) -> DatePicker<Msg>
|
|||||||
{
|
{
|
||||||
DatePicker::new( value )
|
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
147
src/widget/date_picker/tests.rs
Normal file
147
src/widget/date_picker/tests.rs
Normal 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();
|
||||||
|
}
|
||||||
16
src/widget/date_picker/theme.rs
Normal file
16
src/widget/date_picker/theme.rs
Normal 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;
|
||||||
@@ -75,6 +75,9 @@ use super::pressable::pressable;
|
|||||||
use super::text;
|
use super::text;
|
||||||
use super::Element;
|
use super::Element;
|
||||||
|
|
||||||
|
#[ cfg( test ) ]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
/// Default scrim opacity over the underlying surface.
|
/// Default scrim opacity over the underlying surface.
|
||||||
pub const SCRIM_ALPHA: f32 = 0.45;
|
pub const SCRIM_ALPHA: f32 = 0.45;
|
||||||
/// Default card max-width (logical pixels). Override with
|
/// Default card max-width (logical pixels). Override with
|
||||||
@@ -327,125 +330,3 @@ pub fn dialog<Msg: Clone>() -> Dialog<Msg>
|
|||||||
{
|
{
|
||||||
Dialog::new()
|
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
119
src/widget/dialog/tests.rs
Normal 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
456
src/widget/element.rs
Normal 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 )
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -145,38 +145,4 @@ impl External
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[ cfg( test ) ]
|
#[ cfg( test ) ]
|
||||||
mod tests
|
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 );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
35
src/widget/external/tests.rs
vendored
Normal file
35
src/widget/external/tests.rs
vendored
Normal 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
52
src/widget/factory.rs
Normal 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 )
|
||||||
|
}
|
||||||
@@ -96,43 +96,4 @@ impl<Msg: Clone + 'static> From<Flex<Msg>> for Element<Msg>
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[ cfg( test ) ]
|
#[ cfg( test ) ]
|
||||||
mod tests
|
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 );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
40
src/widget/flex/tests.rs
Normal file
40
src/widget/flex/tests.rs
Normal 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
309
src/widget/handlers.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
107
src/widget/image/mod.rs
Normal 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
111
src/widget/image/tests.rs
Normal 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
55
src/widget/laid_out.rs
Normal 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,39 +5,10 @@ use crate::types::{ Rect, WidgetId };
|
|||||||
use crate::render::Canvas;
|
use crate::render::Canvas;
|
||||||
use super::Element;
|
use super::Element;
|
||||||
|
|
||||||
mod theme
|
mod theme;
|
||||||
{
|
|
||||||
use crate::types::Color;
|
#[cfg(test)]
|
||||||
pub fn label_color() -> Color { crate::theme::palette().text_primary }
|
mod tests;
|
||||||
pub fn subtitle_color() -> Color { crate::theme::palette().text_secondary }
|
|
||||||
pub fn trailing_color() -> Color { crate::theme::palette().text_secondary }
|
|
||||||
/// Alpha-only overlay tuned for the active mode (white on dark, navy on light).
|
|
||||||
pub fn hover_bg() -> Color
|
|
||||||
{
|
|
||||||
let p = crate::theme::palette();
|
|
||||||
Color { r: p.text_primary.r, g: p.text_primary.g, b: p.text_primary.b, a: 0.06 }
|
|
||||||
}
|
|
||||||
pub fn press_bg() -> Color
|
|
||||||
{
|
|
||||||
let p = crate::theme::palette();
|
|
||||||
Color { r: p.text_primary.r, g: p.text_primary.g, b: p.text_primary.b, a: 0.12 }
|
|
||||||
}
|
|
||||||
/// Solid dark surface for the [`ListItem::selected`](super::ListItem::selected) state.
|
|
||||||
/// `palette.text_primary` happens to be the right colour for both
|
|
||||||
/// modes (navy on light, white on dark) so the white-on-dark contrast
|
|
||||||
/// pattern flips into a navy-on-white contrast in dark mode without
|
|
||||||
/// needing a separate slot.
|
|
||||||
pub fn selected_bg() -> Color { crate::theme::palette().text_primary }
|
|
||||||
pub fn focus_color() -> Color { crate::theme::palette().accent }
|
|
||||||
pub const LABEL_SIZE: f32 = 16.0;
|
|
||||||
pub const SUBTITLE_SIZE: f32 = 13.0;
|
|
||||||
pub const TRAILING_SIZE: f32 = 14.0;
|
|
||||||
pub const HEIGHT: f32 = 56.0;
|
|
||||||
pub const HEIGHT_SUB: f32 = 68.0;
|
|
||||||
pub const PAD_H: f32 = 16.0;
|
|
||||||
pub const RADIUS: f32 = 12.0;
|
|
||||||
pub const FOCUS_W: f32 = 2.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A row inside a list with a primary label and optional subtitle / trailing
|
/// A row inside a list with a primary label and optional subtitle / trailing
|
||||||
/// text.
|
/// text.
|
||||||
@@ -250,19 +221,3 @@ impl<Msg: Clone + 'static> From<ListItem<Msg>> for Element<Msg>
|
|||||||
Element::ListItem( l )
|
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() );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
14
src/widget/list_item/tests.rs
Normal file
14
src/widget/list_item/tests.rs
Normal 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() );
|
||||||
|
}
|
||||||
33
src/widget/list_item/theme.rs
Normal file
33
src/widget/list_item/theme.rs
Normal 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;
|
||||||
@@ -87,849 +87,20 @@ pub mod time_picker;
|
|||||||
pub mod color_picker;
|
pub mod color_picker;
|
||||||
pub mod dialog;
|
pub mod dialog;
|
||||||
pub mod external;
|
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
|
pub mod element;
|
||||||
/// interactive widget kind; the layout pass clones the relevant callbacks /
|
pub mod handlers;
|
||||||
/// values from the [`Element`] tree into here so input handlers can dispatch
|
pub mod laid_out;
|
||||||
/// in O(1) without re-walking the tree.
|
pub mod factory;
|
||||||
///
|
|
||||||
/// `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>
|
pub use element::Element;
|
||||||
{
|
pub use handlers::WidgetHandlers;
|
||||||
/// Mirror the wipe-on-drop behaviour of [`text_edit::TextEdit`] for the
|
pub use laid_out::LaidOutWidget;
|
||||||
/// per-frame handler snapshots the runtime keeps. When the snapshot
|
pub use factory::{ button, icon_button, text_edit, image, text, container, external };
|
||||||
/// 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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Type alias for the message-mapping closure shared across an
|
/// Type alias for the message-mapping closure shared across an
|
||||||
/// [`Element::map`] walk. Stored as `Arc<dyn Fn>` so every per-widget
|
/// [`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
|
/// `map_msg` can clone and re-share it without copying the closure body
|
||||||
/// — the same closure is invoked once per emitted message, regardless
|
/// — the same closure is invoked once per emitted message, regardless
|
||||||
/// of how many leaves the sub-tree has.
|
/// of how many leaves the sub-tree has.
|
||||||
pub( crate ) type MapFn<Msg, U> = Arc<dyn Fn( Msg ) -> U>;
|
pub( crate ) type MapFn<Msg, U> = std::sync::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 )
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -40,10 +40,10 @@ use crate::layout::spacer::spacer;
|
|||||||
|
|
||||||
use super::Element;
|
use super::Element;
|
||||||
|
|
||||||
mod theme
|
mod theme;
|
||||||
{
|
|
||||||
pub const SPACING: f32 = 12.0;
|
#[ cfg( test ) ]
|
||||||
}
|
mod tests;
|
||||||
|
|
||||||
/// One page of a [`Notebook`] — a label for the tab strip and the
|
/// One page of a [`Notebook`] — a label for the tab strip and the
|
||||||
/// element to show when this page is active.
|
/// element to show when this page is active.
|
||||||
@@ -177,73 +177,3 @@ pub fn notebook<Msg: Clone + 'static>() -> Notebook<Msg>
|
|||||||
{
|
{
|
||||||
Notebook::new()
|
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
Reference in New Issue
Block a user