From 4aa3480b647b7cabf1127e56ccaf907effa39bd2 Mon Sep 17 00:00:00 2001 From: "Pedro M. de Echanove Pasquin" Date: Fri, 15 May 2026 23:46:56 +0200 Subject: [PATCH] =?UTF-8?q?refactor:=20split=20every=20monolithic=20module?= =?UTF-8?q?=20into=20focused=20submodules=20Each=20source=20file=20that=20?= =?UTF-8?q?had=20grown=20beyond=20a=20single=20concern=20is=20replaced=20b?= =?UTF-8?q?y=20an=20identically-named=20directory=20containing=20focused?= =?UTF-8?q?=20submodules.=20`src/event=5Floop/mod.rs`=20(878=20lines)=20be?= =?UTF-8?q?comes=20a=20directory=20with=20clipboard,=20context=5Fmenu,=20c?= =?UTF-8?q?ursor=5Fshape,=20drag,=20focus,=20handlers,=20invalidation,=20o?= =?UTF-8?q?verlays=5Freconcile,=20repeat,=20run,=20surface,=20text=5Fediti?= =?UTF-8?q?ng,=20and=20tooltip.=20Every=20widget,=20input=20handler,=20and?= =?UTF-8?q?=20theme=20component=20follows=20the=20same=20split.=20Public?= =?UTF-8?q?=20interfaces=20are=20unchanged=20=E2=80=94=20only=20the=20inte?= =?UTF-8?q?rnal=20file=20layout=20moves.=20image=20bumped=20from=200.25.2?= =?UTF-8?q?=20to=200.25.9.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.toml | 2 +- benches/lookup.rs | 2 + src/draw/mod.rs | 2 +- src/event_loop/app_data.rs | 1909 +--------------- src/event_loop/clipboard.rs | 48 + src/event_loop/context_menu.rs | 189 ++ src/event_loop/cursor_shape.rs | 221 ++ src/event_loop/drag.rs | 119 + src/event_loop/error.rs | 61 + src/event_loop/focus.rs | 157 ++ src/event_loop/handlers.rs | 2 + src/event_loop/invalidation.rs | 76 + src/event_loop/mod.rs | 890 +------- src/event_loop/overlays_reconcile.rs | 289 +++ src/event_loop/repeat.rs | 50 + src/event_loop/run.rs | 482 ++++ src/event_loop/surface.rs | 380 +++ src/event_loop/text_editing/cursor.rs | 193 ++ src/event_loop/text_editing/ime.rs | 71 + src/event_loop/text_editing/insert_delete.rs | 194 ++ src/event_loop/text_editing/mod.rs | 12 + src/event_loop/text_editing/selection.rs | 263 +++ src/event_loop/tooltip.rs | 144 ++ src/input/dispatch/coords.rs | 28 + src/input/dispatch/mod.rs | 20 + .../{dispatch.rs => dispatch/outcomes.rs} | 83 +- src/input/dispatch/password_toggle.rs | 48 + src/input/gesture.rs | 1071 --------- src/input/gesture/mod.rs | 524 +++++ src/input/gesture/outcomes.rs | 84 + src/input/gesture/tests.rs | 470 ++++ src/input/keyboard.rs | 645 ------ src/input/keyboard/dispatch.rs | 41 + src/input/keyboard/mod.rs | 165 ++ src/input/keyboard/nav.rs | 72 + src/input/keyboard/shortcuts.rs | 100 + src/input/keyboard/text_keys.rs | 183 ++ src/input/mod.rs | 1 + src/input/pointer.rs | 504 ---- src/input/pointer/enter.rs | 43 + src/input/pointer/helpers.rs | 41 + src/input/pointer/mod.rs | 69 + src/input/pointer/motion.rs | 145 ++ src/input/pointer/press.rs | 257 +++ src/input/pointer/release.rs | 48 + src/input/pointer/scroll.rs | 67 + src/input/repeat/button.rs | 89 + src/input/repeat/key.rs | 100 + src/input/repeat/mod.rs | 5 + src/input/{touch.rs => touch/mod.rs} | 0 src/theme/accessors.rs | 116 + src/theme/active.rs | 150 ++ src/theme/assets.rs | 484 ++++ src/theme/error.rs | 35 + src/theme/mod.rs | 1064 +-------- src/theme/palette.rs | 122 + src/theme/prefs.rs | 147 ++ src/theme/schema.rs | 1452 ------------ src/theme/schema/mod.rs | 394 ++++ src/theme/schema/raw.rs | 601 +++++ src/theme/schema/refs.rs | 194 ++ src/theme/schema/tests.rs | 292 +++ src/theme/search.rs | 55 + src/theme/typography.rs | 20 + .../mod.rs} | 29 +- src/widget/anchored_overlay/tests.rs | 24 + src/widget/{button.rs => button/mod.rs} | 153 +- src/widget/button/tests.rs | 101 + src/widget/button/theme.rs | 48 + src/widget/{checkbox.rs => checkbox/mod.rs} | 44 +- src/widget/checkbox/tests.rs | 19 + src/widget/checkbox/theme.rs | 19 + .../{color_picker.rs => color_picker/mod.rs} | 176 +- src/widget/color_picker/tests.rs | 159 ++ src/widget/color_picker/theme.rs | 12 + src/widget/{combo.rs => combo/mod.rs} | 173 +- src/widget/combo/tests.rs | 168 ++ src/widget/{container.rs => container/mod.rs} | 120 +- src/widget/container/tests.rs | 114 + .../{date_picker.rs => date_picker/mod.rs} | 168 +- src/widget/date_picker/tests.rs | 147 ++ src/widget/date_picker/theme.rs | 16 + src/widget/{dialog.rs => dialog/mod.rs} | 125 +- src/widget/dialog/tests.rs | 119 + src/widget/element.rs | 456 ++++ src/widget/{external.rs => external/mod.rs} | 36 +- src/widget/external/tests.rs | 35 + src/widget/factory.rs | 52 + src/widget/{flex.rs => flex/mod.rs} | 41 +- src/widget/flex/tests.rs | 40 + src/widget/handlers.rs | 309 +++ src/widget/image.rs | 217 -- src/widget/image/mod.rs | 107 + src/widget/image/tests.rs | 111 + src/widget/laid_out.rs | 55 + src/widget/{list_item.rs => list_item/mod.rs} | 53 +- src/widget/list_item/tests.rs | 14 + src/widget/list_item/theme.rs | 33 + src/widget/mod.rs | 847 +------ src/widget/{notebook.rs => notebook/mod.rs} | 78 +- src/widget/notebook/tests.rs | 68 + src/widget/notebook/theme.rs | 4 + src/widget/{pressable.rs => pressable/mod.rs} | 103 +- src/widget/pressable/tests.rs | 98 + .../{progress_bar.rs => progress_bar/mod.rs} | 35 +- src/widget/progress_bar/tests.rs | 21 + src/widget/progress_bar/theme.rs | 8 + src/widget/{radio.rs => radio/mod.rs} | 43 +- src/widget/radio/tests.rs | 19 + src/widget/radio/theme.rs | 18 + src/widget/{scroll.rs => scroll/mod.rs} | 55 +- src/widget/scroll/tests.rs | 50 + src/widget/{separator.rs => separator/mod.rs} | 28 +- src/widget/separator/tests.rs | 19 + src/widget/separator/theme.rs | 7 + src/widget/{slider.rs => slider/mod.rs} | 126 +- src/widget/slider/tests.rs | 91 + src/widget/slider/theme.rs | 28 + src/widget/{spinner.rs => spinner/mod.rs} | 60 +- src/widget/spinner/tests.rs | 38 + src/widget/spinner/theme.rs | 20 + src/widget/{tab_bar.rs => tab_bar/mod.rs} | 50 +- src/widget/tab_bar/tests.rs | 38 + src/widget/tab_bar/theme.rs | 6 + src/widget/{text.rs => text/mod.rs} | 101 +- src/widget/text/tests.rs | 96 + src/widget/text_edit.rs | 2029 ----------------- src/widget/text_edit/cursor.rs | 108 + src/widget/text_edit/draw.rs | 397 ++++ src/widget/text_edit/hit_test.rs | 203 ++ src/widget/text_edit/mod.rs | 513 +++++ src/widget/text_edit/tests.rs | 702 ++++++ src/widget/text_edit/theme.rs | 43 + src/widget/text_edit/wrapping.rs | 115 + .../{time_picker.rs => time_picker/mod.rs} | 156 +- src/widget/time_picker/tests.rs | 138 ++ src/widget/time_picker/theme.rs | 12 + src/widget/{toast.rs => toast/mod.rs} | 69 +- src/widget/toast/tests.rs | 48 + src/widget/toast/theme.rs | 15 + src/widget/{toggle.rs => toggle/mod.rs} | 46 +- src/widget/toggle/tests.rs | 20 + src/widget/toggle/theme.rs | 20 + src/widget/{tooltip.rs => tooltip/mod.rs} | 82 +- src/widget/tooltip/tests.rs | 54 + src/widget/tooltip/theme.rs | 22 + src/widget/{viewport.rs => viewport/mod.rs} | 76 +- src/widget/viewport/tests.rs | 75 + src/widget/{vslider.rs => vslider/mod.rs} | 179 +- src/widget/vslider/tests.rs | 151 ++ src/widget/vslider/theme.rs | 22 + .../mod.rs} | 70 +- src/widget/window_button/tests.rs | 50 + src/widget/window_button/theme.rs | 16 + tests/common/mod.rs | 3 + 155 files changed, 13832 insertions(+), 13035 deletions(-) create mode 100644 src/event_loop/clipboard.rs create mode 100644 src/event_loop/context_menu.rs create mode 100644 src/event_loop/cursor_shape.rs create mode 100644 src/event_loop/drag.rs create mode 100644 src/event_loop/error.rs create mode 100644 src/event_loop/focus.rs create mode 100644 src/event_loop/invalidation.rs create mode 100644 src/event_loop/overlays_reconcile.rs create mode 100644 src/event_loop/repeat.rs create mode 100644 src/event_loop/run.rs create mode 100644 src/event_loop/surface.rs create mode 100644 src/event_loop/text_editing/cursor.rs create mode 100644 src/event_loop/text_editing/ime.rs create mode 100644 src/event_loop/text_editing/insert_delete.rs create mode 100644 src/event_loop/text_editing/mod.rs create mode 100644 src/event_loop/text_editing/selection.rs create mode 100644 src/event_loop/tooltip.rs create mode 100644 src/input/dispatch/coords.rs create mode 100644 src/input/dispatch/mod.rs rename src/input/{dispatch.rs => dispatch/outcomes.rs} (63%) create mode 100644 src/input/dispatch/password_toggle.rs delete mode 100644 src/input/gesture.rs create mode 100644 src/input/gesture/mod.rs create mode 100644 src/input/gesture/outcomes.rs create mode 100644 src/input/gesture/tests.rs delete mode 100644 src/input/keyboard.rs create mode 100644 src/input/keyboard/dispatch.rs create mode 100644 src/input/keyboard/mod.rs create mode 100644 src/input/keyboard/nav.rs create mode 100644 src/input/keyboard/shortcuts.rs create mode 100644 src/input/keyboard/text_keys.rs delete mode 100644 src/input/pointer.rs create mode 100644 src/input/pointer/enter.rs create mode 100644 src/input/pointer/helpers.rs create mode 100644 src/input/pointer/mod.rs create mode 100644 src/input/pointer/motion.rs create mode 100644 src/input/pointer/press.rs create mode 100644 src/input/pointer/release.rs create mode 100644 src/input/pointer/scroll.rs create mode 100644 src/input/repeat/button.rs create mode 100644 src/input/repeat/key.rs create mode 100644 src/input/repeat/mod.rs rename src/input/{touch.rs => touch/mod.rs} (100%) create mode 100644 src/theme/accessors.rs create mode 100644 src/theme/active.rs create mode 100644 src/theme/assets.rs create mode 100644 src/theme/error.rs create mode 100644 src/theme/palette.rs create mode 100644 src/theme/prefs.rs delete mode 100644 src/theme/schema.rs create mode 100644 src/theme/schema/mod.rs create mode 100644 src/theme/schema/raw.rs create mode 100644 src/theme/schema/refs.rs create mode 100644 src/theme/schema/tests.rs create mode 100644 src/theme/search.rs create mode 100644 src/theme/typography.rs rename src/widget/{anchored_overlay.rs => anchored_overlay/mod.rs} (81%) create mode 100644 src/widget/anchored_overlay/tests.rs rename src/widget/{button.rs => button/mod.rs} (75%) create mode 100644 src/widget/button/tests.rs create mode 100644 src/widget/button/theme.rs rename src/widget/{checkbox.rs => checkbox/mod.rs} (83%) create mode 100644 src/widget/checkbox/tests.rs create mode 100644 src/widget/checkbox/theme.rs rename src/widget/{color_picker.rs => color_picker/mod.rs} (69%) create mode 100644 src/widget/color_picker/tests.rs create mode 100644 src/widget/color_picker/theme.rs rename src/widget/{combo.rs => combo/mod.rs} (85%) create mode 100644 src/widget/combo/tests.rs rename src/widget/{container.rs => container/mod.rs} (78%) create mode 100644 src/widget/container/tests.rs rename src/widget/{date_picker.rs => date_picker/mod.rs} (75%) create mode 100644 src/widget/date_picker/tests.rs create mode 100644 src/widget/date_picker/theme.rs rename src/widget/{dialog.rs => dialog/mod.rs} (79%) create mode 100644 src/widget/dialog/tests.rs create mode 100644 src/widget/element.rs rename src/widget/{external.rs => external/mod.rs} (85%) create mode 100644 src/widget/external/tests.rs create mode 100644 src/widget/factory.rs rename src/widget/{flex.rs => flex/mod.rs} (74%) create mode 100644 src/widget/flex/tests.rs create mode 100644 src/widget/handlers.rs delete mode 100644 src/widget/image.rs create mode 100644 src/widget/image/mod.rs create mode 100644 src/widget/image/tests.rs create mode 100644 src/widget/laid_out.rs rename src/widget/{list_item.rs => list_item/mod.rs} (79%) create mode 100644 src/widget/list_item/tests.rs create mode 100644 src/widget/list_item/theme.rs rename src/widget/{notebook.rs => notebook/mod.rs} (75%) create mode 100644 src/widget/notebook/tests.rs create mode 100644 src/widget/notebook/theme.rs rename src/widget/{pressable.rs => pressable/mod.rs} (70%) create mode 100644 src/widget/pressable/tests.rs rename src/widget/{progress_bar.rs => progress_bar/mod.rs} (83%) create mode 100644 src/widget/progress_bar/tests.rs create mode 100644 src/widget/progress_bar/theme.rs rename src/widget/{radio.rs => radio/mod.rs} (84%) create mode 100644 src/widget/radio/tests.rs create mode 100644 src/widget/radio/theme.rs rename src/widget/{scroll.rs => scroll/mod.rs} (76%) create mode 100644 src/widget/scroll/tests.rs rename src/widget/{separator.rs => separator/mod.rs} (85%) create mode 100644 src/widget/separator/tests.rs create mode 100644 src/widget/separator/theme.rs rename src/widget/{slider.rs => slider/mod.rs} (80%) create mode 100644 src/widget/slider/tests.rs create mode 100644 src/widget/slider/theme.rs rename src/widget/{spinner.rs => spinner/mod.rs} (75%) create mode 100644 src/widget/spinner/tests.rs create mode 100644 src/widget/spinner/theme.rs rename src/widget/{tab_bar.rs => tab_bar/mod.rs} (82%) create mode 100644 src/widget/tab_bar/tests.rs create mode 100644 src/widget/tab_bar/theme.rs rename src/widget/{text.rs => text/mod.rs} (73%) create mode 100644 src/widget/text/tests.rs delete mode 100644 src/widget/text_edit.rs create mode 100644 src/widget/text_edit/cursor.rs create mode 100644 src/widget/text_edit/draw.rs create mode 100644 src/widget/text_edit/hit_test.rs create mode 100644 src/widget/text_edit/mod.rs create mode 100644 src/widget/text_edit/tests.rs create mode 100644 src/widget/text_edit/theme.rs create mode 100644 src/widget/text_edit/wrapping.rs rename src/widget/{time_picker.rs => time_picker/mod.rs} (76%) create mode 100644 src/widget/time_picker/tests.rs create mode 100644 src/widget/time_picker/theme.rs rename src/widget/{toast.rs => toast/mod.rs} (80%) create mode 100644 src/widget/toast/tests.rs create mode 100644 src/widget/toast/theme.rs rename src/widget/{toggle.rs => toggle/mod.rs} (84%) create mode 100644 src/widget/toggle/tests.rs create mode 100644 src/widget/toggle/theme.rs rename src/widget/{tooltip.rs => tooltip/mod.rs} (70%) create mode 100644 src/widget/tooltip/tests.rs create mode 100644 src/widget/tooltip/theme.rs rename src/widget/{viewport.rs => viewport/mod.rs} (65%) create mode 100644 src/widget/viewport/tests.rs rename src/widget/{vslider.rs => vslider/mod.rs} (69%) create mode 100644 src/widget/vslider/tests.rs create mode 100644 src/widget/vslider/theme.rs rename src/widget/{window_button.rs => window_button/mod.rs} (78%) create mode 100644 src/widget/window_button/tests.rs create mode 100644 src/widget/window_button/theme.rs diff --git a/Cargo.toml b/Cargo.toml index d1cb68d..00fe54e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,7 @@ calloop = "0.14" calloop-wayland-source = "0.4" tiny-skia = "0.12" fontdue = "0.9" -image = { version = "=0.25.2", default-features = false, features = ["png", "jpeg", "webp"] } +image = { version = "=0.25.9", default-features = false, features = ["png", "jpeg", "webp"] } resvg = "0.44" rust-i18n = "3" wayland-protocols = { version = "0.32", features = ["client", "unstable", "staging"] } diff --git a/benches/lookup.rs b/benches/lookup.rs index ab5aac2..54a978c 100644 --- a/benches/lookup.rs +++ b/benches/lookup.rs @@ -41,6 +41,8 @@ fn build_widgets( n: usize ) -> Vec> paint_rect: rect, handlers, keyboard_focusable: true, + cursor: ltk::CursorShape::Default, + tooltip: None, } } ).collect() } diff --git a/src/draw/mod.rs b/src/draw/mod.rs index 6e99e8d..9dcb89b 100644 --- a/src/draw/mod.rs +++ b/src/draw/mod.rs @@ -114,7 +114,7 @@ pub( crate ) struct DrawCtx /// toolkit follows for runtime-internal popups. pub( crate ) fn draw_context_menu( canvas: &mut crate::render::Canvas, - menu: &crate::event_loop::app_data::ContextMenu, + menu: &crate::event_loop::context_menu::ContextMenu, ) { let palette = crate::theme::palette(); diff --git a/src/event_loop/app_data.rs b/src/event_loop/app_data.rs index a563098..0722d17 100644 --- a/src/event_loop/app_data.rs +++ b/src/event_loop/app_data.rs @@ -7,13 +7,8 @@ use smithay_client_toolkit:: output::OutputState, registry::RegistryState, seat::SeatState, - shell:: - { - WaylandSurface, - wlr_layer::{ KeyboardInteractivity, Layer, LayerShell, LayerSurface }, - xdg::{ XdgShell, popup::Popup, window::Window }, - }, - shm::{ Shm, slot::SlotPool }, + shell::{ wlr_layer::LayerShell, xdg::XdgShell }, + shm::Shm, }; use smithay_client_toolkit::reexports::client:: { @@ -26,577 +21,21 @@ use smithay_client_toolkit::reexports::client:: }, QueueHandle, }; -use smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel::ResizeEdge; use wayland_protocols::wp::text_input::zv3::client:: { zwp_text_input_manager_v3::ZwpTextInputManagerV3, - zwp_text_input_v3::{ self, ZwpTextInputV3 }, + zwp_text_input_v3::ZwpTextInputV3, }; use std::collections::HashMap; use std::sync::Arc; -use smithay_client_toolkit::seat::keyboard::KeyEvent; -use calloop::RegistrationToken; - use crate::app::{ App, OverlayId }; -use crate::egl_context::{ EglContext, EglSurface }; -use crate::render::Canvas; -use crate::input::GestureState; -use crate::tree::{ find_handlers, find_widget }; -use crate::widget::WidgetHandlers; +use crate::egl_context::EglContext; +use crate::types::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 -{ - 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 ) -> crate::types::CursorShape -{ - use crate::types::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, - } -} -use crate::types::{ Point, Rect, WidgetId }; -use crate::widget::LaidOutWidget; - -/// 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 -/// [`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, -} - -/// 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 ) - { - 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 - { - 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 } - } -} - -/// 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 ) -} - -/// Snapshot of the key whose repeat timer is currently armed. -/// -/// Stored on [`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 [`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, -} - -/// 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 ), -} - -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: crate::types::Rect, -} - -#[derive( Clone )] -pub struct TooltipVisible -{ - pub focus: SurfaceFocus, - pub anchor: crate::types::Rect, - pub text: String, -} - -/// 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>, - 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 -{ - pub pool: Option, - /// 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, - pub surface: SurfaceKind, - pub canvas: Option, - 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, - pub focused_id: Option, - pub hovered_idx: Option, - pub widget_rects: Vec>, - pub cursor_state: HashMap, - /// 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, - pub pending_text_values: HashMap, - pub scroll_offsets: HashMap, - pub scroll_rects: Vec<( Rect, usize )>, - pub scroll_canvases: HashMap, - /// 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>, - /// Built-in Copy / Cut / Paste context menu when shown on this - /// surface. `None` means no menu is up. - pub context_menu: Option, - /// 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 below ([`AppData::check_long_press_deadlines`]) - /// reaches into `gesture.long_press_*` directly. - pub gesture: GestureState, - /// Previous frame interaction state for damage tracking - pub prev_focused: Option, - pub prev_hovered: Option, - pub prev_pressed: Option, - /// 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, - /// Anchor rect the xdg-popup positioner was last configured with. - /// `None` for non-popup surfaces. - pub last_popup_anchor: Option, - /// 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 SurfaceState -{ - 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>, - 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; - } -} +use super::repeat::{ ButtonRepeatState, KeyRepeatState }; +use super::surface::{ SurfaceFocus, SurfaceState }; +use super::tooltip::{ TooltipPending, TooltipVisible }; pub struct AppData { @@ -696,7 +135,6 @@ pub struct AppData /// before the user's finger / cursor moves — useful on mouse where the /// pointer might sit perfectly still between press and drag. pub pending_drag_inits: Vec, - pub tooltip_pending: Option, pub tooltip_visible: Option, pub qh: QueueHandle, @@ -796,1067 +234,6 @@ impl AppData } } - /// 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 - { - 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, qh: &QueueHandle ) - { - 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(); - } - } - - pub( crate ) fn activate_text_input( &mut self, qh: &QueueHandle ) - { - 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(); - } - } - - 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 = 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 ); - } - } - - /// 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. - 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 { - 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(); - } - - /// Convert a public [`CursorShape`](crate::types::CursorShape) into - /// the wire-protocol [`Shape`] enum the compositor expects. - fn cursor_shape_to_wp( shape: crate::types::CursorShape ) - -> smithay_client_toolkit::reexports::protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::Shape - { - use crate::types::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( crate::types::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( crate::types::CursorShape::Default ); - if let Some( c ) = resize_cursor.filter( |_| allow_chrome ) - { - c - } else if let Some( o ) = app_override { - o - } else if dragging { - crate::types::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 - { - if !matches!( focus, SurfaceFocus::Main ) { return None; } - if !matches!( self.main.surface, crate::event_loop::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 ) - } - - /// 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 - } - - /// `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(); - } - - /// 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. - 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 ) ) - } - - /// 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 ); - } - - /// 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 - { - 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 - { - 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 ) - } - // Configure the main surface size, (re)allocate its rendering target, and // request a redraw. Routes to the GPU or SHM path inside `SurfaceState` // according to whether `self.egl_context` is available. @@ -1918,274 +295,4 @@ impl AppData } soonest } - - 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 - { - 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> - { - 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 = 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 = 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, - } ) - } - - /// 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| - -> Option<( Option, Option, Point, Option )> - { - 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 = crate::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| - { - 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 ); - } - } -} - -#[ 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 - { - 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 ) ); - } } diff --git a/src/event_loop/clipboard.rs b/src/event_loop/clipboard.rs new file mode 100644 index 0000000..671a85c --- /dev/null +++ b/src/event_loop/clipboard.rs @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use super::app_data::AppData; +use super::surface::SurfaceFocus; +use crate::app::App; +use crate::tree::find_handlers; + +impl AppData +{ + /// 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 ); + } +} diff --git a/src/event_loop/context_menu.rs b/src/event_loop/context_menu.rs new file mode 100644 index 0000000..72f7969 --- /dev/null +++ b/src/event_loop/context_menu.rs @@ -0,0 +1,189 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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, +} + +/// 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 ) + { + 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 + { + 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 AppData +{ + /// 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 + } +} diff --git a/src/event_loop/cursor_shape.rs b/src/event_loop/cursor_shape.rs new file mode 100644 index 0000000..5d663df --- /dev/null +++ b/src/event_loop/cursor_shape.rs @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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 +{ + 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 AppData +{ + /// 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 + { + 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 + { + 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 ) ); + } +} diff --git a/src/event_loop/drag.rs b/src/event_loop/drag.rs new file mode 100644 index 0000000..b93d5f8 --- /dev/null +++ b/src/event_loop/drag.rs @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use super::app_data::AppData; +use super::surface::{ SurfaceFocus, SurfaceState }; +use crate::app::App; +use crate::types::Point; + +impl AppData +{ + /// 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| + -> Option<( Option, Option, Point, Option )> + { + 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| + { + 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 ); + } + } +} diff --git a/src/event_loop/error.rs b/src/event_loop/error.rs new file mode 100644 index 0000000..0059963 --- /dev/null +++ b/src/event_loop/error.rs @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +/// 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 {} diff --git a/src/event_loop/focus.rs b/src/event_loop/focus.rs new file mode 100644 index 0000000..4763d2b --- /dev/null +++ b/src/event_loop/focus.rs @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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 AppData +{ + /// 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 + { + 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, qh: &QueueHandle ) + { + 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(); + } + } +} diff --git a/src/event_loop/handlers.rs b/src/event_loop/handlers.rs index 117c575..c181f61 100644 --- a/src/event_loop/handlers.rs +++ b/src/event_loop/handlers.rs @@ -17,6 +17,7 @@ use smithay_client_toolkit:: { WaylandSurface, wlr_layer::{ LayerShellHandler, LayerSurface, LayerSurfaceConfigure }, + xdg::XdgSurface, xdg::popup::{ Popup, PopupConfigure, PopupHandler }, xdg::window::{ Window, WindowConfigure, WindowHandler }, }, @@ -218,6 +219,7 @@ impl WindowHandler for AppData let ( hint_w, hint_h ) = self.app.window_size_hint().unwrap_or( ( 800, 600 ) ); let w = configure.new_size.0.map( |v| v.get() ).unwrap_or( hint_w ); let h = configure.new_size.1.map( |v| v.get() ).unwrap_or( hint_h ); + window.xdg_surface().set_window_geometry( 0, 0, w as i32, h as i32 ); self.on_configure( w, h ); self.app.on_resize( w, h ); } diff --git a/src/event_loop/invalidation.rs b/src/event_loop/invalidation.rs new file mode 100644 index 0000000..b3ec55d --- /dev/null +++ b/src/event_loop/invalidation.rs @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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( data: &mut AppData, 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, + next: &[ crate::app::OverlayId ], +) -> ( Vec, Vec ) +{ + let current_set: HashSet = current.into_iter().collect(); + let next_set: HashSet = 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 ) +} diff --git a/src/event_loop/mod.rs b/src/event_loop/mod.rs index fcd5d1b..dce1520 100644 --- a/src/event_loop/mod.rs +++ b/src/event_loop/mod.rs @@ -2,878 +2,24 @@ // Copyright (C) 2026 Liberux Labs, S. L. pub( crate ) mod app_data; +pub( crate ) mod clipboard; +pub( crate ) mod context_menu; +pub( crate ) mod cursor_shape; +pub( crate ) mod drag; +pub( crate ) mod focus; mod handlers; +pub( crate ) mod repeat; +pub( crate ) mod surface; +pub( crate ) mod text_editing; +pub( crate ) mod tooltip; -pub( crate ) use app_data::{ AppData, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState }; +pub( crate ) mod error; +pub( crate ) mod run; +pub( crate ) mod invalidation; +pub( crate ) mod overlays_reconcile; -use smithay_client_toolkit:: -{ - compositor::{ CompositorState, Surface }, - output::OutputState, - registry::RegistryState, - seat::SeatState, - shell:: - { - WaylandSurface, - wlr_layer::LayerShell, - xdg:: - { - XdgPositioner, XdgShell, XdgSurface, - popup::Popup, - window::WindowDecorations, - }, - }, - shm::Shm, -}; -use smithay_client_toolkit::reexports::client::Connection; -use smithay_client_toolkit::reexports::calloop::EventLoop; -use smithay_client_toolkit::reexports::calloop_wayland_source::WaylandSource; -use calloop::timer::{ Timer, TimeoutAction }; -use wayland_protocols::wp::text_input::zv3::client::zwp_text_input_manager_v3::ZwpTextInputManagerV3; -use wayland_protocols::xdg::shell::client::xdg_positioner:: -{ - Anchor as PositionerAnchor, - ConstraintAdjustment, - Gravity, -}; - -use crate::app::{ App, InvalidationScope, SurfaceTarget }; -use crate::draw::draw_frame; -use crate::types::{ Point, Rect }; -use std::collections::HashSet; - -/// Reasons [`crate::try_run`] (and therefore [`crate::run`]) can fail -/// to bring up the event loop. -/// -/// Every variant maps to a fatal failure during init: the Wayland -/// connection, the calloop event loop, or one of the protocol bindings -/// the runtime cannot operate without (`wl_compositor`, `wl_shm`, -/// `xdg_wm_base`). Once init succeeds, the compositor disconnecting -/// (`BrokenPipe` / `ConnectionReset`) is treated as a clean exit; any -/// other runtime error during the dispatch loop still panics, since the -/// surface is already on screen and the state machine cannot be unwound -/// from this entry point. -/// -/// Embedders that want a software-rendered fallback or that need to -/// degrade gracefully should call [`crate::try_run`] and match on the -/// variants instead of letting [`crate::run`] panic. -#[ derive( Debug ) ] -pub enum RunError -{ - /// `WAYLAND_DISPLAY` is unset, the socket is missing, or the - /// compositor refused the handshake. Includes the detailed reason - /// from the underlying `wayland-client` error. - NoWaylandConnection( String ), - /// The Wayland registry could not be enumerated. Almost always a - /// compositor / driver bug — the registry is the first thing every - /// Wayland client touches. - RegistryInit( String ), - /// `calloop`'s `EventLoop::try_new` or its Wayland source insertion - /// failed (typically an `io` error talking to the kernel). - EventLoop( String ), - /// A required Wayland protocol is missing from the compositor. - /// `name` is the wire-format protocol name; `detail` is the - /// underlying bind error. - MissingProtocol - { - name: &'static str, - detail: String, - }, -} - -impl std::fmt::Display for RunError -{ - fn fmt( &self, f: &mut std::fmt::Formatter<'_> ) -> std::fmt::Result - { - match self - { - Self::NoWaylandConnection( d ) => - write!( f, "Wayland connection failed: {d}" ), - Self::RegistryInit( d ) => - write!( f, "Wayland registry init failed: {d}" ), - Self::EventLoop( d ) => - write!( f, "event-loop setup failed: {d}" ), - Self::MissingProtocol { name, detail } => - write!( f, "Wayland protocol `{name}` unavailable: {detail}" ), - } - } -} - -impl std::error::Error for RunError {} - -/// Run the application, panicking on init failure. Thin wrapper over -/// [`try_run`] kept for backwards-compatibility — embedders that need -/// to recover from a missing compositor or a stripped-down driver -/// should call [`try_run`] instead. -pub( crate ) fn run( 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( 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> = 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::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>| - -> Result - { - 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 = 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::::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::(); - event_loop.handle() - .insert_source( - channel, - |event, _, data: &mut AppData| - { - 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| - { - 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( data: &mut AppData, 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, - next: &[ crate::app::OverlayId ], -) -> ( Vec, Vec ) -{ - let current_set: HashSet = current.into_iter().collect(); - let next_set: HashSet = 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( data: &mut AppData ) -{ - 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 = 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::::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::::new( surface, 0.0, String::new() ); - ss.last_requested_size = spec.size; - ss.layer_anchor = Some( spec.anchor ); - overlays_m.insert( spec.id, ss ); - } -} +pub( crate ) use app_data::AppData; +pub( crate ) use surface::{ LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState }; +pub use error::RunError; +pub( crate ) use run::{ run, try_run }; +pub use invalidation::diff_overlay_ids; diff --git a/src/event_loop/overlays_reconcile.rs b/src/event_loop/overlays_reconcile.rs new file mode 100644 index 0000000..670c4c5 --- /dev/null +++ b/src/event_loop/overlays_reconcile.rs @@ -0,0 +1,289 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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( data: &mut AppData ) +{ + 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 = 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::::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::::new( surface, 0.0, String::new() ); + ss.last_requested_size = spec.size; + ss.layer_anchor = Some( spec.anchor ); + overlays_m.insert( spec.id, ss ); + } +} diff --git a/src/event_loop/repeat.rs b/src/event_loop/repeat.rs new file mode 100644 index 0000000..d69e60c --- /dev/null +++ b/src/event_loop/repeat.rs @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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, +} diff --git a/src/event_loop/run.rs b/src/event_loop/run.rs new file mode 100644 index 0000000..bc0a113 --- /dev/null +++ b/src/event_loop/run.rs @@ -0,0 +1,482 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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( 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( 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> = 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::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>| + -> Result + { + 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 = 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::::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::(); + event_loop.handle() + .insert_source( + channel, + |event, _, data: &mut AppData| + { + 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| + { + 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( () ) +} diff --git a/src/event_loop/surface.rs b/src/event_loop/surface.rs new file mode 100644 index 0000000..42ae3d6 --- /dev/null +++ b/src/event_loop/surface.rs @@ -0,0 +1,380 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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>, + 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 +{ + pub pool: Option, + /// 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, + pub surface: SurfaceKind, + pub canvas: Option, + 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, + pub focused_id: Option, + pub hovered_idx: Option, + pub widget_rects: Vec>, + pub cursor_state: HashMap, + /// 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, + pub pending_text_values: HashMap, + pub scroll_offsets: HashMap, + pub scroll_rects: Vec<( Rect, usize )>, + pub scroll_canvases: HashMap, + /// 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>, + /// Built-in Copy / Cut / Paste context menu when shown on this + /// surface. `None` means no menu is up. + pub context_menu: Option, + /// 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, + /// Previous frame interaction state for damage tracking + pub prev_focused: Option, + pub prev_hovered: Option, + pub prev_pressed: Option, + /// 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, + /// Anchor rect the xdg-popup positioner was last configured with. + /// `None` for non-popup surfaces. + pub last_popup_anchor: Option, + /// 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 SurfaceState +{ + 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>, + 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; + } +} diff --git a/src/event_loop/text_editing/cursor.rs b/src/event_loop/text_editing/cursor.rs new file mode 100644 index 0000000..e3d2d19 --- /dev/null +++ b/src/event_loop/text_editing/cursor.rs @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::app::App; +use crate::event_loop::app_data::AppData; +use crate::event_loop::surface::SurfaceFocus; + +impl AppData +{ + /// 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(); + } +} diff --git a/src/event_loop/text_editing/ime.rs b/src/event_loop/text_editing/ime.rs new file mode 100644 index 0000000..a17ecc2 --- /dev/null +++ b/src/event_loop/text_editing/ime.rs @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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 AppData +{ + pub( crate ) fn activate_text_input( &mut self, qh: &QueueHandle ) + { + 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 ) ) + } +} diff --git a/src/event_loop/text_editing/insert_delete.rs b/src/event_loop/text_editing/insert_delete.rs new file mode 100644 index 0000000..f8a5dde --- /dev/null +++ b/src/event_loop/text_editing/insert_delete.rs @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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 AppData +{ + 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 = 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 ); + } + } +} diff --git a/src/event_loop/text_editing/mod.rs b/src/event_loop/text_editing/mod.rs new file mode 100644 index 0000000..ae315a0 --- /dev/null +++ b/src/event_loop/text_editing/mod.rs @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! 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; diff --git a/src/event_loop/text_editing/selection.rs b/src/event_loop/text_editing/selection.rs new file mode 100644 index 0000000..bbb3eaf --- /dev/null +++ b/src/event_loop/text_editing/selection.rs @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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 AppData +{ + /// `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 + { + 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 + { + 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 ) + } +} diff --git a/src/event_loop/tooltip.rs b/src/event_loop/tooltip.rs new file mode 100644 index 0000000..b33a140 --- /dev/null +++ b/src/event_loop/tooltip.rs @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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 AppData +{ + 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 + { + 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> + { + 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 = 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 = 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, + } ) + } +} diff --git a/src/input/dispatch/coords.rs b/src/input/dispatch/coords.rs new file mode 100644 index 0000000..dd84d2b --- /dev/null +++ b/src/input/dispatch/coords.rs @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::app::App; +use crate::event_loop::{ AppData, SurfaceFocus }; + +impl AppData +{ + /// 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 ) + } +} diff --git a/src/input/dispatch/mod.rs b/src/input/dispatch/mod.rs new file mode 100644 index 0000000..a260a26 --- /dev/null +++ b/src/input/dispatch/mod.rs @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! 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; diff --git a/src/input/dispatch.rs b/src/input/dispatch/outcomes.rs similarity index 63% rename from src/input/dispatch.rs rename to src/input/dispatch/outcomes.rs index cc740be..4722892 100644 --- a/src/input/dispatch.rs +++ b/src/input/dispatch/outcomes.rs @@ -1,93 +1,16 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -//! Dispatch helpers shared by the pointer and touch handlers. -//! -//! The gesture state machine returns abstract outcomes; this file is -//! where those outcomes turn into concrete side-effects on `AppData`: -//! pending-message pushes, app-level callbacks (`on_swipe_*`, -//! `on_drag_move`, `on_drop`, `on_tap`, `overlay_dismiss_msg`), and -//! the redraw / cache invalidation flags that keep the loop moving -//! after a non-Message-producing gesture. -//! -//! Splitting these into a dedicated file means pointer.rs and touch.rs -//! only carry the Wayland-event translation. A future input source -//! (stylus, gamepad) can reuse the same dispatcher by feeding the -//! state machine the same way. - use crate::app::App; use crate::event_loop::{ AppData, SurfaceFocus }; -use crate::types::Point; -use crate::widget::WidgetHandlers; -use super::gesture::{ MoveOutcome, ReleaseEvent }; +use crate::input::gesture::{ MoveOutcome, ReleaseEvent }; impl AppData { - /// 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 AppData -{ - /// Top-left of `focus` in main-surface (global) coordinates. Used to - /// translate per-surface pointer coords into a single coordinate - /// space the app can reason about during drag-and-drop across - /// surfaces. - pub( crate ) fn surface_offset_for( &self, focus: SurfaceFocus ) -> ( f32, f32 ) - { - let SurfaceFocus::Overlay( id ) = focus else { return ( 0.0, 0.0 ); }; - let Some( ss ) = self.overlays.get( &id ) else { return ( 0.0, 0.0 ); }; - let Some( anchor ) = ss.layer_anchor else { return ( 0.0, 0.0 ); }; - let ( sw, sh ) = ( self.main.width as f32, self.main.height as f32 ); - let ( w, h ) = ( ss.width as f32, ss.height as f32 ); - let x = if anchor.left { 0.0 } - else if anchor.right { sw - w } - else { ( sw - w ) / 2.0 }; - let y = if anchor.top { 0.0 } - else if anchor.bottom { sh - h } - else { ( sh - h ) / 2.0 }; - ( x, y ) - } - /// Apply the outcome of a motion event. Non-blocking side-effects /// only: push messages, call app callbacks, set redraw flags. - pub( super ) fn apply_move_outcome + pub( crate ) fn apply_move_outcome ( &mut self, focus: SurfaceFocus, @@ -142,7 +65,7 @@ impl AppData /// release can emit more than one event (e.g. horizontal /// fall-through followed by a vertical commit or fall-through), so /// we walk the list and run each event's side-effects in order. - pub( super ) fn apply_release_events + pub( crate ) fn apply_release_events ( &mut self, focus: SurfaceFocus, diff --git a/src/input/dispatch/password_toggle.rs b/src/input/dispatch/password_toggle.rs new file mode 100644 index 0000000..937ec4a --- /dev/null +++ b/src/input/dispatch/password_toggle.rs @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::app::App; +use crate::event_loop::{ AppData, SurfaceFocus }; +use crate::types::Point; +use crate::widget::WidgetHandlers; + +impl AppData +{ + /// 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 + } + } +} diff --git a/src/input/gesture.rs b/src/input/gesture.rs deleted file mode 100644 index 6fe8e79..0000000 --- a/src/input/gesture.rs +++ /dev/null @@ -1,1071 +0,0 @@ -// SPDX-License-Identifier: LGPL-2.1-only -// Copyright (C) 2026 Liberux Labs, S. L. - -//! 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`] — 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; - -// ─── 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 -{ - /// Position where the current press / touch-down landed. `None` - /// between gestures. - pub start: Option, - /// Widget index under the press, if any. Set on press; taken on - /// release. - pub pressed_idx: Option, - /// Index of the Scroll viewport that owns the current gesture, if - /// the press landed inside one. - pub scrolling_widget: Option, - /// 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, - /// 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, - /// 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, - /// 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, - /// 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, - /// `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, - /// `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 Default for GestureState -{ - fn default() -> Self { Self::new() } -} - -impl GestureState -{ - /// 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], - scroll_rects: &[( Rect, usize )], - ) -> PressOutcome - { - 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], - scroll_offsets: &mut HashMap, - swipe: &SwipeConfig, - global_drag: bool, - ) -> MoveOutcome - { - // 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], - swipe: &SwipeConfig, - global_drag: bool, - ) -> Vec> - { - 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(); } -} - -// ─── Outcomes ──────────────────────────────────────────────────────────────── - -/// 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 -{ - pub hit_idx: Option, - pub initial_slider_msg: Option, -} - -/// Result of [`GestureState::on_move`]. Handler turns this into app -/// callbacks + redraw flags. -pub enum MoveOutcome -{ - /// 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 }, - /// 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, down: Option, horizontal: Option }, -} - -/// 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 -{ - /// 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, -} - -#[ cfg( test ) ] -mod tests -{ - 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, on_long_press: Option ) - -> LaidOutWidget - { - button_full( idx, r, on_press, on_long_press, None ) - } - - fn button_full( - idx: usize, r: Rect, - on_press: Option, on_long_press: Option, on_drag_start: Option, - ) -> LaidOutWidget - { - 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::::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::::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::::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::::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::::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::::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::::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> = vec![]; - let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 200.0, 200.0 ), 42 ) ]; - let mut g = GestureState::::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::::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::::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::::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::::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> = vec![]; - let mut g = GestureState::::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> = vec![]; - let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 7 ) ]; - let mut g = GestureState::::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> = vec![]; - let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 9 ) ]; - let mut g = GestureState::::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> = vec![]; - let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 1 ) ]; - let mut g = GestureState::::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> = vec![]; - let mut g = GestureState::::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> = vec![]; - let mut g = GestureState::::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> = vec![]; - let mut g = GestureState::::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> = vec![]; - let mut g = GestureState::::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::::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::::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> = vec![]; - let mut g = GestureState::::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::::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> = vec![]; - let mut g = GestureState::::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> = vec![]; - let mut g = GestureState::::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> = vec![]; - let mut g = GestureState::::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> = vec![]; - let mut g = GestureState::::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> = vec![]; - let mut g = GestureState::::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::::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() ); - } -} diff --git a/src/input/gesture/mod.rs b/src/input/gesture/mod.rs new file mode 100644 index 0000000..56a5b65 --- /dev/null +++ b/src/input/gesture/mod.rs @@ -0,0 +1,524 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! 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`] — 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 +{ + /// Position where the current press / touch-down landed. `None` + /// between gestures. + pub start: Option, + /// Widget index under the press, if any. Set on press; taken on + /// release. + pub pressed_idx: Option, + /// Index of the Scroll viewport that owns the current gesture, if + /// the press landed inside one. + pub scrolling_widget: Option, + /// 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, + /// 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, + /// 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, + /// 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, + /// 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, + /// `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, + /// `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 Default for GestureState +{ + fn default() -> Self { Self::new() } +} + +impl GestureState +{ + /// 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], + scroll_rects: &[( Rect, usize )], + ) -> PressOutcome + { + 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], + scroll_offsets: &mut HashMap, + swipe: &SwipeConfig, + global_drag: bool, + ) -> MoveOutcome + { + // 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], + swipe: &SwipeConfig, + global_drag: bool, + ) -> Vec> + { + 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(); } +} diff --git a/src/input/gesture/outcomes.rs b/src/input/gesture/outcomes.rs new file mode 100644 index 0000000..652872e --- /dev/null +++ b/src/input/gesture/outcomes.rs @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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 +{ + pub hit_idx: Option, + pub initial_slider_msg: Option, +} + +/// Result of [`GestureState::on_move`]. Handler turns this into app +/// callbacks + redraw flags. +pub enum MoveOutcome +{ + /// 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 }, + /// 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, down: Option, horizontal: Option }, +} + +/// 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 +{ + /// 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, +} diff --git a/src/input/gesture/tests.rs b/src/input/gesture/tests.rs new file mode 100644 index 0000000..866f9f6 --- /dev/null +++ b/src/input/gesture/tests.rs @@ -0,0 +1,470 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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, on_long_press: Option ) + -> LaidOutWidget +{ + button_full( idx, r, on_press, on_long_press, None ) +} + +fn button_full( + idx: usize, r: Rect, + on_press: Option, on_long_press: Option, on_drag_start: Option, +) -> LaidOutWidget +{ + 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::::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::::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::::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::::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::::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::::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::::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> = vec![]; + let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 200.0, 200.0 ), 42 ) ]; + let mut g = GestureState::::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::::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::::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::::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::::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> = vec![]; + let mut g = GestureState::::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> = vec![]; + let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 7 ) ]; + let mut g = GestureState::::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> = vec![]; + let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 9 ) ]; + let mut g = GestureState::::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> = vec![]; + let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 1 ) ]; + let mut g = GestureState::::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> = vec![]; + let mut g = GestureState::::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> = vec![]; + let mut g = GestureState::::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> = vec![]; + let mut g = GestureState::::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> = vec![]; + let mut g = GestureState::::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::::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::::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> = vec![]; + let mut g = GestureState::::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::::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> = vec![]; + let mut g = GestureState::::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> = vec![]; + let mut g = GestureState::::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> = vec![]; + let mut g = GestureState::::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> = vec![]; + let mut g = GestureState::::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> = vec![]; + let mut g = GestureState::::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::::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() ); +} diff --git a/src/input/keyboard.rs b/src/input/keyboard.rs deleted file mode 100644 index 3c0ef09..0000000 --- a/src/input/keyboard.rs +++ /dev/null @@ -1,645 +0,0 @@ -// SPDX-License-Identifier: LGPL-2.1-only -// Copyright (C) 2026 Liberux Labs, S. L. - -//! 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 KeyboardHandler for AppData -{ - fn enter( - &mut self, - _conn: &Connection, - _qh: &QueueHandle, - _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, - _keyboard: &WlKeyboard, - _surface: &WlSurface, - _serial: u32, - ) - { - self.stop_key_repeat(); - self.keyboard_focus = SurfaceFocus::Main; - } - - fn press_key( - &mut self, - _conn: &Connection, - _qh: &QueueHandle, - _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, - _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, - _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, - _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, - _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 AppData -{ - /// 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 - { - 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| - { - 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| - { - 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 - } -} diff --git a/src/input/keyboard/dispatch.rs b/src/input/keyboard/dispatch.rs new file mode 100644 index 0000000..997c74e --- /dev/null +++ b/src/input/keyboard/dispatch.rs @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use smithay_client_toolkit::seat::keyboard::{ KeyEvent, Keysym }; + +use crate::app::App; +use crate::event_loop::{ AppData, SurfaceFocus }; + +impl AppData +{ + /// 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(); + } +} diff --git a/src/input/keyboard/mod.rs b/src/input/keyboard/mod.rs new file mode 100644 index 0000000..bb95512 --- /dev/null +++ b/src/input/keyboard/mod.rs @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! 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 KeyboardHandler for AppData +{ + fn enter( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _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, + _keyboard: &WlKeyboard, + _surface: &WlSurface, + _serial: u32, + ) + { + self.stop_key_repeat(); + self.keyboard_focus = SurfaceFocus::Main; + } + + fn press_key( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _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, + _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, + _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, + _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, + _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 ); + } +} diff --git a/src/input/keyboard/nav.rs b/src/input/keyboard/nav.rs new file mode 100644 index 0000000..9b4df53 --- /dev/null +++ b/src/input/keyboard/nav.rs @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::app::App; +use crate::event_loop::{ AppData, SurfaceFocus }; + +impl AppData +{ + /// 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 + } +} diff --git a/src/input/keyboard/shortcuts.rs b/src/input/keyboard/shortcuts.rs new file mode 100644 index 0000000..ae84ee4 --- /dev/null +++ b/src/input/keyboard/shortcuts.rs @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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 AppData +{ + 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 ) + { + 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 ) + { + // 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 ); + } + } + } +} diff --git a/src/input/keyboard/text_keys.rs b/src/input/keyboard/text_keys.rs new file mode 100644 index 0000000..b7254a2 --- /dev/null +++ b/src/input/keyboard/text_keys.rs @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use smithay_client_toolkit::seat::keyboard::{ KeyEvent, Keysym }; + +use crate::app::App; +use crate::event_loop::{ AppData, SurfaceFocus }; +use crate::tree::find_handlers; + +impl AppData +{ + 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 ); + } + } +} diff --git a/src/input/mod.rs b/src/input/mod.rs index dfbd89b..92338dc 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -27,6 +27,7 @@ pub( crate ) mod keyboard; pub( crate ) mod pointer; pub( crate ) mod touch; pub( crate ) mod dispatch; +pub( crate ) mod repeat; // `GestureState` is the only type consumers outside `input/` need — // `AppData` stores one per `SurfaceState` and the long-press deadline diff --git a/src/input/pointer.rs b/src/input/pointer.rs deleted file mode 100644 index 8af6b64..0000000 --- a/src/input/pointer.rs +++ /dev/null @@ -1,504 +0,0 @@ -// SPDX-License-Identifier: LGPL-2.1-only -// Copyright (C) 2026 Liberux Labs, S. L. - -//! 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 PointerHandler for AppData -{ - fn pointer_frame( - &mut self, - _conn: &Connection, - qh: &QueueHandle, - _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( - widget_rects: &[crate::widget::LaidOutWidget], - idx: Option, -) -> 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 AppData -{ - /// 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(), - } - } -} diff --git a/src/input/pointer/enter.rs b/src/input/pointer/enter.rs new file mode 100644 index 0000000..bddf24f --- /dev/null +++ b/src/input/pointer/enter.rs @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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 AppData +{ + pub( super ) fn on_pointer_enter( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _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 ); + } + } +} diff --git a/src/input/pointer/helpers.rs b/src/input/pointer/helpers.rs new file mode 100644 index 0000000..7c919f1 --- /dev/null +++ b/src/input/pointer/helpers.rs @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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 AppData +{ + /// 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( + widget_rects: &[crate::widget::LaidOutWidget], + idx: Option, +) -> bool +{ + idx.and_then( |i| find_handlers( widget_rects, i ) ) + .map( |h| !h.is_slider() ) + .unwrap_or( false ) +} diff --git a/src/input/pointer/mod.rs b/src/input/pointer/mod.rs new file mode 100644 index 0000000..913b0dd --- /dev/null +++ b/src/input/pointer/mod.rs @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! 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 PointerHandler for AppData +{ + fn pointer_frame( + &mut self, + conn: &Connection, + qh: &QueueHandle, + 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 ), + _ => {} + } + } + } +} diff --git a/src/input/pointer/motion.rs b/src/input/pointer/motion.rs new file mode 100644 index 0000000..d010d44 --- /dev/null +++ b/src/input/pointer/motion.rs @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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 AppData +{ + pub( super ) fn on_pointer_motion( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _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 ); + } +} diff --git a/src/input/pointer/press.rs b/src/input/pointer/press.rs new file mode 100644 index 0000000..df6f9c7 --- /dev/null +++ b/src/input/pointer/press.rs @@ -0,0 +1,257 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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 AppData +{ + pub( super ) fn on_pointer_right_click( + &mut self, + _conn: &Connection, + qh: &QueueHandle, + _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, + _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 ); + } +} diff --git a/src/input/pointer/release.rs b/src/input/pointer/release.rs new file mode 100644 index 0000000..8f112bf --- /dev/null +++ b/src/input/pointer/release.rs @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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 AppData +{ + pub( super ) fn on_pointer_release( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _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 ); + } +} diff --git a/src/input/pointer/scroll.rs b/src/input/pointer/scroll.rs new file mode 100644 index 0000000..e00877a --- /dev/null +++ b/src/input/pointer/scroll.rs @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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 AppData +{ + pub( super ) fn on_pointer_axis( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _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 ); + } + } +} diff --git a/src/input/repeat/button.rs b/src/input/repeat/button.rs new file mode 100644 index 0000000..6cf78a0 --- /dev/null +++ b/src/input/repeat/button.rs @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use calloop::timer::{ Timer, TimeoutAction }; + +use crate::app::App; +use crate::event_loop::{ AppData, SurfaceFocus }; +use crate::event_loop::repeat::ButtonRepeatState; + +impl AppData +{ + /// 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| + { + 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 ); + } + } +} diff --git a/src/input/repeat/key.rs b/src/input/repeat/key.rs new file mode 100644 index 0000000..f4e389f --- /dev/null +++ b/src/input/repeat/key.rs @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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 AppData +{ + /// 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 + { + 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| + { + 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 ); + } + } +} diff --git a/src/input/repeat/mod.rs b/src/input/repeat/mod.rs new file mode 100644 index 0000000..db06b04 --- /dev/null +++ b/src/input/repeat/mod.rs @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +pub( crate ) mod key; +pub( crate ) mod button; diff --git a/src/input/touch.rs b/src/input/touch/mod.rs similarity index 100% rename from src/input/touch.rs rename to src/input/touch/mod.rs diff --git a/src/theme/accessors.rs b/src/theme/accessors.rs new file mode 100644 index 0000000..3493ded --- /dev/null +++ b/src/theme/accessors.rs @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! 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 +{ + 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 +{ + 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> +{ + 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 +{ + 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` 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 )> +{ + 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 +{ + 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 ) +} diff --git a/src/theme/active.rs b/src/theme/active.rs new file mode 100644 index 0000000..e27b08e --- /dev/null +++ b/src/theme/active.rs @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! 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, + 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> = 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 +{ + 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 +} diff --git a/src/theme/assets.rs b/src/theme/assets.rs new file mode 100644 index 0000000..f962f65 --- /dev/null +++ b/src/theme/assets.rs @@ -0,0 +1,484 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! 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 +{ + 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 +{ + 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 +{ + 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 +{ + 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 +{ + 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 +{ + 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 +{ + 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 +{ + 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 +{ + 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.` where +/// `` 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 +{ + 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 +{ + branding_raster( name, sw, sh ) + .or_else( || branding_asset( name, "svg" ) ) +} + +/// Walk `dir` for files named `WIDTHxHEIGHT.` (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 +{ + 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::(), h_str.parse::() ) 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` 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 +{ + 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>, 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>, 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/.svg` variant first and falls back to +/// `catalogue/line/.svg`; returns `None` when neither file +/// exists or the active document has no on-disk root. +pub fn icon_path( name: &str ) -> Option +{ + 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>, 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##""##; + 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##""##; + 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"", 0 ).is_none() ); // size 0 + } +} diff --git a/src/theme/error.rs b/src/theme/error.rs new file mode 100644 index 0000000..4faa40c --- /dev/null +++ b/src/theme/error.rs @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! 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 {} diff --git a/src/theme/mod.rs b/src/theme/mod.rs index abe6e38..45f4fda 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -41,23 +41,7 @@ //! ltk-theme`) or at the `LTK_THEMES_DIR` environment variable for //! development installations. -use std::collections::HashMap; -use std::path::{ Path, PathBuf }; -use std::sync::{ Arc, Mutex, OnceLock, RwLock }; - -fn system_fontdb() -> Arc -{ - static DB: OnceLock> = OnceLock::new(); - DB.get_or_init( || { - let mut db = resvg::usvg::fontdb::Database::new(); - db.load_system_fonts(); - Arc::new( db ) - } ).clone() -} - -use serde::{ Deserialize, Serialize }; - -use crate::types::Color; +use std::sync::{ Arc, OnceLock }; // ─── Submodules ────────────────────────────────────────────────────────────── // @@ -82,6 +66,15 @@ pub mod document; pub ( crate ) mod schema; pub ( crate ) mod fallback; +pub mod accessors; +pub mod active; +pub mod assets; +pub mod error; +pub mod palette; +pub mod prefs; +pub mod search; +pub mod typography; + pub use paint::{ ColorStop, GradientSpace, LinearGradient, Paint, RadialGradient }; pub use shadow::{ BlendMode, InsetShadow, Shadow, ShadowsRef }; pub use surface::Surface; @@ -92,1026 +85,23 @@ pub use font_registry::{ FontKey, FontLoadError, FontRegistry }; pub use slots::{ Metadata, Slot, SlotStore }; pub use document::{ Mode, ThemeDocument }; -// ─── Palette ───────────────────────────────────────────────────────────────── +pub use accessors::{ color, color_or, paint, palette, resolve_surface, shadows, surface, text_style, window_controls }; +pub use active::{ active_document, active_mode, active_theme_id, is_fallback_active, set_active_document, set_active_mode }; +pub use assets::{ app_default_icon, app_icon, branding_asset, branding_image, branding_raster, decode_svg_bytes, icon_path, icon_rgba, launcher_icon, lockscreen, logo, logo_horizontal, logo_square, tint_symbolic, wallpaper }; +pub use error::ThemeError; +pub use palette::Palette; +pub use prefs::{ LauncherSpec, ThemeMode, ThemePreference, WallpaperFit, WallpaperSpec, WindowControlsSpec }; +pub use search::{ build_font_registry, search_paths }; -/// Semantic colour tokens shared by every widget. -#[ derive( Debug, Clone, Copy ) ] -pub struct Palette +/// Shared `usvg` font database, lazily populated from the host's system +/// fonts. resvg's default `Options::fontdb` is empty, so SVGs containing +/// `` would otherwise rasterise with no glyphs at all. +pub ( crate ) fn system_fontdb() -> Arc { - /// 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, -} - -// ─── 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 [`Palette::bg`]. - pub path: Option, - 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, -} - -// ─── 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 [`palette`] and - /// [`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 ) ), - } - } -} - -// ─── 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 -{ - 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 -} - -// ─── 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 ), - } - } -} - -// ─── Active state ──────────────────────────────────────────────────────────── - -#[ derive( Clone ) ] -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`]. - document: Arc, - 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. - is_fallback: bool, -} - -// `RwLock::new` and `Option::None` are both const, so this needs no lazy init. -static ACTIVE: RwLock> = 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. -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. - if let Ok( mut cache ) = SVG_CACHE.lock() - { - if let Some( ref mut map ) = *cache { map.clear(); } - } -} - -/// 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 ([`color`], [`surface()`], …) are not -/// expressive enough — e.g. iterating `mode.slots.entries`. -pub fn active_document() -> Arc -{ - 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 -} - -// ─── Slot-typed shorthands ─────────────────────────────────────────────────── -// -// 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. - -/// 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 -{ - 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 -{ - 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> -{ - 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 -{ - 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` 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 )> -{ - 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 -{ - 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 ) -} - -/// 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 -{ - 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 -{ - 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 } ) -} - -/// 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 -{ - 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 -{ - 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 -{ - branding_asset( "launcher", "svg" ) -} - -/// 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 -{ - 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 -{ - 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 -{ - branding_asset( "logo/horizontal", "svg" ) -} - -/// 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 -{ - 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.` where -/// `` 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 -{ - 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 -{ - branding_raster( name, sw, sh ) - .or_else( || branding_asset( name, "svg" ) ) -} - -/// Walk `dir` for files named `WIDTHxHEIGHT.` (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 -{ - 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::(), h_str.parse::() ) 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 ) -} - -/// 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 -{ - let doc = ensure_active().document; - if doc.fonts.is_empty() { return None; } - Some( FontRegistry::from_families_lenient( &doc.fonts ) ) -} - -// ─── Typography ────────────────────────────────────────────────────────────── - -/// 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 mod typography -{ - 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; -} - -// ─── 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` 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 -{ - 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>, u32, u32 )>>> - = Mutex::new( None ); - -/// 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>, 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/.svg` variant first and falls back to -/// `catalogue/line/.svg`; returns `None` when neither file -/// exists or the active document has no on-disk root. -pub fn icon_path( name: &str ) -> Option -{ - 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>, 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 ) -} - -// ─── 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 ( super ) 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, - } -} - -// ─── Errors ────────────────────────────────────────────────────────────────── - -#[ derive( Debug ) ] -pub enum ThemeError -{ - Io( PathBuf, std::io::Error ), - ParseJson( PathBuf, serde_json::Error ), - NotFound( String ), - InvalidColor( String ), - UnknownColorRef( String ), -} - -impl std::fmt::Display for ThemeError -{ - fn fmt( &self, f: &mut std::fmt::Formatter<'_> ) -> std::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 {} - -// ─── Tests ─────────────────────────────────────────────────────────────────── - -#[ 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##""##; - 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##""##; - 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"", 0 ).is_none() ); // size 0 - } - - #[ 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 ); - } - - #[ 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 ); - } + static DB: OnceLock> = OnceLock::new(); + DB.get_or_init( || { + let mut db = resvg::usvg::fontdb::Database::new(); + db.load_system_fonts(); + Arc::new( db ) + } ).clone() } diff --git a/src/theme/palette.rs b/src/theme/palette.rs new file mode 100644 index 0000000..36f3c67 --- /dev/null +++ b/src/theme/palette.rs @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! 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 ); + } +} diff --git a/src/theme/prefs.rs b/src/theme/prefs.rs new file mode 100644 index 0000000..1e4bd3b --- /dev/null +++ b/src/theme/prefs.rs @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! 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, + 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 ); + } +} diff --git a/src/theme/schema.rs b/src/theme/schema.rs deleted file mode 100644 index a7de459..0000000 --- a/src/theme/schema.rs +++ /dev/null @@ -1,1452 +0,0 @@ -// SPDX-License-Identifier: LGPL-2.1-only -// Copyright (C) 2026 Liberux Labs, S. L. - -//! 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 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, Serialize, Serializer }; -use serde::de::Error as DeError; - -use crate::types::Color; - -use super::document::{ Mode, ThemeDocument }; -use super::fonts::{ FontFamilyDef, FontSource }; -use super::paint::{ ColorStop, GradientSpace, LinearGradient, Paint, RadialGradient }; -use super::shadow::{ BlendMode, InsetShadow, Shadow, ShadowsRef }; -use super::slots::{ Metadata, Slot, SlotStore }; -use super::surface::Surface; -use super::text_style:: -{ - FontRef, FontStyle, LineHeight, TextDecoration, TextStyle, TextTransform, -}; -use super:: -{ - default_window_controls, LauncherSpec, ThemeError, ThemeMode, WallpaperFit, - WallpaperSpec, WindowControlsSpec, -}; - -// ─── 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 - { - 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 - { - 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 - { - 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::().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 - { - // Integer 0..=255 or float 0..=255. - if let Ok( n ) = s.parse::() - { - if n <= 255 { return Some( n as f32 ); } - return None; - } - if let Ok( f ) = s.parse::() - { - 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( c: &Color, ser: S ) -> Result - where S: Serializer - { - ser.serialize_str( &format( *c ) ) - } - - pub fn deserialize<'de, D>( de: D ) -> Result - 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_serde::parse( s ) -} - -// ─── Enum mirrors with defaults ────────────────────────────────────────────── - -#[ derive( Debug, Clone, Copy, Serialize, Deserialize ) ] -#[ serde( rename_all = "kebab-case" ) ] -enum RawGradientSpace -{ - Srgb, - LinearRgb, - Oklab, -} - -impl Default for RawGradientSpace { fn default() -> Self { RawGradientSpace::LinearRgb } } - -impl From 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" ) ] -enum RawBlendMode -{ - Normal, - PlusLighter, - Overlay, - Multiply, - Screen, -} - -impl Default for RawBlendMode { fn default() -> Self { RawBlendMode::Normal } } - -impl From 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" ) ] -enum RawFontStyle { Normal, Italic } - -impl Default for RawFontStyle { fn default() -> Self { RawFontStyle::Normal } } - -impl From 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" ) ] -enum RawTextTransform { None, Uppercase, Lowercase, Capitalize } - -impl Default for RawTextTransform { fn default() -> Self { RawTextTransform::None } } - -impl From 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" ) ] -enum RawTextDecoration { None, Underline, Strikethrough } - -impl Default for RawTextDecoration { fn default() -> Self { RawTextDecoration::None } } - -impl From 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 ) ] -struct RawMetadata -{ - #[ serde( default, skip_serializing_if = "Option::is_none" ) ] - semantic: Option, - #[ serde( default, skip_serializing_if = "Option::is_none" ) ] - fluent: Option, - #[ serde( default, skip_serializing_if = "Option::is_none" ) ] - usage: Option, - #[ serde( default, skip_serializing_if = "Option::is_none" ) ] - note: Option, -} - -impl From 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 ) ] -struct RawColorStop -{ - #[ serde( alias = "position" ) ] - pos: f32, - #[ serde( with = "color_serde" ) ] - color: Color, -} - -impl From 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 ) ] -enum RawPaint -{ - Solid - { - #[ serde( with = "color_serde" ) ] - color: Color, - }, - Linear - { - angle_deg: f32, - #[ serde( default ) ] - space: RawGradientSpace, - stops: Vec, - }, - Radial - { - center: [f32; 2], - radius: f32, - #[ serde( default ) ] - space: RawGradientSpace, - stops: Vec, - }, -} - -impl From 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` per slot at parse time. Exceeding the cap truncates -/// the tail and logs once via stderr. -const MAX_GRADIENT_STOPS: usize = 64; - -fn collect_capped_stops( raw: Vec ) -> Vec -{ - 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 ) ] -struct RawShadow -{ - offset: [f32; 2], - blur: f32, - #[ serde( default ) ] - spread: f32, - #[ serde( with = "color_serde" ) ] - color: Color, - #[ serde( default ) ] - blend: RawBlendMode, -} - -impl From 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 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 ) ] -enum RawShadowsRef -{ - Named( String ), - Inline( Vec ), -} - -impl From 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 ) ] -struct RawSurfaceBody -{ - fill: Box, - #[ serde( default, skip_serializing_if = "Option::is_none" ) ] - shadows: Option, - #[ serde( default ) ] - inset_shadows: Vec, -} - -impl From 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 ) ] -enum RawLineHeight -{ - Px { px: f32 }, - Multiplier { mul: f32 }, -} - -impl From 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 ) ] -struct RawFontRef -{ - r#ref: String, -} - -impl From for FontRef -{ - fn from( r: RawFontRef ) -> Self { FontRef::Named( r.r#ref ) } -} - -#[ derive( Debug, Clone, Serialize, Deserialize ) ] -#[ serde( deny_unknown_fields ) ] -struct RawTextStyleBody -{ - family: RawFontRef, - weight: u16, - #[ serde( default ) ] - style: RawFontStyle, - size: f32, - line_height: RawLineHeight, - #[ serde( default ) ] - letter_spacing: f32, - #[ serde( default ) ] - transform: RawTextTransform, - #[ serde( default ) ] - decoration: RawTextDecoration, - #[ serde( default, skip_serializing_if = "Option::is_none" ) ] - color: Option, -} - -impl From 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" ) ] -enum RawSlot -{ - Color - { - #[ serde( with = "color_serde" ) ] - value: Color, - #[ serde( default ) ] - meta: RawMetadata, - }, - Linear - { - angle_deg: f32, - #[ serde( default ) ] - space: RawGradientSpace, - stops: Vec, - #[ serde( default ) ] - meta: RawMetadata, - }, - Radial - { - center: [f32; 2], - radius: f32, - #[ serde( default ) ] - space: RawGradientSpace, - stops: Vec, - #[ serde( default ) ] - meta: RawMetadata, - }, - Shadows - { - shadows: Vec, - #[ serde( default ) ] - meta: RawMetadata, - }, - Surface - { - #[ serde( flatten ) ] - body: RawSurfaceBody, - #[ serde( default ) ] - meta: RawMetadata, - }, - Typography - { - #[ serde( flatten ) ] - body: RawTextStyleBody, - #[ serde( default ) ] - meta: RawMetadata, - }, -} - -impl From 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 ) ] -struct RawFontSource -{ - weight: u16, - #[ serde( default ) ] - style: RawFontStyle, - path: String, -} - -#[ derive( Debug, Clone, Serialize, Deserialize ) ] -#[ serde( deny_unknown_fields ) ] -struct RawFontFamily -{ - name: String, - #[ serde( default ) ] - fallbacks: Vec, - #[ serde( default ) ] - sources: Vec, -} - -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(), - } -} - -// ─── Wallpaper / window_controls ───────────────────────────────────────────── - -#[ derive( Debug, Clone, Serialize, Deserialize ) ] -#[ serde( deny_unknown_fields ) ] -struct RawWallpaper -{ - path: String, - #[ serde( default ) ] - fit: WallpaperFit, -} - -#[ derive( Debug, Clone, Serialize, Deserialize ) ] -#[ serde( deny_unknown_fields ) ] -struct RawLauncher -{ - background: String, - border_radius: f32, -} - -#[ derive( Debug, Clone, Serialize, Deserialize, Default ) ] -#[ serde( deny_unknown_fields ) ] -struct RawWindowControls -{ - #[ serde( default ) ] bar_bg: Option, - #[ serde( default ) ] icon: Option, - #[ serde( default ) ] hover_bg: Option, - #[ serde( default ) ] pressed_bg: Option, - #[ serde( default ) ] close_hover_bg: Option, - #[ serde( default ) ] close_icon: Option, - #[ serde( default ) ] focus_ring: Option, -} - -// ─── Mode / Modes / Document ───────────────────────────────────────────────── - -#[ derive( Debug, Clone, Serialize, Deserialize ) ] -#[ serde( deny_unknown_fields ) ] -struct RawMode -{ - #[ serde( default, skip_serializing_if = "Option::is_none" ) ] - wallpaper: Option, - #[ serde( default, skip_serializing_if = "Option::is_none" ) ] - lockscreen: Option, - #[ serde( default, skip_serializing_if = "Option::is_none" ) ] - launcher: Option, - #[ serde( default, skip_serializing_if = "Option::is_none" ) ] - window_controls: Option, - #[ serde( default ) ] - slots: HashMap, -} - -#[ derive( Debug, Clone, Serialize, Deserialize ) ] -#[ serde( deny_unknown_fields ) ] -struct RawModes -{ - light: RawMode, - dark: RawMode, -} - -#[ derive( Debug, Clone, Serialize, Deserialize ) ] -#[ serde( deny_unknown_fields ) ] -struct RawThemeMeta -{ - id: String, - name: String, -} - -#[ derive( Debug, Clone, Serialize, Deserialize ) ] -#[ serde( deny_unknown_fields ) ] -struct RawThemeDocument -{ - theme: RawThemeMeta, - #[ serde( default ) ] - fonts: HashMap, - modes: RawModes, -} - -// ─── Conversion from raw to runtime types ──────────────────────────────────── - -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 -{ - // 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 -{ - 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 -{ - 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 -{ - 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, - }) -} - -/// 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. -fn extract_colors_map( value: &serde_json::Value ) - -> Result, 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. -fn extract_gradients_map( value: &serde_json::Value ) - -> Result, 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. -fn extract_inset_stacks_map( value: &serde_json::Value ) - -> Result, 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, 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. -fn resolve_refs( - value: &mut serde_json::Value, - colors: &HashMap, - tokens: &HashMap, -) -> 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, - tokens: &HashMap, -) -> Result -{ - 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 ) ) -} - -/// Load a theme document from a directory containing a `theme.json`. -pub fn load_document_from_dir( dir: &Path ) -> Result -{ - 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(), - } -} - -// ─── Tests ─────────────────────────────────────────────────────────────────── - -#[ cfg( test ) ] -mod tests -{ - use super::*; - - #[ 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 = ( 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 = ( 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 = ( 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::( bad ).is_err() ); - } -} diff --git a/src/theme/schema/mod.rs b/src/theme/schema/mod.rs new file mode 100644 index 0000000..3313153 --- /dev/null +++ b/src/theme/schema/mod.rs @@ -0,0 +1,394 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! 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 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 + { + 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 + { + 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 + { + 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::().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 + { + // Integer 0..=255 or float 0..=255. + if let Ok( n ) = s.parse::() + { + if n <= 255 { return Some( n as f32 ); } + return None; + } + if let Ok( f ) = s.parse::() + { + 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( c: &Color, ser: S ) -> Result + where S: Serializer + { + ser.serialize_str( &format( *c ) ) + } + + pub fn deserialize<'de, D>( de: D ) -> Result + 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_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 +{ + // 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 +{ + 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 +{ + 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 +{ + 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 +{ + 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(), + } +} diff --git a/src/theme/schema/raw.rs b/src/theme/schema/raw.rs new file mode 100644 index 0000000..70a48dd --- /dev/null +++ b/src/theme/schema/raw.rs @@ -0,0 +1,601 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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 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 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 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 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 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, + #[ serde( default, skip_serializing_if = "Option::is_none" ) ] + pub( super ) fluent: Option, + #[ serde( default, skip_serializing_if = "Option::is_none" ) ] + pub( super ) usage: Option, + #[ serde( default, skip_serializing_if = "Option::is_none" ) ] + pub( super ) note: Option, +} + +impl From 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 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, + }, + Radial + { + center: [f32; 2], + radius: f32, + #[ serde( default ) ] + space: RawGradientSpace, + stops: Vec, + }, +} + +impl From 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` 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 ) -> Vec +{ + 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 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 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 ), +} + +impl From 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, + #[ serde( default, skip_serializing_if = "Option::is_none" ) ] + pub( super ) shadows: Option, + #[ serde( default ) ] + pub( super ) inset_shadows: Vec, +} + +impl From 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 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 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, +} + +impl From 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, + #[ serde( default ) ] + meta: RawMetadata, + }, + Radial + { + center: [f32; 2], + radius: f32, + #[ serde( default ) ] + space: RawGradientSpace, + stops: Vec, + #[ serde( default ) ] + meta: RawMetadata, + }, + Shadows + { + shadows: Vec, + #[ serde( default ) ] + meta: RawMetadata, + }, + Surface + { + #[ serde( flatten ) ] + body: RawSurfaceBody, + #[ serde( default ) ] + meta: RawMetadata, + }, + Typography + { + #[ serde( flatten ) ] + body: RawTextStyleBody, + #[ serde( default ) ] + meta: RawMetadata, + }, +} + +impl From 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, + #[ serde( default ) ] + pub( super ) sources: Vec, +} + +// ─── 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, + #[ serde( default ) ] pub( super ) icon: Option, + #[ serde( default ) ] pub( super ) hover_bg: Option, + #[ serde( default ) ] pub( super ) pressed_bg: Option, + #[ serde( default ) ] pub( super ) close_hover_bg: Option, + #[ serde( default ) ] pub( super ) close_icon: Option, + #[ serde( default ) ] pub( super ) focus_ring: Option, +} + +// ─── 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, + #[ serde( default, skip_serializing_if = "Option::is_none" ) ] + pub( super ) lockscreen: Option, + #[ serde( default, skip_serializing_if = "Option::is_none" ) ] + pub( super ) launcher: Option, + #[ serde( default, skip_serializing_if = "Option::is_none" ) ] + pub( super ) window_controls: Option, + #[ serde( default ) ] + pub( super ) slots: HashMap, +} + +#[ 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, + pub( super ) modes: RawModes, +} diff --git a/src/theme/schema/refs.rs b/src/theme/schema/refs.rs new file mode 100644 index 0000000..4e42c95 --- /dev/null +++ b/src/theme/schema/refs.rs @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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, 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, 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, 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, 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, + tokens: &HashMap, +) -> 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, + tokens: &HashMap, +) -> Result +{ + 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 ) ) +} diff --git a/src/theme/schema/tests.rs b/src/theme/schema/tests.rs new file mode 100644 index 0000000..c1adbaf --- /dev/null +++ b/src/theme/schema/tests.rs @@ -0,0 +1,292 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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 = ( 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 = ( 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 = ( 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::( bad ).is_err() ); +} diff --git a/src/theme/search.rs b/src/theme/search.rs new file mode 100644 index 0000000..e52539c --- /dev/null +++ b/src/theme/search.rs @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! 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 +{ + 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 +{ + let doc = ensure_active().document; + if doc.fonts.is_empty() { return None; } + Some( FontRegistry::from_families_lenient( &doc.fonts ) ) +} diff --git a/src/theme/typography.rs b/src/theme/typography.rs new file mode 100644 index 0000000..30532af --- /dev/null +++ b/src/theme/typography.rs @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! 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; diff --git a/src/widget/anchored_overlay.rs b/src/widget/anchored_overlay/mod.rs similarity index 81% rename from src/widget/anchored_overlay.rs rename to src/widget/anchored_overlay/mod.rs index 937ac33..5b1ed68 100644 --- a/src/widget/anchored_overlay.rs +++ b/src/widget/anchored_overlay/mod.rs @@ -24,6 +24,9 @@ use crate::types::{ Rect, WidgetId }; use super::Element; +#[ cfg( test ) ] +mod tests; + /// A wrapper that re-positions its child relative to an anchor widget /// found in the previous frame's layout snapshot. pub struct AnchoredOverlay @@ -96,29 +99,3 @@ impl From> for Element 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 ); - } -} diff --git a/src/widget/anchored_overlay/tests.rs b/src/widget/anchored_overlay/tests.rs new file mode 100644 index 0000000..e566a7a --- /dev/null +++ b/src/widget/anchored_overlay/tests.rs @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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 ); +} diff --git a/src/widget/button.rs b/src/widget/button/mod.rs similarity index 75% rename from src/widget/button.rs rename to src/widget/button/mod.rs index ba349a1..c40a4d7 100644 --- a/src/widget/button.rs +++ b/src/widget/button/mod.rs @@ -9,54 +9,10 @@ use super::Element; // Theme colors driven by the process-wide palette (see `ltk::theme`). // Non-colour geometry (radius, font size, focus width, etc.) is static — only // palette tokens respond to light/dark mode. -mod theme -{ - use crate::types::Color; +mod theme; - /// Primary button background — brand accent. - pub fn p_default_bg() -> Color { crate::theme::palette().accent } - /// Primary button border — a darker tone of the accent so the pill - /// reads as a discrete affordance over surfaces that share the - /// background hue. Computed by darkening the linear-RGB components - /// of the accent rather than carrying yet another palette slot. - pub fn p_default_border() -> Color - { - let a = crate::theme::palette().accent; - Color { r: a.r * 0.55, g: a.g * 0.55, b: a.b * 0.55, a: a.a } - } - /// Primary button label colour. Falls back to the theme's - /// `text_primary`; themes whose accent does not pair well with - /// their text-primary token can override the palette so this - /// reads cleanly. - pub fn p_default_text() -> Color { crate::theme::palette().text_primary } - /// Disabled primary background — uses the theme's divider token, - /// the lowest-contrast neutral available across modes. - pub fn p_disabled_bg() -> Color { crate::theme::palette().divider } - /// Disabled primary / secondary / tertiary text — uses the - /// theme's secondary text token. - pub fn p_disabled_text() -> Color { crate::theme::palette().text_secondary } - /// Secondary button default background — matches the surface_alt surface. - pub fn s_bg() -> Color { crate::theme::palette().surface_alt } - /// Secondary button border. - pub fn s_border() -> Color { crate::theme::palette().text_primary } - /// Disabled secondary background — slightly lighter than the - /// disabled primary so the two states stay visually distinct. - pub fn s_disabled_bg() -> Color { crate::theme::palette().surface_alt } - /// Disabled secondary border — uses the divider token. - pub fn s_disabled_border() -> Color { crate::theme::palette().divider } - /// Tertiary button text colour. - pub fn t_text() -> Color { crate::theme::palette().text_primary } - /// Keyboard focus ring / hover circle. - pub fn focus_color() -> Color { crate::theme::palette().accent } - - pub const S_BORDER_W: f32 = 2.0; - pub const P_BORDER_W: f32 = 1.0; - pub const FOCUS_W: f32 = 3.0; - pub const HEIGHT: f32 = 48.0; - pub const RADIUS: f32 = 100.0; - pub const FONT_SIZE: f32 = 16.0; - pub const PAD_H: f32 = 24.0; -} +#[ cfg( test ) ] +mod tests; /// Visual style of a text button. #[ derive( Clone, Default ) ] @@ -498,106 +454,3 @@ impl Button } } } - -#[ 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 ); - } -} diff --git a/src/widget/button/tests.rs b/src/widget/button/tests.rs new file mode 100644 index 0000000..5bb17cd --- /dev/null +++ b/src/widget/button/tests.rs @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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 ); +} diff --git a/src/widget/button/theme.rs b/src/widget/button/theme.rs new file mode 100644 index 0000000..e941697 --- /dev/null +++ b/src/widget/button/theme.rs @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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; diff --git a/src/widget/checkbox.rs b/src/widget/checkbox/mod.rs similarity index 83% rename from src/widget/checkbox.rs rename to src/widget/checkbox/mod.rs index c3ccb53..0cdcf67 100644 --- a/src/widget/checkbox.rs +++ b/src/widget/checkbox/mod.rs @@ -5,25 +5,10 @@ use crate::types::{ Rect, WidgetId }; use crate::render::Canvas; use super::Element; -mod theme -{ - use crate::types::Color; - pub fn box_border() -> Color { crate::theme::palette().divider } - pub fn box_checked() -> Color { crate::theme::palette().accent } - /// Tick mark — uses the page-background colour so it reads as - /// the inverse of the accent fill regardless of mode. - pub fn check_color() -> Color { crate::theme::palette().bg } - pub fn focus_color() -> Color { crate::theme::palette().accent } - pub fn label_color() -> Color { crate::theme::palette().text_primary } - pub const BOX_SIZE: f32 = 24.0; - pub const RADIUS: f32 = 4.0; - pub const BORDER_W: f32 = 2.0; - pub const GAP: f32 = 12.0; - pub const HEIGHT: f32 = 48.0; - pub const FOCUS_W: f32 = 3.0; - pub const CHECK_W: f32 = 2.5; - pub const FONT_SIZE: f32 = 16.0; -} +mod theme; + +#[cfg(test)] +mod tests; /// A two-state opt-in control with a square box and a check glyph. /// @@ -194,24 +179,3 @@ impl From> for Element 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 ); - } -} diff --git a/src/widget/checkbox/tests.rs b/src/widget/checkbox/tests.rs new file mode 100644 index 0000000..16619f4 --- /dev/null +++ b/src/widget/checkbox/tests.rs @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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 ); +} diff --git a/src/widget/checkbox/theme.rs b/src/widget/checkbox/theme.rs new file mode 100644 index 0000000..606d093 --- /dev/null +++ b/src/widget/checkbox/theme.rs @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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; diff --git a/src/widget/color_picker.rs b/src/widget/color_picker/mod.rs similarity index 69% rename from src/widget/color_picker.rs rename to src/widget/color_picker/mod.rs index 8685340..7eaacb2 100644 --- a/src/widget/color_picker.rs +++ b/src/widget/color_picker/mod.rs @@ -32,18 +32,9 @@ use crate::theme::{ ColorStop, GradientSpace, LinearGradient, Paint }; use super::Element; -mod theme -{ - use crate::types::Color; - pub fn surface_alt() -> Color { crate::theme::palette().surface_alt } - pub fn divider() -> Color { crate::theme::palette().divider } - pub fn text_muted() -> Color { crate::theme::palette().text_secondary } - pub const PADDING: f32 = 16.0; - pub const RADIUS: f32 = 16.0; - pub const SWATCH_SZ: f32 = 40.0; - pub const LABEL_FS: f32 = 12.0; - pub const SPACING: f32 = 8.0; -} +mod theme; +#[ cfg( test ) ] +mod tests; /// Format a [`Color`] as `#RRGGBB` (or `#RRGGBBAA` when `with_alpha` /// is true and the colour is not fully opaque). Bytes are clamped to @@ -382,164 +373,3 @@ pub fn color_picker( value: Color ) -> ColorPicker { 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 = 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 = color_picker::( Color::WHITE ).build(); - } - - #[ test ] - fn build_does_not_panic_with_alpha_and_callback() - { - let _: Element = color_picker::( 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})" ); - } - } -} diff --git a/src/widget/color_picker/tests.rs b/src/widget/color_picker/tests.rs new file mode 100644 index 0000000..5703e3d --- /dev/null +++ b/src/widget/color_picker/tests.rs @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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 = 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 = color_picker::( Color::WHITE ).build(); +} + +#[ test ] +fn build_does_not_panic_with_alpha_and_callback() +{ + let _: Element = color_picker::( 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})" ); + } +} diff --git a/src/widget/color_picker/theme.rs b/src/widget/color_picker/theme.rs new file mode 100644 index 0000000..58ea04a --- /dev/null +++ b/src/widget/color_picker/theme.rs @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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; diff --git a/src/widget/combo.rs b/src/widget/combo/mod.rs similarity index 85% rename from src/widget/combo.rs rename to src/widget/combo/mod.rs index 8ada41b..e19e81d 100644 --- a/src/widget/combo.rs +++ b/src/widget/combo/mod.rs @@ -104,6 +104,9 @@ use crate::types::WidgetId; use super::Element; +#[ cfg( test ) ] +mod tests; + /// Stable identifier of the trigger pill used as the popup's anchor. /// Read by [`crate::widget::anchored_overlay::AnchoredOverlay`] in the /// draw pass to position the popup flush below the trigger. A single @@ -805,173 +808,3 @@ pub fn combo( state: ComboState, items: Vec ) -> Combo { 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() ); - } -} diff --git a/src/widget/combo/tests.rs b/src/widget/combo/tests.rs new file mode 100644 index 0000000..7cb515f --- /dev/null +++ b/src/widget/combo/tests.rs @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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() ); +} diff --git a/src/widget/container.rs b/src/widget/container/mod.rs similarity index 78% rename from src/widget/container.rs rename to src/widget/container/mod.rs index 2d78f00..31b0efe 100644 --- a/src/widget/container.rs +++ b/src/widget/container/mod.rs @@ -6,6 +6,9 @@ use crate::types::{ Color, Corners }; use crate::render::Canvas; use super::Element; +#[ cfg( test ) ] +mod tests; + /// A transparent wrapper that adds a background color or a themed /// surface and padding around any child [`Element`]. /// @@ -291,120 +294,3 @@ pub fn container( child: impl Into> ) -> Container { 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 ); - } -} - diff --git a/src/widget/container/tests.rs b/src/widget/container/tests.rs new file mode 100644 index 0000000..ac2882d --- /dev/null +++ b/src/widget/container/tests.rs @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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 ); +} diff --git a/src/widget/date_picker.rs b/src/widget/date_picker/mod.rs similarity index 75% rename from src/widget/date_picker.rs rename to src/widget/date_picker/mod.rs index 07d77c0..cd244a4 100644 --- a/src/widget/date_picker.rs +++ b/src/widget/date_picker/mod.rs @@ -43,22 +43,9 @@ use crate::layout::wrap_grid::grid; use super::Element; -mod theme -{ - use crate::types::Color; - pub fn surface() -> Color { crate::theme::palette().surface } - pub fn surface_alt() -> Color { crate::theme::palette().surface_alt } - pub fn text() -> Color { crate::theme::palette().text_primary } - pub fn text_muted() -> Color { crate::theme::palette().text_secondary } - pub fn accent() -> Color { crate::theme::palette().accent } - pub const PADDING: f32 = 16.0; - pub const RADIUS: f32 = 16.0; - pub const HEADER_FS: f32 = 16.0; - pub const DOW_FS: f32 = 12.0; - pub const DAY_FS: f32 = 14.0; - pub const CELL_SIZE: f32 = 36.0; - pub const SPACING: f32 = 4.0; -} +mod theme; +#[ cfg( test ) ] +mod tests; /// A calendar date in the proleptic Gregorian calendar. No time /// component, no timezone. `month` is 1–12, `day` is 1–31 (further @@ -493,152 +480,3 @@ pub fn date_picker( value: Date ) -> DatePicker { 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 = 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 = 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 = 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 = 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 = date_picker::( 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 = date_picker( Date::new( 2024, 7, 15 ) ); - d.view_month = 0; - let _: Element = d.build(); - } -} diff --git a/src/widget/date_picker/tests.rs b/src/widget/date_picker/tests.rs new file mode 100644 index 0000000..fe34727 --- /dev/null +++ b/src/widget/date_picker/tests.rs @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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 = 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 = 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 = 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 = 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 = date_picker::( 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 = date_picker( Date::new( 2024, 7, 15 ) ); + d.view_month = 0; + let _: Element = d.build(); +} diff --git a/src/widget/date_picker/theme.rs b/src/widget/date_picker/theme.rs new file mode 100644 index 0000000..ed34b76 --- /dev/null +++ b/src/widget/date_picker/theme.rs @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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; diff --git a/src/widget/dialog.rs b/src/widget/dialog/mod.rs similarity index 79% rename from src/widget/dialog.rs rename to src/widget/dialog/mod.rs index 8736036..440f2c8 100644 --- a/src/widget/dialog.rs +++ b/src/widget/dialog/mod.rs @@ -75,6 +75,9 @@ use super::pressable::pressable; use super::text; use super::Element; +#[ cfg( test ) ] +mod tests; + /// Default scrim opacity over the underlying surface. pub const SCRIM_ALPHA: f32 = 0.45; /// Default card max-width (logical pixels). Override with @@ -327,125 +330,3 @@ pub fn dialog() -> Dialog { 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::::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::::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::::new() - .body( spacer() ) - .body( spacer() ); - assert!( d.body.is_some() ); - } - - #[ test ] - fn action_builder_appends_in_order() - { - let d = Dialog::::new() - .action( spacer() ) - .action( spacer() ) - .action( spacer() ); - assert_eq!( d.actions.len(), 3 ); - } - - #[ test ] - fn modal_builder_toggles_flag() - { - assert!( !Dialog::::new().modal( false ).modal ); - assert!( Dialog::::new().modal( true ).modal ); - } - - #[ test ] - fn dismiss_on_scrim_builder_records_message() - { - let d = Dialog::::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::::new().cancel( Msg::Cancel ); - assert_eq!( d.cancel_msg, Some( Msg::Cancel ) ); - } - - #[ test ] - fn max_width_builder_overrides_default() - { - let d = Dialog::::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::::new() - .modal( true ) - .dismiss_on_scrim( Msg::Dismiss ); - let _: Element = d.into(); - } - - #[ test ] - fn non_modal_with_dismiss_lowers_cleanly() - { - let d = Dialog::::new() - .modal( false ) - .dismiss_on_scrim( Msg::Dismiss ) - .cancel( Msg::Cancel ) - .title( "Title" ); - // Lowering must not panic. - let _: Element = 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::::new() - .title( "Confirm" ) - .subtitle( "Are you sure?" ) - .cancel( Msg::Cancel ); - let _: Element = d.into(); - } - -} diff --git a/src/widget/dialog/tests.rs b/src/widget/dialog/tests.rs new file mode 100644 index 0000000..69fadce --- /dev/null +++ b/src/widget/dialog/tests.rs @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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::::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::::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::::new() + .body( spacer() ) + .body( spacer() ); + assert!( d.body.is_some() ); +} + +#[ test ] +fn action_builder_appends_in_order() +{ + let d = Dialog::::new() + .action( spacer() ) + .action( spacer() ) + .action( spacer() ); + assert_eq!( d.actions.len(), 3 ); +} + +#[ test ] +fn modal_builder_toggles_flag() +{ + assert!( !Dialog::::new().modal( false ).modal ); + assert!( Dialog::::new().modal( true ).modal ); +} + +#[ test ] +fn dismiss_on_scrim_builder_records_message() +{ + let d = Dialog::::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::::new().cancel( Msg::Cancel ); + assert_eq!( d.cancel_msg, Some( Msg::Cancel ) ); +} + +#[ test ] +fn max_width_builder_overrides_default() +{ + let d = Dialog::::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::::new() + .modal( true ) + .dismiss_on_scrim( Msg::Dismiss ); + let _: Element = d.into(); +} + +#[ test ] +fn non_modal_with_dismiss_lowers_cleanly() +{ + let d = Dialog::::new() + .modal( false ) + .dismiss_on_scrim( Msg::Dismiss ) + .cancel( Msg::Cancel ) + .title( "Title" ); + // Lowering must not panic. + let _: Element = 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::::new() + .title( "Confirm" ) + .subtitle( "Are you sure?" ) + .cancel( Msg::Cancel ); + let _: Element = d.into(); +} diff --git a/src/widget/element.rs b/src/widget/element.rs new file mode 100644 index 0000000..d6bee19 --- /dev/null +++ b/src/widget/element.rs @@ -0,0 +1,456 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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 +{ + Button( button::Button ), + Container( container::Container ), + TextEdit( text_edit::TextEdit ), + Image( image::Image ), + Column( crate::layout::column::Column ), + Row( crate::layout::row::Row ), + Stack( crate::layout::stack::Stack ), + Text( text::Text ), + Spacer( crate::layout::spacer::Spacer ), + Scroll( scroll::Scroll ), + Viewport( viewport::Viewport ), + WrapGrid( crate::layout::wrap_grid::WrapGrid ), + Slider( slider::Slider ), + VSlider( vslider::VSlider ), + Toggle( toggle::Toggle ), + Separator( separator::Separator ), + ProgressBar( progress_bar::ProgressBar ), + Checkbox( checkbox::Checkbox ), + Radio( radio::Radio ), + ListItem( list_item::ListItem ), + WindowButton( window_button::WindowButton ), + Pressable( pressable::Pressable ), + Flex( flex::Flex ), + AnchoredOverlay( anchored_overlay::AnchoredOverlay ), + Spinner( spinner::Spinner ), + External( external::External ), +} + +impl Element +{ + 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 + { + 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 Element +{ + /// 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` no longer references `Msg`. The standard Elm / + /// `iced` shape: a sub-view defined as `fn view( …) -> Element` + /// can be embedded inside a parent that produces `Element` + /// by calling `.map( AppMsg::Sub )`. + /// + /// Cost: `O( leaves )` allocations for the closure-wrapping in the + /// `Arc` 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 { + /// # button( "Save" ).on_press( SubMsg::Save ).into() + /// # } + /// # fn _ex() -> Element { + /// sub_view().map( AppMsg::Sub ) + /// # } + /// ``` + pub fn map( self, f: F ) -> Element + where + U: Clone + 'static, + F: Fn( Msg ) -> U + 'static, + { + let f: MapFn = Arc::new( f ); + self.map_arc( &f ) + } + + pub( crate ) fn map_arc( self, f: &MapFn ) -> Element + 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 From> for Element +{ + fn from( c: container::Container ) -> Self + { + Element::Container( c ) + } +} + +impl From> for Element +{ + fn from( b: button::Button ) -> Self + { + Element::Button( b ) + } +} + +impl From> for Element +{ + fn from( t: text_edit::TextEdit ) -> Self + { + Element::TextEdit( t ) + } +} + +impl From for Element +{ + fn from( i: image::Image ) -> Self + { + Element::Image( i ) + } +} + +impl From for Element +{ + fn from( e: external::External ) -> Self + { + Element::External( e ) + } +} + +impl From> for Element +{ + fn from( c: crate::layout::column::Column ) -> Self + { + Element::Column( c ) + } +} + +impl From> for Element +{ + fn from( r: crate::layout::row::Row ) -> Self + { + Element::Row( r ) + } +} + +impl From> for Element +{ + fn from( s: crate::layout::stack::Stack ) -> Self + { + Element::Stack( s ) + } +} diff --git a/src/widget/external.rs b/src/widget/external/mod.rs similarity index 85% rename from src/widget/external.rs rename to src/widget/external/mod.rs index 774e7fc..01c03da 100644 --- a/src/widget/external.rs +++ b/src/widget/external/mod.rs @@ -145,38 +145,4 @@ impl External } #[ cfg( test ) ] -mod tests -{ - use super::*; - use std::sync::Arc; - - fn dummy_source() -> ExternalSource - { - ExternalSource::Texture( Arc::new( | _gl, _rect | None ) ) - } - - #[ test ] - fn preferred_size_returns_constructed_dimensions() - { - let w = External::new( 320.0, 200.0, dummy_source() ); - assert_eq!( w.preferred_size( 9999.0 ), ( 320.0, 200.0 ) ); - } - - #[ test ] - fn opacity_default_is_one() - { - let w = External::new( 1.0, 1.0, dummy_source() ); - assert_eq!( w.opacity, 1.0 ); - } - - #[ test ] - fn opacity_setter_clamps_to_unit_range() - { - let above = External::new( 1.0, 1.0, dummy_source() ).opacity( 1.7 ); - let below = External::new( 1.0, 1.0, dummy_source() ).opacity( -0.4 ); - let mid = External::new( 1.0, 1.0, dummy_source() ).opacity( 0.5 ); - assert_eq!( above.opacity, 1.0 ); - assert_eq!( below.opacity, 0.0 ); - assert_eq!( mid.opacity, 0.5 ); - } -} +mod tests; diff --git a/src/widget/external/tests.rs b/src/widget/external/tests.rs new file mode 100644 index 0000000..9911669 --- /dev/null +++ b/src/widget/external/tests.rs @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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 ); +} diff --git a/src/widget/factory.rs b/src/widget/factory.rs new file mode 100644 index 0000000..b69e698 --- /dev/null +++ b/src/widget/factory.rs @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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( label: impl Into ) -> Button +{ + Button::new( label.into() ) +} + +pub fn icon_button( rgba: Arc>, img_w: u32, img_h: u32 ) -> Button +{ + Button::new_icon( rgba, img_w, img_h ) +} + +pub fn text_edit( + placeholder: impl Into, + value: impl Into, +) -> TextEdit +{ + TextEdit::new( placeholder.into(), value.into() ) +} + +pub fn image( rgba: Arc>, width: u32, height: u32 ) -> Image +{ + Image::new( rgba, width, height ) +} + +pub fn text( content: impl Into ) -> Text +{ + Text::new( content ) +} + +pub fn container( child: impl Into> ) -> Container +{ + 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 ) +} diff --git a/src/widget/flex.rs b/src/widget/flex/mod.rs similarity index 74% rename from src/widget/flex.rs rename to src/widget/flex/mod.rs index 81cc08a..27cf103 100644 --- a/src/widget/flex.rs +++ b/src/widget/flex/mod.rs @@ -96,43 +96,4 @@ impl From> for Element } #[ cfg( test ) ] -mod tests -{ - use super::*; - use crate::layout::spacer::spacer; - use crate::render::Canvas; - - fn make_canvas() -> Canvas { Canvas::new( 800, 600 ) } - - #[ test ] - fn new_defaults_to_weight_one() - { - let f = Flex::<()>::new( spacer() ); - assert_eq!( f.weight, 1 ); - } - - #[ test ] - fn weight_builder_sets_relative_weight() - { - let f = Flex::<()>::new( spacer() ).weight( 5 ); - assert_eq!( f.weight, 5 ); - } - - #[ test ] - fn preferred_size_reports_zero_width_so_row_treats_it_as_filler() - { - let canvas = make_canvas(); - let f = Flex::<()>::new( spacer().width( 100.0 ).height( 40.0 ) ); - let ( w, _ ) = f.preferred_size( 600.0, &canvas ); - assert_eq!( w, 0.0, "flex must contribute zero width to the row's intrinsic-width sum" ); - } - - #[ test ] - fn preferred_size_passes_child_height_through() - { - let canvas = make_canvas(); - let f = Flex::<()>::new( spacer().width( 100.0 ).height( 40.0 ) ); - let ( _, h ) = f.preferred_size( 600.0, &canvas ); - assert_eq!( h, 40.0 ); - } -} +mod tests; diff --git a/src/widget/flex/tests.rs b/src/widget/flex/tests.rs new file mode 100644 index 0000000..b077afd --- /dev/null +++ b/src/widget/flex/tests.rs @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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 ); +} diff --git a/src/widget/handlers.rs b/src/widget/handlers.rs new file mode 100644 index 0000000..1b9a12d --- /dev/null +++ b/src/widget/handlers.rs @@ -0,0 +1,309 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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 +{ + None, + Button + { + on_press: Option, + on_long_press: Option, + on_drag_start: Option, + /// 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, + repeating: bool, + }, + Toggle { on_toggle: Option }, + Checkbox { on_toggle: Option }, + Radio { on_select: Option }, + ListItem { on_press: Option }, + WindowButton { on_press: Option }, + TextEdit + { + value: String, + on_change: Option Msg>>, + on_submit: Option, + /// `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, + }, + Slider + { + on_change: Option Msg>>, + axis: slider::SliderAxis, + }, +} + +impl Drop for WidgetHandlers +{ + /// 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 Clone for WidgetHandlers +{ + 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 WidgetHandlers +{ + 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 + { + 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 + { + 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 + { + 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 + { + 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 + { + 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 + { + 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 + { + 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, + } + } +} diff --git a/src/widget/image.rs b/src/widget/image.rs deleted file mode 100644 index deec22e..0000000 --- a/src/widget/image.rs +++ /dev/null @@ -1,217 +0,0 @@ -// SPDX-License-Identifier: LGPL-2.1-only -// Copyright (C) 2026 Liberux Labs, S. L. - -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` clone. -/// -/// ```rust,no_run -/// # use std::sync::Arc; -/// # use ltk::{ img_widget, Element }; -/// # #[ derive( Clone ) ] enum Msg {} -/// # fn _ex( rgba_bytes: Arc>, width: u32, height: u32 ) -> Element { -/// 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>, - /// 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>, 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> - { - 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> - { - 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 ); - } -} diff --git a/src/widget/image/mod.rs b/src/widget/image/mod.rs new file mode 100644 index 0000000..e36a0fc --- /dev/null +++ b/src/widget/image/mod.rs @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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` clone. +/// +/// ```rust,no_run +/// # use std::sync::Arc; +/// # use ltk::{ img_widget, Element }; +/// # #[ derive( Clone ) ] enum Msg {} +/// # fn _ex( rgba_bytes: Arc>, width: u32, height: u32 ) -> Element { +/// 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>, + /// 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>, 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> + { + 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; diff --git a/src/widget/image/tests.rs b/src/widget/image/tests.rs new file mode 100644 index 0000000..6dbaf0d --- /dev/null +++ b/src/widget/image/tests.rs @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use super::*; + +fn solid_rgba( w: u32, h: u32 ) -> Arc> +{ + 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 ); +} diff --git a/src/widget/laid_out.rs b/src/widget/laid_out.rs new file mode 100644 index 0000000..e8eb77a --- /dev/null +++ b/src/widget/laid_out.rs @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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 +{ + pub rect: Rect, + pub flat_idx: usize, + pub id: Option, + pub paint_rect: Rect, + pub handlers: WidgetHandlers, + 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, +} + +impl Clone for LaidOutWidget +{ + 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(), + } + } +} diff --git a/src/widget/list_item.rs b/src/widget/list_item/mod.rs similarity index 79% rename from src/widget/list_item.rs rename to src/widget/list_item/mod.rs index a8ef9e4..3e691cc 100644 --- a/src/widget/list_item.rs +++ b/src/widget/list_item/mod.rs @@ -5,39 +5,10 @@ use crate::types::{ Rect, WidgetId }; use crate::render::Canvas; use super::Element; -mod theme -{ - use crate::types::Color; - pub fn label_color() -> Color { crate::theme::palette().text_primary } - pub fn subtitle_color() -> Color { crate::theme::palette().text_secondary } - pub fn trailing_color() -> Color { crate::theme::palette().text_secondary } - /// Alpha-only overlay tuned for the active mode (white on dark, navy on light). - pub fn hover_bg() -> Color - { - let p = crate::theme::palette(); - Color { r: p.text_primary.r, g: p.text_primary.g, b: p.text_primary.b, a: 0.06 } - } - pub fn press_bg() -> Color - { - let p = crate::theme::palette(); - Color { r: p.text_primary.r, g: p.text_primary.g, b: p.text_primary.b, a: 0.12 } - } - /// Solid dark surface for the [`ListItem::selected`](super::ListItem::selected) state. - /// `palette.text_primary` happens to be the right colour for both - /// modes (navy on light, white on dark) so the white-on-dark contrast - /// pattern flips into a navy-on-white contrast in dark mode without - /// needing a separate slot. - pub fn selected_bg() -> Color { crate::theme::palette().text_primary } - pub fn focus_color() -> Color { crate::theme::palette().accent } - pub const LABEL_SIZE: f32 = 16.0; - pub const SUBTITLE_SIZE: f32 = 13.0; - pub const TRAILING_SIZE: f32 = 14.0; - pub const HEIGHT: f32 = 56.0; - pub const HEIGHT_SUB: f32 = 68.0; - pub const PAD_H: f32 = 16.0; - pub const RADIUS: f32 = 12.0; - pub const FOCUS_W: f32 = 2.0; -} +mod theme; + +#[cfg(test)] +mod tests; /// A row inside a list with a primary label and optional subtitle / trailing /// text. @@ -250,19 +221,3 @@ impl From> for Element 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() ); - } -} diff --git a/src/widget/list_item/tests.rs b/src/widget/list_item/tests.rs new file mode 100644 index 0000000..611dd94 --- /dev/null +++ b/src/widget/list_item/tests.rs @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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() ); +} diff --git a/src/widget/list_item/theme.rs b/src/widget/list_item/theme.rs new file mode 100644 index 0000000..c030f68 --- /dev/null +++ b/src/widget/list_item/theme.rs @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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; diff --git a/src/widget/mod.rs b/src/widget/mod.rs index b588104..c83982d 100755 --- a/src/widget/mod.rs +++ b/src/widget/mod.rs @@ -87,849 +87,20 @@ pub mod time_picker; pub mod color_picker; pub mod dialog; pub mod external; -use std::sync::Arc; -use crate::types::{ Point, Rect, WidgetId }; -use crate::render::Canvas; -/// Per-leaf interaction snapshot captured during layout. One variant per -/// interactive widget kind; the layout pass clones the relevant callbacks / -/// values from the [`Element`] tree into here so input handlers can dispatch -/// in O(1) without re-walking the tree. -/// -/// `None` is used for focusable widgets that emit no message (e.g. a disabled -/// button or a focusable container) — the entry still appears in -/// `widget_rects` for hit testing and focus traversal. -pub enum WidgetHandlers -{ - None, - Button - { - on_press: Option, - on_long_press: Option, - on_drag_start: Option, - /// 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, - repeating: bool, - }, - Toggle { on_toggle: Option }, - Checkbox { on_toggle: Option }, - Radio { on_select: Option }, - ListItem { on_press: Option }, - WindowButton { on_press: Option }, - TextEdit - { - value: String, - on_change: Option Msg>>, - on_submit: Option, - /// `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, - }, - Slider - { - on_change: Option Msg>>, - axis: slider::SliderAxis, - }, -} +pub mod element; +pub mod handlers; +pub mod laid_out; +pub mod factory; -impl Drop for WidgetHandlers -{ - /// Mirror the wipe-on-drop behaviour of [`text_edit::TextEdit`] for the - /// per-frame handler snapshots the runtime keeps. When the snapshot - /// was built from a secure text edit we scrub the value bytes here so - /// the heap allocation that backs the cloned `String` is overwritten - /// before it is returned to the allocator. Non-secure variants run an - /// inert path; the field-level drops that fire after this fn handle - /// the rest of the data. - fn drop( &mut self ) - { - if let WidgetHandlers::TextEdit { value, secure: true, .. } = self - { - // SAFETY: identical reasoning to `TextEdit::drop` — we only - // write zeros into the underlying byte buffer, leaving valid - // UTF-8 (NUL bytes) in place until the auto-drop frees it. - let bytes = unsafe { value.as_mut_vec() }; - crate::secure_mem::secure_zero( bytes ); - } - } -} - -impl Clone for WidgetHandlers -{ - 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 WidgetHandlers -{ - 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 - { - 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 - { - 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 - { - 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 - { - 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 - { - 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 - { - 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 - { - 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 -{ - pub rect: Rect, - pub flat_idx: usize, - pub id: Option, - pub paint_rect: Rect, - pub handlers: WidgetHandlers, - 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, -} - -impl Clone for LaidOutWidget -{ - 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 -{ - Button( button::Button ), - Container( container::Container ), - TextEdit( text_edit::TextEdit ), - Image( image::Image ), - Column( crate::layout::column::Column ), - Row( crate::layout::row::Row ), - Stack( crate::layout::stack::Stack ), - Text( text::Text ), - Spacer( crate::layout::spacer::Spacer ), - Scroll( scroll::Scroll ), - Viewport( viewport::Viewport ), - WrapGrid( crate::layout::wrap_grid::WrapGrid ), - Slider( slider::Slider ), - VSlider( vslider::VSlider ), - Toggle( toggle::Toggle ), - Separator( separator::Separator ), - ProgressBar( progress_bar::ProgressBar ), - Checkbox( checkbox::Checkbox ), - Radio( radio::Radio ), - ListItem( list_item::ListItem ), - WindowButton( window_button::WindowButton ), - Pressable( pressable::Pressable ), - Flex( flex::Flex ), - AnchoredOverlay( anchored_overlay::AnchoredOverlay ), - Spinner( spinner::Spinner ), - External( external::External ), -} - -impl Element -{ - 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 - { - match self - { - Element::Button( b ) => WidgetHandlers::Button - { - on_press: b.on_press.clone(), - on_long_press: b.on_long_press.clone(), - on_drag_start: b.on_drag_start.clone(), - on_escape: None, - repeating: b.repeating, - }, - Element::Pressable( p ) => WidgetHandlers::Button - { - on_press: p.on_press.clone(), - on_long_press: p.on_long_press.clone(), - on_drag_start: p.on_drag_start.clone(), - on_escape: p.on_escape.clone(), - repeating: false, - }, - Element::Toggle( t ) => WidgetHandlers::Toggle { on_toggle: t.on_toggle.clone() }, - Element::Checkbox( c ) => WidgetHandlers::Checkbox { on_toggle: c.on_toggle.clone() }, - Element::Radio( r ) => WidgetHandlers::Radio { on_select: r.on_select.clone() }, - Element::ListItem( l ) => WidgetHandlers::ListItem { on_press: l.on_press.clone() }, - Element::WindowButton( b ) => WidgetHandlers::WindowButton { on_press: b.on_press.clone() }, - Element::TextEdit( t ) => - { - WidgetHandlers::TextEdit - { - value: t.value.clone(), - on_change: t.on_change.clone(), - on_submit: t.on_submit.clone(), - // `secure` here drives memory wipe-on-drop and - // the IME bypass — so any password field (with - // or without a toggle) opts in, regardless of - // the user's current visibility choice. - secure: t.secure || t.password_toggle.is_some(), - multiline: t.is_multiline(), - align: t.align, - font_size: t.font_size, - select_on_focus: t.select_on_focus, - password_toggle_msg: t.password_toggle.as_ref().map( |( _, m )| m.clone() ), - } - } - Element::Slider( s ) => - { - WidgetHandlers::Slider - { - on_change: s.on_change.clone(), - axis: slider::SliderAxis::Horizontal, - } - } - Element::VSlider( s ) => - { - WidgetHandlers::Slider - { - on_change: s.on_change.clone(), - axis: slider::SliderAxis::Vertical, - } - } - _ => WidgetHandlers::None, - } - } -} +pub use element::Element; +pub use handlers::WidgetHandlers; +pub use laid_out::LaidOutWidget; +pub use factory::{ button, icon_button, text_edit, image, text, container, external }; /// Type alias for the message-mapping closure shared across an /// [`Element::map`] walk. Stored as `Arc` so every per-widget /// `map_msg` can clone and re-share it without copying the closure body /// — the same closure is invoked once per emitted message, regardless /// of how many leaves the sub-tree has. -pub( crate ) type MapFn = Arc U>; - -impl Element -{ - /// 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` no longer references `Msg`. The standard Elm / - /// `iced` shape: a sub-view defined as `fn view( …) -> Element` - /// can be embedded inside a parent that produces `Element` - /// by calling `.map( AppMsg::Sub )`. - /// - /// Cost: `O( leaves )` allocations for the closure-wrapping in the - /// `Arc` 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 { - /// # button( "Save" ).on_press( SubMsg::Save ).into() - /// # } - /// # fn _ex() -> Element { - /// sub_view().map( AppMsg::Sub ) - /// # } - /// ``` - pub fn map( self, f: F ) -> Element - where - U: Clone + 'static, - F: Fn( Msg ) -> U + 'static, - { - let f: MapFn = Arc::new( f ); - self.map_arc( &f ) - } - - pub( crate ) fn map_arc( self, f: &MapFn ) -> Element - 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 From> for Element -{ - fn from( c: container::Container ) -> Self - { - Element::Container( c ) - } -} - -impl From> for Element -{ - fn from( b: button::Button ) -> Self - { - Element::Button( b ) - } -} - -impl From> for Element -{ - fn from( t: text_edit::TextEdit ) -> Self - { - Element::TextEdit( t ) - } -} - -impl From for Element -{ - fn from( i: image::Image ) -> Self - { - Element::Image( i ) - } -} - -impl From for Element -{ - fn from( e: external::External ) -> Self - { - Element::External( e ) - } -} - -impl From> for Element -{ - fn from( c: crate::layout::column::Column ) -> Self - { - Element::Column( c ) - } -} - -impl From> for Element -{ - fn from( r: crate::layout::row::Row ) -> Self - { - Element::Row( r ) - } -} - -impl From> for Element -{ - fn from( s: crate::layout::stack::Stack ) -> Self - { - Element::Stack( s ) - } -} - -pub fn button( label: impl Into ) -> button::Button -{ - button::Button::new( label.into() ) -} - -pub fn icon_button( rgba: Arc>, img_w: u32, img_h: u32 ) -> button::Button -{ - button::Button::new_icon( rgba, img_w, img_h ) -} - -pub fn text_edit( - placeholder: impl Into, - value: impl Into, -) -> text_edit::TextEdit -{ - text_edit::TextEdit::new( placeholder.into(), value.into() ) -} - -pub fn image( rgba: Arc>, width: u32, height: u32 ) -> image::Image -{ - image::Image::new( rgba, width, height ) -} - -pub fn text( content: impl Into ) -> text::Text -{ - text::Text::new( content ) -} - -pub fn container( child: impl Into> ) -> container::Container -{ - container::Container::new( child ) -} - -/// Build an [`external::External`] widget that hosts content rendered by -/// a caller-managed GL texture producer. -pub fn external( width: f32, height: f32, source: external::ExternalSource ) -> external::External -{ - external::External::new( width, height, source ) -} +pub( crate ) type MapFn = std::sync::Arc U>; diff --git a/src/widget/notebook.rs b/src/widget/notebook/mod.rs similarity index 75% rename from src/widget/notebook.rs rename to src/widget/notebook/mod.rs index d4d28d2..7b26630 100644 --- a/src/widget/notebook.rs +++ b/src/widget/notebook/mod.rs @@ -40,10 +40,10 @@ use crate::layout::spacer::spacer; use super::Element; -mod theme -{ - pub const SPACING: f32 = 12.0; -} +mod theme; + +#[ cfg( test ) ] +mod tests; /// One page of a [`Notebook`] — a label for the tab strip and the /// element to show when this page is active. @@ -177,73 +177,3 @@ pub fn notebook() -> Notebook { 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 = 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 = 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 = 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 = 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 = 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 = notebook() - .page( "A", spacer() ) - .page( "B", spacer() ) - .selected( 99 ); - let _: Element = n.build(); - } -} diff --git a/src/widget/notebook/tests.rs b/src/widget/notebook/tests.rs new file mode 100644 index 0000000..3d11036 --- /dev/null +++ b/src/widget/notebook/tests.rs @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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 = 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 = 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 = 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 = 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 = 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 = notebook() + .page( "A", spacer() ) + .page( "B", spacer() ) + .selected( 99 ); + let _: Element = n.build(); +} diff --git a/src/widget/notebook/theme.rs b/src/widget/notebook/theme.rs new file mode 100644 index 0000000..518218a --- /dev/null +++ b/src/widget/notebook/theme.rs @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +pub const SPACING: f32 = 12.0; diff --git a/src/widget/pressable.rs b/src/widget/pressable/mod.rs similarity index 70% rename from src/widget/pressable.rs rename to src/widget/pressable/mod.rs index edc44da..59a212a 100644 --- a/src/widget/pressable.rs +++ b/src/widget/pressable/mod.rs @@ -5,6 +5,9 @@ use crate::render::Canvas; use crate::types::WidgetId; use super::Element; +#[ cfg( test ) ] +mod tests; + /// Wraps any [`Element`] and emits a message on tap. Use when you want /// click-to-emit on something richer than a [`Button`](super::button::Button) /// — for example a [`Container`](super::container::Container) styled as a @@ -194,103 +197,3 @@ impl From> for Element Element::Pressable( p ) } } - -#[ cfg( test ) ] -mod tests -{ - use super::*; - use crate::layout::spacer::spacer; - use crate::render::Canvas; - use crate::types::WidgetId; - - #[ derive( Clone, Debug, PartialEq, Eq ) ] - enum Msg { Tap, Hold, Drag, Cancel } - - #[ test ] - fn new_defaults_have_no_handlers() - { - let p = Pressable::::new( spacer() ); - assert!( p.on_press.is_none() ); - assert!( p.on_long_press.is_none() ); - assert!( p.on_drag_start.is_none() ); - assert!( p.on_escape.is_none() ); - assert!( !p.swallow ); - assert!( p.id.is_none() ); - assert!( !p.has_handler() ); - } - - #[ test ] - fn on_press_builder_arms_tap_message() - { - let p = Pressable::new( spacer() ).on_press( Msg::Tap ); - assert_eq!( p.on_press, Some( Msg::Tap ) ); - assert!( p.has_handler() ); - } - - #[ test ] - fn on_long_press_builder_arms_long_press_message() - { - let p = Pressable::new( spacer() ).on_long_press( Msg::Hold ); - assert_eq!( p.on_long_press, Some( Msg::Hold ) ); - assert!( p.has_handler() ); - } - - #[ test ] - fn on_drag_start_builder_arms_drag_message() - { - let p = Pressable::new( spacer() ).on_drag_start( Msg::Drag ); - assert_eq!( p.on_drag_start, Some( Msg::Drag ) ); - assert!( p.has_handler() ); - } - - #[ test ] - fn has_handler_is_true_when_any_callback_is_set() - { - assert!( Pressable::::new( spacer() ).on_press( Msg::Tap ).has_handler() ); - assert!( Pressable::::new( spacer() ).on_long_press( Msg::Hold ).has_handler() ); - assert!( Pressable::::new( spacer() ).on_drag_start( Msg::Drag ).has_handler() ); - assert!( Pressable::::new( spacer() ) - .on_press( Msg::Tap ).on_long_press( Msg::Hold ).on_drag_start( Msg::Drag ).has_handler() ); - } - - #[ test ] - fn on_escape_builder_arms_escape_message() - { - let p = Pressable::new( spacer() ).on_escape( Msg::Cancel ); - assert_eq!( p.on_escape, Some( Msg::Cancel ) ); - assert!( p.has_handler() ); - } - - #[ test ] - fn swallow_builder_makes_pressable_hit_testable_without_callbacks() - { - let p = Pressable::::new( spacer() ).swallow( true ); - assert!( p.swallow ); - assert!( p.on_press.is_none() ); - assert!( p.has_handler() ); - } - - #[ test ] - fn swallow_off_with_no_callbacks_is_invisible_to_input() - { - let p = Pressable::::new( spacer() ).swallow( false ); - assert!( !p.has_handler() ); - } - - #[ test ] - fn id_builder_assigns_widget_id() - { - let id = WidgetId( "my_card" ); - let p = Pressable::::new( spacer() ).id( id ); - assert_eq!( p.id, Some( id ) ); - } - - #[ test ] - fn preferred_size_delegates_to_child() - { - let canvas = Canvas::new( 800, 600 ); - let child = spacer().width( 50.0 ).height( 30.0 ); - let p = Pressable::::new( child ); - assert_eq!( p.preferred_size( 400.0, &canvas ), ( 50.0, 30.0 ) ); - } -} diff --git a/src/widget/pressable/tests.rs b/src/widget/pressable/tests.rs new file mode 100644 index 0000000..2cadbc7 --- /dev/null +++ b/src/widget/pressable/tests.rs @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use super::*; +use crate::layout::spacer::spacer; +use crate::render::Canvas; +use crate::types::WidgetId; + +#[ derive( Clone, Debug, PartialEq, Eq ) ] +enum Msg { Tap, Hold, Drag, Cancel } + +#[ test ] +fn new_defaults_have_no_handlers() +{ + let p = Pressable::::new( spacer() ); + assert!( p.on_press.is_none() ); + assert!( p.on_long_press.is_none() ); + assert!( p.on_drag_start.is_none() ); + assert!( p.on_escape.is_none() ); + assert!( !p.swallow ); + assert!( p.id.is_none() ); + assert!( !p.has_handler() ); +} + +#[ test ] +fn on_press_builder_arms_tap_message() +{ + let p = Pressable::new( spacer() ).on_press( Msg::Tap ); + assert_eq!( p.on_press, Some( Msg::Tap ) ); + assert!( p.has_handler() ); +} + +#[ test ] +fn on_long_press_builder_arms_long_press_message() +{ + let p = Pressable::new( spacer() ).on_long_press( Msg::Hold ); + assert_eq!( p.on_long_press, Some( Msg::Hold ) ); + assert!( p.has_handler() ); +} + +#[ test ] +fn on_drag_start_builder_arms_drag_message() +{ + let p = Pressable::new( spacer() ).on_drag_start( Msg::Drag ); + assert_eq!( p.on_drag_start, Some( Msg::Drag ) ); + assert!( p.has_handler() ); +} + +#[ test ] +fn has_handler_is_true_when_any_callback_is_set() +{ + assert!( Pressable::::new( spacer() ).on_press( Msg::Tap ).has_handler() ); + assert!( Pressable::::new( spacer() ).on_long_press( Msg::Hold ).has_handler() ); + assert!( Pressable::::new( spacer() ).on_drag_start( Msg::Drag ).has_handler() ); + assert!( Pressable::::new( spacer() ) + .on_press( Msg::Tap ).on_long_press( Msg::Hold ).on_drag_start( Msg::Drag ).has_handler() ); +} + +#[ test ] +fn on_escape_builder_arms_escape_message() +{ + let p = Pressable::new( spacer() ).on_escape( Msg::Cancel ); + assert_eq!( p.on_escape, Some( Msg::Cancel ) ); + assert!( p.has_handler() ); +} + +#[ test ] +fn swallow_builder_makes_pressable_hit_testable_without_callbacks() +{ + let p = Pressable::::new( spacer() ).swallow( true ); + assert!( p.swallow ); + assert!( p.on_press.is_none() ); + assert!( p.has_handler() ); +} + +#[ test ] +fn swallow_off_with_no_callbacks_is_invisible_to_input() +{ + let p = Pressable::::new( spacer() ).swallow( false ); + assert!( !p.has_handler() ); +} + +#[ test ] +fn id_builder_assigns_widget_id() +{ + let id = WidgetId( "my_card" ); + let p = Pressable::::new( spacer() ).id( id ); + assert_eq!( p.id, Some( id ) ); +} + +#[ test ] +fn preferred_size_delegates_to_child() +{ + let canvas = Canvas::new( 800, 600 ); + let child = spacer().width( 50.0 ).height( 30.0 ); + let p = Pressable::::new( child ); + assert_eq!( p.preferred_size( 400.0, &canvas ), ( 50.0, 30.0 ) ); +} diff --git a/src/widget/progress_bar.rs b/src/widget/progress_bar/mod.rs similarity index 83% rename from src/widget/progress_bar.rs rename to src/widget/progress_bar/mod.rs index 3b75576..084b241 100644 --- a/src/widget/progress_bar.rs +++ b/src/widget/progress_bar/mod.rs @@ -5,14 +5,10 @@ use crate::types::{ Color, Rect }; use crate::render::Canvas; use super::Element; -mod theme -{ - use crate::types::Color; - pub fn track_bg() -> Color { crate::theme::palette().divider } - pub fn fill() -> Color { crate::theme::palette().accent } - pub const TRACK_H: f32 = 6.0; - pub const HEIGHT: f32 = 20.0; -} +mod theme; + +#[cfg(test)] +mod tests; /// A linear progress indicator for determinate operations. /// @@ -118,26 +114,3 @@ impl From for Element Element::ProgressBar( p ) } } - -#[cfg(test)] -mod tests -{ - use super::*; - - #[test] - fn value_clamped_on_creation() - { - let p = progress_bar( 1.5 ); - assert_eq!( p.value, 1.0 ); - let p = progress_bar( -0.5 ); - assert_eq!( p.value, 0.0 ); - } - - #[test] - fn preferred_width_fills_available() - { - let p = progress_bar( 0.5 ); - let ( w, _ ) = p.preferred_size( 300.0 ); - assert_eq!( w, 300.0 ); - } -} diff --git a/src/widget/progress_bar/tests.rs b/src/widget/progress_bar/tests.rs new file mode 100644 index 0000000..18f5af7 --- /dev/null +++ b/src/widget/progress_bar/tests.rs @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use super::*; + +#[test] +fn value_clamped_on_creation() +{ + let p = progress_bar( 1.5 ); + assert_eq!( p.value, 1.0 ); + let p = progress_bar( -0.5 ); + assert_eq!( p.value, 0.0 ); +} + +#[test] +fn preferred_width_fills_available() +{ + let p = progress_bar( 0.5 ); + let ( w, _ ) = p.preferred_size( 300.0 ); + assert_eq!( w, 300.0 ); +} diff --git a/src/widget/progress_bar/theme.rs b/src/widget/progress_bar/theme.rs new file mode 100644 index 0000000..845031e --- /dev/null +++ b/src/widget/progress_bar/theme.rs @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::types::Color; +pub fn track_bg() -> Color { crate::theme::palette().divider } +pub fn fill() -> Color { crate::theme::palette().accent } +pub const TRACK_H: f32 = 6.0; +pub const HEIGHT: f32 = 20.0; diff --git a/src/widget/radio.rs b/src/widget/radio/mod.rs similarity index 84% rename from src/widget/radio.rs rename to src/widget/radio/mod.rs index 87d01e7..47e86ec 100644 --- a/src/widget/radio.rs +++ b/src/widget/radio/mod.rs @@ -5,24 +5,10 @@ use crate::types::{ Rect, WidgetId }; use crate::render::Canvas; use super::Element; -mod theme -{ - use crate::types::Color; - pub fn ring_color() -> Color { crate::theme::palette().divider } - pub fn selected() -> Color { crate::theme::palette().accent } - /// Inner dot — uses the page-background colour so it reads as - /// the inverse of the accent fill regardless of mode. - pub fn dot_color() -> Color { crate::theme::palette().bg } - pub fn focus_color() -> Color { crate::theme::palette().accent } - pub fn label_color() -> Color { crate::theme::palette().text_primary } - pub const OUTER_SIZE: f32 = 24.0; - pub const DOT_SIZE: f32 = 12.0; - pub const BORDER_W: f32 = 2.0; - pub const GAP: f32 = 12.0; - pub const HEIGHT: f32 = 48.0; - pub const FOCUS_W: f32 = 3.0; - pub const FONT_SIZE: f32 = 16.0; -} +mod theme; + +#[cfg(test)] +mod tests; /// One option inside a mutually-exclusive group. /// @@ -189,24 +175,3 @@ impl From> for Element Element::Radio( r ) } } - -#[cfg(test)] -mod tests -{ - use super::*; - - #[test] - fn radio_default_state() - { - let r = radio::<()>( true ); - assert!( r.selected ); - assert!( r.on_select.is_none() ); - } - - #[test] - fn radio_unselected() - { - let r = radio::<()>( false ); - assert!( !r.selected ); - } -} diff --git a/src/widget/radio/tests.rs b/src/widget/radio/tests.rs new file mode 100644 index 0000000..a260e39 --- /dev/null +++ b/src/widget/radio/tests.rs @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use super::*; + +#[test] +fn radio_default_state() +{ + let r = radio::<()>( true ); + assert!( r.selected ); + assert!( r.on_select.is_none() ); +} + +#[test] +fn radio_unselected() +{ + let r = radio::<()>( false ); + assert!( !r.selected ); +} diff --git a/src/widget/radio/theme.rs b/src/widget/radio/theme.rs new file mode 100644 index 0000000..c7421f8 --- /dev/null +++ b/src/widget/radio/theme.rs @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::types::Color; +pub fn ring_color() -> Color { crate::theme::palette().divider } +pub fn selected() -> Color { crate::theme::palette().accent } +/// Inner dot — uses the page-background colour so it reads as +/// the inverse of the accent fill regardless of mode. +pub fn dot_color() -> Color { crate::theme::palette().bg } +pub fn focus_color() -> Color { crate::theme::palette().accent } +pub fn label_color() -> Color { crate::theme::palette().text_primary } +pub const OUTER_SIZE: f32 = 24.0; +pub const DOT_SIZE: f32 = 12.0; +pub const BORDER_W: f32 = 2.0; +pub const GAP: f32 = 12.0; +pub const HEIGHT: f32 = 48.0; +pub const FOCUS_W: f32 = 3.0; +pub const FONT_SIZE: f32 = 16.0; diff --git a/src/widget/scroll.rs b/src/widget/scroll/mod.rs similarity index 76% rename from src/widget/scroll.rs rename to src/widget/scroll/mod.rs index 3f38584..0a49410 100644 --- a/src/widget/scroll.rs +++ b/src/widget/scroll/mod.rs @@ -5,6 +5,9 @@ use crate::render::Canvas; use crate::types::WidgetId; use crate::widget::Element; +#[cfg(test)] +mod tests; + /// A vertically scrollable viewport that clips its child to its allocated rect. /// /// The child can be any element — typically a [`Column`](crate::layout::column::Column) @@ -85,58 +88,6 @@ pub(crate) fn clamp_offset( offset: f32, content_h: f32, viewport_h: f32 ) -> f3 offset.clamp( 0.0, max ) } -#[cfg(test)] -mod tests -{ - use super::clamp_offset; - - #[test] - fn offset_zero_when_content_fits() - { - // Content shorter than viewport — no scrolling possible - assert_eq!( clamp_offset( 50.0, 300.0, 500.0 ), 0.0 ); - } - - #[test] - fn offset_clamped_to_zero_when_negative() - { - assert_eq!( clamp_offset( -10.0, 600.0, 400.0 ), 0.0 ); - } - - #[test] - fn offset_clamped_to_max() - { - // max = 600 - 400 = 200; offset 999 → clamped to 200 - assert_eq!( clamp_offset( 999.0, 600.0, 400.0 ), 200.0 ); - } - - #[test] - fn offset_within_range_unchanged() - { - // max = 600 - 400 = 200; offset 100 stays 100 - assert_eq!( clamp_offset( 100.0, 600.0, 400.0 ), 100.0 ); - } - - #[test] - fn zero_offset_stays_zero() - { - assert_eq!( clamp_offset( 0.0, 600.0, 400.0 ), 0.0 ); - } - - #[test] - fn exact_max_offset_is_valid() - { - assert_eq!( clamp_offset( 200.0, 600.0, 400.0 ), 200.0 ); - } - - #[test] - fn content_equal_to_viewport_gives_zero_max() - { - // No overflow — max = 0 - assert_eq!( clamp_offset( 10.0, 400.0, 400.0 ), 0.0 ); - } -} - /// Create a scrollable viewport wrapping `child`. /// /// The parent layout controls the viewport size by assigning a rect to this widget. diff --git a/src/widget/scroll/tests.rs b/src/widget/scroll/tests.rs new file mode 100644 index 0000000..857eb8d --- /dev/null +++ b/src/widget/scroll/tests.rs @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use super::clamp_offset; + +#[test] +fn offset_zero_when_content_fits() +{ + // Content shorter than viewport — no scrolling possible + assert_eq!( clamp_offset( 50.0, 300.0, 500.0 ), 0.0 ); +} + +#[test] +fn offset_clamped_to_zero_when_negative() +{ + assert_eq!( clamp_offset( -10.0, 600.0, 400.0 ), 0.0 ); +} + +#[test] +fn offset_clamped_to_max() +{ + // max = 600 - 400 = 200; offset 999 → clamped to 200 + assert_eq!( clamp_offset( 999.0, 600.0, 400.0 ), 200.0 ); +} + +#[test] +fn offset_within_range_unchanged() +{ + // max = 600 - 400 = 200; offset 100 stays 100 + assert_eq!( clamp_offset( 100.0, 600.0, 400.0 ), 100.0 ); +} + +#[test] +fn zero_offset_stays_zero() +{ + assert_eq!( clamp_offset( 0.0, 600.0, 400.0 ), 0.0 ); +} + +#[test] +fn exact_max_offset_is_valid() +{ + assert_eq!( clamp_offset( 200.0, 600.0, 400.0 ), 200.0 ); +} + +#[test] +fn content_equal_to_viewport_gives_zero_max() +{ + // No overflow — max = 0 + assert_eq!( clamp_offset( 10.0, 400.0, 400.0 ), 0.0 ); +} diff --git a/src/widget/separator.rs b/src/widget/separator/mod.rs similarity index 85% rename from src/widget/separator.rs rename to src/widget/separator/mod.rs index 56b9351..ebe5fc8 100644 --- a/src/widget/separator.rs +++ b/src/widget/separator/mod.rs @@ -5,13 +5,7 @@ use crate::types::{ Color, Rect }; use crate::render::Canvas; use super::Element; -mod theme -{ - use crate::types::Color; - pub fn color() -> Color { crate::theme::palette().divider } - pub const THICKNESS: f32 = 1.0; - pub const PAD_V: f32 = 8.0; -} +mod theme; /// A horizontal divider line. /// @@ -116,22 +110,4 @@ impl From for Element } #[cfg(test)] -mod tests -{ - use super::*; - - #[test] - fn default_thickness() - { - let s = separator(); - assert_eq!( s.thickness, theme::THICKNESS ); - } - - #[test] - fn preferred_height_includes_padding() - { - let s = separator(); - let ( _, h ) = s.preferred_size( 200.0 ); - assert_eq!( h, theme::THICKNESS + theme::PAD_V * 2.0 ); - } -} +mod tests; diff --git a/src/widget/separator/tests.rs b/src/widget/separator/tests.rs new file mode 100644 index 0000000..f898eb0 --- /dev/null +++ b/src/widget/separator/tests.rs @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use super::*; + +#[test] +fn default_thickness() +{ + let s = separator(); + assert_eq!( s.thickness, theme::THICKNESS ); +} + +#[test] +fn preferred_height_includes_padding() +{ + let s = separator(); + let ( _, h ) = s.preferred_size( 200.0 ); + assert_eq!( h, theme::THICKNESS + theme::PAD_V * 2.0 ); +} diff --git a/src/widget/separator/theme.rs b/src/widget/separator/theme.rs new file mode 100644 index 0000000..c630d16 --- /dev/null +++ b/src/widget/separator/theme.rs @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::types::Color; +pub fn color() -> Color { crate::theme::palette().divider } +pub const THICKNESS: f32 = 1.0; +pub const PAD_V: f32 = 8.0; diff --git a/src/widget/slider.rs b/src/widget/slider/mod.rs similarity index 80% rename from src/widget/slider.rs rename to src/widget/slider/mod.rs index 7edfd85..b4dd9fd 100644 --- a/src/widget/slider.rs +++ b/src/widget/slider/mod.rs @@ -6,6 +6,10 @@ use crate::types::{ Point, Rect }; use crate::render::Canvas; use super::Element; +mod theme; +#[ cfg( test ) ] +mod tests; + /// Which axis a slider tracks. Used by input dispatch to pick the right /// `value_from_*_in_rect` formula for a [`Slider`] (horizontal) or /// [`crate::widget::vslider::VSlider`] (vertical). @@ -32,35 +36,6 @@ pub fn value_from_pos_in_rect( rect: Rect, pos: Point, axis: SliderAxis ) -> f32 } } -mod theme -{ - use crate::types::Color; - pub fn track_bg() -> Color { crate::theme::palette().divider } - pub fn track_fill() -> Color { crate::theme::palette().accent } - /// Thumb — uses the page-background colour so it reads as the - /// inverse of the accent fill regardless of mode. - pub fn thumb() -> Color { crate::theme::palette().bg } - pub fn thumb_border() -> Color { crate::theme::palette().text_primary } - pub const TRACK_H: f32 = 10.0; - pub const THUMB_SIZE: f32 = 24.0; - pub const HEIGHT: f32 = 36.0; - pub const THUMB_BORDER_W: f32 = 2.0; - /// White ring + inner coloured pill of the [`super::Slider::accent_thumb`] - /// variant. Outer diameter is [`ACCENT_THUMB_OUTER`]; the visible - /// pill is smaller than [`THUMB_SIZE`], which is the hit-target / - /// reserved layout footprint shared with the default thumb. - pub const ACCENT_THUMB_BORDER_W: f32 = 4.0; - pub const ACCENT_THUMB_OUTER: f32 = 20.0; - - /// Default theme slot id for the [`Slider`](super::Slider) track - /// background. Mirrors the equivalent constant in `vslider::theme` - /// so the same default-theme entries cover both axes — only the - /// rendering geometry differs between the widgets. - pub const SURFACE_TRACK: &str = "surface-slider-track"; - /// Default theme slot id for the [`Slider`](super::Slider) fill. - pub const SURFACE_FILL: &str = "surface-slider-fill"; -} - /// Intersect `inner` with the `saved` outer clip and return the rect /// list to install with [`crate::render::Canvas::set_clip_rects`]. /// @@ -446,96 +421,3 @@ impl From> for Element Element::Slider( s ) } } - -#[cfg(test)] -mod tests -{ - use super::*; - use crate::types::Rect; - - #[test] - fn value_clamped_on_creation() - { - let s = slider::<()>( 1.5 ); - assert_eq!( s.value, 1.0 ); - let s = slider::<()>( -0.5 ); - assert_eq!( s.value, 0.0 ); - } - - #[test] - fn value_from_x_left_edge() - { - let s = slider::<()>( 0.5 ); - let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 36.0 }; - let v = s.value_from_x( rect, 0.0 ); - assert_eq!( v, 0.0 ); - } - - #[test] - fn value_from_x_right_edge() - { - let s = slider::<()>( 0.5 ); - let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 36.0 }; - let v = s.value_from_x( rect, 200.0 ); - assert_eq!( v, 1.0 ); - } - - #[test] - fn value_from_x_center() - { - let s = slider::<()>( 0.0 ); - let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 36.0 }; - let v = s.value_from_x( rect, 100.0 ); - assert!( (v - 0.5).abs() < 0.1 ); - } - - #[test] - fn axis_dispatch_horizontal_uses_x() - { - let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 36.0 }; - let v = value_from_pos_in_rect( - rect, - Point { x: 200.0, y: 9999.0 }, - SliderAxis::Horizontal, - ); - assert_eq!( v, 1.0 ); - } - - #[test] - fn axis_dispatch_vertical_uses_y() - { - let rect = Rect { x: 0.0, y: 0.0, width: 56.0, height: 160.0 }; - // y=0 at the top of a vertical rect is value=1.0, regardless of x. - let v = value_from_pos_in_rect( - rect, - Point { x: 9999.0, y: 0.0 }, - SliderAxis::Vertical, - ); - assert_eq!( v, 1.0 ); - } - - #[test] - fn track_paint_default_is_none() - { - let s = slider::<()>( 0.5 ); - assert!( s.track_paint.is_none() ); - } - - #[test] - fn track_paint_builder_stores_paint() - { - use crate::theme::{ ColorStop, GradientSpace, LinearGradient, Paint }; - use crate::types::Color; - let g = Paint::Linear( LinearGradient - { - angle_deg: 90.0, - stops: vec![ - ColorStop { position: 0.0, color: Color::rgba( 1.0, 0.0, 0.0, 1.0 ) }, - ColorStop { position: 1.0, color: Color::rgba( 0.0, 0.0, 1.0, 1.0 ) }, - ], - space: GradientSpace::Srgb, - } ); - let s = slider::<()>( 0.5 ).track_paint( g ); - assert!( s.track_paint.is_some() ); - } -} diff --git a/src/widget/slider/tests.rs b/src/widget/slider/tests.rs new file mode 100644 index 0000000..dc79c36 --- /dev/null +++ b/src/widget/slider/tests.rs @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use super::*; +use crate::types::Rect; + +#[test] +fn value_clamped_on_creation() +{ + let s = slider::<()>( 1.5 ); + assert_eq!( s.value, 1.0 ); + let s = slider::<()>( -0.5 ); + assert_eq!( s.value, 0.0 ); +} + +#[test] +fn value_from_x_left_edge() +{ + let s = slider::<()>( 0.5 ); + let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 36.0 }; + let v = s.value_from_x( rect, 0.0 ); + assert_eq!( v, 0.0 ); +} + +#[test] +fn value_from_x_right_edge() +{ + let s = slider::<()>( 0.5 ); + let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 36.0 }; + let v = s.value_from_x( rect, 200.0 ); + assert_eq!( v, 1.0 ); +} + +#[test] +fn value_from_x_center() +{ + let s = slider::<()>( 0.0 ); + let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 36.0 }; + let v = s.value_from_x( rect, 100.0 ); + assert!( (v - 0.5).abs() < 0.1 ); +} + +#[test] +fn axis_dispatch_horizontal_uses_x() +{ + let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 36.0 }; + let v = value_from_pos_in_rect( + rect, + Point { x: 200.0, y: 9999.0 }, + SliderAxis::Horizontal, + ); + assert_eq!( v, 1.0 ); +} + +#[test] +fn axis_dispatch_vertical_uses_y() +{ + let rect = Rect { x: 0.0, y: 0.0, width: 56.0, height: 160.0 }; + // y=0 at the top of a vertical rect is value=1.0, regardless of x. + let v = value_from_pos_in_rect( + rect, + Point { x: 9999.0, y: 0.0 }, + SliderAxis::Vertical, + ); + assert_eq!( v, 1.0 ); +} + +#[test] +fn track_paint_default_is_none() +{ + let s = slider::<()>( 0.5 ); + assert!( s.track_paint.is_none() ); +} + +#[test] +fn track_paint_builder_stores_paint() +{ + use crate::theme::{ ColorStop, GradientSpace, LinearGradient, Paint }; + use crate::types::Color; + let g = Paint::Linear( LinearGradient + { + angle_deg: 90.0, + stops: vec![ + ColorStop { position: 0.0, color: Color::rgba( 1.0, 0.0, 0.0, 1.0 ) }, + ColorStop { position: 1.0, color: Color::rgba( 0.0, 0.0, 1.0, 1.0 ) }, + ], + space: GradientSpace::Srgb, + } ); + let s = slider::<()>( 0.5 ).track_paint( g ); + assert!( s.track_paint.is_some() ); +} diff --git a/src/widget/slider/theme.rs b/src/widget/slider/theme.rs new file mode 100644 index 0000000..4f028f8 --- /dev/null +++ b/src/widget/slider/theme.rs @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::types::Color; +pub fn track_bg() -> Color { crate::theme::palette().divider } +pub fn track_fill() -> Color { crate::theme::palette().accent } +/// Thumb — uses the page-background colour so it reads as the +/// inverse of the accent fill regardless of mode. +pub fn thumb() -> Color { crate::theme::palette().bg } +pub fn thumb_border() -> Color { crate::theme::palette().text_primary } +pub const TRACK_H: f32 = 10.0; +pub const THUMB_SIZE: f32 = 24.0; +pub const HEIGHT: f32 = 36.0; +pub const THUMB_BORDER_W: f32 = 2.0; +/// White ring + inner coloured pill of the [`super::Slider::accent_thumb`] +/// variant. Outer diameter is [`ACCENT_THUMB_OUTER`]; the visible +/// pill is smaller than [`THUMB_SIZE`], which is the hit-target / +/// reserved layout footprint shared with the default thumb. +pub const ACCENT_THUMB_BORDER_W: f32 = 4.0; +pub const ACCENT_THUMB_OUTER: f32 = 20.0; + +/// Default theme slot id for the [`Slider`](super::Slider) track +/// background. Mirrors the equivalent constant in `vslider::theme` +/// so the same default-theme entries cover both axes — only the +/// rendering geometry differs between the widgets. +pub const SURFACE_TRACK: &str = "surface-slider-track"; +/// Default theme slot id for the [`Slider`](super::Slider) fill. +pub const SURFACE_FILL: &str = "surface-slider-fill"; diff --git a/src/widget/spinner.rs b/src/widget/spinner/mod.rs similarity index 75% rename from src/widget/spinner.rs rename to src/widget/spinner/mod.rs index 11b68bd..46e0598 100644 --- a/src/widget/spinner.rs +++ b/src/widget/spinner/mod.rs @@ -27,26 +27,7 @@ use crate::types::{ Color, Rect }; use crate::render::Canvas; use super::Element; -mod theme -{ - use crate::types::Color; - pub fn fill() -> Color { crate::theme::palette().accent } - pub fn track() -> Color - { - // Quarter-opacity copy of the accent so the moving arc reads - // against a faint guide ring instead of empty surface. - let a = crate::theme::palette().accent; - Color::rgba( a.r, a.g, a.b, 0.20 ) - } - pub const SIZE: f32 = 32.0; - pub const STROKE_W: f32 = 3.0; - /// Fraction of the full circle that the moving arc covers. - pub const ARC_FRAC: f32 = 0.30; - /// Number of straight segments used to approximate one full circle. - /// 36 is enough for a smooth arc at the default 32 px diameter and - /// keeps the per-frame draw call count low. - pub const SEGMENTS: u32 = 36; -} +mod theme; /// An indeterminate progress spinner. /// @@ -183,41 +164,4 @@ impl From for Element } #[ cfg( test ) ] -mod tests -{ - use super::*; - - #[ test ] - fn defaults() - { - let s = spinner(); - assert_eq!( s.phase, 0.0 ); - assert_eq!( s.size, theme::SIZE ); - assert_eq!( s.stroke_w, theme::STROKE_W ); - } - - #[ test ] - fn builders_apply() - { - let s = spinner().phase( 0.42 ).size( 64.0 ).stroke_width( 5.0 ); - assert_eq!( s.phase, 0.42 ); - assert_eq!( s.size, 64.0 ); - assert_eq!( s.stroke_w, 5.0 ); - } - - #[ test ] - fn preferred_size_clamps_to_max_width() - { - let s = spinner().size( 100.0 ); - assert_eq!( s.preferred_size( 40.0 ), ( 40.0, 40.0 ) ); - assert_eq!( s.preferred_size( 200.0 ), ( 100.0, 100.0 ) ); - } - - #[ test ] - fn preferred_size_is_square() - { - let s = spinner(); - let ( w, h ) = s.preferred_size( 999.0 ); - assert_eq!( w, h ); - } -} +mod tests; diff --git a/src/widget/spinner/tests.rs b/src/widget/spinner/tests.rs new file mode 100644 index 0000000..37f0287 --- /dev/null +++ b/src/widget/spinner/tests.rs @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use super::*; + +#[ test ] +fn defaults() +{ + let s = spinner(); + assert_eq!( s.phase, 0.0 ); + assert_eq!( s.size, theme::SIZE ); + assert_eq!( s.stroke_w, theme::STROKE_W ); +} + +#[ test ] +fn builders_apply() +{ + let s = spinner().phase( 0.42 ).size( 64.0 ).stroke_width( 5.0 ); + assert_eq!( s.phase, 0.42 ); + assert_eq!( s.size, 64.0 ); + assert_eq!( s.stroke_w, 5.0 ); +} + +#[ test ] +fn preferred_size_clamps_to_max_width() +{ + let s = spinner().size( 100.0 ); + assert_eq!( s.preferred_size( 40.0 ), ( 40.0, 40.0 ) ); + assert_eq!( s.preferred_size( 200.0 ), ( 100.0, 100.0 ) ); +} + +#[ test ] +fn preferred_size_is_square() +{ + let s = spinner(); + let ( w, h ) = s.preferred_size( 999.0 ); + assert_eq!( w, h ); +} diff --git a/src/widget/spinner/theme.rs b/src/widget/spinner/theme.rs new file mode 100644 index 0000000..dd0b48b --- /dev/null +++ b/src/widget/spinner/theme.rs @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::types::Color; +pub fn fill() -> Color { crate::theme::palette().accent } +pub fn track() -> Color +{ + // Quarter-opacity copy of the accent so the moving arc reads + // against a faint guide ring instead of empty surface. + let a = crate::theme::palette().accent; + Color::rgba( a.r, a.g, a.b, 0.20 ) +} +pub const SIZE: f32 = 32.0; +pub const STROKE_W: f32 = 3.0; +/// Fraction of the full circle that the moving arc covers. +pub const ARC_FRAC: f32 = 0.30; +/// Number of straight segments used to approximate one full circle. +/// 36 is enough for a smooth arc at the default 32 px diameter and +/// keeps the per-frame draw call count low. +pub const SEGMENTS: u32 = 36; diff --git a/src/widget/tab_bar.rs b/src/widget/tab_bar/mod.rs similarity index 82% rename from src/widget/tab_bar.rs rename to src/widget/tab_bar/mod.rs index c3aae5b..6e00673 100644 --- a/src/widget/tab_bar.rs +++ b/src/widget/tab_bar/mod.rs @@ -23,12 +23,10 @@ use crate::types::Color; use super::Element; -mod theme -{ - pub const RADIUS: f32 = 100.0; - pub const PADDING: f32 = 4.0; - pub const SPACING: f32 = 4.0; -} +mod theme; + +#[ cfg( test ) ] +mod tests; /// Segmented horizontal tab selector. One row of pressable cells with /// the active cell painted as a filled pill and inactive cells as plain @@ -166,43 +164,3 @@ where { TabBar::new( labels ) } - -#[ cfg( test ) ] -mod tests -{ - use super::*; - - #[ derive( Clone, Debug, PartialEq, Eq ) ] - enum Msg { Pick( usize ) } - - #[ test ] - fn defaults() - { - let t: TabBar = TabBar::new( vec![ "a", "b", "c" ] ); - assert_eq!( t.labels.len(), 3 ); - assert_eq!( t.selected, 0 ); - assert!( t.on_select.is_none() ); - } - - #[ test ] - fn selected_builder() - { - let t: TabBar = TabBar::new( vec![ "a", "b" ] ).selected( 1 ); - assert_eq!( t.selected, 1 ); - } - - #[ test ] - fn on_select_invokes_callback() - { - let t: TabBar = TabBar::new( vec![ "a", "b" ] ).on_select( Msg::Pick ); - let cb = t.on_select.as_ref().expect( "callback set" ); - assert_eq!( cb( 1 ), Msg::Pick( 1 ) ); - } - - #[ test ] - fn strip_bg_none_disables_background() - { - let t: TabBar = TabBar::new( vec![ "a" ] ).strip_bg_none(); - assert!( t.strip_bg.is_none() ); - } -} diff --git a/src/widget/tab_bar/tests.rs b/src/widget/tab_bar/tests.rs new file mode 100644 index 0000000..b4ca084 --- /dev/null +++ b/src/widget/tab_bar/tests.rs @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use super::*; + +#[ derive( Clone, Debug, PartialEq, Eq ) ] +enum Msg { Pick( usize ) } + +#[ test ] +fn defaults() +{ + let t: TabBar = TabBar::new( vec![ "a", "b", "c" ] ); + assert_eq!( t.labels.len(), 3 ); + assert_eq!( t.selected, 0 ); + assert!( t.on_select.is_none() ); +} + +#[ test ] +fn selected_builder() +{ + let t: TabBar = TabBar::new( vec![ "a", "b" ] ).selected( 1 ); + assert_eq!( t.selected, 1 ); +} + +#[ test ] +fn on_select_invokes_callback() +{ + let t: TabBar = TabBar::new( vec![ "a", "b" ] ).on_select( Msg::Pick ); + let cb = t.on_select.as_ref().expect( "callback set" ); + assert_eq!( cb( 1 ), Msg::Pick( 1 ) ); +} + +#[ test ] +fn strip_bg_none_disables_background() +{ + let t: TabBar = TabBar::new( vec![ "a" ] ).strip_bg_none(); + assert!( t.strip_bg.is_none() ); +} diff --git a/src/widget/tab_bar/theme.rs b/src/widget/tab_bar/theme.rs new file mode 100644 index 0000000..16a84fd --- /dev/null +++ b/src/widget/tab_bar/theme.rs @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +pub const RADIUS: f32 = 100.0; +pub const PADDING: f32 = 4.0; +pub const SPACING: f32 = 4.0; diff --git a/src/widget/text.rs b/src/widget/text/mod.rs similarity index 73% rename from src/widget/text.rs rename to src/widget/text/mod.rs index 174da1e..271f46d 100644 --- a/src/widget/text.rs +++ b/src/widget/text/mod.rs @@ -10,6 +10,9 @@ use crate::types::{ Color, Rect }; use crate::render::Canvas; use super::Element; +#[ cfg( test ) ] +mod tests; + #[ derive( Debug, Clone, Copy, PartialEq ) ] pub enum TextAlign { @@ -297,101 +300,3 @@ impl From for Element Element::Text( t ) } } - -#[ cfg( test ) ] -mod tests -{ - use super::*; - use crate::render::Canvas; - - fn make_canvas() -> Canvas { Canvas::new( 800, 600 ) } - - #[ test ] - fn new_uses_documented_defaults() - { - let t = Text::new( "hello" ); - assert_eq!( t.content, "hello" ); - assert_eq!( t.size, 16.0 ); - assert_eq!( t.color, Color::WHITE ); - assert_eq!( t.align, TextAlign::Left ); - } - - #[ test ] - fn size_builder_overrides_default() - { - let t = Text::new( "" ).size( 32.0 ); - assert_eq!( t.size, 32.0 ); - } - - #[ test ] - fn color_builder_overrides_default() - { - let t = Text::new( "" ).color( Color::rgb( 1.0, 0.0, 0.0 ) ); - assert_eq!( t.color, Color::rgb( 1.0, 0.0, 0.0 ) ); - } - - #[ test ] - fn align_builder_can_target_each_variant() - { - assert_eq!( Text::new( "" ).align( TextAlign::Left ).align, TextAlign::Left ); - assert_eq!( Text::new( "" ).align( TextAlign::Center ).align, TextAlign::Center ); - assert_eq!( Text::new( "" ).align( TextAlign::Right ).align, TextAlign::Right ); - } - - #[ test ] - fn align_center_shorthand_matches_explicit_align() - { - let a = Text::new( "" ).align_center(); - let b = Text::new( "" ).align( TextAlign::Center ); - assert_eq!( a.align, b.align ); - } - - #[ test ] - fn preferred_size_returns_non_negative_dimensions() - { - let canvas = make_canvas(); - let ( w, h ) = Text::new( "ltk" ).preferred_size( 200.0, &canvas ); - assert!( w >= 0.0 ); - assert!( h > 0.0, "non-empty text reports a positive line height" ); - } - - #[ test ] - fn preferred_size_caps_width_at_max_width() - { - // A long string must not exceed the parent-supplied bound — text - // elides at draw time, so the layout pass needs to see at most - // `max_width`. - let canvas = make_canvas(); - let long = "the quick brown fox jumps over the lazy dog"; - let ( w, _ ) = Text::new( long ).size( 24.0 ).preferred_size( 64.0, &canvas ); - assert!( w <= 64.0, "preferred width must be clamped to max_width (got {w})" ); - } - - #[ test ] - fn preferred_size_height_scales_with_font_size() - { - let canvas = make_canvas(); - let ( _, h_small ) = Text::new( "x" ).size( 12.0 ).preferred_size( 200.0, &canvas ); - let ( _, h_large ) = Text::new( "x" ).size( 48.0 ).preferred_size( 200.0, &canvas ); - assert!( h_large > h_small, "larger font must report taller line height" ); - } - - #[ test ] - fn empty_content_still_reports_a_line_height() - { - let canvas = make_canvas(); - let ( _, h ) = Text::new( "" ).preferred_size( 200.0, &canvas ); - // Even an empty string keeps the baseline metrics so a hidden field - // keeps its row height inside a column. - assert!( h > 0.0 ); - } - - #[ test ] - fn text_align_enum_implements_partial_eq() - { - // Compile-time guard: TextAlign must keep deriving PartialEq so - // downstream theming code can compare alignments. - assert_eq!( TextAlign::Left, TextAlign::Left ); - assert_ne!( TextAlign::Left, TextAlign::Right ); - } -} diff --git a/src/widget/text/tests.rs b/src/widget/text/tests.rs new file mode 100644 index 0000000..88fe232 --- /dev/null +++ b/src/widget/text/tests.rs @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use super::*; +use crate::render::Canvas; + +fn make_canvas() -> Canvas { Canvas::new( 800, 600 ) } + +#[ test ] +fn new_uses_documented_defaults() +{ + let t = Text::new( "hello" ); + assert_eq!( t.content, "hello" ); + assert_eq!( t.size, 16.0 ); + assert_eq!( t.color, Color::WHITE ); + assert_eq!( t.align, TextAlign::Left ); +} + +#[ test ] +fn size_builder_overrides_default() +{ + let t = Text::new( "" ).size( 32.0 ); + assert_eq!( t.size, 32.0 ); +} + +#[ test ] +fn color_builder_overrides_default() +{ + let t = Text::new( "" ).color( Color::rgb( 1.0, 0.0, 0.0 ) ); + assert_eq!( t.color, Color::rgb( 1.0, 0.0, 0.0 ) ); +} + +#[ test ] +fn align_builder_can_target_each_variant() +{ + assert_eq!( Text::new( "" ).align( TextAlign::Left ).align, TextAlign::Left ); + assert_eq!( Text::new( "" ).align( TextAlign::Center ).align, TextAlign::Center ); + assert_eq!( Text::new( "" ).align( TextAlign::Right ).align, TextAlign::Right ); +} + +#[ test ] +fn align_center_shorthand_matches_explicit_align() +{ + let a = Text::new( "" ).align_center(); + let b = Text::new( "" ).align( TextAlign::Center ); + assert_eq!( a.align, b.align ); +} + +#[ test ] +fn preferred_size_returns_non_negative_dimensions() +{ + let canvas = make_canvas(); + let ( w, h ) = Text::new( "ltk" ).preferred_size( 200.0, &canvas ); + assert!( w >= 0.0 ); + assert!( h > 0.0, "non-empty text reports a positive line height" ); +} + +#[ test ] +fn preferred_size_caps_width_at_max_width() +{ + // A long string must not exceed the parent-supplied bound — text + // elides at draw time, so the layout pass needs to see at most + // `max_width`. + let canvas = make_canvas(); + let long = "the quick brown fox jumps over the lazy dog"; + let ( w, _ ) = Text::new( long ).size( 24.0 ).preferred_size( 64.0, &canvas ); + assert!( w <= 64.0, "preferred width must be clamped to max_width (got {w})" ); +} + +#[ test ] +fn preferred_size_height_scales_with_font_size() +{ + let canvas = make_canvas(); + let ( _, h_small ) = Text::new( "x" ).size( 12.0 ).preferred_size( 200.0, &canvas ); + let ( _, h_large ) = Text::new( "x" ).size( 48.0 ).preferred_size( 200.0, &canvas ); + assert!( h_large > h_small, "larger font must report taller line height" ); +} + +#[ test ] +fn empty_content_still_reports_a_line_height() +{ + let canvas = make_canvas(); + let ( _, h ) = Text::new( "" ).preferred_size( 200.0, &canvas ); + // Even an empty string keeps the baseline metrics so a hidden field + // keeps its row height inside a column. + assert!( h > 0.0 ); +} + +#[ test ] +fn text_align_enum_implements_partial_eq() +{ + // Compile-time guard: TextAlign must keep deriving PartialEq so + // downstream theming code can compare alignments. + assert_eq!( TextAlign::Left, TextAlign::Left ); + assert_ne!( TextAlign::Left, TextAlign::Right ); +} diff --git a/src/widget/text_edit.rs b/src/widget/text_edit.rs deleted file mode 100644 index b70293a..0000000 --- a/src/widget/text_edit.rs +++ /dev/null @@ -1,2029 +0,0 @@ -// SPDX-License-Identifier: LGPL-2.1-only -// Copyright (C) 2026 Liberux Labs, S. L. - -use std::sync::Arc; -use crate::types::{ Rect, WidgetId }; -use crate::render::Canvas; -use crate::secure_mem::secure_zero; -use super::Element; - -/// One visual row of a wrapped multiline TextEdit. `start..end` is a -/// byte range inside the value; the renderer draws those bytes on a -/// single visual row, even if the underlying logical line (the run -/// between two `\n` characters) spans several rows. Soft wraps do -/// not mutate the buffer — the value stays a flat string with hard -/// breaks; only the rendering / hit-testing model treats wraps as -/// row boundaries. -#[ derive( Clone, Copy, Debug ) ] -pub( crate ) struct VisualLine -{ - pub start: usize, - pub end: usize, -} - -/// Wrap `value` into a list of visual lines that each fit within -/// `inner_width` when rendered at `font_size`. Hard `\n` breaks are -/// always row boundaries; long logical lines are additionally split -/// at the last whitespace before the row would overflow, falling -/// back to a per-character break inside a single oversized word. -/// -/// O(N) per call where N is `value.len()` — `draw_multiline` runs -/// it once per frame, which is fine for the kilobyte-scale buffers a -/// `text_edit` is meant to hold (anything larger should use a -/// dedicated text-area widget with cached layout). -pub( crate ) fn compute_visual_lines( - canvas: &Canvas, - value: &str, - inner_width: f32, - font_size: f32, -) -> Vec -{ - let mut out = Vec::new(); - if value.is_empty() - { - out.push( VisualLine { start: 0, end: 0 } ); - return out; - } - let max_w = inner_width.max( 1.0 ); - let mut byte_pos = 0usize; - for logical_line in value.split( '\n' ) - { - let line_start = byte_pos; - let line_end = byte_pos + logical_line.len(); - if line_start == line_end - { - // Empty logical line still occupies a row. - out.push( VisualLine { start: line_start, end: line_end } ); - } else { - wrap_logical_line( canvas, value, line_start, line_end, max_w, font_size, &mut out ); - } - byte_pos = line_end + 1; // step past the '\n' - } - out -} - -fn wrap_logical_line( - canvas: &Canvas, - value: &str, - start: usize, - end: usize, - max_width: f32, - font_size: f32, - out: &mut Vec, -) -{ - let mut cur = start; - while cur < end - { - let segment = &value[cur..end]; - let break_at = find_wrap_offset( canvas, segment, max_width, font_size ); - // Always advance at least one byte so a degenerate measure - // (zero-width font, ridiculously narrow rect) still - // terminates instead of looping forever. - let raw_next = cur + break_at; - let next = raw_next.max( cur + 1 ).min( end ); - out.push( VisualLine { start: cur, end: next } ); - cur = next; - } -} - -/// Walk `segment` left-to-right accumulating glyph widths and return -/// the byte offset (within `segment`) where the visual line should -/// break. Prefers a break *after* the most recent whitespace; falls -/// back to mid-character break if a single word is wider than -/// `max_width`. -fn find_wrap_offset( - canvas: &Canvas, - segment: &str, - max_width: f32, - font_size: f32, -) -> usize -{ - let mut acc_w = 0.0f32; - let mut last_break_after: Option = None; - for ( i, ch ) in segment.char_indices() - { - let glyph = ch.to_string(); - let w = canvas.measure_text( &glyph, font_size ); - if acc_w + w > max_width && i > 0 - { - return last_break_after.unwrap_or( i ); - } - acc_w += w; - if ch == ' ' || ch == '\t' - { - last_break_after = Some( i + ch.len_utf8() ); - } - } - segment.len() -} - -/// Axis-aligned intersection of two rects. Returns `None` when the rects -/// do not overlap (zero-area / negative-extent results count as -/// "no overlap" so the caller can `filter_map` straight into a clip -/// list). -fn rect_intersect( a: Rect, b: Rect ) -> Option -{ - let x0 = a.x.max( b.x ); - let y0 = a.y.max( b.y ); - let x1 = ( a.x + a.width ).min( b.x + b.width ); - let y1 = ( a.y + a.height ).min( b.y + b.height ); - if x1 > x0 && y1 > y0 - { - Some( Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } ) - } else { - None - } -} - -/// Convert a pointer position inside the widget rect to the byte offset -/// in `value` the cursor should land on. Free function so the runtime -/// can call it with the value snapshot it already holds in -/// [`crate::widget::WidgetHandlers::TextEdit`] without re-walking the -/// element tree to find the original [`TextEdit`] struct. -/// -/// `multiline` and `secure` mirror the matching fields on the source -/// widget; `cursor_pos` is the current cursor — needed by the -/// multiline branch to reproduce the same vertical scroll the most -/// recent [`TextEdit::draw`] call computed. -pub( crate ) fn byte_offset_at( - canvas: &Canvas, - rect: Rect, - pos: crate::types::Point, - value: &str, - multiline: bool, - secure: bool, - cursor_pos: usize, - align: super::text::TextAlign, - font_size: f32, -) -> usize -{ - if value.is_empty() { return 0; } - - if multiline && !secure - { - let line_h = theme::FONT_SIZE * theme::LINE_H_MULT; - let inner_h = ( rect.height - theme::PAD_V_MULTI * 2.0 ).max( line_h ); - let visible_lines = ( inner_h / line_h ).floor().max( 1.0 ) as usize; - let inner_width = ( rect.width - theme::PAD_H * 2.0 ).max( theme::FONT_SIZE ); - - // Recompute the visual layout from current state. Same call - // as `draw_multiline` makes, so the click maps to the line / - // column the user actually sees. - let visual_lines = compute_visual_lines( canvas, value, inner_width, theme::FONT_SIZE ); - let safe_cursor = cursor_pos.min( value.len() ); - let cursor_visual_idx = visual_lines.iter() - .position( |vl| safe_cursor >= vl.start && safe_cursor <= vl.end ) - .unwrap_or( visual_lines.len().saturating_sub( 1 ) ); - let total_lines = visual_lines.len(); - let max_first = total_lines.saturating_sub( visible_lines ); - let first_line = if cursor_visual_idx + 1 > visible_lines - { - ( cursor_visual_idx + 1 - visible_lines ).min( max_first ) - } else { 0 }; - - let visual_idx = ( ( pos.y - rect.y - theme::PAD_V_MULTI ) / line_h ) - .max( 0.0 ) as usize; - let target_idx = ( first_line + visual_idx ).min( total_lines.saturating_sub( 1 ) ); - let vl = visual_lines[ target_idx ]; - - byte_offset_in_line( canvas, value, vl.start, vl.end, pos.x - rect.x - theme::PAD_H, false, theme::FONT_SIZE ) - } else { - // Mirror the horizontal scroll *and* alignment the renderer - // applies so a click lands on the glyph the user actually - // sees, not on the byte offset that would correspond to an - // unscrolled, left-aligned layout. - let scroll_x = single_line_scroll_x( canvas, rect, value, cursor_pos, secure, font_size ); - let align_x = single_line_align_offset( canvas, rect, value, secure, align, font_size ); - byte_offset_in_line( - canvas, value, 0, value.len(), - pos.x - rect.x - theme::PAD_H - align_x + scroll_x, - secure, - font_size, - ) - } -} - -/// Horizontal scroll offset, in pixels, applied to a single-line -/// `text_edit` so the cursor stays visible inside the inner rect when -/// the value is wider than the box. Stateless — derived purely from -/// the current cursor position. Three regimes: -/// -/// * cursor in the **left half** of the visible band → no scroll, the -/// start of the text reads naturally; -/// * cursor in the **right half from the end** → anchor to end so the -/// tail of the value is in view (this is the "typing past the right -/// edge" case the user sees the most); -/// * cursor anywhere else → centre the cursor in the visible band so -/// navigation with the arrow keys keeps trailing context on both -/// sides instead of jumping the text on every keystroke. -/// -/// `secure` toggles bullet substitution before measuring so a -/// password field scrolls based on the displayed bullets, not the -/// underlying value's glyph widths. -pub( crate ) fn single_line_scroll_x( - canvas: &Canvas, - rect: Rect, - value: &str, - cursor: usize, - secure: bool, - font_size: f32, -) -> f32 -{ - let inner_width = ( rect.width - theme::PAD_H * 2.0 ).max( font_size ); - let safe_cursor = cursor.min( value.len() ); - - let display_value: String = if secure - { - "\u{2022}".repeat( value.chars().count() ) - } else { - value.to_string() - }; - let prefix_text: String = if secure - { - "\u{2022}".repeat( value[..safe_cursor].chars().count() ) - } else { - value[..safe_cursor].to_string() - }; - - let total_w = canvas.measure_text( &display_value, font_size ); - if total_w <= inner_width { return 0.0; } - let prefix_w = canvas.measure_text( &prefix_text, font_size ); - let suffix_w = ( total_w - prefix_w ).max( 0.0 ); - const MARGIN: f32 = 4.0; - - if prefix_w <= inner_width / 2.0 - { - 0.0 - } else if suffix_w <= inner_width / 2.0 { - ( total_w - inner_width + MARGIN ).max( 0.0 ) - } else { - prefix_w - inner_width / 2.0 - } -} - -/// Horizontal offset, in pixels, applied on top of `single_line_scroll_x` -/// to honour the field's [`super::text::TextAlign`]. Only meaningful -/// while the value still fits inside `inner_width`; once the text -/// overflows, scrolling owns the layout and the alignment offset -/// collapses to `0` so anchoring the cursor / end of text reads -/// naturally without fighting the alignment. -pub( crate ) fn single_line_align_offset( - canvas: &Canvas, - rect: Rect, - value: &str, - secure: bool, - align: super::text::TextAlign, - font_size: f32, -) -> f32 -{ - use super::text::TextAlign; - if matches!( align, TextAlign::Left ) { return 0.0; } - let inner_width = ( rect.width - theme::PAD_H * 2.0 ).max( font_size ); - let display_value: String = if secure - { - "\u{2022}".repeat( value.chars().count() ) - } else { - value.to_string() - }; - let total_w = canvas.measure_text( &display_value, font_size ); - if total_w >= inner_width { return 0.0; } - match align - { - TextAlign::Left => 0.0, - TextAlign::Center => ( inner_width - total_w ) * 0.5, - TextAlign::Right => inner_width - total_w, - } -} - -/// Walk the slice `value[line_start..line_end]` char by char, -/// accumulating widths, and return the byte offset (within `value`) -/// where the click `target_x` falls. The "snap to nearest boundary" -/// rule is the standard `text input is text input` behaviour: if the -/// click is past the half-glyph mark, the cursor lands *after* the -/// glyph. `secure` toggles bullet substitution at measurement time so -/// the same logic works for password fields. -fn byte_offset_in_line( - canvas: &Canvas, - value: &str, - line_start: usize, - line_end: usize, - target_x: f32, - secure: bool, - font_size: f32, -) -> usize -{ - let line = &value[line_start..line_end]; - if target_x <= 0.0 { return line_start; } - let mut acc_w = 0.0f32; - let mut last_byte = line_start; - for ( ofs, ch ) in line.char_indices() - { - let glyph_str = if secure { "\u{2022}".to_string() } else { ch.to_string() }; - let w = canvas.measure_text( &glyph_str, font_size ); - if target_x < acc_w + w * 0.5 - { - return line_start + ofs; - } - acc_w += w; - last_byte = line_start + ofs + ch.len_utf8(); - } - last_byte.min( line_end ) -} - -/// Locate the visual line containing `cursor` in the wrap layout for -/// `value` inside `rect`. When the cursor sits exactly on a soft-wrap -/// boundary (so it satisfies both the previous line's `end` and the -/// next line's `start`), prefer the *previous* line — that matches the -/// rendering convention in `draw_multiline`, which paints the caret at -/// the trailing edge of the prior visual row rather than the leading -/// edge of the new one. -fn current_visual_line( - canvas: &Canvas, - rect: Rect, - value: &str, - cursor: usize, -) -> Option<( Vec, usize )> -{ - let inner_width = ( rect.width - theme::PAD_H * 2.0 ).max( theme::FONT_SIZE ); - let visual_lines = compute_visual_lines( canvas, value, inner_width, theme::FONT_SIZE ); - if visual_lines.is_empty() { return None; } - let safe_cursor = cursor.min( value.len() ); - let idx = visual_lines.iter() - .position( |vl| safe_cursor >= vl.start && safe_cursor <= vl.end ) - .unwrap_or( visual_lines.len() - 1 ); - Some( ( visual_lines, idx ) ) -} - -/// Move the text cursor one visual row up. Returns the new byte offset -/// or `None` when the cursor is already on the first visual row, so -/// the caller can fall through to sibling-widget keyboard navigation. -/// -/// Visual X is preserved across the move: the new cursor lands as close -/// as possible (with snap-to-nearest-glyph) to the same horizontal -/// position the cursor had on its origin line. No "preferred column" -/// state is kept across multiple consecutive Up presses, so a long -/// short → long sequence will drift toward the end of every short -/// line — same simplification GTK / Cocoa make for plain controls. -pub( crate ) fn cursor_visual_up( - canvas: &Canvas, - rect: Rect, - value: &str, - cursor: usize, -) -> Option -{ - let ( lines, idx ) = current_visual_line( canvas, rect, value, cursor )?; - if idx == 0 { return None; } - let cur = lines[ idx ]; - let safe = cursor.min( value.len() ); - let target_x = canvas.measure_text( &value[cur.start..safe], theme::FONT_SIZE ); - let prev = lines[ idx - 1 ]; - Some( byte_offset_in_line( canvas, value, prev.start, prev.end, target_x, false, theme::FONT_SIZE ) ) -} - -/// Mirror of [`cursor_visual_up`] for the Down arrow. -pub( crate ) fn cursor_visual_down( - canvas: &Canvas, - rect: Rect, - value: &str, - cursor: usize, -) -> Option -{ - let ( lines, idx ) = current_visual_line( canvas, rect, value, cursor )?; - if idx + 1 >= lines.len() { return None; } - let cur = lines[ idx ]; - let safe = cursor.min( value.len() ); - let target_x = canvas.measure_text( &value[cur.start..safe], theme::FONT_SIZE ); - let next = lines[ idx + 1 ]; - Some( byte_offset_in_line( canvas, value, next.start, next.end, target_x, false, theme::FONT_SIZE ) ) -} - -/// Byte offset of the start of the cursor's current visual line. -/// Falls back to `0` when the wrap layout cannot be computed (e.g. -/// degenerate rect), so the Home key always has a sensible target. -pub( crate ) fn cursor_visual_home( - canvas: &Canvas, - rect: Rect, - value: &str, - cursor: usize, -) -> usize -{ - current_visual_line( canvas, rect, value, cursor ) - .map( |( lines, idx )| lines[ idx ].start ) - .unwrap_or( 0 ) -} - -/// Byte offset of the end of the cursor's current visual line. For a -/// hard-`\n`-terminated row this is the byte just before the newline; -/// for a soft-wrapped row it is the wrap point (which is also the -/// start of the next visual row, but the caret renders at the trailing -/// edge of *this* row by convention). -pub( crate ) fn cursor_visual_end( - canvas: &Canvas, - rect: Rect, - value: &str, - cursor: usize, -) -> usize -{ - current_visual_line( canvas, rect, value, cursor ) - .map( |( lines, idx )| lines[ idx ].end ) - .unwrap_or( value.len() ) -} - -/// Hit rect for the eye icon at the right edge of a `TextEdit` -/// configured with [`TextEdit::password_toggle`]. Returned in the -/// same coordinate space as the field's `rect`. Pointer / touch -/// dispatch consult this before falling through to cursor placement -/// — a tap inside the zone fires the toggle message instead of -/// moving the caret. -pub fn password_toggle_hit_zone( rect: Rect ) -> Rect -{ - let zone_w = ( theme::PASSWORD_TOGGLE_SIZE - + theme::PASSWORD_TOGGLE_SLOP * 2.0 - + theme::PAD_H * 0.5 ).min( rect.width ); - Rect - { - x: rect.x + rect.width - zone_w, - y: rect.y, - width: zone_w, - height: rect.height, - } -} - -mod theme -{ - use crate::types::Color; - pub fn bg() -> Color { crate::theme::palette().surface_alt } - pub fn border() -> Color { crate::theme::palette().divider } - pub fn text() -> Color { crate::theme::palette().text_primary } - pub fn placeholder() -> Color { crate::theme::palette().text_secondary } - pub fn focus_border() -> Color { crate::theme::palette().text_primary } - pub fn cursor() -> Color { crate::theme::palette().text_primary } - /// Selection highlight — accent colour at low opacity so the text - /// underneath stays readable. - pub fn selection() -> Color - { - let a = crate::theme::palette().accent; - Color::rgba( a.r, a.g, a.b, 0.35 ) - } - pub const BORDER_W: f32 = 1.5; - pub const FOCUS_BORDER_W: f32 = 2.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 = 20.0; - /// Vertical padding inside multiline boxes — the single-line variant - /// centres the text vertically in `HEIGHT`, so it doesn't need this. - pub const PAD_V_MULTI: f32 = 12.0; - /// Line height multiplier on top of [`FONT_SIZE`] when laying out - /// multiline content. 1.4 leaves enough room above and below each - /// line that adjacent rows do not visually crowd each other. - pub const LINE_H_MULT: f32 = 1.4; - /// Default visible row count for a [`TextEdit`] in multiline mode. - pub const ROWS_DEFAULT: u32 = 5; - /// Corner radius applied to multiline boxes — the pill shape used by - /// the single-line variant looks wrong at multi-row heights. - pub const RADIUS_MULTI: f32 = 12.0; - /// Side length, in logical pixels, of the - /// [`super::TextEdit::password_toggle`] eye icon. - pub const PASSWORD_TOGGLE_SIZE: f32 = 20.0; - /// Extra horizontal slop on each side of the eye icon's hit zone - /// to make the toggle finger-friendly even on touch panels. - pub const PASSWORD_TOGGLE_SLOP: f32 = 4.0; -} - -/// A text input field. -/// -/// Single-line by default; switches to a multi-row text-area via -/// [`Self::multiline`]. Single-line mode honours the optional inline -/// builders for picker-style fields: -/// -/// * [`Self::align`] — horizontal alignment of the displayed text; -/// * [`Self::borderless`] — drop the surrounding pill / border so the -/// field can sit inside a parent that already paints its own -/// surface; -/// * [`Self::fixed_width`] — pin the preferred width to a specific -/// number of pixels instead of claiming `max_width`; -/// * [`Self::font_size`] — override the text font size; -/// * [`Self::select_on_focus`] — auto-select the value on focus so -/// the next keystroke replaces it (numeric pickers, short-form -/// inputs). -/// * [`Self::password_toggle`] — pin a built-in show / hide-password -/// eye icon to the right edge of the field; the bullet -/// substitution flips with the externally-owned `visible` state -/// on each tap. -/// -/// ```rust,no_run -/// # use ltk::{ text_edit, Element }; -/// # #[ derive( Clone ) ] enum Msg { UsernameChanged( String ), Submit } -/// # struct App { username: String } -/// # impl App { fn _ex( &self ) -> Element { -/// text_edit( "Username", &self.username ) -/// .on_change( |s| Msg::UsernameChanged( s ) ) -/// .on_submit( Msg::Submit ) -/// .into() -/// # }} -/// ``` -/// -/// ## Password field with show / hide toggle -/// -/// ```rust,no_run -/// # use ltk::{ text_edit, Element }; -/// # #[ derive( Clone ) ] enum Msg { PasswordChanged( String ), TogglePassword } -/// # struct App { password: String, password_visible: bool } -/// # impl App { fn _ex( &self ) -> Element { -/// text_edit( "Password", &self.password ) -/// .on_change( |s| Msg::PasswordChanged( s ) ) -/// .password_toggle( self.password_visible, Msg::TogglePassword ) -/// .into() -/// # }} -/// ``` -/// -/// `password_toggle` overrides [`Self::secure`] when both are set — -/// the toggle's `visible` parameter drives the bullet substitution -/// from then on. The widget still wipes the buffer on drop and -/// skips the IME registration (the same hardening -/// [`Self::secure`] gives) regardless of the current visibility, -/// so flipping the eye does not weaken the field's threat model -/// at runtime. -pub struct TextEdit -{ - /// Placeholder text shown when the field is empty. - pub placeholder: String, - /// Current field value. - pub value: String, - /// Callback invoked with the new value on every keystroke. - /// `Arc` (not `Box`) so the layout pass can clone it into the per-leaf - /// handler snapshot for O(1) dispatch on input events. - pub on_change: Option Msg>>, - /// Message emitted when the user presses Enter. - pub on_submit: Option, - /// When `true`, the value is rendered as bullet characters (password mode). - pub secure: bool, - /// When `true`, the widget renders as a multi-row text area: the - /// box grows to [`Self::rows`] visible rows, line breaks in the - /// value are honoured at draw time, and pressing Enter inserts a - /// `\n` rather than firing [`Self::on_submit`]. Ignored when - /// [`Self::secure`] is set — passwords are always single-line. - pub multiline: bool, - /// Visible row count when `multiline` is `true`. Drives - /// `preferred_size`'s height calculation so a multiline field - /// claims a sensible vertical slot in the parent layout. Ignored - /// when `multiline` is `false`. - pub rows: u32, - /// Byte offset of the text cursor within `value` (used by insert_str/backspace). - pub cursor_pos: usize, - /// Optional stable identifier for focus management. - pub id: Option, - /// Override the pointer cursor shape on hover. `None` falls back - /// to the I-beam default that matches every desktop convention. - pub cursor: Option, - /// Horizontal alignment of the displayed text inside the inner - /// content rect. Only takes effect on the single-line path when - /// the value fits inside the inner width — once the value - /// overflows, the internal `single_line_scroll_x` helper takes - /// over and the alignment offset collapses to `0` so scrolling - /// reads naturally. Default `TextAlign::Left`. - pub align: super::text::TextAlign, - /// Skip the field's background fill and border stroke. Useful - /// when the [`TextEdit`] is dropped inside another container - /// that paints its own surface (e.g. the digit cells inside - /// [`crate::widget::time_picker::TimePicker`]) and a second pill - /// would only add visual noise. - pub borderless: bool, - /// Override the preferred width reported to the parent layout. - /// Without this the single-line `TextEdit` claims `max_width` and - /// fills whatever rect the parent allocates — which is the right - /// default for forms but wrong when the field needs to be sized - /// to fit a fixed number of glyphs (date / time pickers, inline - /// numeric inputs). - pub fixed_width: Option, - /// Font size in pixels for the single-line draw path. Defaults to - /// the theme's `FONT_SIZE` constant. Multiline mode ignores this - /// for now and always uses the default — multiline soft-wrap - /// layout depends on the constant in several places that are not - /// yet parameterised. - pub font_size: f32, - /// When `true`, focusing the field selects the whole value so the - /// next keystroke replaces it. Standard behaviour for numeric - /// pickers and short-form fields where the user usually wants to - /// retype rather than edit. Default `false` — long-form fields - /// keep the cursor at the end on focus. - pub select_on_focus: bool, - /// Self-managed "show / hide password" eye affordance — when - /// `Some( ( visible, on_toggle ) )` the field renders an - /// `actions/visible` ↔ `actions/invisible` icon at its right - /// edge, taps on that icon dispatch `on_toggle` instead of - /// placing the cursor, and the bullet substitution flips with - /// `visible` (overriding `secure`). Set on a field that already - /// has `secure( true )` and the explicit flag becomes redundant - /// — the toggle controls the visibility from then on. - pub password_toggle: Option<( bool, Msg )>, -} - -impl TextEdit -{ - /// Create a text field with the given placeholder and initial value. - /// - /// The cursor is placed at the end of the initial value. - pub fn new( placeholder: String, value: String ) -> Self - { - let cursor_pos = value.len(); - Self - { - placeholder, - value, - on_change: None, - on_submit: None, - secure: false, - multiline: false, - rows: theme::ROWS_DEFAULT, - cursor_pos, - id: None, - cursor: None, - align: super::text::TextAlign::Left, - borderless: false, - fixed_width: None, - font_size: theme::FONT_SIZE, - select_on_focus: false, - password_toggle: None, - } - } - - /// Add a "show / hide password" eye toggle pinned to the right - /// edge of the field. `visible` controls whether the value - /// renders as bullets (`false`) or plain text (`true`); a tap on - /// the icon emits `on_toggle` so the caller can flip its own - /// `bool` state and re-render. Works with or without an explicit - /// [`Self::secure`] — when this is set, the toggle's `visible` - /// drives the bullet substitution and the `secure` field is - /// ignored. - pub fn password_toggle( mut self, visible: bool, on_toggle: Msg ) -> Self - { - self.password_toggle = Some( ( visible, on_toggle ) ); - self - } - - /// Effective secure flag honoured by drawing / measurement / - /// hit-testing — [`Self::password_toggle`] takes precedence over - /// the manual [`Self::secure`] when both are set. - pub fn effective_secure( &self ) -> bool - { - match &self.password_toggle - { - Some( ( visible, _ ) ) => !visible, - None => self.secure, - } - } - - /// Override the font size used by the single-line draw path. - /// Defaults to the theme's `FONT_SIZE` constant. Ignored in - /// multiline mode. - pub fn font_size( mut self, px: f32 ) -> Self - { - self.font_size = px.max( 1.0 ); - self - } - - /// Select the whole value when the field receives focus, so the - /// next keystroke replaces it. Default `false`. - pub fn select_on_focus( mut self, on: bool ) -> Self - { - self.select_on_focus = on; - self - } - - /// Set the horizontal alignment of the displayed text. Default - /// [`TextAlign::Left`](super::text::TextAlign::Left). - pub fn align( mut self, a: super::text::TextAlign ) -> Self - { - self.align = a; - self - } - - /// Skip the field's background fill and border stroke — useful - /// when the field is nested inside a container that already - /// paints its own surface. - pub fn borderless( mut self, on: bool ) -> Self - { - self.borderless = on; - self - } - - /// Override the preferred width reported to the parent layout. - /// Pass `None` (default) to fall back to claiming `max_width`. - pub fn fixed_width( mut self, w: f32 ) -> Self - { - self.fixed_width = Some( w ); - self - } - - /// Override the pointer cursor shape shown on hover. Defaults to - /// [`CursorShape::Text`](crate::CursorShape::Text) (I-beam). - pub fn cursor( mut self, shape: crate::types::CursorShape ) -> Self - { - self.cursor = Some( shape ); - self - } - - /// Switch to multiline (text-area) mode. The box is laid out with - /// [`Self::rows`] visible rows of height, line breaks in the value - /// are rendered as separate rows, and Enter inserts a `\n` instead - /// of firing [`Self::on_submit`]. Ignored when [`Self::secure`] is - /// `true`. - pub fn multiline( mut self, m: bool ) -> Self - { - self.multiline = m; - self - } - - /// Configure the number of visible rows in multiline mode. Defaults - /// to 5; ignored when [`Self::multiline`] is `false`. - pub fn rows( mut self, n: u32 ) -> Self - { - self.rows = n.max( 1 ); - self - } - - /// Set the callback invoked on every keystroke with the updated value. - pub fn on_change( mut self, f: impl Fn(String) -> Msg + 'static ) -> Self - { - self.on_change = Some( Arc::new( f ) ); - self - } - - /// Set the message emitted when Enter is pressed. - pub fn on_submit( mut self, msg: Msg ) -> Self - { - self.on_submit = Some( msg ); - self - } - - /// Enable or disable password mode. - /// - /// When `true`, this widget: - /// - /// 1. Renders the value as bullet characters (`•`) instead of the - /// raw glyphs. - /// 2. Forces single-line mode (multiline + secure is mutually - /// exclusive — passwords don't have line breaks). - /// 3. Wipes the underlying byte buffer with zero before the - /// `String` allocation is returned to the allocator. The wipe - /// runs in `Drop` for both the `TextEdit` itself and for the - /// per-frame [`crate::widget::WidgetHandlers::TextEdit`] snapshot - /// the runtime keeps for input dispatch — so the in-tree copies - /// that ltk owns never linger as plain text in freed memory. - /// - /// # Threat model — what `secure` covers - /// - /// Inside the widget tree the runtime keeps two copies of the - /// value for the lifetime of one frame: the `TextEdit` itself and - /// the `WidgetHandlers` snapshot. Both run the `secure_zero` wipe - /// on `Drop`, so when the next frame replaces them (the typical - /// case — `view()` rebuilds every frame) the freed allocations are - /// overwritten before being released back to the allocator. The - /// wipe uses volatile writes + a `compiler_fence` so the optimiser - /// cannot elide it as dead code (the implementation is in the - /// crate-private `secure_mem` module). - /// - /// # What `secure` does **not** cover - /// - /// * **The application's own state.** The `String` you pass in - /// through `text_edit( placeholder, &self.password )` lives on - /// **your** struct, not on the widget. Wiping it is - /// your job — typically a `Drop` impl on the credential - /// container, or an explicit - /// `secure_mem::secure_zero( password.as_bytes_mut() )` after - /// the auth handshake completes. - /// * **Callback-allocated copies.** Every keystroke passes through - /// `on_change( |s: String| ... )`, which receives a fresh - /// `String` clone. If your closure stores or forwards that - /// `String` (e.g. clone it into a worker thread for PAM), each - /// stored copy is the consumer's responsibility to wipe. ltk - /// only owns the buffers it allocated itself. - /// * **OS-level disclosure surfaces.** Swap-out, hibernation - /// images, and core dumps are outside any user-space wipe's - /// reach. For threat models that require resistance to these, - /// compile against an `mlock`-aware allocator, disable swap on - /// the credential mount, and restrict core-dump capability with - /// `prctl( PR_SET_DUMPABLE, 0 )` on the process. - /// * **Compositor-side records.** Wayland text-input protocols can - /// surface preedit / commit strings to the compositor's IME - /// stack; `secure` skips text-input-v3 registration so this path - /// stays closed. Verify your compositor honours that — most do, - /// but the protocol does not strictly require it. - /// - /// See the in-repo `SECURITY.md` for the full threat-model write-up - /// (the *Hardening features* section enumerates each guarantee and - /// its boundary). - pub fn secure( mut self, s: bool ) -> Self - { - self.secure = s; - self - } - - /// Assign a stable identifier for focus management. - pub fn id( mut self, id: WidgetId ) -> Self - { - self.id = Some( id ); - self - } - - /// Return the preferred `(width, height)` given available `max_width`. - /// - /// Single-line: theme-defined `HEIGHT`. - /// Multiline: enough room for [`Self::rows`] lines plus padding. - pub fn preferred_size( &self, max_width: f32, _canvas: &Canvas ) -> (f32, f32) - { - if self.multiline && !self.effective_secure() - { - let line_h = theme::FONT_SIZE * theme::LINE_H_MULT; - let h = self.rows as f32 * line_h + theme::PAD_V_MULTI * 2.0; - ( max_width, h ) - } else { - let w = self.fixed_width - .map( |fw| fw.min( max_width ) ) - .unwrap_or( max_width ); - ( w, theme::HEIGHT ) - } - } - - /// `true` when the widget is laid out as a multi-row text area — - /// i.e. [`Self::multiline`] was set and [`Self::effective_secure`] - /// is `false`. A `password_toggle` field collapses to single-line - /// like an explicit `secure( true )` does. - pub fn is_multiline( &self ) -> bool - { - self.multiline && !self.effective_secure() - } - - /// Translate a pointer position inside `rect` to the byte offset - /// in [`Self::value`] that the cursor should land on. Thin - /// wrapper around `byte_offset_at` using this widget's value / - /// flags. - pub fn byte_offset_at_self( - &self, - canvas: &Canvas, - rect: Rect, - pos: crate::types::Point, - cursor_pos: usize, - ) -> usize - { - byte_offset_at( - canvas, rect, pos, &self.value, self.is_multiline(), self.effective_secure(), cursor_pos, self.align, self.font_size, - ) - } - - /// Border stroke is centered on `rect`, so half the stroke width plus ~1 px - /// of antialiasing bleed sits outside. The widest stroke is the focused - /// border, so use that as the envelope. - pub fn paint_bounds( &self, rect: Rect ) -> Rect - { - rect.expand( theme::FOCUS_BORDER_W * 0.5 + 1.0 ) - } - - /// Return the display string — bullet characters in secure mode, plain value otherwise. - pub fn display_text( &self ) -> String - { - if self.effective_secure() - { - "\u{2022}".repeat( self.value.chars().count() ) - } else { - self.value.clone() - } - } - - /// Draw the field into `canvas` at `rect`. - /// - /// `cursor_pos` is the byte offset of the text cursor — supplied by the runtime - /// from its persistent cursor state rather than from the widget itself. - /// `selection_anchor` is the other end of the selection range; when - /// `selection_anchor == cursor_pos` no highlight is painted. - pub fn draw( - &self, - canvas: &mut Canvas, - rect: Rect, - focused: bool, - cursor_pos: usize, - selection_anchor: usize, - ) - { - if self.is_multiline() - { - self.draw_multiline( canvas, rect, focused, cursor_pos, selection_anchor ); - return; - } - // Borderless fields skip the surface fill + border stroke - // entirely. They still get the inner clip + scroll + alignment - // treatment below, so the field behaves like a regular - // text input — only the chrome is suppressed. - if !self.borderless - { - let border_c = if focused { theme::focus_border() } else { theme::border() }; - let border_w = if focused { theme::FOCUS_BORDER_W } else { theme::BORDER_W }; - canvas.fill_rect( rect, theme::bg(), theme::RADIUS ); - canvas.stroke_rect( rect, border_c, border_w, theme::RADIUS ); - } - - let font_size = self.font_size; - let text_y = rect.y + (rect.height + font_size) / 2.0 - 2.0; - let text = self.display_text(); - let secure = self.effective_secure(); - // When `password_toggle` is set we reserve a column on the - // right edge for the eye icon: scroll / align / clip all - // behave as if the field were narrower so cursor + text - // never slide under the icon. - let toggle_reserve = if self.password_toggle.is_some() - { - theme::PASSWORD_TOGGLE_SIZE + theme::PASSWORD_TOGGLE_SLOP * 2.0 - } - else - { - 0.0 - }; - let text_rect = Rect - { - width: ( rect.width - toggle_reserve ).max( theme::PAD_H * 2.0 ), - ..rect - }; - let scroll_x = single_line_scroll_x( canvas, text_rect, &self.value, cursor_pos, secure, font_size ); - let align_x = single_line_align_offset( canvas, text_rect, &self.value, secure, self.align, font_size ); - - // Clip text / cursor / selection to the inner band so the - // scrolled-off portion does not bleed past the pill stroke. - // Save the parent clip first, intersect with our inner rect, - // and restore on exit so a `text_edit` wrapped in scroll(...) - // keeps the parent's clip honoured. - let outer_clip = canvas.clip_bounds(); - let inner_rect = Rect - { - x: rect.x + theme::PAD_H * 0.5, - y: rect.y, - width: ( rect.width - theme::PAD_H - toggle_reserve ).max( 0.0 ), - height: rect.height, - }; - let composed_clip: Vec = if outer_clip.is_empty() - { - vec![ inner_rect ] - } else { - outer_clip.iter() - .filter_map( |o| rect_intersect( *o, inner_rect ) ) - .collect() - }; - canvas.set_clip_rects( &composed_clip ); - - // Selection highlight (single-line). Painted before the text - // so the glyphs sit on top of the tinted band. - if focused && selection_anchor != cursor_pos - { - let ( s, e ) = ( - cursor_pos.min( selection_anchor ).min( self.value.len() ), - cursor_pos.max( selection_anchor ).min( self.value.len() ), - ); - let prefix_text = if secure - { - "\u{2022}".repeat( self.value[..s].chars().count() ) - } else { - self.value[..s].to_string() - }; - let span_text = if secure - { - "\u{2022}".repeat( self.value[s..e].chars().count() ) - } else { - self.value[s..e].to_string() - }; - let x0 = rect.x + theme::PAD_H + align_x - scroll_x - + canvas.measure_text( &prefix_text, font_size ); - let w = canvas.measure_text( &span_text, font_size ); - let sel_rect = Rect - { - x: x0, y: rect.y + 6.0, - width: w, height: rect.height - 12.0, - }; - canvas.fill_rect( sel_rect, theme::selection(), 2.0 ); - } - - if text.is_empty() - { - // Placeholder follows the same alignment as the value - // would, so an empty centred / right-aligned field still - // reads with the placeholder where the digits will land. - let placeholder_align_x = single_line_align_offset( - canvas, rect, &self.placeholder, false, self.align, font_size, - ); - canvas.draw_text( - &self.placeholder, - rect.x + theme::PAD_H + placeholder_align_x, - text_y, - font_size, - theme::placeholder(), - ); - } else { - canvas.draw_text( - &text, - rect.x + theme::PAD_H + align_x - scroll_x, - text_y, - font_size, - theme::text(), - ); - } - - if focused - { - let safe_cursor = cursor_pos.min( self.value.len() ); - let cursor_text = if secure - { - "\u{2022}".repeat( self.value[..safe_cursor].chars().count() ) - } else { - self.value[..safe_cursor].to_string() - }; - let cursor_x = rect.x + theme::PAD_H + align_x - scroll_x - + canvas.measure_text( &cursor_text, font_size ); - let cursor_rect = Rect - { - x: cursor_x, - y: rect.y + 8.0, - width: 2.0, - height: rect.height - 16.0, - }; - canvas.fill_rect( cursor_rect, theme::cursor(), 0.0 ); - } - - // Restore whatever clip was active on entry. - if outer_clip.is_empty() - { - canvas.clear_clip(); - } else { - canvas.set_clip_rects( &outer_clip ); - } - - // Eye icon for `password_toggle`. Drawn outside the inner - // clip so it never gets occluded by overflowing text — the - // preceding clip-restore is the reason this lives here and - // not earlier in the function. Tinted with the placeholder - // colour so the icon reads as ambient affordance rather - // than a primary control. - if let Some( ( visible, _ ) ) = self.password_toggle.as_ref() - { - let icon_name = if *visible { "actions/invisible" } else { "actions/visible" }; - let icon_px = theme::PASSWORD_TOGGLE_SIZE.round() as u32; - if let Some( ( rgba, iw, ih ) ) = crate::theme::icon_rgba( icon_name, icon_px ) - { - let tinted = crate::theme::tint_symbolic( &rgba, theme::placeholder() ); - let zone = password_toggle_hit_zone( rect ); - let dest = Rect - { - x: ( zone.x + ( zone.width - iw as f32 ) / 2.0 ).round(), - y: ( rect.y + ( rect.height - ih as f32 ) / 2.0 ).round(), - width: iw as f32, - height: ih as f32, - }; - canvas.draw_image_data( &tinted, iw, ih, dest, 1.0 ); - } - } - } - - fn draw_multiline( - &self, - canvas: &mut Canvas, - rect: Rect, - focused: bool, - cursor_pos: usize, - selection_anchor: usize, - ) - { - let border_c = if focused { theme::focus_border() } else { theme::border() }; - let border_w = if focused { theme::FOCUS_BORDER_W } else { theme::BORDER_W }; - canvas.fill_rect( rect, theme::bg(), theme::RADIUS_MULTI ); - canvas.stroke_rect( rect, border_c, border_w, theme::RADIUS_MULTI ); - - let line_h = theme::FONT_SIZE * theme::LINE_H_MULT; - let inner_h = ( rect.height - theme::PAD_V_MULTI * 2.0 ).max( line_h ); - let visible_lines = ( inner_h / line_h ).floor().max( 1.0 ) as usize; - let inner_width = ( rect.width - theme::PAD_H * 2.0 ).max( theme::FONT_SIZE ); - - // Compute the visual-line layout once per draw. Iterators - // later go through this list; long logical lines are - // soft-wrapped at the last whitespace before the row would - // overflow without mutating the buffer. - let visual_lines = compute_visual_lines( canvas, &self.value, inner_width, theme::FONT_SIZE ); - - // Vertical auto-scroll: keep the cursor's visual line on - // screen. The cursor sits in the *first* visual line whose - // `end >= cursor` — at a wrap boundary that places it at the - // trailing edge of the previous line rather than the start of - // the next, matching the convention of every standard text - // editor. - let safe_cursor = cursor_pos.min( self.value.len() ); - let cursor_visual_idx = visual_lines.iter() - .position( |vl| safe_cursor >= vl.start && safe_cursor <= vl.end ) - .unwrap_or( visual_lines.len().saturating_sub( 1 ) ); - let total_lines = visual_lines.len(); - let max_first = total_lines.saturating_sub( visible_lines ); - let first_line = if cursor_visual_idx + 1 > visible_lines - { - ( cursor_visual_idx + 1 - visible_lines ).min( max_first ) - } else { 0 }; - - let baseline_0 = rect.y + theme::PAD_V_MULTI + theme::FONT_SIZE; - - // Clip text to the inner rect so partial lines at the bottom - // stay inside the box and do not bleed onto sibling widgets - // or the border stroke. Save the parent clip first, then - // intersect it with our inner rect, so a `scroll(...)`-wrapped - // multiline edit keeps the parent's clip honoured. - let outer_clip = canvas.clip_bounds(); - let inner_rect = Rect - { - x: rect.x + theme::PAD_H * 0.5, - y: rect.y + theme::PAD_V_MULTI * 0.5, - width: ( rect.width - theme::PAD_H ).max( 0.0 ), - height: ( rect.height - theme::PAD_V_MULTI ).max( 0.0 ), - }; - let composed_clip: Vec = if outer_clip.is_empty() - { - vec![ inner_rect ] - } else { - outer_clip.iter() - .filter_map( |o| rect_intersect( *o, inner_rect ) ) - .collect() - }; - canvas.set_clip_rects( &composed_clip ); - - // Selection highlight (multiline). Painted before the text so - // the glyphs sit on top of the tinted band(s). Iterates - // visual lines, so a soft-wrapped logical line gets one - // highlight rect per visual row automatically. - if focused && selection_anchor != cursor_pos - { - let s = cursor_pos.min( selection_anchor ).min( self.value.len() ); - let e = cursor_pos.max( selection_anchor ).min( self.value.len() ); - for ( i, vl ) in visual_lines.iter().enumerate().skip( first_line ) - { - let visual_idx = i - first_line; - let y = rect.y + theme::PAD_V_MULTI + visual_idx as f32 * line_h; - if y > rect.y + rect.height - theme::PAD_V_MULTI { break; } - // Intersection of [s, e] with this visual line's - // byte range. Empty / non-overlapping → skip. - let span_start = s.max( vl.start ); - let span_end = e.min( vl.end ); - if span_start >= span_end { continue; } - let prefix_text = &self.value[ vl.start..span_start ]; - let span_text = &self.value[ span_start..span_end ]; - let x0 = rect.x + theme::PAD_H + canvas.measure_text( prefix_text, theme::FONT_SIZE ); - let w = canvas.measure_text( span_text, theme::FONT_SIZE ).max( 4.0 ); - let sel_rect = Rect - { - x: x0, y, - width: w, height: line_h, - }; - canvas.fill_rect( sel_rect, theme::selection(), 2.0 ); - } - } - - if self.value.is_empty() - { - canvas.draw_text( - &self.placeholder, - rect.x + theme::PAD_H, - baseline_0, - theme::FONT_SIZE, - theme::placeholder(), - ); - } else { - for ( i, vl ) in visual_lines.iter().enumerate().skip( first_line ) - { - let visual_idx = i - first_line; - let y = baseline_0 + visual_idx as f32 * line_h; - if y > rect.y + rect.height - theme::PAD_V_MULTI + line_h - { - break; - } - let line = &self.value[ vl.start..vl.end ]; - canvas.draw_text( - line, - rect.x + theme::PAD_H, - y, - theme::FONT_SIZE, - theme::text(), - ); - } - } - - if focused - { - let vl = visual_lines[ cursor_visual_idx ]; - let line_prefix = &self.value[ vl.start..safe_cursor ]; - let cursor_x = rect.x + theme::PAD_H - + canvas.measure_text( line_prefix, theme::FONT_SIZE ); - let visual_y_idx = cursor_visual_idx.saturating_sub( first_line ); - let cursor_y = rect.y + theme::PAD_V_MULTI + visual_y_idx as f32 * line_h; - let cursor_rect = Rect - { - x: cursor_x, - y: cursor_y + 2.0, - width: 2.0, - height: theme::FONT_SIZE + 4.0, - }; - canvas.fill_rect( cursor_rect, theme::cursor(), 0.0 ); - } - - // Restore whatever clip was active on entry — a single empty - // `Vec` from `clip_bounds` means "no clip", which we model by - // calling `clear_clip` instead of pushing an empty slice. - if outer_clip.is_empty() - { - canvas.clear_clip(); - } else { - canvas.set_clip_rects( &outer_clip ); - } - } - - /// Wrap this widget in an [`Element`]. - pub fn into_element( self ) -> Element - { - Element::TextEdit( self ) - } - - /// Insert a string at the current cursor position, advance the cursor, and - /// return the `on_change` message if one is set. - pub fn insert_str( &mut self, s: &str ) -> Option - { - self.value.insert_str( self.cursor_pos.min( self.value.len() ), s ); - self.cursor_pos = (self.cursor_pos + s.len()).min( self.value.len() ); - self.on_change.as_ref().map( |f| f( self.value.clone() ) ) - } - - /// Delete the character before the cursor and return the `on_change` message - /// if one is set. Does nothing if the cursor is already at position 0. - pub fn backspace( &mut self ) -> Option - { - if self.cursor_pos == 0 { return None; } - let chars: Vec = self.value.chars().collect(); - let char_pos = self.value[..self.cursor_pos].chars().count(); - if char_pos == 0 { return None; } - let mut chars = chars; - let removed = chars.remove( char_pos - 1 ); - self.cursor_pos -= removed.len_utf8(); - self.value = chars.iter().collect(); - self.on_change.as_ref().map( |f| f( self.value.clone() ) ) - } - - pub( crate ) fn map_msg( self, f: &super::MapFn ) -> TextEdit - where - U: Clone + 'static, - Msg: 'static, - { - // Wrap on_change the same way the slider does. on_submit is a - // plain `Option`, so it goes through the user mapper once. - let on_change = self.on_change.clone().map( |old| -> Arc U> - { - let mapper = Arc::clone( f ); - Arc::new( move |s| ( *mapper )( ( *old )( s ) ) ) - } ); - TextEdit - { - placeholder: self.placeholder.clone(), - value: self.value.clone(), - on_change, - on_submit: self.on_submit.clone().map( |m| ( *f )( m ) ), - secure: self.secure, - multiline: self.multiline, - rows: self.rows, - cursor_pos: self.cursor_pos, - id: self.id, - cursor: self.cursor, - align: self.align, - borderless: self.borderless, - fixed_width: self.fixed_width, - font_size: self.font_size, - select_on_focus: self.select_on_focus, - password_toggle: self.password_toggle.clone().map( |( v, m )| ( v, ( *f )( m ) ) ), - } - } -} - -impl Drop for TextEdit -{ - /// When `secure( true )` is set, scrub the value bytes before the - /// underlying `String` allocation is returned to the allocator. The - /// non-secure path is a no-op so the cost is paid only by widgets - /// that opted into credential handling. - fn drop( &mut self ) - { - if self.secure || self.password_toggle.is_some() - { - // SAFETY: as_mut_vec exposes the underlying byte buffer of the - // String. We only overwrite each byte with zero, which is valid - // UTF-8 (a sequence of NUL codepoints), so the String invariant - // is preserved through to the Vec drop that runs immediately - // after this fn returns. - let bytes = unsafe { self.value.as_mut_vec() }; - secure_zero( bytes ); - } - } -} - -#[ cfg( test ) ] -mod tests -{ - use super::TextEdit; - - #[ derive( Clone, Debug, PartialEq, Eq ) ] - enum Msg - { - Changed( String ), - Submitted, - } - - fn new_te( value: &str ) -> TextEdit - { - TextEdit::new( "placeholder".into(), value.into() ) - } - - // ── Construction ────────────────────────────────────────────────────────── - - #[ test ] - fn new_places_cursor_at_end_of_initial_value() - { - let t = new_te( "hello" ); - assert_eq!( t.cursor_pos, 5 ); - assert_eq!( t.value, "hello" ); - } - - #[ test ] - fn new_with_empty_value_starts_with_zero_cursor() - { - let t = new_te( "" ); - assert_eq!( t.cursor_pos, 0 ); - assert!( t.value.is_empty() ); - } - - #[ test ] - fn new_with_multibyte_value_uses_byte_length_for_cursor() - { - // "café" is 5 bytes (c-a-f-é where é is 2 bytes in UTF-8). - let t = new_te( "café" ); - assert_eq!( t.cursor_pos, 5 ); - assert_eq!( t.value.len(), 5 ); - } - - // ── insert_str ──────────────────────────────────────────────────────────── - - #[ test ] - fn insert_str_at_end_appends_and_advances_cursor() - { - let mut t = new_te( "ab" ); - assert_eq!( t.cursor_pos, 2 ); - let _ = t.insert_str( "c" ); - assert_eq!( t.value, "abc" ); - assert_eq!( t.cursor_pos, 3 ); - } - - #[ test ] - fn insert_str_at_middle_inserts_and_advances_cursor_by_byte_len() - { - let mut t = new_te( "ab" ); - t.cursor_pos = 1; - let _ = t.insert_str( "X" ); - assert_eq!( t.value, "aXb" ); - assert_eq!( t.cursor_pos, 2 ); - } - - #[ test ] - fn insert_str_multibyte_advances_cursor_by_utf8_byte_count() - { - let mut t = new_te( "" ); - let _ = t.insert_str( "é" ); // 2 bytes - assert_eq!( t.value, "é" ); - assert_eq!( t.cursor_pos, 2 ); - let _ = t.insert_str( "🦀" ); // 4 bytes - assert_eq!( t.cursor_pos, 6 ); - } - - #[ test ] - fn insert_str_clamps_runaway_cursor() - { - // If cursor_pos is somehow past value.len() (defensive), insert should - // behave as "insert at end" rather than panic. - let mut t = new_te( "ab" ); - t.cursor_pos = 99; - let _ = t.insert_str( "c" ); - assert_eq!( t.value, "abc" ); - // Cursor is clamped to new value length. - assert_eq!( t.cursor_pos, 3 ); - } - - #[ test ] - fn insert_str_returns_on_change_with_new_value() - { - let mut t = new_te( "ab" ) - .on_change( |s| Msg::Changed( s ) ); - let msg = t.insert_str( "c" ); - assert_eq!( msg, Some( Msg::Changed( "abc".into() ) ) ); - } - - #[ test ] - fn insert_str_without_on_change_returns_none() - { - let mut t = new_te( "ab" ); - assert_eq!( t.insert_str( "c" ), None ); - } - - // ── backspace ───────────────────────────────────────────────────────────── - - #[ test ] - fn backspace_at_zero_cursor_is_noop() - { - let mut t = new_te( "abc" ); - t.cursor_pos = 0; - assert_eq!( t.backspace(), None ); - assert_eq!( t.value, "abc" ); - assert_eq!( t.cursor_pos, 0 ); - } - - #[ test ] - fn backspace_at_end_removes_last_char() - { - let mut t = new_te( "abc" ); - let _ = t.backspace(); - assert_eq!( t.value, "ab" ); - assert_eq!( t.cursor_pos, 2 ); - } - - #[ test ] - fn backspace_in_middle_removes_char_before_cursor() - { - let mut t = new_te( "abc" ); - t.cursor_pos = 2; - let _ = t.backspace(); - assert_eq!( t.value, "ac" ); - assert_eq!( t.cursor_pos, 1 ); - } - - #[ test ] - fn backspace_handles_multibyte_acute() - { - // "é" is two bytes; backspacing must remove the whole codepoint. - let mut t = new_te( "café" ); - let _ = t.backspace(); - assert_eq!( t.value, "caf" ); - assert_eq!( t.cursor_pos, 3 ); - } - - #[ test ] - fn backspace_handles_emoji_codepoint() - { - // Crab emoji is 4 bytes in UTF-8; cursor must back up by 4. - let mut t = new_te( "x🦀" ); - assert_eq!( t.cursor_pos, 5 ); - let _ = t.backspace(); - assert_eq!( t.value, "x" ); - assert_eq!( t.cursor_pos, 1 ); - } - - #[ test ] - fn backspace_returns_on_change_with_new_value() - { - let mut t = new_te( "ab" ) - .on_change( |s| Msg::Changed( s ) ); - let msg = t.backspace(); - assert_eq!( msg, Some( Msg::Changed( "a".into() ) ) ); - } - - // ── display_text + secure ───────────────────────────────────────────────── - - #[ test ] - fn display_text_plain_returns_value_verbatim() - { - let t = new_te( "hello" ); - assert_eq!( t.display_text(), "hello" ); - } - - #[ test ] - fn display_text_secure_returns_bullets_one_per_codepoint() - { - // One bullet per codepoint, NOT per byte. "café" has 4 codepoints, - // so the masked text must be 4 bullets — not 5 (the byte length). - let t = new_te( "café" ).secure( true ); - let masked = t.display_text(); - assert_eq!( masked.chars().count(), 4 ); - assert!( masked.chars().all( |c| c == '\u{2022}' ) ); - } - - #[ test ] - fn display_text_secure_with_emoji_one_bullet_per_codepoint() - { - // Crab emoji is one codepoint — exactly one bullet, even though it - // occupies four bytes in UTF-8. - let t = new_te( "🦀" ).secure( true ); - assert_eq!( t.display_text().chars().count(), 1 ); - } - - #[ test ] - fn secure_mode_does_not_touch_underlying_value() - { - let t = new_te( "secret" ).secure( true ); - assert_eq!( t.value, "secret" ); - } - - #[ test ] - fn secure_toggles_via_builder() - { - let t = new_te( "x" ).secure( true ).secure( false ); - assert_eq!( t.display_text(), "x" ); - } - - // ── secure-mode credential zeroize ──────────────────────────────────────── - - #[ test ] - fn secure_flag_propagates_to_widget_handler_snapshot() - { - use crate::widget::{ Element, WidgetHandlers }; - - // The runtime takes a `WidgetHandlers` snapshot of every interactive - // widget once per frame. The `secure` flag must travel with the - // snapshot so the handler's own `Drop` (which mirrors `TextEdit`'s - // wipe-on-drop) knows whether to scrub on release. - let widget: Element<()> = TextEdit::new( "p".into(), "secret".into() ) - .secure( true ) - .into_element(); - let h = widget.handlers(); - match h - { - WidgetHandlers::TextEdit { secure, .. } => assert!( secure, "secure flag must propagate" ), - _ => panic!( "expected TextEdit handler" ), - } - } - - #[ test ] - fn multiline_flag_propagates_to_widget_handler_snapshot() - { - use crate::widget::{ Element, WidgetHandlers }; - - let widget: Element<()> = TextEdit::new( "p".into(), "x".into() ) - .multiline( true ) - .into_element(); - match widget.handlers() - { - WidgetHandlers::TextEdit { multiline, .. } => assert!( multiline, "multiline flag must propagate" ), - _ => panic!( "expected TextEdit handler" ), - } - } - - #[ test ] - fn secure_overrides_multiline_in_widget_handlers() - { - use crate::widget::{ Element, WidgetHandlers }; - - // `is_multiline()` requires `!secure` — a secure password field - // must remain single-line even if the caller also asked for - // multiline. The handler snapshot reads `is_multiline()`, so - // `multiline` here must be `false`. - let widget: Element<()> = TextEdit::new( "p".into(), "x".into() ) - .multiline( true ) - .secure( true ) - .into_element(); - match widget.handlers() - { - WidgetHandlers::TextEdit { multiline, secure, .. } => - { - assert!( secure, "secure must propagate" ); - assert!( !multiline, "secure must override multiline" ); - } - _ => panic!( "expected TextEdit handler" ), - } - } - - #[ test ] - fn non_secure_flag_propagates_to_widget_handler_snapshot() - { - use crate::widget::{ Element, WidgetHandlers }; - - let widget: Element<()> = TextEdit::new( "p".into(), "x".into() ) - .into_element(); - match widget.handlers() - { - WidgetHandlers::TextEdit { secure, .. } => assert!( !secure, "default is non-secure" ), - _ => panic!( "expected TextEdit handler" ), - } - } - - #[ test ] - fn secure_drop_zeros_underlying_string_buffer() - { - // Reach into the value's byte buffer right before the drop, run the - // drop logic by replacing the binding, and confirm the bytes that - // had been the credential are now zero. We cannot inspect the - // allocation after the actual drop (UB), so this test exercises the - // same path that `Drop::drop` runs internally. - let mut t: TextEdit<()> = TextEdit::new( "p".into(), "secret".into() ).secure( true ); - assert_eq!( t.value, "secret" ); - - // Manually run the wipe the way Drop will; the next assertion checks - // the buffer is zeroed before the auto-drop releases it. - // SAFETY: same as the production wipe path above — overwriting - // every byte with zero leaves the String holding a sequence of NUL - // codepoints, which is valid UTF-8. - let bytes = unsafe { t.value.as_mut_vec() }; - crate::secure_mem::secure_zero( bytes ); - assert!( bytes.iter().all( |&b| b == 0 ) ); - assert_eq!( bytes.len(), 6 ); - } - - #[ test ] - fn non_secure_text_edit_drop_is_a_noop_for_value() - { - // A non-secure widget must not pay the wipe cost. We cannot observe - // the absence of writes directly, but we can confirm that the - // underlying bytes still match the original content right up to - // the moment of drop. - let t: TextEdit<()> = TextEdit::new( "p".into(), "value".into() ); - assert_eq!( t.value.as_bytes(), b"value" ); - // Drop runs at end of scope without touching the bytes. - } - - // ── on_submit / on_change wiring ────────────────────────────────────────── - - #[ test ] - fn on_submit_stores_message_unchanged() - { - let t = new_te( "" ).on_submit( Msg::Submitted ); - assert_eq!( t.on_submit, Some( Msg::Submitted ) ); - } - - // ── full edit roundtrip ─────────────────────────────────────────────────── - - #[ test ] - fn type_word_then_clear_with_backspace() - { - let mut t = new_te( "" ); - for ch in "hola".chars() - { - let _ = t.insert_str( &ch.to_string() ); - } - assert_eq!( t.value, "hola" ); - assert_eq!( t.cursor_pos, 4 ); - while t.cursor_pos > 0 - { - let _ = t.backspace(); - } - assert!( t.value.is_empty() ); - assert_eq!( t.cursor_pos, 0 ); - } - - // ── multiline mode ─────────────────────────────────────────────────────── - - #[ test ] - fn multiline_default_off() - { - let t = new_te( "" ); - assert!( !t.multiline ); - assert!( !t.is_multiline() ); - } - - #[ test ] - fn multiline_builder_enables() - { - let t = new_te( "" ).multiline( true ); - assert!( t.multiline ); - assert!( t.is_multiline() ); - } - - #[ test ] - fn multiline_grows_preferred_height_with_rows() - { - use crate::render::Canvas; - let canvas = Canvas::new( 800, 600 ); - let single = new_te( "" ); - let ( _, h_single ) = single.preferred_size( 200.0, &canvas ); - assert_eq!( h_single, super::theme::HEIGHT ); - - let multi = new_te( "" ).multiline( true ).rows( 5 ); - let ( _, h_multi ) = multi.preferred_size( 200.0, &canvas ); - assert!( h_multi > h_single, "multiline must claim more vertical space" ); - - let bigger = new_te( "" ).multiline( true ).rows( 10 ); - let ( _, h_bigger ) = bigger.preferred_size( 200.0, &canvas ); - assert!( h_bigger > h_multi, "more rows → taller box" ); - } - - #[ test ] - fn rows_clamps_zero_to_one() - { - let t = new_te( "" ).multiline( true ).rows( 0 ); - assert_eq!( t.rows, 1 ); - } - - #[ test ] - fn secure_overrides_multiline_in_is_multiline_query() - { - // A `multiline + secure` configuration must remain single-line: - // passwords carrying a literal `\n` would defeat the password - // mode rendering and turn the credential boundary fuzzy. - let t: TextEdit<()> = TextEdit::new( "p".into(), "x".into() ) - .multiline( true ) - .secure( true ); - assert!( t.multiline ); // raw flag preserved - assert!( !t.is_multiline() ); // effective mode is single-line - } - - #[ test ] - fn insert_str_can_carry_newline_in_multiline_mode() - { - // The widget itself does not gate `\n` — that decision lives at - // dispatch time. We just confirm `\n` round-trips through - // insert_str + the value buffer untouched. - let mut t = new_te( "ab" ).multiline( true ); - let _ = t.insert_str( "\n" ); - assert_eq!( t.value, "ab\n" ); - assert_eq!( t.cursor_pos, 3 ); - let _ = t.insert_str( "c" ); - assert_eq!( t.value, "ab\nc" ); - } - - // ── builders for the inline / picker variants ──────────────────────────── - - #[ test ] - fn defaults_for_new_inline_builders() - { - let t: TextEdit<()> = TextEdit::new( "p".into(), "v".into() ); - assert_eq!( t.align, super::super::text::TextAlign::Left ); - assert!( !t.borderless ); - assert!( t.fixed_width.is_none() ); - assert_eq!( t.font_size, super::theme::FONT_SIZE ); - assert!( !t.select_on_focus ); - } - - #[ test ] - fn align_builder_sets_alignment() - { - use super::super::text::TextAlign; - let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).align( TextAlign::Center ); - assert_eq!( t.align, TextAlign::Center ); - let t = t.align( TextAlign::Right ); - assert_eq!( t.align, TextAlign::Right ); - } - - #[ test ] - fn borderless_builder_toggles_flag() - { - let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).borderless( true ); - assert!( t.borderless ); - } - - #[ test ] - fn fixed_width_builder_stores_value() - { - let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).fixed_width( 72.0 ); - assert_eq!( t.fixed_width, Some( 72.0 ) ); - } - - #[ test ] - fn font_size_builder_clamps_to_at_least_one_pixel() - { - let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).font_size( 28.0 ); - assert_eq!( t.font_size, 28.0 ); - let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).font_size( -3.0 ); - assert_eq!( t.font_size, 1.0 ); - } - - #[ test ] - fn select_on_focus_builder_toggles_flag() - { - let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).select_on_focus( true ); - assert!( t.select_on_focus ); - } - - #[ test ] - fn fixed_width_overrides_preferred_size_width() - { - let canvas = crate::render::Canvas::new( 1, 1 ); - let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).fixed_width( 64.0 ); - let ( w, _h ) = t.preferred_size( 1000.0, &canvas ); - assert_eq!( w, 64.0 ); - } - - #[ test ] - fn fixed_width_capped_by_max_width() - { - let canvas = crate::render::Canvas::new( 1, 1 ); - let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).fixed_width( 500.0 ); - let ( w, _h ) = t.preferred_size( 200.0, &canvas ); - // fixed_width is capped by the parent's available width to - // avoid overflowing tight rows. - assert_eq!( w, 200.0 ); - } - - #[ test ] - fn no_fixed_width_falls_back_to_max_width() - { - let canvas = crate::render::Canvas::new( 1, 1 ); - let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ); - let ( w, _h ) = t.preferred_size( 320.0, &canvas ); - assert_eq!( w, 320.0 ); - } - - #[ test ] - fn align_propagates_to_widget_handler_snapshot() - { - use crate::widget::{ Element, WidgetHandlers }; - use super::super::text::TextAlign; - let widget: Element<()> = TextEdit::new( "".into(), "".into() ) - .align( TextAlign::Center ) - .into_element(); - match widget.handlers() - { - WidgetHandlers::TextEdit { align, .. } => assert_eq!( align, TextAlign::Center ), - _ => panic!( "expected TextEdit handler" ), - } - } - - #[ test ] - fn font_size_propagates_to_widget_handler_snapshot() - { - use crate::widget::{ Element, WidgetHandlers }; - let widget: Element<()> = TextEdit::new( "".into(), "".into() ) - .font_size( 28.0 ) - .into_element(); - match widget.handlers() - { - WidgetHandlers::TextEdit { font_size, .. } => assert!( ( font_size - 28.0 ).abs() < 1e-6 ), - _ => panic!( "expected TextEdit handler" ), - } - } - - #[ test ] - fn select_on_focus_propagates_to_widget_handler_snapshot() - { - use crate::widget::{ Element, WidgetHandlers }; - let widget: Element<()> = TextEdit::new( "".into(), "".into() ) - .select_on_focus( true ) - .into_element(); - match widget.handlers() - { - WidgetHandlers::TextEdit { select_on_focus, .. } => assert!( select_on_focus ), - _ => panic!( "expected TextEdit handler" ), - } - } - - // ── password_toggle ─────────────────────────────────────────────────────── - - #[ derive( Clone, Debug, PartialEq, Eq ) ] enum ToggleMsg { Flip } - - #[ test ] - fn password_toggle_default_is_none() - { - let t = TextEdit::::new( "".into(), "".into() ); - assert!( t.password_toggle.is_none() ); - } - - #[ test ] - fn password_toggle_builder_records_visible_and_msg() - { - let t = TextEdit::::new( "".into(), "".into() ) - .password_toggle( true, ToggleMsg::Flip ); - assert_eq!( t.password_toggle, Some( ( true, ToggleMsg::Flip ) ) ); - } - - #[ test ] - fn effective_secure_falls_back_to_secure_field_when_no_toggle() - { - let plain = TextEdit::::new( "".into(), "".into() ); - let secret = TextEdit::::new( "".into(), "".into() ).secure( true ); - assert!( !plain.effective_secure() ); - assert!( secret.effective_secure() ); - } - - #[ test ] - fn password_toggle_overrides_secure_field() - { - // When the toggle is set, its `visible` controls bullets - // regardless of the explicit `secure` flag. - let visible_with_secure = TextEdit::::new( "".into(), "".into() ) - .secure( true ) - .password_toggle( true, ToggleMsg::Flip ); - let hidden_no_secure = TextEdit::::new( "".into(), "".into() ) - .password_toggle( false, ToggleMsg::Flip ); - assert!( !visible_with_secure.effective_secure() ); - assert!( hidden_no_secure.effective_secure() ); - } - - #[ test ] - fn password_toggle_hidden_substitutes_bullets_in_display_text() - { - let t = TextEdit::::new( "".into(), "abc".into() ) - .password_toggle( false, ToggleMsg::Flip ); - assert_eq!( t.display_text(), "•••" ); - } - - #[ test ] - fn password_toggle_visible_returns_value_verbatim() - { - let t = TextEdit::::new( "".into(), "abc".into() ) - .password_toggle( true, ToggleMsg::Flip ); - assert_eq!( t.display_text(), "abc" ); - } - - #[ test ] - fn password_toggle_collapses_multiline_to_single_line() - { - // `is_multiline()` follows `effective_secure` — a hidden - // password collapses to single-line even if `multiline(true)` - // was set, matching the behaviour of an explicit `secure`. - let t = TextEdit::::new( "".into(), "".into() ) - .multiline( true ) - .password_toggle( false, ToggleMsg::Flip ); - assert!( !t.is_multiline() ); - } - - #[ test ] - fn password_toggle_msg_propagates_to_widget_handler_snapshot() - { - use crate::widget::{ Element, WidgetHandlers }; - let widget: Element = TextEdit::new( "".into(), "".into() ) - .password_toggle( false, ToggleMsg::Flip ) - .into_element(); - // `handlers()` returns a value of `WidgetHandlers`, which - // implements `Drop` (wipe-on-drop for secure fields), so we - // cannot destructure it by value-move. Bind first, then match - // on a reference. - let h = widget.handlers(); - match &h - { - WidgetHandlers::TextEdit { password_toggle_msg, secure, .. } => - { - assert_eq!( password_toggle_msg.as_ref(), Some( &ToggleMsg::Flip ) ); - // Snapshot's `secure` is true even though `visible=false` - // because the field carries a password regardless. - assert!( *secure ); - } - _ => panic!( "expected TextEdit handler" ), - } - } - - #[ test ] - fn password_toggle_msg_none_when_no_toggle_configured() - { - use crate::widget::{ Element, WidgetHandlers }; - let widget: Element = TextEdit::new( "".into(), "".into() ) - .into_element(); - let h = widget.handlers(); - match &h - { - WidgetHandlers::TextEdit { password_toggle_msg, .. } => - { - assert!( password_toggle_msg.is_none() ); - } - _ => panic!( "expected TextEdit handler" ), - } - } - - #[ test ] - fn password_toggle_hit_zone_sits_at_right_edge() - { - use crate::types::{ Point, Rect }; - let rect = Rect { x: 100.0, y: 50.0, width: 240.0, height: 48.0 }; - let zone = super::password_toggle_hit_zone( rect ); - // Right edge of the zone matches the field's right edge. - assert!( ( ( zone.x + zone.width ) - ( rect.x + rect.width ) ).abs() < 0.01 ); - // Wide enough to cover the icon + slop on both sides + half pad. - assert!( zone.width >= super::theme::PASSWORD_TOGGLE_SIZE ); - // A point on the centre-right hits the zone. - let p = Point { x: rect.x + rect.width - 8.0, y: rect.y + rect.height / 2.0 }; - assert!( zone.contains( p ) ); - // A point well to the left of the zone misses it. - let p = Point { x: rect.x + 10.0, y: rect.y + rect.height / 2.0 }; - assert!( !zone.contains( p ) ); - } - - #[ test ] - fn password_toggle_hit_zone_clamped_when_field_narrower_than_icon() - { - use crate::types::Rect; - let rect = Rect { x: 0.0, y: 0.0, width: 4.0, height: 24.0 }; - let zone = super::password_toggle_hit_zone( rect ); - // Zone never extends past the field's right edge. - assert!( zone.x + zone.width <= rect.x + rect.width + f32::EPSILON ); - } - - #[ test ] - fn password_toggle_drop_zeros_underlying_string_buffer() - { - // Same wipe-on-drop guarantee `secure( true )` gives — a - // `password_toggle` field is sensitive even when currently - // visible, so the buffer must be scrubbed on drop. - let t = TextEdit::::new( "".into(), "shhh".into() ) - .password_toggle( true, ToggleMsg::Flip ); - let ptr = t.value.as_ptr(); - let len = t.value.len(); - drop( t ); - // SAFETY: `String` deallocates the buffer in its `Drop`, but - // the bytes at `ptr..ptr+len` were zeroed *before* the - // allocator was notified — reading them now is a use-after- - // free, so we don't. Instead we simply assert that the test - // reaches this line without panicking; the `secure_zero` - // path runs unconditionally for password_toggle fields. - let _ = ( ptr, len ); - } -} diff --git a/src/widget/text_edit/cursor.rs b/src/widget/text_edit/cursor.rs new file mode 100644 index 0000000..0aa4603 --- /dev/null +++ b/src/widget/text_edit/cursor.rs @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::render::Canvas; +use crate::types::Rect; + +use super::hit_test::byte_offset_in_line; +use super::theme; +use super::wrapping::{ compute_visual_lines, VisualLine }; + +/// Locate the visual line containing `cursor` in the wrap layout for +/// `value` inside `rect`. When the cursor sits exactly on a soft-wrap +/// boundary (so it satisfies both the previous line's `end` and the +/// next line's `start`), prefer the *previous* line — that matches the +/// rendering convention in `draw_multiline`, which paints the caret at +/// the trailing edge of the prior visual row rather than the leading +/// edge of the new one. +fn current_visual_line( + canvas: &Canvas, + rect: Rect, + value: &str, + cursor: usize, +) -> Option<( Vec, usize )> +{ + let inner_width = ( rect.width - theme::PAD_H * 2.0 ).max( theme::FONT_SIZE ); + let visual_lines = compute_visual_lines( canvas, value, inner_width, theme::FONT_SIZE ); + if visual_lines.is_empty() { return None; } + let safe_cursor = cursor.min( value.len() ); + let idx = visual_lines.iter() + .position( |vl| safe_cursor >= vl.start && safe_cursor <= vl.end ) + .unwrap_or( visual_lines.len() - 1 ); + Some( ( visual_lines, idx ) ) +} + +/// Move the text cursor one visual row up. Returns the new byte offset +/// or `None` when the cursor is already on the first visual row, so +/// the caller can fall through to sibling-widget keyboard navigation. +/// +/// Visual X is preserved across the move: the new cursor lands as close +/// as possible (with snap-to-nearest-glyph) to the same horizontal +/// position the cursor had on its origin line. No "preferred column" +/// state is kept across multiple consecutive Up presses, so a long +/// short → long sequence will drift toward the end of every short +/// line — same simplification GTK / Cocoa make for plain controls. +pub( crate ) fn cursor_visual_up( + canvas: &Canvas, + rect: Rect, + value: &str, + cursor: usize, +) -> Option +{ + let ( lines, idx ) = current_visual_line( canvas, rect, value, cursor )?; + if idx == 0 { return None; } + let cur = lines[ idx ]; + let safe = cursor.min( value.len() ); + let target_x = canvas.measure_text( &value[cur.start..safe], theme::FONT_SIZE ); + let prev = lines[ idx - 1 ]; + Some( byte_offset_in_line( canvas, value, prev.start, prev.end, target_x, false, theme::FONT_SIZE ) ) +} + +/// Mirror of [`cursor_visual_up`] for the Down arrow. +pub( crate ) fn cursor_visual_down( + canvas: &Canvas, + rect: Rect, + value: &str, + cursor: usize, +) -> Option +{ + let ( lines, idx ) = current_visual_line( canvas, rect, value, cursor )?; + if idx + 1 >= lines.len() { return None; } + let cur = lines[ idx ]; + let safe = cursor.min( value.len() ); + let target_x = canvas.measure_text( &value[cur.start..safe], theme::FONT_SIZE ); + let next = lines[ idx + 1 ]; + Some( byte_offset_in_line( canvas, value, next.start, next.end, target_x, false, theme::FONT_SIZE ) ) +} + +/// Byte offset of the start of the cursor's current visual line. +/// Falls back to `0` when the wrap layout cannot be computed (e.g. +/// degenerate rect), so the Home key always has a sensible target. +pub( crate ) fn cursor_visual_home( + canvas: &Canvas, + rect: Rect, + value: &str, + cursor: usize, +) -> usize +{ + current_visual_line( canvas, rect, value, cursor ) + .map( |( lines, idx )| lines[ idx ].start ) + .unwrap_or( 0 ) +} + +/// Byte offset of the end of the cursor's current visual line. For a +/// hard-`\n`-terminated row this is the byte just before the newline; +/// for a soft-wrapped row it is the wrap point (which is also the +/// start of the next visual row, but the caret renders at the trailing +/// edge of *this* row by convention). +pub( crate ) fn cursor_visual_end( + canvas: &Canvas, + rect: Rect, + value: &str, + cursor: usize, +) -> usize +{ + current_visual_line( canvas, rect, value, cursor ) + .map( |( lines, idx )| lines[ idx ].end ) + .unwrap_or( value.len() ) +} diff --git a/src/widget/text_edit/draw.rs b/src/widget/text_edit/draw.rs new file mode 100644 index 0000000..130ece7 --- /dev/null +++ b/src/widget/text_edit/draw.rs @@ -0,0 +1,397 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::render::Canvas; +use crate::types::Rect; + +use super::TextEdit; +use super::hit_test::{ single_line_align_offset, single_line_scroll_x }; +use super::theme; +use super::wrapping::compute_visual_lines; + +/// Axis-aligned intersection of two rects. Returns `None` when the rects +/// do not overlap (zero-area / negative-extent results count as +/// "no overlap" so the caller can `filter_map` straight into a clip +/// list). +fn rect_intersect( a: Rect, b: Rect ) -> Option +{ + let x0 = a.x.max( b.x ); + let y0 = a.y.max( b.y ); + let x1 = ( a.x + a.width ).min( b.x + b.width ); + let y1 = ( a.y + a.height ).min( b.y + b.height ); + if x1 > x0 && y1 > y0 + { + Some( Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } ) + } else { + None + } +} + +/// Hit rect for the eye icon at the right edge of a `TextEdit` +/// configured with [`TextEdit::password_toggle`]. Returned in the +/// same coordinate space as the field's `rect`. Pointer / touch +/// dispatch consult this before falling through to cursor placement +/// — a tap inside the zone fires the toggle message instead of +/// moving the caret. +pub fn password_toggle_hit_zone( rect: Rect ) -> Rect +{ + let zone_w = ( theme::PASSWORD_TOGGLE_SIZE + + theme::PASSWORD_TOGGLE_SLOP * 2.0 + + theme::PAD_H * 0.5 ).min( rect.width ); + Rect + { + x: rect.x + rect.width - zone_w, + y: rect.y, + width: zone_w, + height: rect.height, + } +} + +impl TextEdit +{ + /// Draw the field into `canvas` at `rect`. + /// + /// `cursor_pos` is the byte offset of the text cursor — supplied by the runtime + /// from its persistent cursor state rather than from the widget itself. + /// `selection_anchor` is the other end of the selection range; when + /// `selection_anchor == cursor_pos` no highlight is painted. + pub fn draw( + &self, + canvas: &mut Canvas, + rect: Rect, + focused: bool, + cursor_pos: usize, + selection_anchor: usize, + ) + { + if self.is_multiline() + { + self.draw_multiline( canvas, rect, focused, cursor_pos, selection_anchor ); + return; + } + // Borderless fields skip the surface fill + border stroke + // entirely. They still get the inner clip + scroll + alignment + // treatment below, so the field behaves like a regular + // text input — only the chrome is suppressed. + if !self.borderless + { + let border_c = if focused { theme::focus_border() } else { theme::border() }; + let border_w = if focused { theme::FOCUS_BORDER_W } else { theme::BORDER_W }; + canvas.fill_rect( rect, theme::bg(), theme::RADIUS ); + canvas.stroke_rect( rect, border_c, border_w, theme::RADIUS ); + } + + let font_size = self.font_size; + let text_y = rect.y + (rect.height + font_size) / 2.0 - 2.0; + let text = self.display_text(); + let secure = self.effective_secure(); + // When `password_toggle` is set we reserve a column on the + // right edge for the eye icon: scroll / align / clip all + // behave as if the field were narrower so cursor + text + // never slide under the icon. + let toggle_reserve = if self.password_toggle.is_some() + { + theme::PASSWORD_TOGGLE_SIZE + theme::PASSWORD_TOGGLE_SLOP * 2.0 + } + else + { + 0.0 + }; + let text_rect = Rect + { + width: ( rect.width - toggle_reserve ).max( theme::PAD_H * 2.0 ), + ..rect + }; + let scroll_x = single_line_scroll_x( canvas, text_rect, &self.value, cursor_pos, secure, font_size ); + let align_x = single_line_align_offset( canvas, text_rect, &self.value, secure, self.align, font_size ); + + // Clip text / cursor / selection to the inner band so the + // scrolled-off portion does not bleed past the pill stroke. + // Save the parent clip first, intersect with our inner rect, + // and restore on exit so a `text_edit` wrapped in scroll(...) + // keeps the parent's clip honoured. + let outer_clip = canvas.clip_bounds(); + let inner_rect = Rect + { + x: rect.x + theme::PAD_H * 0.5, + y: rect.y, + width: ( rect.width - theme::PAD_H - toggle_reserve ).max( 0.0 ), + height: rect.height, + }; + let composed_clip: Vec = if outer_clip.is_empty() + { + vec![ inner_rect ] + } else { + outer_clip.iter() + .filter_map( |o| rect_intersect( *o, inner_rect ) ) + .collect() + }; + canvas.set_clip_rects( &composed_clip ); + + // Selection highlight (single-line). Painted before the text + // so the glyphs sit on top of the tinted band. + if focused && selection_anchor != cursor_pos + { + let ( s, e ) = ( + cursor_pos.min( selection_anchor ).min( self.value.len() ), + cursor_pos.max( selection_anchor ).min( self.value.len() ), + ); + let prefix_text = if secure + { + "\u{2022}".repeat( self.value[..s].chars().count() ) + } else { + self.value[..s].to_string() + }; + let span_text = if secure + { + "\u{2022}".repeat( self.value[s..e].chars().count() ) + } else { + self.value[s..e].to_string() + }; + let x0 = rect.x + theme::PAD_H + align_x - scroll_x + + canvas.measure_text( &prefix_text, font_size ); + let w = canvas.measure_text( &span_text, font_size ); + let sel_rect = Rect + { + x: x0, y: rect.y + 6.0, + width: w, height: rect.height - 12.0, + }; + canvas.fill_rect( sel_rect, theme::selection(), 2.0 ); + } + + if text.is_empty() + { + // Placeholder follows the same alignment as the value + // would, so an empty centred / right-aligned field still + // reads with the placeholder where the digits will land. + let placeholder_align_x = single_line_align_offset( + canvas, rect, &self.placeholder, false, self.align, font_size, + ); + canvas.draw_text( + &self.placeholder, + rect.x + theme::PAD_H + placeholder_align_x, + text_y, + font_size, + theme::placeholder(), + ); + } else { + canvas.draw_text( + &text, + rect.x + theme::PAD_H + align_x - scroll_x, + text_y, + font_size, + theme::text(), + ); + } + + if focused + { + let safe_cursor = cursor_pos.min( self.value.len() ); + let cursor_text = if secure + { + "\u{2022}".repeat( self.value[..safe_cursor].chars().count() ) + } else { + self.value[..safe_cursor].to_string() + }; + let cursor_x = rect.x + theme::PAD_H + align_x - scroll_x + + canvas.measure_text( &cursor_text, font_size ); + let cursor_rect = Rect + { + x: cursor_x, + y: rect.y + 8.0, + width: 2.0, + height: rect.height - 16.0, + }; + canvas.fill_rect( cursor_rect, theme::cursor(), 0.0 ); + } + + // Restore whatever clip was active on entry. + if outer_clip.is_empty() + { + canvas.clear_clip(); + } else { + canvas.set_clip_rects( &outer_clip ); + } + + // Eye icon for `password_toggle`. Drawn outside the inner + // clip so it never gets occluded by overflowing text — the + // preceding clip-restore is the reason this lives here and + // not earlier in the function. Tinted with the placeholder + // colour so the icon reads as ambient affordance rather + // than a primary control. + if let Some( ( visible, _ ) ) = self.password_toggle.as_ref() + { + let icon_name = if *visible { "actions/invisible" } else { "actions/visible" }; + let icon_px = theme::PASSWORD_TOGGLE_SIZE.round() as u32; + if let Some( ( rgba, iw, ih ) ) = crate::theme::icon_rgba( icon_name, icon_px ) + { + let tinted = crate::theme::tint_symbolic( &rgba, theme::placeholder() ); + let zone = password_toggle_hit_zone( rect ); + let dest = Rect + { + x: ( zone.x + ( zone.width - iw as f32 ) / 2.0 ).round(), + y: ( rect.y + ( rect.height - ih as f32 ) / 2.0 ).round(), + width: iw as f32, + height: ih as f32, + }; + canvas.draw_image_data( &tinted, iw, ih, dest, 1.0 ); + } + } + } + + fn draw_multiline( + &self, + canvas: &mut Canvas, + rect: Rect, + focused: bool, + cursor_pos: usize, + selection_anchor: usize, + ) + { + let border_c = if focused { theme::focus_border() } else { theme::border() }; + let border_w = if focused { theme::FOCUS_BORDER_W } else { theme::BORDER_W }; + canvas.fill_rect( rect, theme::bg(), theme::RADIUS_MULTI ); + canvas.stroke_rect( rect, border_c, border_w, theme::RADIUS_MULTI ); + + let line_h = theme::FONT_SIZE * theme::LINE_H_MULT; + let inner_h = ( rect.height - theme::PAD_V_MULTI * 2.0 ).max( line_h ); + let visible_lines = ( inner_h / line_h ).floor().max( 1.0 ) as usize; + let inner_width = ( rect.width - theme::PAD_H * 2.0 ).max( theme::FONT_SIZE ); + + // Compute the visual-line layout once per draw. Iterators + // later go through this list; long logical lines are + // soft-wrapped at the last whitespace before the row would + // overflow without mutating the buffer. + let visual_lines = compute_visual_lines( canvas, &self.value, inner_width, theme::FONT_SIZE ); + + // Vertical auto-scroll: keep the cursor's visual line on + // screen. The cursor sits in the *first* visual line whose + // `end >= cursor` — at a wrap boundary that places it at the + // trailing edge of the previous line rather than the start of + // the next, matching the convention of every standard text + // editor. + let safe_cursor = cursor_pos.min( self.value.len() ); + let cursor_visual_idx = visual_lines.iter() + .position( |vl| safe_cursor >= vl.start && safe_cursor <= vl.end ) + .unwrap_or( visual_lines.len().saturating_sub( 1 ) ); + let total_lines = visual_lines.len(); + let max_first = total_lines.saturating_sub( visible_lines ); + let first_line = if cursor_visual_idx + 1 > visible_lines + { + ( cursor_visual_idx + 1 - visible_lines ).min( max_first ) + } else { 0 }; + + let baseline_0 = rect.y + theme::PAD_V_MULTI + theme::FONT_SIZE; + + // Clip text to the inner rect so partial lines at the bottom + // stay inside the box and do not bleed onto sibling widgets + // or the border stroke. Save the parent clip first, then + // intersect it with our inner rect, so a `scroll(...)`-wrapped + // multiline edit keeps the parent's clip honoured. + let outer_clip = canvas.clip_bounds(); + let inner_rect = Rect + { + x: rect.x + theme::PAD_H * 0.5, + y: rect.y + theme::PAD_V_MULTI * 0.5, + width: ( rect.width - theme::PAD_H ).max( 0.0 ), + height: ( rect.height - theme::PAD_V_MULTI ).max( 0.0 ), + }; + let composed_clip: Vec = if outer_clip.is_empty() + { + vec![ inner_rect ] + } else { + outer_clip.iter() + .filter_map( |o| rect_intersect( *o, inner_rect ) ) + .collect() + }; + canvas.set_clip_rects( &composed_clip ); + + // Selection highlight (multiline). Painted before the text so + // the glyphs sit on top of the tinted band(s). Iterates + // visual lines, so a soft-wrapped logical line gets one + // highlight rect per visual row automatically. + if focused && selection_anchor != cursor_pos + { + let s = cursor_pos.min( selection_anchor ).min( self.value.len() ); + let e = cursor_pos.max( selection_anchor ).min( self.value.len() ); + for ( i, vl ) in visual_lines.iter().enumerate().skip( first_line ) + { + let visual_idx = i - first_line; + let y = rect.y + theme::PAD_V_MULTI + visual_idx as f32 * line_h; + if y > rect.y + rect.height - theme::PAD_V_MULTI { break; } + // Intersection of [s, e] with this visual line's + // byte range. Empty / non-overlapping → skip. + let span_start = s.max( vl.start ); + let span_end = e.min( vl.end ); + if span_start >= span_end { continue; } + let prefix_text = &self.value[ vl.start..span_start ]; + let span_text = &self.value[ span_start..span_end ]; + let x0 = rect.x + theme::PAD_H + canvas.measure_text( prefix_text, theme::FONT_SIZE ); + let w = canvas.measure_text( span_text, theme::FONT_SIZE ).max( 4.0 ); + let sel_rect = Rect + { + x: x0, y, + width: w, height: line_h, + }; + canvas.fill_rect( sel_rect, theme::selection(), 2.0 ); + } + } + + if self.value.is_empty() + { + canvas.draw_text( + &self.placeholder, + rect.x + theme::PAD_H, + baseline_0, + theme::FONT_SIZE, + theme::placeholder(), + ); + } else { + for ( i, vl ) in visual_lines.iter().enumerate().skip( first_line ) + { + let visual_idx = i - first_line; + let y = baseline_0 + visual_idx as f32 * line_h; + if y > rect.y + rect.height - theme::PAD_V_MULTI + line_h + { + break; + } + let line = &self.value[ vl.start..vl.end ]; + canvas.draw_text( + line, + rect.x + theme::PAD_H, + y, + theme::FONT_SIZE, + theme::text(), + ); + } + } + + if focused + { + let vl = visual_lines[ cursor_visual_idx ]; + let line_prefix = &self.value[ vl.start..safe_cursor ]; + let cursor_x = rect.x + theme::PAD_H + + canvas.measure_text( line_prefix, theme::FONT_SIZE ); + let visual_y_idx = cursor_visual_idx.saturating_sub( first_line ); + let cursor_y = rect.y + theme::PAD_V_MULTI + visual_y_idx as f32 * line_h; + let cursor_rect = Rect + { + x: cursor_x, + y: cursor_y + 2.0, + width: 2.0, + height: theme::FONT_SIZE + 4.0, + }; + canvas.fill_rect( cursor_rect, theme::cursor(), 0.0 ); + } + + // Restore whatever clip was active on entry — a single empty + // `Vec` from `clip_bounds` means "no clip", which we model by + // calling `clear_clip` instead of pushing an empty slice. + if outer_clip.is_empty() + { + canvas.clear_clip(); + } else { + canvas.set_clip_rects( &outer_clip ); + } + } +} diff --git a/src/widget/text_edit/hit_test.rs b/src/widget/text_edit/hit_test.rs new file mode 100644 index 0000000..b0f64c3 --- /dev/null +++ b/src/widget/text_edit/hit_test.rs @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::render::Canvas; +use crate::types::{ Point, Rect }; + +use super::theme; +use super::wrapping::compute_visual_lines; + +/// Convert a pointer position inside the widget rect to the byte offset +/// in `value` the cursor should land on. Free function so the runtime +/// can call it with the value snapshot it already holds in +/// [`crate::widget::WidgetHandlers::TextEdit`] without re-walking the +/// element tree to find the original [`super::TextEdit`] struct. +/// +/// `multiline` and `secure` mirror the matching fields on the source +/// widget; `cursor_pos` is the current cursor — needed by the +/// multiline branch to reproduce the same vertical scroll the most +/// recent [`super::TextEdit::draw`] call computed. +pub( crate ) fn byte_offset_at( + canvas: &Canvas, + rect: Rect, + pos: Point, + value: &str, + multiline: bool, + secure: bool, + cursor_pos: usize, + align: crate::widget::text::TextAlign, + font_size: f32, +) -> usize +{ + if value.is_empty() { return 0; } + + if multiline && !secure + { + let line_h = theme::FONT_SIZE * theme::LINE_H_MULT; + let inner_h = ( rect.height - theme::PAD_V_MULTI * 2.0 ).max( line_h ); + let visible_lines = ( inner_h / line_h ).floor().max( 1.0 ) as usize; + let inner_width = ( rect.width - theme::PAD_H * 2.0 ).max( theme::FONT_SIZE ); + + // Recompute the visual layout from current state. Same call + // as `draw_multiline` makes, so the click maps to the line / + // column the user actually sees. + let visual_lines = compute_visual_lines( canvas, value, inner_width, theme::FONT_SIZE ); + let safe_cursor = cursor_pos.min( value.len() ); + let cursor_visual_idx = visual_lines.iter() + .position( |vl| safe_cursor >= vl.start && safe_cursor <= vl.end ) + .unwrap_or( visual_lines.len().saturating_sub( 1 ) ); + let total_lines = visual_lines.len(); + let max_first = total_lines.saturating_sub( visible_lines ); + let first_line = if cursor_visual_idx + 1 > visible_lines + { + ( cursor_visual_idx + 1 - visible_lines ).min( max_first ) + } else { 0 }; + + let visual_idx = ( ( pos.y - rect.y - theme::PAD_V_MULTI ) / line_h ) + .max( 0.0 ) as usize; + let target_idx = ( first_line + visual_idx ).min( total_lines.saturating_sub( 1 ) ); + let vl = visual_lines[ target_idx ]; + + byte_offset_in_line( canvas, value, vl.start, vl.end, pos.x - rect.x - theme::PAD_H, false, theme::FONT_SIZE ) + } else { + // Mirror the horizontal scroll *and* alignment the renderer + // applies so a click lands on the glyph the user actually + // sees, not on the byte offset that would correspond to an + // unscrolled, left-aligned layout. + let scroll_x = single_line_scroll_x( canvas, rect, value, cursor_pos, secure, font_size ); + let align_x = single_line_align_offset( canvas, rect, value, secure, align, font_size ); + byte_offset_in_line( + canvas, value, 0, value.len(), + pos.x - rect.x - theme::PAD_H - align_x + scroll_x, + secure, + font_size, + ) + } +} + +/// Horizontal scroll offset, in pixels, applied to a single-line +/// `text_edit` so the cursor stays visible inside the inner rect when +/// the value is wider than the box. Stateless — derived purely from +/// the current cursor position. Three regimes: +/// +/// * cursor in the **left half** of the visible band → no scroll, the +/// start of the text reads naturally; +/// * cursor in the **right half from the end** → anchor to end so the +/// tail of the value is in view (this is the "typing past the right +/// edge" case the user sees the most); +/// * cursor anywhere else → centre the cursor in the visible band so +/// navigation with the arrow keys keeps trailing context on both +/// sides instead of jumping the text on every keystroke. +/// +/// `secure` toggles bullet substitution before measuring so a +/// password field scrolls based on the displayed bullets, not the +/// underlying value's glyph widths. +pub( crate ) fn single_line_scroll_x( + canvas: &Canvas, + rect: Rect, + value: &str, + cursor: usize, + secure: bool, + font_size: f32, +) -> f32 +{ + let inner_width = ( rect.width - theme::PAD_H * 2.0 ).max( font_size ); + let safe_cursor = cursor.min( value.len() ); + + let display_value: String = if secure + { + "\u{2022}".repeat( value.chars().count() ) + } else { + value.to_string() + }; + let prefix_text: String = if secure + { + "\u{2022}".repeat( value[..safe_cursor].chars().count() ) + } else { + value[..safe_cursor].to_string() + }; + + let total_w = canvas.measure_text( &display_value, font_size ); + if total_w <= inner_width { return 0.0; } + let prefix_w = canvas.measure_text( &prefix_text, font_size ); + let suffix_w = ( total_w - prefix_w ).max( 0.0 ); + const MARGIN: f32 = 4.0; + + if prefix_w <= inner_width / 2.0 + { + 0.0 + } else if suffix_w <= inner_width / 2.0 { + ( total_w - inner_width + MARGIN ).max( 0.0 ) + } else { + prefix_w - inner_width / 2.0 + } +} + +/// Horizontal offset, in pixels, applied on top of `single_line_scroll_x` +/// to honour the field's [`crate::widget::text::TextAlign`]. Only meaningful +/// while the value still fits inside `inner_width`; once the text +/// overflows, scrolling owns the layout and the alignment offset +/// collapses to `0` so anchoring the cursor / end of text reads +/// naturally without fighting the alignment. +pub( crate ) fn single_line_align_offset( + canvas: &Canvas, + rect: Rect, + value: &str, + secure: bool, + align: crate::widget::text::TextAlign, + font_size: f32, +) -> f32 +{ + use crate::widget::text::TextAlign; + if matches!( align, TextAlign::Left ) { return 0.0; } + let inner_width = ( rect.width - theme::PAD_H * 2.0 ).max( font_size ); + let display_value: String = if secure + { + "\u{2022}".repeat( value.chars().count() ) + } else { + value.to_string() + }; + let total_w = canvas.measure_text( &display_value, font_size ); + if total_w >= inner_width { return 0.0; } + match align + { + TextAlign::Left => 0.0, + TextAlign::Center => ( inner_width - total_w ) * 0.5, + TextAlign::Right => inner_width - total_w, + } +} + +/// Walk the slice `value[line_start..line_end]` char by char, +/// accumulating widths, and return the byte offset (within `value`) +/// where the click `target_x` falls. The "snap to nearest boundary" +/// rule is the standard `text input is text input` behaviour: if the +/// click is past the half-glyph mark, the cursor lands *after* the +/// glyph. `secure` toggles bullet substitution at measurement time so +/// the same logic works for password fields. +pub( super ) fn byte_offset_in_line( + canvas: &Canvas, + value: &str, + line_start: usize, + line_end: usize, + target_x: f32, + secure: bool, + font_size: f32, +) -> usize +{ + let line = &value[line_start..line_end]; + if target_x <= 0.0 { return line_start; } + let mut acc_w = 0.0f32; + let mut last_byte = line_start; + for ( ofs, ch ) in line.char_indices() + { + let glyph_str = if secure { "\u{2022}".to_string() } else { ch.to_string() }; + let w = canvas.measure_text( &glyph_str, font_size ); + if target_x < acc_w + w * 0.5 + { + return line_start + ofs; + } + acc_w += w; + last_byte = line_start + ofs + ch.len_utf8(); + } + last_byte.min( line_end ) +} diff --git a/src/widget/text_edit/mod.rs b/src/widget/text_edit/mod.rs new file mode 100644 index 0000000..de7f894 --- /dev/null +++ b/src/widget/text_edit/mod.rs @@ -0,0 +1,513 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Text input field — single-line or multiline. The widget itself +//! owns layout / draw; the runtime side of text editing +//! (insert, delete, cursor movement, selection, clipboard) +//! lives in [`crate::event_loop::text_editing`]. + +use std::sync::Arc; + +use crate::render::Canvas; +use crate::secure_mem::secure_zero; +use crate::types::{ Rect, WidgetId }; + +use super::Element; + +pub( crate ) mod theme; +pub( crate ) mod wrapping; +pub( crate ) mod hit_test; +pub( crate ) mod cursor; +mod draw; +#[ cfg( test ) ] +mod tests; + +pub use draw::password_toggle_hit_zone; +pub( crate ) use hit_test::byte_offset_at; +pub( crate ) use cursor::{ cursor_visual_down, cursor_visual_end, cursor_visual_home, cursor_visual_up }; + +/// A text input field. +/// +/// Single-line by default; switches to a multi-row text-area via +/// [`Self::multiline`]. Single-line mode honours the optional inline +/// builders for picker-style fields: +/// +/// * [`Self::align`] — horizontal alignment of the displayed text; +/// * [`Self::borderless`] — drop the surrounding pill / border so the +/// field can sit inside a parent that already paints its own +/// surface; +/// * [`Self::fixed_width`] — pin the preferred width to a specific +/// number of pixels instead of claiming `max_width`; +/// * [`Self::font_size`] — override the text font size; +/// * [`Self::select_on_focus`] — auto-select the value on focus so +/// the next keystroke replaces it (numeric pickers, short-form +/// inputs). +/// * [`Self::password_toggle`] — pin a built-in show / hide-password +/// eye icon to the right edge of the field; the bullet +/// substitution flips with the externally-owned `visible` state +/// on each tap. +/// +/// ```rust,no_run +/// # use ltk::{ text_edit, Element }; +/// # #[ derive( Clone ) ] enum Msg { UsernameChanged( String ), Submit } +/// # struct App { username: String } +/// # impl App { fn _ex( &self ) -> Element { +/// text_edit( "Username", &self.username ) +/// .on_change( |s| Msg::UsernameChanged( s ) ) +/// .on_submit( Msg::Submit ) +/// .into() +/// # }} +/// ``` +/// +/// ## Password field with show / hide toggle +/// +/// ```rust,no_run +/// # use ltk::{ text_edit, Element }; +/// # #[ derive( Clone ) ] enum Msg { PasswordChanged( String ), TogglePassword } +/// # struct App { password: String, password_visible: bool } +/// # impl App { fn _ex( &self ) -> Element { +/// text_edit( "Password", &self.password ) +/// .on_change( |s| Msg::PasswordChanged( s ) ) +/// .password_toggle( self.password_visible, Msg::TogglePassword ) +/// .into() +/// # }} +/// ``` +/// +/// `password_toggle` overrides [`Self::secure`] when both are set — +/// the toggle's `visible` parameter drives the bullet substitution +/// from then on. The widget still wipes the buffer on drop and +/// skips the IME registration (the same hardening +/// [`Self::secure`] gives) regardless of the current visibility, +/// so flipping the eye does not weaken the field's threat model +/// at runtime. +pub struct TextEdit +{ + /// Placeholder text shown when the field is empty. + pub placeholder: String, + /// Current field value. + pub value: String, + /// Callback invoked with the new value on every keystroke. + /// `Arc` (not `Box`) so the layout pass can clone it into the per-leaf + /// handler snapshot for O(1) dispatch on input events. + pub on_change: Option Msg>>, + /// Message emitted when the user presses Enter. + pub on_submit: Option, + /// When `true`, the value is rendered as bullet characters (password mode). + pub secure: bool, + /// When `true`, the widget renders as a multi-row text area: the + /// box grows to [`Self::rows`] visible rows, line breaks in the + /// value are honoured at draw time, and pressing Enter inserts a + /// `\n` rather than firing [`Self::on_submit`]. Ignored when + /// [`Self::secure`] is set — passwords are always single-line. + pub multiline: bool, + /// Visible row count when `multiline` is `true`. Drives + /// `preferred_size`'s height calculation so a multiline field + /// claims a sensible vertical slot in the parent layout. Ignored + /// when `multiline` is `false`. + pub rows: u32, + /// Byte offset of the text cursor within `value` (used by insert_str/backspace). + pub cursor_pos: usize, + /// Optional stable identifier for focus management. + pub id: Option, + /// Override the pointer cursor shape on hover. `None` falls back + /// to the I-beam default that matches every desktop convention. + pub cursor: Option, + /// Horizontal alignment of the displayed text inside the inner + /// content rect. Only takes effect on the single-line path when + /// the value fits inside the inner width — once the value + /// overflows, the internal `single_line_scroll_x` helper takes + /// over and the alignment offset collapses to `0` so scrolling + /// reads naturally. Default `TextAlign::Left`. + pub align: super::text::TextAlign, + /// Skip the field's background fill and border stroke. Useful + /// when the [`TextEdit`] is dropped inside another container + /// that paints its own surface (e.g. the digit cells inside + /// [`crate::widget::time_picker::TimePicker`]) and a second pill + /// would only add visual noise. + pub borderless: bool, + /// Override the preferred width reported to the parent layout. + /// Without this the single-line `TextEdit` claims `max_width` and + /// fills whatever rect the parent allocates — which is the right + /// default for forms but wrong when the field needs to be sized + /// to fit a fixed number of glyphs (date / time pickers, inline + /// numeric inputs). + pub fixed_width: Option, + /// Font size in pixels for the single-line draw path. Defaults to + /// the theme's `FONT_SIZE` constant. Multiline mode ignores this + /// for now and always uses the default — multiline soft-wrap + /// layout depends on the constant in several places that are not + /// yet parameterised. + pub font_size: f32, + /// When `true`, focusing the field selects the whole value so the + /// next keystroke replaces it. Standard behaviour for numeric + /// pickers and short-form fields where the user usually wants to + /// retype rather than edit. Default `false` — long-form fields + /// keep the cursor at the end on focus. + pub select_on_focus: bool, + /// Self-managed "show / hide password" eye affordance — when + /// `Some( ( visible, on_toggle ) )` the field renders an + /// `actions/visible` ↔ `actions/invisible` icon at its right + /// edge, taps on that icon dispatch `on_toggle` instead of + /// placing the cursor, and the bullet substitution flips with + /// `visible` (overriding `secure`). Set on a field that already + /// has `secure( true )` and the explicit flag becomes redundant + /// — the toggle controls the visibility from then on. + pub password_toggle: Option<( bool, Msg )>, +} + +impl TextEdit +{ + /// Create a text field with the given placeholder and initial value. + /// + /// The cursor is placed at the end of the initial value. + pub fn new( placeholder: String, value: String ) -> Self + { + let cursor_pos = value.len(); + Self + { + placeholder, + value, + on_change: None, + on_submit: None, + secure: false, + multiline: false, + rows: theme::ROWS_DEFAULT, + cursor_pos, + id: None, + cursor: None, + align: super::text::TextAlign::Left, + borderless: false, + fixed_width: None, + font_size: theme::FONT_SIZE, + select_on_focus: false, + password_toggle: None, + } + } + + /// Add a "show / hide password" eye toggle pinned to the right + /// edge of the field. `visible` controls whether the value + /// renders as bullets (`false`) or plain text (`true`); a tap on + /// the icon emits `on_toggle` so the caller can flip its own + /// `bool` state and re-render. Works with or without an explicit + /// [`Self::secure`] — when this is set, the toggle's `visible` + /// drives the bullet substitution and the `secure` field is + /// ignored. + pub fn password_toggle( mut self, visible: bool, on_toggle: Msg ) -> Self + { + self.password_toggle = Some( ( visible, on_toggle ) ); + self + } + + /// Effective secure flag honoured by drawing / measurement / + /// hit-testing — [`Self::password_toggle`] takes precedence over + /// the manual [`Self::secure`] when both are set. + pub fn effective_secure( &self ) -> bool + { + match &self.password_toggle + { + Some( ( visible, _ ) ) => !visible, + None => self.secure, + } + } + + /// Override the font size used by the single-line draw path. + /// Defaults to the theme's `FONT_SIZE` constant. Ignored in + /// multiline mode. + pub fn font_size( mut self, px: f32 ) -> Self + { + self.font_size = px.max( 1.0 ); + self + } + + /// Select the whole value when the field receives focus, so the + /// next keystroke replaces it. Default `false`. + pub fn select_on_focus( mut self, on: bool ) -> Self + { + self.select_on_focus = on; + self + } + + /// Set the horizontal alignment of the displayed text. Default + /// [`TextAlign::Left`](super::text::TextAlign::Left). + pub fn align( mut self, a: super::text::TextAlign ) -> Self + { + self.align = a; + self + } + + /// Skip the field's background fill and border stroke — useful + /// when the field is nested inside a container that already + /// paints its own surface. + pub fn borderless( mut self, on: bool ) -> Self + { + self.borderless = on; + self + } + + /// Override the preferred width reported to the parent layout. + /// Pass `None` (default) to fall back to claiming `max_width`. + pub fn fixed_width( mut self, w: f32 ) -> Self + { + self.fixed_width = Some( w ); + self + } + + /// Override the pointer cursor shape shown on hover. Defaults to + /// [`CursorShape::Text`](crate::CursorShape::Text) (I-beam). + pub fn cursor( mut self, shape: crate::types::CursorShape ) -> Self + { + self.cursor = Some( shape ); + self + } + + /// Switch to multiline (text-area) mode. The box is laid out with + /// [`Self::rows`] visible rows of height, line breaks in the value + /// are rendered as separate rows, and Enter inserts a `\n` instead + /// of firing [`Self::on_submit`]. Ignored when [`Self::secure`] is + /// `true`. + pub fn multiline( mut self, m: bool ) -> Self + { + self.multiline = m; + self + } + + /// Configure the number of visible rows in multiline mode. Defaults + /// to 5; ignored when [`Self::multiline`] is `false`. + pub fn rows( mut self, n: u32 ) -> Self + { + self.rows = n.max( 1 ); + self + } + + /// Set the callback invoked on every keystroke with the updated value. + pub fn on_change( mut self, f: impl Fn(String) -> Msg + 'static ) -> Self + { + self.on_change = Some( Arc::new( f ) ); + self + } + + /// Set the message emitted when Enter is pressed. + pub fn on_submit( mut self, msg: Msg ) -> Self + { + self.on_submit = Some( msg ); + self + } + + /// Enable or disable password mode. + /// + /// When `true`, this widget: + /// + /// 1. Renders the value as bullet characters (`•`) instead of the + /// raw glyphs. + /// 2. Forces single-line mode (multiline + secure is mutually + /// exclusive — passwords don't have line breaks). + /// 3. Wipes the underlying byte buffer with zero before the + /// `String` allocation is returned to the allocator. The wipe + /// runs in `Drop` for both the `TextEdit` itself and for the + /// per-frame [`crate::widget::WidgetHandlers::TextEdit`] snapshot + /// the runtime keeps for input dispatch — so the in-tree copies + /// that ltk owns never linger as plain text in freed memory. + /// + /// # Threat model — what `secure` covers + /// + /// Inside the widget tree the runtime keeps two copies of the + /// value for the lifetime of one frame: the `TextEdit` itself and + /// the `WidgetHandlers` snapshot. Both run the `secure_zero` wipe + /// on `Drop`, so when the next frame replaces them (the typical + /// case — `view()` rebuilds every frame) the freed allocations are + /// overwritten before being released back to the allocator. The + /// wipe uses volatile writes + a `compiler_fence` so the optimiser + /// cannot elide it as dead code (the implementation is in the + /// crate-private `secure_mem` module). + /// + /// # What `secure` does **not** cover + /// + /// * **The application's own state.** The `String` you pass in + /// through `text_edit( placeholder, &self.password )` lives on + /// **your** struct, not on the widget. Wiping it is + /// your job — typically a `Drop` impl on the credential + /// container, or an explicit + /// `secure_mem::secure_zero( password.as_bytes_mut() )` after + /// the auth handshake completes. + /// * **Callback-allocated copies.** Every keystroke passes through + /// `on_change( |s: String| ... )`, which receives a fresh + /// `String` clone. If your closure stores or forwards that + /// `String` (e.g. clone it into a worker thread for PAM), each + /// stored copy is the consumer's responsibility to wipe. ltk + /// only owns the buffers it allocated itself. + /// * **OS-level disclosure surfaces.** Swap-out, hibernation + /// images, and core dumps are outside any user-space wipe's + /// reach. For threat models that require resistance to these, + /// compile against an `mlock`-aware allocator, disable swap on + /// the credential mount, and restrict core-dump capability with + /// `prctl( PR_SET_DUMPABLE, 0 )` on the process. + /// * **Compositor-side records.** Wayland text-input protocols can + /// surface preedit / commit strings to the compositor's IME + /// stack; `secure` skips text-input-v3 registration so this path + /// stays closed. Verify your compositor honours that — most do, + /// but the protocol does not strictly require it. + /// + /// See the in-repo `SECURITY.md` for the full threat-model write-up + /// (the *Hardening features* section enumerates each guarantee and + /// its boundary). + pub fn secure( mut self, s: bool ) -> Self + { + self.secure = s; + self + } + + /// Assign a stable identifier for focus management. + pub fn id( mut self, id: WidgetId ) -> Self + { + self.id = Some( id ); + self + } + + /// Return the preferred `(width, height)` given available `max_width`. + /// + /// Single-line: theme-defined `HEIGHT`. + /// Multiline: enough room for [`Self::rows`] lines plus padding. + pub fn preferred_size( &self, max_width: f32, _canvas: &Canvas ) -> (f32, f32) + { + if self.multiline && !self.effective_secure() + { + let line_h = theme::FONT_SIZE * theme::LINE_H_MULT; + let h = self.rows as f32 * line_h + theme::PAD_V_MULTI * 2.0; + ( max_width, h ) + } else { + let w = self.fixed_width + .map( |fw| fw.min( max_width ) ) + .unwrap_or( max_width ); + ( w, theme::HEIGHT ) + } + } + + /// `true` when the widget is laid out as a multi-row text area — + /// i.e. [`Self::multiline`] was set and [`Self::effective_secure`] + /// is `false`. A `password_toggle` field collapses to single-line + /// like an explicit `secure( true )` does. + pub fn is_multiline( &self ) -> bool + { + self.multiline && !self.effective_secure() + } + + /// Translate a pointer position inside `rect` to the byte offset + /// in [`Self::value`] that the cursor should land on. Thin + /// wrapper around `byte_offset_at` using this widget's value / + /// flags. + pub fn byte_offset_at_self( + &self, + canvas: &Canvas, + rect: Rect, + pos: crate::types::Point, + cursor_pos: usize, + ) -> usize + { + byte_offset_at( + canvas, rect, pos, &self.value, self.is_multiline(), self.effective_secure(), cursor_pos, self.align, self.font_size, + ) + } + + /// Border stroke is centered on `rect`, so half the stroke width plus ~1 px + /// of antialiasing bleed sits outside. The widest stroke is the focused + /// border, so use that as the envelope. + pub fn paint_bounds( &self, rect: Rect ) -> Rect + { + rect.expand( theme::FOCUS_BORDER_W * 0.5 + 1.0 ) + } + + /// Return the display string — bullet characters in secure mode, plain value otherwise. + pub fn display_text( &self ) -> String + { + if self.effective_secure() + { + "\u{2022}".repeat( self.value.chars().count() ) + } else { + self.value.clone() + } + } + + /// Wrap this widget in an [`Element`]. + pub fn into_element( self ) -> Element + { + Element::TextEdit( self ) + } + + /// Insert a string at the current cursor position, advance the cursor, and + /// return the `on_change` message if one is set. + pub fn insert_str( &mut self, s: &str ) -> Option + { + self.value.insert_str( self.cursor_pos.min( self.value.len() ), s ); + self.cursor_pos = (self.cursor_pos + s.len()).min( self.value.len() ); + self.on_change.as_ref().map( |f| f( self.value.clone() ) ) + } + + /// Delete the character before the cursor and return the `on_change` message + /// if one is set. Does nothing if the cursor is already at position 0. + pub fn backspace( &mut self ) -> Option + { + if self.cursor_pos == 0 { return None; } + let chars: Vec = self.value.chars().collect(); + let char_pos = self.value[..self.cursor_pos].chars().count(); + if char_pos == 0 { return None; } + let mut chars = chars; + let removed = chars.remove( char_pos - 1 ); + self.cursor_pos -= removed.len_utf8(); + self.value = chars.iter().collect(); + self.on_change.as_ref().map( |f| f( self.value.clone() ) ) + } + + pub( crate ) fn map_msg( self, f: &super::MapFn ) -> TextEdit + where + U: Clone + 'static, + Msg: 'static, + { + // Wrap on_change the same way the slider does. on_submit is a + // plain `Option`, so it goes through the user mapper once. + let on_change = self.on_change.clone().map( |old| -> Arc U> + { + let mapper = Arc::clone( f ); + Arc::new( move |s| ( *mapper )( ( *old )( s ) ) ) + } ); + TextEdit + { + placeholder: self.placeholder.clone(), + value: self.value.clone(), + on_change, + on_submit: self.on_submit.clone().map( |m| ( *f )( m ) ), + secure: self.secure, + multiline: self.multiline, + rows: self.rows, + cursor_pos: self.cursor_pos, + id: self.id, + cursor: self.cursor, + align: self.align, + borderless: self.borderless, + fixed_width: self.fixed_width, + font_size: self.font_size, + select_on_focus: self.select_on_focus, + password_toggle: self.password_toggle.clone().map( |( v, m )| ( v, ( *f )( m ) ) ), + } + } +} + +impl Drop for TextEdit +{ + /// When `secure( true )` is set, scrub the value bytes before the + /// underlying `String` allocation is returned to the allocator. The + /// non-secure path is a no-op so the cost is paid only by widgets + /// that opted into credential handling. + fn drop( &mut self ) + { + if self.secure || self.password_toggle.is_some() + { + // SAFETY: as_mut_vec exposes the underlying byte buffer of the + // String. We only overwrite each byte with zero, which is valid + // UTF-8 (a sequence of NUL codepoints), so the String invariant + // is preserved through to the Vec drop that runs immediately + // after this fn returns. + let bytes = unsafe { self.value.as_mut_vec() }; + secure_zero( bytes ); + } + } +} diff --git a/src/widget/text_edit/tests.rs b/src/widget/text_edit/tests.rs new file mode 100644 index 0000000..0e8b7dd --- /dev/null +++ b/src/widget/text_edit/tests.rs @@ -0,0 +1,702 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use super::TextEdit; + +#[ derive( Clone, Debug, PartialEq, Eq ) ] +enum Msg +{ + Changed( String ), + Submitted, +} + +fn new_te( value: &str ) -> TextEdit +{ + TextEdit::new( "placeholder".into(), value.into() ) +} + +// ── Construction ────────────────────────────────────────────────────────── + +#[ test ] +fn new_places_cursor_at_end_of_initial_value() +{ + let t = new_te( "hello" ); + assert_eq!( t.cursor_pos, 5 ); + assert_eq!( t.value, "hello" ); +} + +#[ test ] +fn new_with_empty_value_starts_with_zero_cursor() +{ + let t = new_te( "" ); + assert_eq!( t.cursor_pos, 0 ); + assert!( t.value.is_empty() ); +} + +#[ test ] +fn new_with_multibyte_value_uses_byte_length_for_cursor() +{ + // "café" is 5 bytes (c-a-f-é where é is 2 bytes in UTF-8). + let t = new_te( "café" ); + assert_eq!( t.cursor_pos, 5 ); + assert_eq!( t.value.len(), 5 ); +} + +// ── insert_str ──────────────────────────────────────────────────────────── + +#[ test ] +fn insert_str_at_end_appends_and_advances_cursor() +{ + let mut t = new_te( "ab" ); + assert_eq!( t.cursor_pos, 2 ); + let _ = t.insert_str( "c" ); + assert_eq!( t.value, "abc" ); + assert_eq!( t.cursor_pos, 3 ); +} + +#[ test ] +fn insert_str_at_middle_inserts_and_advances_cursor_by_byte_len() +{ + let mut t = new_te( "ab" ); + t.cursor_pos = 1; + let _ = t.insert_str( "X" ); + assert_eq!( t.value, "aXb" ); + assert_eq!( t.cursor_pos, 2 ); +} + +#[ test ] +fn insert_str_multibyte_advances_cursor_by_utf8_byte_count() +{ + let mut t = new_te( "" ); + let _ = t.insert_str( "é" ); // 2 bytes + assert_eq!( t.value, "é" ); + assert_eq!( t.cursor_pos, 2 ); + let _ = t.insert_str( "🦀" ); // 4 bytes + assert_eq!( t.cursor_pos, 6 ); +} + +#[ test ] +fn insert_str_clamps_runaway_cursor() +{ + // If cursor_pos is somehow past value.len() (defensive), insert should + // behave as "insert at end" rather than panic. + let mut t = new_te( "ab" ); + t.cursor_pos = 99; + let _ = t.insert_str( "c" ); + assert_eq!( t.value, "abc" ); + // Cursor is clamped to new value length. + assert_eq!( t.cursor_pos, 3 ); +} + +#[ test ] +fn insert_str_returns_on_change_with_new_value() +{ + let mut t = new_te( "ab" ) + .on_change( |s| Msg::Changed( s ) ); + let msg = t.insert_str( "c" ); + assert_eq!( msg, Some( Msg::Changed( "abc".into() ) ) ); +} + +#[ test ] +fn insert_str_without_on_change_returns_none() +{ + let mut t = new_te( "ab" ); + assert_eq!( t.insert_str( "c" ), None ); +} + +// ── backspace ───────────────────────────────────────────────────────────── + +#[ test ] +fn backspace_at_zero_cursor_is_noop() +{ + let mut t = new_te( "abc" ); + t.cursor_pos = 0; + assert_eq!( t.backspace(), None ); + assert_eq!( t.value, "abc" ); + assert_eq!( t.cursor_pos, 0 ); +} + +#[ test ] +fn backspace_at_end_removes_last_char() +{ + let mut t = new_te( "abc" ); + let _ = t.backspace(); + assert_eq!( t.value, "ab" ); + assert_eq!( t.cursor_pos, 2 ); +} + +#[ test ] +fn backspace_in_middle_removes_char_before_cursor() +{ + let mut t = new_te( "abc" ); + t.cursor_pos = 2; + let _ = t.backspace(); + assert_eq!( t.value, "ac" ); + assert_eq!( t.cursor_pos, 1 ); +} + +#[ test ] +fn backspace_handles_multibyte_acute() +{ + // "é" is two bytes; backspacing must remove the whole codepoint. + let mut t = new_te( "café" ); + let _ = t.backspace(); + assert_eq!( t.value, "caf" ); + assert_eq!( t.cursor_pos, 3 ); +} + +#[ test ] +fn backspace_handles_emoji_codepoint() +{ + // Crab emoji is 4 bytes in UTF-8; cursor must back up by 4. + let mut t = new_te( "x🦀" ); + assert_eq!( t.cursor_pos, 5 ); + let _ = t.backspace(); + assert_eq!( t.value, "x" ); + assert_eq!( t.cursor_pos, 1 ); +} + +#[ test ] +fn backspace_returns_on_change_with_new_value() +{ + let mut t = new_te( "ab" ) + .on_change( |s| Msg::Changed( s ) ); + let msg = t.backspace(); + assert_eq!( msg, Some( Msg::Changed( "a".into() ) ) ); +} + +// ── display_text + secure ───────────────────────────────────────────────── + +#[ test ] +fn display_text_plain_returns_value_verbatim() +{ + let t = new_te( "hello" ); + assert_eq!( t.display_text(), "hello" ); +} + +#[ test ] +fn display_text_secure_returns_bullets_one_per_codepoint() +{ + // One bullet per codepoint, NOT per byte. "café" has 4 codepoints, + // so the masked text must be 4 bullets — not 5 (the byte length). + let t = new_te( "café" ).secure( true ); + let masked = t.display_text(); + assert_eq!( masked.chars().count(), 4 ); + assert!( masked.chars().all( |c| c == '\u{2022}' ) ); +} + +#[ test ] +fn display_text_secure_with_emoji_one_bullet_per_codepoint() +{ + // Crab emoji is one codepoint — exactly one bullet, even though it + // occupies four bytes in UTF-8. + let t = new_te( "🦀" ).secure( true ); + assert_eq!( t.display_text().chars().count(), 1 ); +} + +#[ test ] +fn secure_mode_does_not_touch_underlying_value() +{ + let t = new_te( "secret" ).secure( true ); + assert_eq!( t.value, "secret" ); +} + +#[ test ] +fn secure_toggles_via_builder() +{ + let t = new_te( "x" ).secure( true ).secure( false ); + assert_eq!( t.display_text(), "x" ); +} + +// ── secure-mode credential zeroize ──────────────────────────────────────── + +#[ test ] +fn secure_flag_propagates_to_widget_handler_snapshot() +{ + use crate::widget::{ Element, WidgetHandlers }; + + // The runtime takes a `WidgetHandlers` snapshot of every interactive + // widget once per frame. The `secure` flag must travel with the + // snapshot so the handler's own `Drop` (which mirrors `TextEdit`'s + // wipe-on-drop) knows whether to scrub on release. + let widget: Element<()> = TextEdit::new( "p".into(), "secret".into() ) + .secure( true ) + .into_element(); + let h = widget.handlers(); + match h + { + WidgetHandlers::TextEdit { secure, .. } => assert!( secure, "secure flag must propagate" ), + _ => panic!( "expected TextEdit handler" ), + } +} + +#[ test ] +fn multiline_flag_propagates_to_widget_handler_snapshot() +{ + use crate::widget::{ Element, WidgetHandlers }; + + let widget: Element<()> = TextEdit::new( "p".into(), "x".into() ) + .multiline( true ) + .into_element(); + match widget.handlers() + { + WidgetHandlers::TextEdit { multiline, .. } => assert!( multiline, "multiline flag must propagate" ), + _ => panic!( "expected TextEdit handler" ), + } +} + +#[ test ] +fn secure_overrides_multiline_in_widget_handlers() +{ + use crate::widget::{ Element, WidgetHandlers }; + + // `is_multiline()` requires `!secure` — a secure password field + // must remain single-line even if the caller also asked for + // multiline. The handler snapshot reads `is_multiline()`, so + // `multiline` here must be `false`. + let widget: Element<()> = TextEdit::new( "p".into(), "x".into() ) + .multiline( true ) + .secure( true ) + .into_element(); + match widget.handlers() + { + WidgetHandlers::TextEdit { multiline, secure, .. } => + { + assert!( secure, "secure must propagate" ); + assert!( !multiline, "secure must override multiline" ); + } + _ => panic!( "expected TextEdit handler" ), + } +} + +#[ test ] +fn non_secure_flag_propagates_to_widget_handler_snapshot() +{ + use crate::widget::{ Element, WidgetHandlers }; + + let widget: Element<()> = TextEdit::new( "p".into(), "x".into() ) + .into_element(); + match widget.handlers() + { + WidgetHandlers::TextEdit { secure, .. } => assert!( !secure, "default is non-secure" ), + _ => panic!( "expected TextEdit handler" ), + } +} + +#[ test ] +fn secure_drop_zeros_underlying_string_buffer() +{ + // Reach into the value's byte buffer right before the drop, run the + // drop logic by replacing the binding, and confirm the bytes that + // had been the credential are now zero. We cannot inspect the + // allocation after the actual drop (UB), so this test exercises the + // same path that `Drop::drop` runs internally. + let mut t: TextEdit<()> = TextEdit::new( "p".into(), "secret".into() ).secure( true ); + assert_eq!( t.value, "secret" ); + + // Manually run the wipe the way Drop will; the next assertion checks + // the buffer is zeroed before the auto-drop releases it. + // SAFETY: same as the production wipe path above — overwriting + // every byte with zero leaves the String holding a sequence of NUL + // codepoints, which is valid UTF-8. + let bytes = unsafe { t.value.as_mut_vec() }; + crate::secure_mem::secure_zero( bytes ); + assert!( bytes.iter().all( |&b| b == 0 ) ); + assert_eq!( bytes.len(), 6 ); +} + +#[ test ] +fn non_secure_text_edit_drop_is_a_noop_for_value() +{ + // A non-secure widget must not pay the wipe cost. We cannot observe + // the absence of writes directly, but we can confirm that the + // underlying bytes still match the original content right up to + // the moment of drop. + let t: TextEdit<()> = TextEdit::new( "p".into(), "value".into() ); + assert_eq!( t.value.as_bytes(), b"value" ); + // Drop runs at end of scope without touching the bytes. +} + +// ── on_submit / on_change wiring ────────────────────────────────────────── + +#[ test ] +fn on_submit_stores_message_unchanged() +{ + let t = new_te( "" ).on_submit( Msg::Submitted ); + assert_eq!( t.on_submit, Some( Msg::Submitted ) ); +} + +// ── full edit roundtrip ─────────────────────────────────────────────────── + +#[ test ] +fn type_word_then_clear_with_backspace() +{ + let mut t = new_te( "" ); + for ch in "hola".chars() + { + let _ = t.insert_str( &ch.to_string() ); + } + assert_eq!( t.value, "hola" ); + assert_eq!( t.cursor_pos, 4 ); + while t.cursor_pos > 0 + { + let _ = t.backspace(); + } + assert!( t.value.is_empty() ); + assert_eq!( t.cursor_pos, 0 ); +} + +// ── multiline mode ─────────────────────────────────────────────────────── + +#[ test ] +fn multiline_default_off() +{ + let t = new_te( "" ); + assert!( !t.multiline ); + assert!( !t.is_multiline() ); +} + +#[ test ] +fn multiline_builder_enables() +{ + let t = new_te( "" ).multiline( true ); + assert!( t.multiline ); + assert!( t.is_multiline() ); +} + +#[ test ] +fn multiline_grows_preferred_height_with_rows() +{ + use crate::render::Canvas; + let canvas = Canvas::new( 800, 600 ); + let single = new_te( "" ); + let ( _, h_single ) = single.preferred_size( 200.0, &canvas ); + assert_eq!( h_single, super::theme::HEIGHT ); + + let multi = new_te( "" ).multiline( true ).rows( 5 ); + let ( _, h_multi ) = multi.preferred_size( 200.0, &canvas ); + assert!( h_multi > h_single, "multiline must claim more vertical space" ); + + let bigger = new_te( "" ).multiline( true ).rows( 10 ); + let ( _, h_bigger ) = bigger.preferred_size( 200.0, &canvas ); + assert!( h_bigger > h_multi, "more rows → taller box" ); +} + +#[ test ] +fn rows_clamps_zero_to_one() +{ + let t = new_te( "" ).multiline( true ).rows( 0 ); + assert_eq!( t.rows, 1 ); +} + +#[ test ] +fn secure_overrides_multiline_in_is_multiline_query() +{ + // A `multiline + secure` configuration must remain single-line: + // passwords carrying a literal `\n` would defeat the password + // mode rendering and turn the credential boundary fuzzy. + let t: TextEdit<()> = TextEdit::new( "p".into(), "x".into() ) + .multiline( true ) + .secure( true ); + assert!( t.multiline ); // raw flag preserved + assert!( !t.is_multiline() ); // effective mode is single-line +} + +#[ test ] +fn insert_str_can_carry_newline_in_multiline_mode() +{ + // The widget itself does not gate `\n` — that decision lives at + // dispatch time. We just confirm `\n` round-trips through + // insert_str + the value buffer untouched. + let mut t = new_te( "ab" ).multiline( true ); + let _ = t.insert_str( "\n" ); + assert_eq!( t.value, "ab\n" ); + assert_eq!( t.cursor_pos, 3 ); + let _ = t.insert_str( "c" ); + assert_eq!( t.value, "ab\nc" ); +} + +// ── builders for the inline / picker variants ──────────────────────────── + +#[ test ] +fn defaults_for_new_inline_builders() +{ + let t: TextEdit<()> = TextEdit::new( "p".into(), "v".into() ); + assert_eq!( t.align, super::super::text::TextAlign::Left ); + assert!( !t.borderless ); + assert!( t.fixed_width.is_none() ); + assert_eq!( t.font_size, super::theme::FONT_SIZE ); + assert!( !t.select_on_focus ); +} + +#[ test ] +fn align_builder_sets_alignment() +{ + use super::super::text::TextAlign; + let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).align( TextAlign::Center ); + assert_eq!( t.align, TextAlign::Center ); + let t = t.align( TextAlign::Right ); + assert_eq!( t.align, TextAlign::Right ); +} + +#[ test ] +fn borderless_builder_toggles_flag() +{ + let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).borderless( true ); + assert!( t.borderless ); +} + +#[ test ] +fn fixed_width_builder_stores_value() +{ + let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).fixed_width( 72.0 ); + assert_eq!( t.fixed_width, Some( 72.0 ) ); +} + +#[ test ] +fn font_size_builder_clamps_to_at_least_one_pixel() +{ + let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).font_size( 28.0 ); + assert_eq!( t.font_size, 28.0 ); + let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).font_size( -3.0 ); + assert_eq!( t.font_size, 1.0 ); +} + +#[ test ] +fn select_on_focus_builder_toggles_flag() +{ + let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).select_on_focus( true ); + assert!( t.select_on_focus ); +} + +#[ test ] +fn fixed_width_overrides_preferred_size_width() +{ + let canvas = crate::render::Canvas::new( 1, 1 ); + let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).fixed_width( 64.0 ); + let ( w, _h ) = t.preferred_size( 1000.0, &canvas ); + assert_eq!( w, 64.0 ); +} + +#[ test ] +fn fixed_width_capped_by_max_width() +{ + let canvas = crate::render::Canvas::new( 1, 1 ); + let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).fixed_width( 500.0 ); + let ( w, _h ) = t.preferred_size( 200.0, &canvas ); + // fixed_width is capped by the parent's available width to + // avoid overflowing tight rows. + assert_eq!( w, 200.0 ); +} + +#[ test ] +fn no_fixed_width_falls_back_to_max_width() +{ + let canvas = crate::render::Canvas::new( 1, 1 ); + let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ); + let ( w, _h ) = t.preferred_size( 320.0, &canvas ); + assert_eq!( w, 320.0 ); +} + +#[ test ] +fn align_propagates_to_widget_handler_snapshot() +{ + use crate::widget::{ Element, WidgetHandlers }; + use super::super::text::TextAlign; + let widget: Element<()> = TextEdit::new( "".into(), "".into() ) + .align( TextAlign::Center ) + .into_element(); + match widget.handlers() + { + WidgetHandlers::TextEdit { align, .. } => assert_eq!( align, TextAlign::Center ), + _ => panic!( "expected TextEdit handler" ), + } +} + +#[ test ] +fn font_size_propagates_to_widget_handler_snapshot() +{ + use crate::widget::{ Element, WidgetHandlers }; + let widget: Element<()> = TextEdit::new( "".into(), "".into() ) + .font_size( 28.0 ) + .into_element(); + match widget.handlers() + { + WidgetHandlers::TextEdit { font_size, .. } => assert!( ( font_size - 28.0 ).abs() < 1e-6 ), + _ => panic!( "expected TextEdit handler" ), + } +} + +#[ test ] +fn select_on_focus_propagates_to_widget_handler_snapshot() +{ + use crate::widget::{ Element, WidgetHandlers }; + let widget: Element<()> = TextEdit::new( "".into(), "".into() ) + .select_on_focus( true ) + .into_element(); + match widget.handlers() + { + WidgetHandlers::TextEdit { select_on_focus, .. } => assert!( select_on_focus ), + _ => panic!( "expected TextEdit handler" ), + } +} + +// ── password_toggle ─────────────────────────────────────────────────────── + +#[ derive( Clone, Debug, PartialEq, Eq ) ] enum ToggleMsg { Flip } + +#[ test ] +fn password_toggle_default_is_none() +{ + let t = TextEdit::::new( "".into(), "".into() ); + assert!( t.password_toggle.is_none() ); +} + +#[ test ] +fn password_toggle_builder_records_visible_and_msg() +{ + let t = TextEdit::::new( "".into(), "".into() ) + .password_toggle( true, ToggleMsg::Flip ); + assert_eq!( t.password_toggle, Some( ( true, ToggleMsg::Flip ) ) ); +} + +#[ test ] +fn effective_secure_falls_back_to_secure_field_when_no_toggle() +{ + let plain = TextEdit::::new( "".into(), "".into() ); + let secret = TextEdit::::new( "".into(), "".into() ).secure( true ); + assert!( !plain.effective_secure() ); + assert!( secret.effective_secure() ); +} + +#[ test ] +fn password_toggle_overrides_secure_field() +{ + // When the toggle is set, its `visible` controls bullets + // regardless of the explicit `secure` flag. + let visible_with_secure = TextEdit::::new( "".into(), "".into() ) + .secure( true ) + .password_toggle( true, ToggleMsg::Flip ); + let hidden_no_secure = TextEdit::::new( "".into(), "".into() ) + .password_toggle( false, ToggleMsg::Flip ); + assert!( !visible_with_secure.effective_secure() ); + assert!( hidden_no_secure.effective_secure() ); +} + +#[ test ] +fn password_toggle_hidden_substitutes_bullets_in_display_text() +{ + let t = TextEdit::::new( "".into(), "abc".into() ) + .password_toggle( false, ToggleMsg::Flip ); + assert_eq!( t.display_text(), "•••" ); +} + +#[ test ] +fn password_toggle_visible_returns_value_verbatim() +{ + let t = TextEdit::::new( "".into(), "abc".into() ) + .password_toggle( true, ToggleMsg::Flip ); + assert_eq!( t.display_text(), "abc" ); +} + +#[ test ] +fn password_toggle_collapses_multiline_to_single_line() +{ + // `is_multiline()` follows `effective_secure` — a hidden + // password collapses to single-line even if `multiline(true)` + // was set, matching the behaviour of an explicit `secure`. + let t = TextEdit::::new( "".into(), "".into() ) + .multiline( true ) + .password_toggle( false, ToggleMsg::Flip ); + assert!( !t.is_multiline() ); +} + +#[ test ] +fn password_toggle_msg_propagates_to_widget_handler_snapshot() +{ + use crate::widget::{ Element, WidgetHandlers }; + let widget: Element = TextEdit::new( "".into(), "".into() ) + .password_toggle( false, ToggleMsg::Flip ) + .into_element(); + // `handlers()` returns a value of `WidgetHandlers`, which + // implements `Drop` (wipe-on-drop for secure fields), so we + // cannot destructure it by value-move. Bind first, then match + // on a reference. + let h = widget.handlers(); + match &h + { + WidgetHandlers::TextEdit { password_toggle_msg, secure, .. } => + { + assert_eq!( password_toggle_msg.as_ref(), Some( &ToggleMsg::Flip ) ); + // Snapshot's `secure` is true even though `visible=false` + // because the field carries a password regardless. + assert!( *secure ); + } + _ => panic!( "expected TextEdit handler" ), + } +} + +#[ test ] +fn password_toggle_msg_none_when_no_toggle_configured() +{ + use crate::widget::{ Element, WidgetHandlers }; + let widget: Element = TextEdit::new( "".into(), "".into() ) + .into_element(); + let h = widget.handlers(); + match &h + { + WidgetHandlers::TextEdit { password_toggle_msg, .. } => + { + assert!( password_toggle_msg.is_none() ); + } + _ => panic!( "expected TextEdit handler" ), + } +} + +#[ test ] +fn password_toggle_hit_zone_sits_at_right_edge() +{ + use crate::types::{ Point, Rect }; + let rect = Rect { x: 100.0, y: 50.0, width: 240.0, height: 48.0 }; + let zone = super::password_toggle_hit_zone( rect ); + // Right edge of the zone matches the field's right edge. + assert!( ( ( zone.x + zone.width ) - ( rect.x + rect.width ) ).abs() < 0.01 ); + // Wide enough to cover the icon + slop on both sides + half pad. + assert!( zone.width >= super::theme::PASSWORD_TOGGLE_SIZE ); + // A point on the centre-right hits the zone. + let p = Point { x: rect.x + rect.width - 8.0, y: rect.y + rect.height / 2.0 }; + assert!( zone.contains( p ) ); + // A point well to the left of the zone misses it. + let p = Point { x: rect.x + 10.0, y: rect.y + rect.height / 2.0 }; + assert!( !zone.contains( p ) ); +} + +#[ test ] +fn password_toggle_hit_zone_clamped_when_field_narrower_than_icon() +{ + use crate::types::Rect; + let rect = Rect { x: 0.0, y: 0.0, width: 4.0, height: 24.0 }; + let zone = super::password_toggle_hit_zone( rect ); + // Zone never extends past the field's right edge. + assert!( zone.x + zone.width <= rect.x + rect.width + f32::EPSILON ); +} + +#[ test ] +fn password_toggle_drop_zeros_underlying_string_buffer() +{ + // Same wipe-on-drop guarantee `secure( true )` gives — a + // `password_toggle` field is sensitive even when currently + // visible, so the buffer must be scrubbed on drop. + let t = TextEdit::::new( "".into(), "shhh".into() ) + .password_toggle( true, ToggleMsg::Flip ); + let ptr = t.value.as_ptr(); + let len = t.value.len(); + drop( t ); + // SAFETY: `String` deallocates the buffer in its `Drop`, but + // the bytes at `ptr..ptr+len` were zeroed *before* the + // allocator was notified — reading them now is a use-after- + // free, so we don't. Instead we simply assert that the test + // reaches this line without panicking; the `secure_zero` + // path runs unconditionally for password_toggle fields. + let _ = ( ptr, len ); +} diff --git a/src/widget/text_edit/theme.rs b/src/widget/text_edit/theme.rs new file mode 100644 index 0000000..09ddfbf --- /dev/null +++ b/src/widget/text_edit/theme.rs @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::types::Color; + +pub fn bg() -> Color { crate::theme::palette().surface_alt } +pub fn border() -> Color { crate::theme::palette().divider } +pub fn text() -> Color { crate::theme::palette().text_primary } +pub fn placeholder() -> Color { crate::theme::palette().text_secondary } +pub fn focus_border() -> Color { crate::theme::palette().text_primary } +pub fn cursor() -> Color { crate::theme::palette().text_primary } +/// Selection highlight — accent colour at low opacity so the text +/// underneath stays readable. +pub fn selection() -> Color +{ + let a = crate::theme::palette().accent; + Color::rgba( a.r, a.g, a.b, 0.35 ) +} + +pub const BORDER_W: f32 = 1.5; +pub const FOCUS_BORDER_W: f32 = 2.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 = 20.0; +/// Vertical padding inside multiline boxes — the single-line variant +/// centres the text vertically in `HEIGHT`, so it doesn't need this. +pub const PAD_V_MULTI: f32 = 12.0; +/// Line height multiplier on top of [`FONT_SIZE`] when laying out +/// multiline content. 1.4 leaves enough room above and below each +/// line that adjacent rows do not visually crowd each other. +pub const LINE_H_MULT: f32 = 1.4; +/// Default visible row count for a [`super::TextEdit`] in multiline mode. +pub const ROWS_DEFAULT: u32 = 5; +/// Corner radius applied to multiline boxes — the pill shape used by +/// the single-line variant looks wrong at multi-row heights. +pub const RADIUS_MULTI: f32 = 12.0; +/// Side length, in logical pixels, of the +/// [`super::TextEdit::password_toggle`] eye icon. +pub const PASSWORD_TOGGLE_SIZE: f32 = 20.0; +/// Extra horizontal slop on each side of the eye icon's hit zone +/// to make the toggle finger-friendly even on touch panels. +pub const PASSWORD_TOGGLE_SLOP: f32 = 4.0; diff --git a/src/widget/text_edit/wrapping.rs b/src/widget/text_edit/wrapping.rs new file mode 100644 index 0000000..86c42f6 --- /dev/null +++ b/src/widget/text_edit/wrapping.rs @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::render::Canvas; + +/// One visual row of a wrapped multiline TextEdit. `start..end` is a +/// byte range inside the value; the renderer draws those bytes on a +/// single visual row, even if the underlying logical line (the run +/// between two `\n` characters) spans several rows. Soft wraps do +/// not mutate the buffer — the value stays a flat string with hard +/// breaks; only the rendering / hit-testing model treats wraps as +/// row boundaries. +#[ derive( Clone, Copy, Debug ) ] +pub( crate ) struct VisualLine +{ + pub start: usize, + pub end: usize, +} + +/// Wrap `value` into a list of visual lines that each fit within +/// `inner_width` when rendered at `font_size`. Hard `\n` breaks are +/// always row boundaries; long logical lines are additionally split +/// at the last whitespace before the row would overflow, falling +/// back to a per-character break inside a single oversized word. +/// +/// O(N) per call where N is `value.len()` — `draw_multiline` runs +/// it once per frame, which is fine for the kilobyte-scale buffers a +/// `text_edit` is meant to hold (anything larger should use a +/// dedicated text-area widget with cached layout). +pub( crate ) fn compute_visual_lines( + canvas: &Canvas, + value: &str, + inner_width: f32, + font_size: f32, +) -> Vec +{ + let mut out = Vec::new(); + if value.is_empty() + { + out.push( VisualLine { start: 0, end: 0 } ); + return out; + } + let max_w = inner_width.max( 1.0 ); + let mut byte_pos = 0usize; + for logical_line in value.split( '\n' ) + { + let line_start = byte_pos; + let line_end = byte_pos + logical_line.len(); + if line_start == line_end + { + // Empty logical line still occupies a row. + out.push( VisualLine { start: line_start, end: line_end } ); + } else { + wrap_logical_line( canvas, value, line_start, line_end, max_w, font_size, &mut out ); + } + byte_pos = line_end + 1; // step past the '\n' + } + out +} + +fn wrap_logical_line( + canvas: &Canvas, + value: &str, + start: usize, + end: usize, + max_width: f32, + font_size: f32, + out: &mut Vec, +) +{ + let mut cur = start; + while cur < end + { + let segment = &value[cur..end]; + let break_at = find_wrap_offset( canvas, segment, max_width, font_size ); + // Always advance at least one byte so a degenerate measure + // (zero-width font, ridiculously narrow rect) still + // terminates instead of looping forever. + let raw_next = cur + break_at; + let next = raw_next.max( cur + 1 ).min( end ); + out.push( VisualLine { start: cur, end: next } ); + cur = next; + } +} + +/// Walk `segment` left-to-right accumulating glyph widths and return +/// the byte offset (within `segment`) where the visual line should +/// break. Prefers a break *after* the most recent whitespace; falls +/// back to mid-character break if a single word is wider than +/// `max_width`. +fn find_wrap_offset( + canvas: &Canvas, + segment: &str, + max_width: f32, + font_size: f32, +) -> usize +{ + let mut acc_w = 0.0f32; + let mut last_break_after: Option = None; + for ( i, ch ) in segment.char_indices() + { + let glyph = ch.to_string(); + let w = canvas.measure_text( &glyph, font_size ); + if acc_w + w > max_width && i > 0 + { + return last_break_after.unwrap_or( i ); + } + acc_w += w; + if ch == ' ' || ch == '\t' + { + last_break_after = Some( i + ch.len_utf8() ); + } + } + segment.len() +} diff --git a/src/widget/time_picker.rs b/src/widget/time_picker/mod.rs similarity index 76% rename from src/widget/time_picker.rs rename to src/widget/time_picker/mod.rs index 371d158..1bcc3eb 100644 --- a/src/widget/time_picker.rs +++ b/src/widget/time_picker/mod.rs @@ -41,18 +41,10 @@ use crate::layout::spacer::spacer; use super::Element; -mod theme -{ - use crate::types::Color; - pub fn text() -> Color { crate::theme::palette().text_primary } - pub fn text_muted() -> Color { crate::theme::palette().text_secondary } - pub fn surface_alt()-> Color { crate::theme::palette().surface_alt } - pub const PADDING: f32 = 16.0; - pub const RADIUS: f32 = 16.0; - pub const VAL_FS: f32 = 28.0; - pub const SEP_FS: f32 = 28.0; - pub const SPACING: f32 = 8.0; -} +mod theme; + +#[ cfg( test ) ] +mod tests; /// A wall-clock time, no date / no timezone. `hour` is 0–23 (24-hour /// representation regardless of [`TimePicker::twelve_hour`] display @@ -473,143 +465,3 @@ pub fn time_picker( value: Time ) -> TimePicker { TimePicker::new( value ) } - -#[ cfg( test ) ] -mod tests -{ - use super::*; - - // ── Time arithmetic ─────────────────────────────────────────────────────── - - #[ test ] - fn time_round_trips_through_seconds() - { - let t = Time::with_seconds( 13, 24, 56 ); - assert_eq!( Time::from_seconds( t.as_seconds() ), t ); - } - - #[ test ] - fn time_from_seconds_wraps_modulo_24h() - { - // One second past midnight → 00:00:01. - assert_eq!( Time::from_seconds( 24 * 3600 + 1 ), Time::with_seconds( 0, 0, 1 ) ); - // One second before midnight (negative) → 23:59:59. - assert_eq!( Time::from_seconds( -1 ), Time::with_seconds( 23, 59, 59 ) ); - } - - #[ test ] - fn time_validity() - { - assert!( Time::with_seconds( 23, 59, 59 ).is_valid() ); - assert!( !Time::with_seconds( 24, 0, 0 ).is_valid() ); - assert!( !Time::with_seconds( 0, 60, 0 ).is_valid() ); - } - - // ── Builders ────────────────────────────────────────────────────────────── - - #[ derive( Clone, Debug, PartialEq, Eq ) ] - enum Msg { Pick( Time ) } - - #[ test ] - fn defaults() - { - let p: TimePicker = time_picker( Time::new( 9, 30 ) ); - assert_eq!( p.value, Time::new( 9, 30 ) ); - assert_eq!( p.minute_step, 1 ); - assert_eq!( p.second_step, 1 ); - assert!( !p.seconds ); - assert!( !p.twelve_hour ); - assert!( p.on_change.is_none() ); - } - - #[ test ] - fn minute_step_clamps_to_thirty() - { - let p: TimePicker = time_picker( Time::new( 0, 0 ) ).minute_step( 99 ); - assert_eq!( p.minute_step, 30 ); - let p: TimePicker = time_picker( Time::new( 0, 0 ) ).minute_step( 0 ); - assert_eq!( p.minute_step, 1 ); - } - - #[ test ] - fn on_change_callback_invokes_with_time() - { - let p: TimePicker = time_picker( Time::new( 9, 30 ) ).on_change( Msg::Pick ); - let cb = p.on_change.as_ref().expect( "set" ); - assert_eq!( cb( Time::new( 10, 0 ) ), Msg::Pick( Time::new( 10, 0 ) ) ); - } - - #[ test ] - fn build_does_not_panic_minimal() - { - let _: Element = time_picker::( Time::new( 9, 30 ) ).build(); - } - - #[ test ] - fn build_does_not_panic_with_twelve_hour_and_seconds() - { - let _: Element = time_picker::( Time::with_seconds( 13, 24, 56 ) ) - .twelve_hour( true ) - .seconds( true ) - .minute_step( 15 ) - .on_change( Msg::Pick ) - .build(); - } - - // ── Step snapping ──────────────────────────────────────────────────────── - - #[ test ] - fn snap_step_delta_advances_to_next_multiple_when_off_step() - { - // Starting at :32 with step 5: up should land on :35 (+3), - // down should land on :30 (−2). - assert_eq!( snap_step_delta( 32, 5, 1 ), 3 ); - assert_eq!( snap_step_delta( 32, 5, -1 ), -2 ); - } - - #[ test ] - fn snap_step_delta_full_step_when_already_aligned() - { - // Starting on a multiple of step takes a full step in the - // requested direction. - assert_eq!( snap_step_delta( 30, 5, 1 ), 5 ); - assert_eq!( snap_step_delta( 30, 5, -1 ), -5 ); - } - - #[ test ] - fn snap_step_delta_clamps_step_to_at_least_one() - { - // A step of 0 is meaningless — clamp to 1 so the picker - // always makes some progress on a click. - assert_eq!( snap_step_delta( 0, 0, 1 ), 1 ); - } - - // ── Typed-input parser ──────────────────────────────────────────────────── - - #[ test ] - fn parse_clamped_digits_strips_non_numeric() - { - assert_eq!( parse_clamped_digits( "0a3", 99 ), Some( 3 ) ); - assert_eq!( parse_clamped_digits( " 12 ", 59 ), Some( 12 ) ); - } - - #[ test ] - fn parse_clamped_digits_clamps_to_max() - { - // A 12-hour field caps at 12; typing "23" reads as 23 then - // clamps to 12. - assert_eq!( parse_clamped_digits( "23", 12 ), Some( 12 ) ); - // A 24-hour field caps at 23. - assert_eq!( parse_clamped_digits( "099", 23 ), Some( 23 ) ); - } - - #[ test ] - fn parse_clamped_digits_empty_returns_none() - { - // `None` lets the caller distinguish "user erased the - // field" from "user committed a zero", so the picker can - // hold the previous value instead of jumping to midnight. - assert!( parse_clamped_digits( "", 59 ).is_none() ); - assert!( parse_clamped_digits( "abc", 59 ).is_none() ); - } -} diff --git a/src/widget/time_picker/tests.rs b/src/widget/time_picker/tests.rs new file mode 100644 index 0000000..b5acec1 --- /dev/null +++ b/src/widget/time_picker/tests.rs @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use super::*; + +// ── Time arithmetic ─────────────────────────────────────────────────────── + +#[ test ] +fn time_round_trips_through_seconds() +{ + let t = Time::with_seconds( 13, 24, 56 ); + assert_eq!( Time::from_seconds( t.as_seconds() ), t ); +} + +#[ test ] +fn time_from_seconds_wraps_modulo_24h() +{ + // One second past midnight → 00:00:01. + assert_eq!( Time::from_seconds( 24 * 3600 + 1 ), Time::with_seconds( 0, 0, 1 ) ); + // One second before midnight (negative) → 23:59:59. + assert_eq!( Time::from_seconds( -1 ), Time::with_seconds( 23, 59, 59 ) ); +} + +#[ test ] +fn time_validity() +{ + assert!( Time::with_seconds( 23, 59, 59 ).is_valid() ); + assert!( !Time::with_seconds( 24, 0, 0 ).is_valid() ); + assert!( !Time::with_seconds( 0, 60, 0 ).is_valid() ); +} + +// ── Builders ────────────────────────────────────────────────────────────── + +#[ derive( Clone, Debug, PartialEq, Eq ) ] +enum Msg { Pick( Time ) } + +#[ test ] +fn defaults() +{ + let p: TimePicker = time_picker( Time::new( 9, 30 ) ); + assert_eq!( p.value, Time::new( 9, 30 ) ); + assert_eq!( p.minute_step, 1 ); + assert_eq!( p.second_step, 1 ); + assert!( !p.seconds ); + assert!( !p.twelve_hour ); + assert!( p.on_change.is_none() ); +} + +#[ test ] +fn minute_step_clamps_to_thirty() +{ + let p: TimePicker = time_picker( Time::new( 0, 0 ) ).minute_step( 99 ); + assert_eq!( p.minute_step, 30 ); + let p: TimePicker = time_picker( Time::new( 0, 0 ) ).minute_step( 0 ); + assert_eq!( p.minute_step, 1 ); +} + +#[ test ] +fn on_change_callback_invokes_with_time() +{ + let p: TimePicker = time_picker( Time::new( 9, 30 ) ).on_change( Msg::Pick ); + let cb = p.on_change.as_ref().expect( "set" ); + assert_eq!( cb( Time::new( 10, 0 ) ), Msg::Pick( Time::new( 10, 0 ) ) ); +} + +#[ test ] +fn build_does_not_panic_minimal() +{ + let _: Element = time_picker::( Time::new( 9, 30 ) ).build(); +} + +#[ test ] +fn build_does_not_panic_with_twelve_hour_and_seconds() +{ + let _: Element = time_picker::( Time::with_seconds( 13, 24, 56 ) ) + .twelve_hour( true ) + .seconds( true ) + .minute_step( 15 ) + .on_change( Msg::Pick ) + .build(); +} + +// ── Step snapping ──────────────────────────────────────────────────────── + +#[ test ] +fn snap_step_delta_advances_to_next_multiple_when_off_step() +{ + // Starting at :32 with step 5: up should land on :35 (+3), + // down should land on :30 (−2). + assert_eq!( snap_step_delta( 32, 5, 1 ), 3 ); + assert_eq!( snap_step_delta( 32, 5, -1 ), -2 ); +} + +#[ test ] +fn snap_step_delta_full_step_when_already_aligned() +{ + // Starting on a multiple of step takes a full step in the + // requested direction. + assert_eq!( snap_step_delta( 30, 5, 1 ), 5 ); + assert_eq!( snap_step_delta( 30, 5, -1 ), -5 ); +} + +#[ test ] +fn snap_step_delta_clamps_step_to_at_least_one() +{ + // A step of 0 is meaningless — clamp to 1 so the picker + // always makes some progress on a click. + assert_eq!( snap_step_delta( 0, 0, 1 ), 1 ); +} + +// ── Typed-input parser ──────────────────────────────────────────────────── + +#[ test ] +fn parse_clamped_digits_strips_non_numeric() +{ + assert_eq!( parse_clamped_digits( "0a3", 99 ), Some( 3 ) ); + assert_eq!( parse_clamped_digits( " 12 ", 59 ), Some( 12 ) ); +} + +#[ test ] +fn parse_clamped_digits_clamps_to_max() +{ + // A 12-hour field caps at 12; typing "23" reads as 23 then + // clamps to 12. + assert_eq!( parse_clamped_digits( "23", 12 ), Some( 12 ) ); + // A 24-hour field caps at 23. + assert_eq!( parse_clamped_digits( "099", 23 ), Some( 23 ) ); +} + +#[ test ] +fn parse_clamped_digits_empty_returns_none() +{ + // `None` lets the caller distinguish "user erased the + // field" from "user committed a zero", so the picker can + // hold the previous value instead of jumping to midnight. + assert!( parse_clamped_digits( "", 59 ).is_none() ); + assert!( parse_clamped_digits( "abc", 59 ).is_none() ); +} diff --git a/src/widget/time_picker/theme.rs b/src/widget/time_picker/theme.rs new file mode 100644 index 0000000..e4e64a8 --- /dev/null +++ b/src/widget/time_picker/theme.rs @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::types::Color; +pub fn text() -> Color { crate::theme::palette().text_primary } +pub fn text_muted() -> Color { crate::theme::palette().text_secondary } +pub fn surface_alt()-> Color { crate::theme::palette().surface_alt } +pub const PADDING: f32 = 16.0; +pub const RADIUS: f32 = 16.0; +pub const VAL_FS: f32 = 28.0; +pub const SEP_FS: f32 = 28.0; +pub const SPACING: f32 = 8.0; diff --git a/src/widget/toast.rs b/src/widget/toast/mod.rs similarity index 80% rename from src/widget/toast.rs rename to src/widget/toast/mod.rs index 2ee041d..2edc2f4 100644 --- a/src/widget/toast.rs +++ b/src/widget/toast/mod.rs @@ -40,21 +40,10 @@ use crate::app::{ Anchor, Layer, OverlayId, OverlaySpec }; use crate::types::{ Color, WidgetId }; use super::Element; -mod theme -{ - use crate::types::Color; - /// Toast pill background — the theme's elevated surface token so - /// the toast reads as a "card" against the page below in both - /// light and dark modes. - pub fn bg() -> Color { crate::theme::palette().surface_alt } - /// Body text colour — primary text token from the theme. - pub fn text() -> Color { crate::theme::palette().text_primary } - pub const HEIGHT: u32 = 56; - pub const BOTTOM_MARGIN: i32 = 32; - pub const PAD_H: f32 = 24.0; - pub const FONT_SIZE: f32 = 16.0; - pub const RADIUS: f32 = 28.0; -} +mod theme; + +#[ cfg( test ) ] +mod tests; /// Builder for a transient bottom-anchored notification overlay. /// @@ -253,53 +242,3 @@ pub fn toast( message: impl Into ) -> Toast { Toast::new( message ) } - -#[ cfg( test ) ] -mod tests -{ - use super::*; - - #[ derive( Clone, Debug, PartialEq, Eq ) ] - enum Msg { Dismiss } - - #[ test ] - fn defaults() - { - let t: Toast = toast( "Saved" ); - assert_eq!( t.message, "Saved" ); - assert_eq!( t.duration, Duration::from_secs( 2 ) ); - assert!( t.on_dismiss.is_none() ); - } - - #[ test ] - fn duration_builder_clamps_negative() - { - let t: Toast = toast( "x" ).duration( -1.0 ); - assert_eq!( t.duration_value(), Duration::ZERO ); - } - - #[ test ] - fn on_dismiss_stored() - { - let t = toast( "x" ).on_dismiss( Msg::Dismiss ); - assert_eq!( t.on_dismiss, Some( Msg::Dismiss ) ); - } - - #[ test ] - fn overlay_uses_stable_id() - { - let a: OverlaySpec = toast( "x" ).id( WidgetId( "toast/a" ) ).overlay(); - let b: OverlaySpec = toast( "y" ).id( WidgetId( "toast/a" ) ).overlay(); - assert_eq!( a.id, b.id, "same WidgetId must yield same OverlayId" ); - let c: OverlaySpec = toast( "x" ).id( WidgetId( "toast/b" ) ).overlay(); - assert_ne!( a.id, c.id, "different WidgetIds must yield different OverlayIds" ); - } - - #[ test ] - fn overlay_is_layer_shell_not_xdg_popup() - { - let o: OverlaySpec = toast( "x" ).overlay(); - assert!( o.anchor_widget_id.is_none() ); - assert_eq!( o.layer, Layer::Overlay ); - } -} diff --git a/src/widget/toast/tests.rs b/src/widget/toast/tests.rs new file mode 100644 index 0000000..47a90b8 --- /dev/null +++ b/src/widget/toast/tests.rs @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use super::*; + +#[ derive( Clone, Debug, PartialEq, Eq ) ] +enum Msg { Dismiss } + +#[ test ] +fn defaults() +{ + let t: Toast = toast( "Saved" ); + assert_eq!( t.message, "Saved" ); + assert_eq!( t.duration, Duration::from_secs( 2 ) ); + assert!( t.on_dismiss.is_none() ); +} + +#[ test ] +fn duration_builder_clamps_negative() +{ + let t: Toast = toast( "x" ).duration( -1.0 ); + assert_eq!( t.duration_value(), Duration::ZERO ); +} + +#[ test ] +fn on_dismiss_stored() +{ + let t = toast( "x" ).on_dismiss( Msg::Dismiss ); + assert_eq!( t.on_dismiss, Some( Msg::Dismiss ) ); +} + +#[ test ] +fn overlay_uses_stable_id() +{ + let a: OverlaySpec = toast( "x" ).id( WidgetId( "toast/a" ) ).overlay(); + let b: OverlaySpec = toast( "y" ).id( WidgetId( "toast/a" ) ).overlay(); + assert_eq!( a.id, b.id, "same WidgetId must yield same OverlayId" ); + let c: OverlaySpec = toast( "x" ).id( WidgetId( "toast/b" ) ).overlay(); + assert_ne!( a.id, c.id, "different WidgetIds must yield different OverlayIds" ); +} + +#[ test ] +fn overlay_is_layer_shell_not_xdg_popup() +{ + let o: OverlaySpec = toast( "x" ).overlay(); + assert!( o.anchor_widget_id.is_none() ); + assert_eq!( o.layer, Layer::Overlay ); +} diff --git a/src/widget/toast/theme.rs b/src/widget/toast/theme.rs new file mode 100644 index 0000000..6009334 --- /dev/null +++ b/src/widget/toast/theme.rs @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::types::Color; +/// Toast pill background — the theme's elevated surface token so +/// the toast reads as a "card" against the page below in both +/// light and dark modes. +pub fn bg() -> Color { crate::theme::palette().surface_alt } +/// Body text colour — primary text token from the theme. +pub fn text() -> Color { crate::theme::palette().text_primary } +pub const HEIGHT: u32 = 56; +pub const BOTTOM_MARGIN: i32 = 32; +pub const PAD_H: f32 = 24.0; +pub const FONT_SIZE: f32 = 16.0; +pub const RADIUS: f32 = 28.0; diff --git a/src/widget/toggle.rs b/src/widget/toggle/mod.rs similarity index 84% rename from src/widget/toggle.rs rename to src/widget/toggle/mod.rs index 4d8e7b8..cee6c26 100644 --- a/src/widget/toggle.rs +++ b/src/widget/toggle/mod.rs @@ -5,26 +5,10 @@ use crate::types::{ Rect, WidgetId }; use crate::render::Canvas; use super::Element; -mod theme -{ - use crate::types::Color; - pub fn track_off() -> Color { crate::theme::palette().divider } - pub fn track_on() -> Color { crate::theme::palette().accent } - /// Thumb — uses the page-background colour so it reads as the - /// inverse of the track fill regardless of mode. - pub fn thumb() -> Color { crate::theme::palette().bg } - pub fn thumb_border() -> Color { crate::theme::palette().text_primary } - pub fn focus_color() -> Color { crate::theme::palette().accent } - pub fn label_color() -> Color { crate::theme::palette().text_primary } - pub const TRACK_W: f32 = 52.0; - pub const TRACK_H: f32 = 30.0; - pub const THUMB_SIZE: f32 = 24.0; - pub const HEIGHT: f32 = 48.0; - pub const GAP: f32 = 12.0; - pub const FOCUS_W: f32 = 3.0; - pub const THUMB_BORDER_W: f32 = 1.0; - pub const FONT_SIZE: f32 = 16.0; -} +mod theme; + +#[cfg(test)] +mod tests; /// A two-state on / off switch. /// @@ -220,25 +204,3 @@ impl From> for Element Element::Toggle( t ) } } - -#[cfg(test)] -mod tests -{ - use super::*; - - #[test] - fn toggle_default_state() - { - let t = toggle::<()>( true ); - assert!( t.value ); - assert!( t.on_toggle.is_none() ); - assert!( t.label.is_none() ); - } - - #[test] - fn toggle_off_state() - { - let t = toggle::<()>( false ); - assert!( !t.value ); - } -} diff --git a/src/widget/toggle/tests.rs b/src/widget/toggle/tests.rs new file mode 100644 index 0000000..4a5121b --- /dev/null +++ b/src/widget/toggle/tests.rs @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use super::*; + +#[test] +fn toggle_default_state() +{ + let t = toggle::<()>( true ); + assert!( t.value ); + assert!( t.on_toggle.is_none() ); + assert!( t.label.is_none() ); +} + +#[test] +fn toggle_off_state() +{ + let t = toggle::<()>( false ); + assert!( !t.value ); +} diff --git a/src/widget/toggle/theme.rs b/src/widget/toggle/theme.rs new file mode 100644 index 0000000..4b6cfdc --- /dev/null +++ b/src/widget/toggle/theme.rs @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::types::Color; +pub fn track_off() -> Color { crate::theme::palette().divider } +pub fn track_on() -> Color { crate::theme::palette().accent } +/// Thumb — uses the page-background colour so it reads as the +/// inverse of the track fill regardless of mode. +pub fn thumb() -> Color { crate::theme::palette().bg } +pub fn thumb_border() -> Color { crate::theme::palette().text_primary } +pub fn focus_color() -> Color { crate::theme::palette().accent } +pub fn label_color() -> Color { crate::theme::palette().text_primary } +pub const TRACK_W: f32 = 52.0; +pub const TRACK_H: f32 = 30.0; +pub const THUMB_SIZE: f32 = 24.0; +pub const HEIGHT: f32 = 48.0; +pub const GAP: f32 = 12.0; +pub const FOCUS_W: f32 = 3.0; +pub const THUMB_BORDER_W: f32 = 1.0; +pub const FONT_SIZE: f32 = 16.0; diff --git a/src/widget/tooltip.rs b/src/widget/tooltip/mod.rs similarity index 70% rename from src/widget/tooltip.rs rename to src/widget/tooltip/mod.rs index 9b1e9c6..9f930b8 100644 --- a/src/widget/tooltip.rs +++ b/src/widget/tooltip/mod.rs @@ -44,28 +44,10 @@ use crate::app::{ Anchor, Layer, OverlayId, OverlaySpec }; use crate::types::{ Color, WidgetId }; use super::Element; -mod theme -{ - use crate::types::Color; - /// Tooltip background — uses the theme's primary text colour as - /// a high-contrast hint pill (dark in light mode, light in dark - /// mode). Tooltips traditionally invert against the surface to - /// stand out. - pub fn bg() -> Color - { - let p = crate::theme::palette().text_primary; - Color::rgba( p.r, p.g, p.b, 0.95 ) - } - /// Tooltip text — `bg-page` so the lettering reads as the - /// inverse of the bg. - pub fn text() -> Color { crate::theme::palette().bg } - pub const PAD_H: f32 = 12.0; - pub const PAD_V: f32 = 6.0; - pub const FONT_SIZE: f32 = 13.0; - pub const RADIUS: f32 = 8.0; - pub const MAX_W: u32 = 320; - pub const HEIGHT: u32 = 28; -} +mod theme; + +#[ cfg( test ) ] +mod tests; /// Builder for an `xdg-popup`-backed hint anchored to a widget. pub struct Tooltip @@ -189,59 +171,3 @@ pub fn tooltip( { Tooltip::new( message, anchor_id ) } - -#[ cfg( test ) ] -mod tests -{ - use super::*; - - #[ test ] - fn defaults() - { - let t: Tooltip<()> = tooltip( "hi", WidgetId( "x" ) ); - assert_eq!( t.message, "hi" ); - assert_eq!( t.anchor_id.0, "x" ); - assert_eq!( t.max_width, theme::MAX_W ); - assert!( t.on_dismiss.is_none() ); - } - - #[ test ] - fn overlay_uses_anchor_widget_id() - { - let id = WidgetId( "btn/save" ); - let o: OverlaySpec<()> = tooltip( "Save", id ).overlay(); - assert_eq!( o.anchor_widget_id, Some( id ) ); - } - - #[ test ] - fn overlay_id_includes_tooltip_namespace() - { - // Same WidgetId used for a tooltip and (hypothetically) some - // other overlay must yield distinct OverlayIds. The tooltip's - // hash includes the literal "tooltip" prefix to namespace it - // against e.g. combo overlays that hash only the WidgetId. - let id = WidgetId( "foo" ); - let tooltip_overlay_id = { - let mut h = DefaultHasher::new(); - "tooltip".hash( &mut h ); - id.0.hash( &mut h ); - OverlayId( h.finish() as u32 ) - }; - let combo_style_id = { - let mut h = DefaultHasher::new(); - id.0.hash( &mut h ); - OverlayId( h.finish() as u32 ) - }; - assert_ne!( tooltip_overlay_id, combo_style_id ); - - let o: OverlaySpec<()> = tooltip( "x", id ).overlay(); - assert_eq!( o.id, tooltip_overlay_id ); - } - - #[ test ] - fn max_width_builder_applies() - { - let t: Tooltip<()> = tooltip( "x", WidgetId( "a" ) ).max_width( 120 ); - assert_eq!( t.max_width, 120 ); - } -} diff --git a/src/widget/tooltip/tests.rs b/src/widget/tooltip/tests.rs new file mode 100644 index 0000000..8eda855 --- /dev/null +++ b/src/widget/tooltip/tests.rs @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use super::*; + +#[ test ] +fn defaults() +{ + let t: Tooltip<()> = tooltip( "hi", WidgetId( "x" ) ); + assert_eq!( t.message, "hi" ); + assert_eq!( t.anchor_id.0, "x" ); + assert_eq!( t.max_width, theme::MAX_W ); + assert!( t.on_dismiss.is_none() ); +} + +#[ test ] +fn overlay_uses_anchor_widget_id() +{ + let id = WidgetId( "btn/save" ); + let o: OverlaySpec<()> = tooltip( "Save", id ).overlay(); + assert_eq!( o.anchor_widget_id, Some( id ) ); +} + +#[ test ] +fn overlay_id_includes_tooltip_namespace() +{ + // Same WidgetId used for a tooltip and (hypothetically) some + // other overlay must yield distinct OverlayIds. The tooltip's + // hash includes the literal "tooltip" prefix to namespace it + // against e.g. combo overlays that hash only the WidgetId. + let id = WidgetId( "foo" ); + let tooltip_overlay_id = { + let mut h = DefaultHasher::new(); + "tooltip".hash( &mut h ); + id.0.hash( &mut h ); + OverlayId( h.finish() as u32 ) + }; + let combo_style_id = { + let mut h = DefaultHasher::new(); + id.0.hash( &mut h ); + OverlayId( h.finish() as u32 ) + }; + assert_ne!( tooltip_overlay_id, combo_style_id ); + + let o: OverlaySpec<()> = tooltip( "x", id ).overlay(); + assert_eq!( o.id, tooltip_overlay_id ); +} + +#[ test ] +fn max_width_builder_applies() +{ + let t: Tooltip<()> = tooltip( "x", WidgetId( "a" ) ).max_width( 120 ); + assert_eq!( t.max_width, 120 ); +} diff --git a/src/widget/tooltip/theme.rs b/src/widget/tooltip/theme.rs new file mode 100644 index 0000000..90c9c7d --- /dev/null +++ b/src/widget/tooltip/theme.rs @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::types::Color; +/// Tooltip background — uses the theme's primary text colour as +/// a high-contrast hint pill (dark in light mode, light in dark +/// mode). Tooltips traditionally invert against the surface to +/// stand out. +pub fn bg() -> Color +{ + let p = crate::theme::palette().text_primary; + Color::rgba( p.r, p.g, p.b, 0.95 ) +} +/// Tooltip text — `bg-page` so the lettering reads as the +/// inverse of the bg. +pub fn text() -> Color { crate::theme::palette().bg } +pub const PAD_H: f32 = 12.0; +pub const PAD_V: f32 = 6.0; +pub const FONT_SIZE: f32 = 13.0; +pub const RADIUS: f32 = 8.0; +pub const MAX_W: u32 = 320; +pub const HEIGHT: u32 = 28; diff --git a/src/widget/viewport.rs b/src/widget/viewport/mod.rs similarity index 65% rename from src/widget/viewport.rs rename to src/widget/viewport/mod.rs index 269a5bc..8bad64b 100644 --- a/src/widget/viewport.rs +++ b/src/widget/viewport/mod.rs @@ -106,78 +106,4 @@ pub fn viewport( child: impl Into> ) -> Viewport } #[ cfg( test ) ] -mod tests -{ - use super::*; - use crate::layout::spacer::spacer; - use crate::render::Canvas; - - fn make_canvas() -> Canvas { Canvas::new( 800, 600 ) } - - #[ test ] - fn new_defaults_are_unset_dimensions_and_no_fade() - { - let v = Viewport::<()>::new( spacer() ); - assert!( v.width.is_none() ); - assert!( v.height.is_none() ); - assert_eq!( v.fade_bottom, 0.0 ); - } - - #[ test ] - fn width_builder_clamps_negative_to_zero() - { - let v = Viewport::<()>::new( spacer() ).width( -50.0 ); - assert_eq!( v.width, Some( 0.0 ) ); - } - - #[ test ] - fn height_builder_clamps_negative_to_zero() - { - let v = Viewport::<()>::new( spacer() ).height( -10.0 ); - assert_eq!( v.height, Some( 0.0 ) ); - } - - #[ test ] - fn fade_bottom_clamps_negative_to_zero() - { - let v = Viewport::<()>::new( spacer() ).fade_bottom( -1.0 ); - assert_eq!( v.fade_bottom, 0.0 ); - } - - #[ test ] - fn fade_bottom_accepts_positive_pixel_count() - { - let v = Viewport::<()>::new( spacer() ).fade_bottom( 16.0 ); - assert_eq!( v.fade_bottom, 16.0 ); - } - - #[ test ] - fn explicit_dimensions_override_child_intrinsic_size() - { - let v = Viewport::<()>::new( spacer().width( 99.0 ).height( 99.0 ) ) - .width( 200.0 ) - .height( 80.0 ); - let canvas = make_canvas(); - let ( w, h ) = v.preferred_size( 600.0, &canvas ); - assert_eq!( w, 200.0 ); - assert_eq!( h, 80.0 ); - } - - #[ test ] - fn unset_width_falls_through_to_max_width() - { - let v = Viewport::<()>::new( spacer().height( 40.0 ) ); - let canvas = make_canvas(); - let ( w, _ ) = v.preferred_size( 400.0, &canvas ); - assert_eq!( w, 400.0 ); - } - - #[ test ] - fn unset_height_falls_through_to_child_intrinsic_height() - { - let v = Viewport::<()>::new( spacer().height( 75.0 ) ); - let canvas = make_canvas(); - let ( _, h ) = v.preferred_size( 400.0, &canvas ); - assert_eq!( h, 75.0 ); - } -} +mod tests; diff --git a/src/widget/viewport/tests.rs b/src/widget/viewport/tests.rs new file mode 100644 index 0000000..420954d --- /dev/null +++ b/src/widget/viewport/tests.rs @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use super::*; +use crate::layout::spacer::spacer; +use crate::render::Canvas; + +fn make_canvas() -> Canvas { Canvas::new( 800, 600 ) } + +#[ test ] +fn new_defaults_are_unset_dimensions_and_no_fade() +{ + let v = Viewport::<()>::new( spacer() ); + assert!( v.width.is_none() ); + assert!( v.height.is_none() ); + assert_eq!( v.fade_bottom, 0.0 ); +} + +#[ test ] +fn width_builder_clamps_negative_to_zero() +{ + let v = Viewport::<()>::new( spacer() ).width( -50.0 ); + assert_eq!( v.width, Some( 0.0 ) ); +} + +#[ test ] +fn height_builder_clamps_negative_to_zero() +{ + let v = Viewport::<()>::new( spacer() ).height( -10.0 ); + assert_eq!( v.height, Some( 0.0 ) ); +} + +#[ test ] +fn fade_bottom_clamps_negative_to_zero() +{ + let v = Viewport::<()>::new( spacer() ).fade_bottom( -1.0 ); + assert_eq!( v.fade_bottom, 0.0 ); +} + +#[ test ] +fn fade_bottom_accepts_positive_pixel_count() +{ + let v = Viewport::<()>::new( spacer() ).fade_bottom( 16.0 ); + assert_eq!( v.fade_bottom, 16.0 ); +} + +#[ test ] +fn explicit_dimensions_override_child_intrinsic_size() +{ + let v = Viewport::<()>::new( spacer().width( 99.0 ).height( 99.0 ) ) + .width( 200.0 ) + .height( 80.0 ); + let canvas = make_canvas(); + let ( w, h ) = v.preferred_size( 600.0, &canvas ); + assert_eq!( w, 200.0 ); + assert_eq!( h, 80.0 ); +} + +#[ test ] +fn unset_width_falls_through_to_max_width() +{ + let v = Viewport::<()>::new( spacer().height( 40.0 ) ); + let canvas = make_canvas(); + let ( w, _ ) = v.preferred_size( 400.0, &canvas ); + assert_eq!( w, 400.0 ); +} + +#[ test ] +fn unset_height_falls_through_to_child_intrinsic_height() +{ + let v = Viewport::<()>::new( spacer().height( 75.0 ) ); + let canvas = make_canvas(); + let ( _, h ) = v.preferred_size( 400.0, &canvas ); + assert_eq!( h, 75.0 ); +} diff --git a/src/widget/vslider.rs b/src/widget/vslider/mod.rs similarity index 69% rename from src/widget/vslider.rs rename to src/widget/vslider/mod.rs index c03ab22..6fb1fb0 100644 --- a/src/widget/vslider.rs +++ b/src/widget/vslider/mod.rs @@ -7,28 +7,10 @@ use crate::render::Canvas; use super::Element; use super::slider::intersect_clip; -mod theme -{ - use crate::types::Color; - /// Slot ids for the Glass surfaces that back the VSlider track and - /// fill. The default theme ships them; downstream themes either - /// override them or let the widget fall through to a flat-colour - /// fallback painted from the [`track_bg`] / [`track_fill`] palette - /// tokens below. - pub const SURFACE_TRACK: &str = "surface-slider-track"; - pub const SURFACE_FILL: &str = "surface-slider-fill"; - /// Flat-colour fallback for the unfilled track — reuses the translucent - /// raised-surface token so the pill reads on both light and dark - /// wallpapers without hardcoding. - pub fn track_bg() -> Color { crate::theme::palette().surface_alt } - /// Flat-colour fallback for the filled portion — brand accent, - /// matching horizontal [`Slider`]. - pub fn track_fill() -> Color { crate::theme::palette().accent } - /// Default pill width in pixels. - pub const WIDTH: f32 = 56.0; - /// Default pill height in pixels. - pub const HEIGHT: f32 = 160.0; -} +mod theme; + +#[ cfg( test ) ] +mod tests; /// Compute the slider value `[0.0, 1.0]` from a tap/drag y position within a /// slider's layout rect. `rect.top` maps to `1.0` and `rect.bottom` to `0.0` @@ -331,156 +313,3 @@ impl From> for Element { fn from( s: VSlider ) -> Self { Element::VSlider( s ) } } - -#[ cfg( test ) ] -mod tests -{ - use super::*; - use crate::render::Canvas; - use crate::types::Rect; - - fn make_canvas() -> Canvas { Canvas::new( 400, 400 ) } - - #[ test ] - fn value_clamped_on_creation() - { - let s: VSlider<()> = vslider( 1.5 ); - assert_eq!( s.value, 1.0 ); - let s: VSlider<()> = vslider( -0.5 ); - assert_eq!( s.value, 0.0 ); - } - - #[ test ] - fn value_from_y_top_is_one() - { - let rect = Rect { x: 0.0, y: 0.0, width: 56.0, height: 160.0 }; - assert_eq!( value_from_y_in_rect( rect, 0.0 ), 1.0 ); - } - - #[ test ] - fn value_from_y_bottom_is_zero() - { - let rect = Rect { x: 0.0, y: 0.0, width: 56.0, height: 160.0 }; - assert_eq!( value_from_y_in_rect( rect, 160.0 ), 0.0 ); - } - - #[ test ] - fn value_from_y_center_is_half() - { - let rect = Rect { x: 0.0, y: 0.0, width: 56.0, height: 160.0 }; - let v = value_from_y_in_rect( rect, 80.0 ); - assert!( ( v - 0.5 ).abs() < 1e-6 ); - } - - #[ test ] - fn value_from_y_above_rect_clamps_to_one() - { - let rect = Rect { x: 0.0, y: 10.0, width: 56.0, height: 160.0 }; - assert_eq!( value_from_y_in_rect( rect, -50.0 ), 1.0 ); - } - - #[ test ] - fn value_from_y_below_rect_clamps_to_zero() - { - let rect = Rect { x: 0.0, y: 10.0, width: 56.0, height: 160.0 }; - assert_eq!( value_from_y_in_rect( rect, 500.0 ), 0.0 ); - } - - #[ test ] - fn value_from_y_respects_rect_offset() - { - // A rect starting at y=100 with height=100: y=100 → 1.0, y=200 → 0.0. - let rect = Rect { x: 0.0, y: 100.0, width: 56.0, height: 100.0 }; - assert_eq!( value_from_y_in_rect( rect, 100.0 ), 1.0 ); - assert_eq!( value_from_y_in_rect( rect, 200.0 ), 0.0 ); - let v = value_from_y_in_rect( rect, 150.0 ); - assert!( ( v - 0.5 ).abs() < 1e-6 ); - } - - #[ test ] - fn size_overrides_defaults() - { - let canvas = make_canvas(); - let s: VSlider<()> = vslider( 0.5 ).size( 40.0, 200.0 ); - let ( w, h ) = s.preferred_size( 500.0, &canvas ); - assert_eq!( w, 40.0 ); - assert_eq!( h, 200.0 ); - } - - #[ test ] - fn size_clamps_to_minimum() - { - let canvas = make_canvas(); - let s: VSlider<()> = vslider( 0.5 ).size( 0.0, 0.0 ); - let ( w, h ) = s.preferred_size( 500.0, &canvas ); - assert_eq!( w, 2.0 ); - assert_eq!( h, 2.0 ); - } - - #[ test ] - fn preferred_size_ignores_max_width() - { - // A VSlider is intrinsically sized — the parent's max_width doesn't - // change what we return. - let canvas = make_canvas(); - let s: VSlider<()> = vslider( 0.5 ); - let ( w_small, _ ) = s.preferred_size( 10.0, &canvas ); - let ( w_big, _ ) = s.preferred_size( 9_999.0, &canvas ); - assert_eq!( w_small, theme::WIDTH ); - assert_eq!( w_big, theme::WIDTH ); - } - - #[ test ] - fn default_dimensions_are_the_theme_constants() - { - let s: VSlider<()> = vslider( 0.0 ); - assert_eq!( s.width, theme::WIDTH ); - assert_eq!( s.height, theme::HEIGHT ); - } - - #[ test ] - fn draw_at_value_zero_does_not_panic() - { - let mut canvas = make_canvas(); - let rect = Rect { x: 10.0, y: 10.0, width: 56.0, height: 160.0 }; - let s: VSlider<()> = vslider( 0.0 ); - s.draw( &mut canvas, rect, false ); - } - - #[ test ] - fn draw_at_value_one_does_not_panic() - { - let mut canvas = make_canvas(); - let rect = Rect { x: 10.0, y: 10.0, width: 56.0, height: 160.0 }; - let s: VSlider<()> = vslider( 1.0 ); - s.draw( &mut canvas, rect, true ); - } - - #[ test ] - fn on_change_is_stored() - { - let s: VSlider = vslider( 0.5 ).on_change( |v| ( v * 100.0 ) as u32 ); - let cb = s.on_change.expect( "on_change was set" ); - assert_eq!( cb( 0.25 ), 25 ); - } - - #[ test ] - fn element_from_vslider() - { - let s: VSlider<()> = vslider( 0.5 ); - let el: Element<()> = s.into(); - assert!( matches!( el, Element::VSlider( _ ) ) ); - } - - #[ test ] - fn paint_bounds_equals_layout_rect() - { - let rect = Rect { x: 4.0, y: 8.0, width: 56.0, height: 160.0 }; - let s: VSlider<()> = vslider( 0.5 ); - let pb = s.paint_bounds( rect ); - assert_eq!( pb.x, rect.x ); - assert_eq!( pb.y, rect.y ); - assert_eq!( pb.width, rect.width ); - assert_eq!( pb.height, rect.height ); - } -} diff --git a/src/widget/vslider/tests.rs b/src/widget/vslider/tests.rs new file mode 100644 index 0000000..66aa328 --- /dev/null +++ b/src/widget/vslider/tests.rs @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use super::*; +use crate::render::Canvas; +use crate::types::Rect; + +fn make_canvas() -> Canvas { Canvas::new( 400, 400 ) } + +#[ test ] +fn value_clamped_on_creation() +{ + let s: VSlider<()> = vslider( 1.5 ); + assert_eq!( s.value, 1.0 ); + let s: VSlider<()> = vslider( -0.5 ); + assert_eq!( s.value, 0.0 ); +} + +#[ test ] +fn value_from_y_top_is_one() +{ + let rect = Rect { x: 0.0, y: 0.0, width: 56.0, height: 160.0 }; + assert_eq!( value_from_y_in_rect( rect, 0.0 ), 1.0 ); +} + +#[ test ] +fn value_from_y_bottom_is_zero() +{ + let rect = Rect { x: 0.0, y: 0.0, width: 56.0, height: 160.0 }; + assert_eq!( value_from_y_in_rect( rect, 160.0 ), 0.0 ); +} + +#[ test ] +fn value_from_y_center_is_half() +{ + let rect = Rect { x: 0.0, y: 0.0, width: 56.0, height: 160.0 }; + let v = value_from_y_in_rect( rect, 80.0 ); + assert!( ( v - 0.5 ).abs() < 1e-6 ); +} + +#[ test ] +fn value_from_y_above_rect_clamps_to_one() +{ + let rect = Rect { x: 0.0, y: 10.0, width: 56.0, height: 160.0 }; + assert_eq!( value_from_y_in_rect( rect, -50.0 ), 1.0 ); +} + +#[ test ] +fn value_from_y_below_rect_clamps_to_zero() +{ + let rect = Rect { x: 0.0, y: 10.0, width: 56.0, height: 160.0 }; + assert_eq!( value_from_y_in_rect( rect, 500.0 ), 0.0 ); +} + +#[ test ] +fn value_from_y_respects_rect_offset() +{ + // A rect starting at y=100 with height=100: y=100 → 1.0, y=200 → 0.0. + let rect = Rect { x: 0.0, y: 100.0, width: 56.0, height: 100.0 }; + assert_eq!( value_from_y_in_rect( rect, 100.0 ), 1.0 ); + assert_eq!( value_from_y_in_rect( rect, 200.0 ), 0.0 ); + let v = value_from_y_in_rect( rect, 150.0 ); + assert!( ( v - 0.5 ).abs() < 1e-6 ); +} + +#[ test ] +fn size_overrides_defaults() +{ + let canvas = make_canvas(); + let s: VSlider<()> = vslider( 0.5 ).size( 40.0, 200.0 ); + let ( w, h ) = s.preferred_size( 500.0, &canvas ); + assert_eq!( w, 40.0 ); + assert_eq!( h, 200.0 ); +} + +#[ test ] +fn size_clamps_to_minimum() +{ + let canvas = make_canvas(); + let s: VSlider<()> = vslider( 0.5 ).size( 0.0, 0.0 ); + let ( w, h ) = s.preferred_size( 500.0, &canvas ); + assert_eq!( w, 2.0 ); + assert_eq!( h, 2.0 ); +} + +#[ test ] +fn preferred_size_ignores_max_width() +{ + // A VSlider is intrinsically sized — the parent's max_width doesn't + // change what we return. + let canvas = make_canvas(); + let s: VSlider<()> = vslider( 0.5 ); + let ( w_small, _ ) = s.preferred_size( 10.0, &canvas ); + let ( w_big, _ ) = s.preferred_size( 9_999.0, &canvas ); + assert_eq!( w_small, theme::WIDTH ); + assert_eq!( w_big, theme::WIDTH ); +} + +#[ test ] +fn default_dimensions_are_the_theme_constants() +{ + let s: VSlider<()> = vslider( 0.0 ); + assert_eq!( s.width, theme::WIDTH ); + assert_eq!( s.height, theme::HEIGHT ); +} + +#[ test ] +fn draw_at_value_zero_does_not_panic() +{ + let mut canvas = make_canvas(); + let rect = Rect { x: 10.0, y: 10.0, width: 56.0, height: 160.0 }; + let s: VSlider<()> = vslider( 0.0 ); + s.draw( &mut canvas, rect, false ); +} + +#[ test ] +fn draw_at_value_one_does_not_panic() +{ + let mut canvas = make_canvas(); + let rect = Rect { x: 10.0, y: 10.0, width: 56.0, height: 160.0 }; + let s: VSlider<()> = vslider( 1.0 ); + s.draw( &mut canvas, rect, true ); +} + +#[ test ] +fn on_change_is_stored() +{ + let s: VSlider = vslider( 0.5 ).on_change( |v| ( v * 100.0 ) as u32 ); + let cb = s.on_change.expect( "on_change was set" ); + assert_eq!( cb( 0.25 ), 25 ); +} + +#[ test ] +fn element_from_vslider() +{ + let s: VSlider<()> = vslider( 0.5 ); + let el: Element<()> = s.into(); + assert!( matches!( el, Element::VSlider( _ ) ) ); +} + +#[ test ] +fn paint_bounds_equals_layout_rect() +{ + let rect = Rect { x: 4.0, y: 8.0, width: 56.0, height: 160.0 }; + let s: VSlider<()> = vslider( 0.5 ); + let pb = s.paint_bounds( rect ); + assert_eq!( pb.x, rect.x ); + assert_eq!( pb.y, rect.y ); + assert_eq!( pb.width, rect.width ); + assert_eq!( pb.height, rect.height ); +} diff --git a/src/widget/vslider/theme.rs b/src/widget/vslider/theme.rs new file mode 100644 index 0000000..a23d6ff --- /dev/null +++ b/src/widget/vslider/theme.rs @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::types::Color; +/// Slot ids for the Glass surfaces that back the VSlider track and +/// fill. The default theme ships them; downstream themes either +/// override them or let the widget fall through to a flat-colour +/// fallback painted from the [`track_bg`] / [`track_fill`] palette +/// tokens below. +pub const SURFACE_TRACK: &str = "surface-slider-track"; +pub const SURFACE_FILL: &str = "surface-slider-fill"; +/// Flat-colour fallback for the unfilled track — reuses the translucent +/// raised-surface token so the pill reads on both light and dark +/// wallpapers without hardcoding. +pub fn track_bg() -> Color { crate::theme::palette().surface_alt } +/// Flat-colour fallback for the filled portion — brand accent, +/// matching horizontal [`Slider`]. +pub fn track_fill() -> Color { crate::theme::palette().accent } +/// Default pill width in pixels. +pub const WIDTH: f32 = 56.0; +/// Default pill height in pixels. +pub const HEIGHT: f32 = 160.0; diff --git a/src/widget/window_button.rs b/src/widget/window_button/mod.rs similarity index 78% rename from src/widget/window_button.rs rename to src/widget/window_button/mod.rs index 1735efd..eae94f9 100644 --- a/src/widget/window_button.rs +++ b/src/widget/window_button/mod.rs @@ -6,22 +6,10 @@ use crate::layout::row::{ row, Row }; use crate::render::Canvas; use crate::types::{ Color, Rect, WidgetId }; -mod theme -{ - use crate::types::Color; +mod theme; - pub fn icon() -> Color { crate::theme::window_controls().icon } - pub fn hover_bg() -> Color { crate::theme::window_controls().hover_bg } - pub fn pressed_bg() -> Color { crate::theme::window_controls().pressed_bg } - pub fn focus_color() -> Color { crate::theme::window_controls().focus_ring } - pub fn close_hover() -> Color { crate::theme::window_controls().close_hover_bg } - pub fn close_icon() -> Color { crate::theme::window_controls().close_icon } - - pub const SIZE: f32 = 36.0; - pub const RADIUS: f32 = 10.0; - pub const FOCUS_W: f32 = 2.0; - pub const STROKE_W: f32 = 2.0; -} +#[ cfg( test ) ] +mod tests; /// Semantic role for a window-decoration button. /// @@ -329,55 +317,3 @@ pub fn window_controls( .push( window_button( maximize_kind ).on_press_maybe( maximize ) ) .push( window_button( WindowButtonKind::Close ).on_press_maybe( close ) ) } - -#[ cfg( test ) ] -mod tests -{ - use super::*; - use crate::render::Canvas; - - #[ test ] - fn default_size_is_decoration_sized() - { - let canvas = Canvas::new( 100, 100 ); - let b = window_button::<()>( WindowButtonKind::Close ); - assert_eq!( b.preferred_size( 100.0, &canvas ), ( theme::SIZE, theme::SIZE ) ); - } - - #[ test ] - fn controls_has_three_children() - { - let controls = window_controls::<()>( - Some( () ), WindowButtonKind::Maximize, Some( () ), Some( () ), - ); - assert_eq!( controls.children.len(), 3 ); - } - - #[ test ] - fn icon_names_resolve_each_kind_to_the_window_catalogue() - { - // Lock the catalogue paths so a future rename of the on-disk - // SVG (e.g. close.svg → x.svg) breaks this test instead of - // silently falling back to the programmatic glyph at runtime. - assert_eq!( icon_name( WindowButtonKind::Minimize ), "window/minimize" ); - assert_eq!( icon_name( WindowButtonKind::Maximize ), "window/maximize" ); - assert_eq!( icon_name( WindowButtonKind::Restore ), "window/restore" ); - assert_eq!( icon_name( WindowButtonKind::Close ), "window/close" ); - } - - #[ test ] - fn paint_bounds_unchanged_after_glyph_refactor() - { - // The SVG glyph paints inside the button rect just like the - // programmatic one did, so paint_bounds must stay equal to - // the previous value (rect expanded by the focus-ring slack). - let b = window_button::<()>( WindowButtonKind::Close ); - let rect = Rect { x: 10.0, y: 10.0, width: 36.0, height: 36.0 }; - let bounds = b.paint_bounds( rect ); - let slack = theme::FOCUS_W * 1.5 + 2.0; - assert!( ( bounds.x - ( rect.x - slack ) ).abs() < 0.01 ); - assert!( ( bounds.y - ( rect.y - slack ) ).abs() < 0.01 ); - assert!( ( bounds.width - ( rect.width + slack * 2.0 ) ).abs() < 0.01 ); - assert!( ( bounds.height - ( rect.height + slack * 2.0 ) ).abs() < 0.01 ); - } -} diff --git a/src/widget/window_button/tests.rs b/src/widget/window_button/tests.rs new file mode 100644 index 0000000..ba1cff1 --- /dev/null +++ b/src/widget/window_button/tests.rs @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use super::*; +use crate::render::Canvas; + +#[ test ] +fn default_size_is_decoration_sized() +{ + let canvas = Canvas::new( 100, 100 ); + let b = window_button::<()>( WindowButtonKind::Close ); + assert_eq!( b.preferred_size( 100.0, &canvas ), ( theme::SIZE, theme::SIZE ) ); +} + +#[ test ] +fn controls_has_three_children() +{ + let controls = window_controls::<()>( + Some( () ), WindowButtonKind::Maximize, Some( () ), Some( () ), + ); + assert_eq!( controls.children.len(), 3 ); +} + +#[ test ] +fn icon_names_resolve_each_kind_to_the_window_catalogue() +{ + // Lock the catalogue paths so a future rename of the on-disk + // SVG (e.g. close.svg → x.svg) breaks this test instead of + // silently falling back to the programmatic glyph at runtime. + assert_eq!( icon_name( WindowButtonKind::Minimize ), "window/minimize" ); + assert_eq!( icon_name( WindowButtonKind::Maximize ), "window/maximize" ); + assert_eq!( icon_name( WindowButtonKind::Restore ), "window/restore" ); + assert_eq!( icon_name( WindowButtonKind::Close ), "window/close" ); +} + +#[ test ] +fn paint_bounds_unchanged_after_glyph_refactor() +{ + // The SVG glyph paints inside the button rect just like the + // programmatic one did, so paint_bounds must stay equal to + // the previous value (rect expanded by the focus-ring slack). + let b = window_button::<()>( WindowButtonKind::Close ); + let rect = Rect { x: 10.0, y: 10.0, width: 36.0, height: 36.0 }; + let bounds = b.paint_bounds( rect ); + let slack = theme::FOCUS_W * 1.5 + 2.0; + assert!( ( bounds.x - ( rect.x - slack ) ).abs() < 0.01 ); + assert!( ( bounds.y - ( rect.y - slack ) ).abs() < 0.01 ); + assert!( ( bounds.width - ( rect.width + slack * 2.0 ) ).abs() < 0.01 ); + assert!( ( bounds.height - ( rect.height + slack * 2.0 ) ).abs() < 0.01 ); +} diff --git a/src/widget/window_button/theme.rs b/src/widget/window_button/theme.rs new file mode 100644 index 0000000..c00d946 --- /dev/null +++ b/src/widget/window_button/theme.rs @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::types::Color; + +pub fn icon() -> Color { crate::theme::window_controls().icon } +pub fn hover_bg() -> Color { crate::theme::window_controls().hover_bg } +pub fn pressed_bg() -> Color { crate::theme::window_controls().pressed_bg } +pub fn focus_color() -> Color { crate::theme::window_controls().focus_ring } +pub fn close_hover() -> Color { crate::theme::window_controls().close_hover_bg } +pub fn close_icon() -> Color { crate::theme::window_controls().close_icon } + +pub const SIZE: f32 = 36.0; +pub const RADIUS: f32 = 10.0; +pub const FOCUS_W: f32 = 2.0; +pub const STROKE_W: f32 = 2.0; diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 48400aa..5b17d8a 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -22,6 +22,7 @@ pub fn lw_none( flat_idx: usize, rect: Rect ) -> LaidOutWidget<()> handlers: WidgetHandlers::None, keyboard_focusable: true, cursor: CursorShape::Default, + tooltip: None, } } @@ -37,6 +38,7 @@ pub fn lw_button( flat_idx: usize, rect: Rect ) -> LaidOutWidget<()> handlers: WidgetHandlers::Button { on_press: Some( () ), on_long_press: None, on_drag_start: None, on_escape: None, repeating: false }, keyboard_focusable: true, cursor: CursorShape::Pointer, + tooltip: None, } } @@ -54,6 +56,7 @@ pub fn lw_chrome( flat_idx: usize, rect: Rect ) -> LaidOutWidget<()> handlers: WidgetHandlers::WindowButton { on_press: Some( () ) }, keyboard_focusable: false, cursor: CursorShape::Pointer, + tooltip: None, } }