From 492553829e97cf50a3072d4d8e5530782be92e2c Mon Sep 17 00:00:00 2001 From: "Pedro M. de Echanove Pasquin" Date: Wed, 9 Sep 2026 17:27:42 +0200 Subject: [PATCH] =?UTF-8?q?widgets,=20input:=20PatternGrid=20for=20pattern?= =?UTF-8?q?=20unlock,=20framed=20icon=20buttons,=20and=20text=20keys=20tha?= =?UTF-8?q?t=20bubble=20past=20a=20focused=20non-text=20widget;=20Debian?= =?UTF-8?q?=200.3.0-2=20PatternGrid=20is=20a=203=C3=973=20drag=20widget=20?= =?UTF-8?q?for=20the=20unlock=20pattern.=20It=20is=20stateless:=20the=20ap?= =?UTF-8?q?p=20owns=20the=20visited=20nodes=20and=20passes=20them=20back?= =?UTF-8?q?=20in=20for=20drawing;=20a=20press=20or=20a=20drag=20that=20rea?= =?UTF-8?q?ches=20a=20node=20within=20the=20snap=20radius=20fires=20on=5Fn?= =?UTF-8?q?ode=20with=20the=20index,=200=20to=208=20row-major,=20and=20rel?= =?UTF-8?q?ease=20fires=20on=5Frelease,=20so=20the=20crossing=20and=20mini?= =?UTF-8?q?mum-length=20rules=20stay=20in=20the=20app.=20Geometry=20lives?= =?UTF-8?q?=20in=20widget/pattern=5Fgrid/theme.rs=20(size,=20dot=20and=20n?= =?UTF-8?q?ode=20radii,=20stroke=20width,=20snap=20radius,=20ring=20alpha)?= =?UTF-8?q?,=20and=20the=20snap=20radius=20is=20resolved=20through=20the?= =?UTF-8?q?=20widget-scaling=20mode=20at=20layout=20time=20like=20the=20sl?= =?UTF-8?q?ider=20thumb=20(set=5Fpattern=5Fsnap=5Fpx).=20The=20gesture=20m?= =?UTF-8?q?achine=20reuses=20the=20slider's=20drag=20ownership:=20WidgetHa?= =?UTF-8?q?ndlers::is=5Fdrag=5Ftarget=20covers=20both,=20so=20hover=20pain?= =?UTF-8?q?ting,=20pointer=20capture=20and=20release=20behave=20the=20same?= =?UTF-8?q?,=20and=20hover=5Faffects=5Fpaint=20no=20longer=20special-cases?= =?UTF-8?q?=20sliders.=20The=20a11y=20tree=20maps=20it=20to=20GenericConta?= =?UTF-8?q?iner,=20since=20a=20drawn=20gesture=20has=20no=20assistive=20eq?= =?UTF-8?q?uivalent=20and=20the=20app=20must=20offer=20another=20method.?= =?UTF-8?q?=20Exported=20as=20pattern=5Fgrid=20/=20PatternGrid,=20document?= =?UTF-8?q?ed=20in=20widgets.md,=20theming.md=20and=20architecture.md,=20w?= =?UTF-8?q?ith=20tests=20for=20node=20centres,=20a=20wide=20rect,=20snappi?= =?UTF-8?q?ng,=20the=20builders,=20the=20handler=20snapshot=20and=20map=5F?= =?UTF-8?q?msg,=20and=20a=20Security=20tab=20in=20examples/widgets.rs=20th?= =?UTF-8?q?at=20shows=20the=20drawn=20path.=20Icon=20buttons=20given=20a?= =?UTF-8?q?=20width=20or=20height=20are=20framed=20keys:=20preferred=5Fsiz?= =?UTF-8?q?e=20honours=20the=20explicit=20size=20instead=20of=20the=20icon?= =?UTF-8?q?=20size,=20and=20drawing=20paints=20the=20variant's=20backgroun?= =?UTF-8?q?d,=20border=20and=20focus=20ring,=20shared=20with=20text=20butt?= =?UTF-8?q?ons=20through=20paint=5Fframe,=20then=20centres=20the=20icon.?= =?UTF-8?q?=20An=20icon=20button=20without=20a=20size=20keeps=20the=20circ?= =?UTF-8?q?ular=20hover=20overlay=20it=20had.=20This=20is=20what=20lets=20?= =?UTF-8?q?the=20PIN=20keypad's=20backspace=20and=20OK=20keys=20sit=20in?= =?UTF-8?q?=20the=20same=20circles=20as=20the=20digits.=20Keyboard=20routi?= =?UTF-8?q?ng=20now=20matches=20what=20App::on=5Fkey=20documents.=20handle?= =?UTF-8?q?=5Fkey=5Fdefault,=20Backspace,=20Delete=20and=20Space=20checked?= =?UTF-8?q?=20focused=5Fidx.is=5Fsome()=20and=20sent=20the=20key=20into=20?= =?UTF-8?q?text=20insertion=20whenever=20any=20widget=20had=20focus,=20whi?= =?UTF-8?q?ch=20is=20a=20no-op=20on=20a=20button,=20so=20a=20focused=20but?= =?UTF-8?q?ton=20or=20pressable=20swallowed=20typed=20characters.=20focuse?= =?UTF-8?q?d=5Ftext=5Finput=20replaces=20the=20five=20copies=20of=20that?= =?UTF-8?q?=20check;=20characters,=20Backspace=20and=20Delete=20stay=20in?= =?UTF-8?q?=20a=20focused=20text=20input=20and=20reach=20the=20app=20other?= =?UTF-8?q?wise,=20Return=20and=20Space=20still=20go=20to=20the=20focused?= =?UTF-8?q?=20widget's=20submit=20or=20press=20message=20when=20it=20has?= =?UTF-8?q?=20one,=20and=20the=20arrow,=20Home=20and=20End=20handlers=20us?= =?UTF-8?q?e=20the=20same=20helper.=20The=20lockscreen=20relies=20on=20thi?= =?UTF-8?q?s=20to=20take=20PIN=20digits=20with=20its=20dots=20row=20focuse?= =?UTF-8?q?d.=20debian/changelog:=200.3.0-2.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- debian/changelog | 8 ++ docs/architecture.md | 4 +- docs/theming.md | 6 +- docs/widgets.md | 53 +++++++++- examples/widgets.rs | 43 +++++++- src/a11y/tree.rs | 1 + src/draw/layout.rs | 4 + src/input/gesture/mod.rs | 22 +++- src/input/keyboard/text_keys.rs | 65 +++++------- src/input/pointer/helpers.rs | 2 +- src/lib.rs | 4 +- src/widget/button/mod.rs | 81 +++++++++------ src/widget/button/tests.rs | 11 ++ src/widget/element.rs | 18 +++- src/widget/handlers.rs | 53 ++++++++++ src/widget/mod.rs | 3 +- src/widget/pattern_grid/mod.rs | 166 +++++++++++++++++++++++++++++++ src/widget/pattern_grid/tests.rs | 74 ++++++++++++++ src/widget/pattern_grid/theme.rs | 9 ++ 19 files changed, 540 insertions(+), 87 deletions(-) create mode 100644 src/widget/pattern_grid/mod.rs create mode 100644 src/widget/pattern_grid/tests.rs create mode 100644 src/widget/pattern_grid/theme.rs diff --git a/debian/changelog b/debian/changelog index 0b302d4..49ec0ef 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,11 @@ +ltk (0.3.0-2) unstable; urgency=low + + * PatternGrid: a 3×3 drag widget for pattern unlock that reports the node under the pointer and the release; the app owns the path. Theme tokens under widget/pattern_grid/theme.rs, AccessKit GenericContainer role, docs, tests and a Security tab in the widgets example. + * Framed icon buttons: icon_button honours width, height and variant and paints the same frame as a text button, so icon keys line up with text keys. + * Keyboard routing: characters, Backspace and Delete stay in a focused text input and reach App::on_key otherwise, as documented; a focused button or pressable no longer swallows them. + + -- Pedro M. de Echanove Pasquin Wed, 09 Sep 2026 12:00:00 +0200 + ltk (0.3.0-1) unstable; urgency=low * New upstream release: xdg-session-management-v1 client support (in-tree wayland-scanner bindings under protocols/), runtime session persistence in $XDG_STATE_HOME// (session id, clean-exit marker, App::save_state bytes; saved periodically, on close and on SIGTERM/SIGINT), clean exit on signals. diff --git a/docs/architecture.md b/docs/architecture.md index c630fc9..54b9682 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -95,7 +95,7 @@ In practice, that model is easiest to adopt in three steps: - `on_swipe_up`, `on_swipe_down`, `on_swipe_progress`, `on_swipe_down_progress` (follow-the-finger). - `on_tap` — taps that miss every widget. -- `on_key` / `on_key_with_modifiers` — global hotkeys. +- `on_key` / `on_key_with_modifiers` — global hotkeys. Characters, Backspace and Delete stay in a focused text input and reach the app otherwise, a focused button or pressable included; Return and Space go to the focused widget's submit or press message when it has one; arrows are offered to the app first and fall back to the text cursor or list navigation. - `swipe_threshold`, `swipe_down_threshold` — gesture sensitivity. **Implement for animations and focus** @@ -309,7 +309,7 @@ A short, honest list of what ltk does not currently provide. None of these are a **AT-SPI2 / assistive technology bridge — wired through AccessKit, with composite widgets still flat.** Combo, Notebook tabs, DatePicker and TimePicker render as collections of inner widgets and currently expose those leaves individually (a combo trigger reads as "Button" + its caption, the popup items as `ListItem`s inside an overlay). Promoting them to their semantic roles (`ComboBoxMenuButton` with `Expanded` state, `TabList`/`Tab`/`TabPanel`, `Date`) needs each compound widget to declare an "outer role hint" the layout pass can attach to the `LaidOutWidget` it pushes. Tracked separately. - ltk delegates the AT-SPI2 D-Bus protocol to `accesskit_unix`. After every layout pass, the runtime hands the platform adapter a fresh `accesskit::TreeUpdate` built from `widget_rects`: a `Window` root whose children are the main surface's widgets, with each overlay grouped under its own `Dialog` node and rich-text runs nested as `TextRun` children. Buttons / toggles / checkboxes / radios / list items map to `Role::Button` / `Role::Switch` / `Role::CheckBox` / `Role::RadioButton` / `Role::ListItem`; sliders to `Role::Slider`; single- and multi-line text edits to `Role::TextInput` / `Role::MultilineTextInput`; non-interactive labels, images, separators and progress bars surface as `Label` / `Image` / `Splitter` / `ProgressIndicator` nodes. Inbound action requests are translated into the matching widget message on the next iteration of the run loop: `Click` and `Focus` on every interactive node, `SetValue` on sliders and text edits, `Increment` / `Decrement` on sliders, and the scroll actions on scroll viewports. Live regions are wired too — `Container::live_region( true )` marks a subtree `Live::Polite` so status messages and OSDs announce themselves on appearance. + ltk delegates the AT-SPI2 D-Bus protocol to `accesskit_unix`. After every layout pass, the runtime hands the platform adapter a fresh `accesskit::TreeUpdate` built from `widget_rects`: a `Window` root whose children are the main surface's widgets, with each overlay grouped under its own `Dialog` node and rich-text runs nested as `TextRun` children. Buttons / toggles / checkboxes / radios / list items map to `Role::Button` / `Role::Switch` / `Role::CheckBox` / `Role::RadioButton` / `Role::ListItem`; sliders to `Role::Slider`; pattern grids to `Role::GenericContainer` (a drawn gesture has no assistive equivalent, so the app must offer another unlock method); single- and multi-line text edits to `Role::TextInput` / `Role::MultilineTextInput`; non-interactive labels, images, separators and progress bars surface as `Label` / `Image` / `Splitter` / `ProgressIndicator` nodes. Inbound action requests are translated into the matching widget message on the next iteration of the run loop: `Click` and `Focus` on every interactive node, `SetValue` on sliders and text edits, `Increment` / `Decrement` on sliders, and the scroll actions on scroll viewports. Live regions are wired too — `Container::live_region( true )` marks a subtree `Live::Polite` so status messages and OSDs announce themselves on appearance. The adapter is constructed unconditionally — `accesskit_unix::Adapter::new` is infallible and simply stays inactive when no AT client is attached — and the tree is only built when AT-SPI2 is actually observing the application, so the pipeline costs nothing on headless runners. The current cut covers the common cases — buttons, lists, form fields, dialogs — and intentionally leaves room for follow-up: diff --git a/docs/theming.md b/docs/theming.md index 5af83e7..d6ad923 100644 --- a/docs/theming.md +++ b/docs/theming.md @@ -428,11 +428,11 @@ of them falls back to embedded defaults. | `surface` | card / panel surface | color | | `surface-alt` | text-input field background | color | | `text-primary` | regular text colour | color | -| `text-secondary` | muted / placeholder text | color | -| `accent` | toggle on, slider fill, focus ring | color | +| `text-secondary` | muted / placeholder text, pattern-grid idle dots | color | +| `accent` | toggle on, slider fill, focus ring, pattern-grid stroke and visited dots | color | | `divider` | separator, toggle off, list item border | color | | `icon` | icon-button glyph colour | color | -| `danger` | destructive / error foreground | color | +| `danger` | destructive / error foreground, pattern-grid path in error | color | | `danger-bg` | soft fill behind error states | color | The default theme defines `danger` but not `danger-bg`, which diff --git a/docs/widgets.md b/docs/widgets.md index 4003506..9cb5a3e 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -16,7 +16,7 @@ patterns built from these widgets, see [`docs/cookbook.md`](./cookbook.md). - [Stateful binary controls](#stateful-binary-controls) - [`toggle`](#toggle) · [`checkbox`](#checkbox) · [`radio`](#radio) - [Continuous controls](#continuous-controls) - - [`slider`](#slider) · [`vslider`](#vslider) · [`progress_bar`](#progress_bar) + - [`slider`](#slider) · [`vslider`](#vslider) · [`progress_bar`](#progress_bar) · [`pattern_grid`](#pattern_grid) - [Text input and display](#text-input-and-display) - [`text`](#text) · [`text_edit`](#text_edit) · [`rich_text`](#rich_text) - [Decoration and chrome](#decoration-and-chrome) @@ -83,6 +83,25 @@ count; pass `Length::widget( n )` instead to follow the widget-scaling mode, which is what stock icons (the [`list_item`](#list_item) disclosure arrow) already do — use it when the two sit side by side and should match. +Give it a `width( … )` or `height( … )` and it becomes a framed key: it +claims that size, paints the background and border of its `variant` like +a text [`button`](#button), and centres the icon inside at `icon_size`. +That is how a keypad mixes digit keys with an icon key of the same shape: + +```rust,no_run +# use std::sync::Arc; +# use ltk::{ icon_button, ButtonVariant, Element }; +# #[ derive( Clone ) ] enum Msg { Backspace } +# fn _ex( rgba_bytes: Arc>, w: u32, h: u32 ) -> Element { +icon_button( rgba_bytes, w, h ) + .variant( ButtonVariant::Secondary ) + .width( 64.0 ).height( 64.0 ) + .icon_size( 28.0 ) + .on_press( Msg::Backspace ) +.into() +# } +``` + **See also**: [`button`](#button), [`window_button`](#window_button) (specialised icon button for window decorations). @@ -324,6 +343,38 @@ progress_bar( self.download_fraction ) **See also**: [`spinner`](#spinner) for indeterminate operations without a known fraction. +### `pattern_grid` + +A 3×3 grid of dots for drawing an unlock pattern with a finger or the +pointer. The widget is stateless: the app owns the list of visited nodes +(0 to 8, row-major) and passes it back in for drawing. + +**When**: lock screens and enrolment forms for a pattern secret. + +```rust,no_run +# use ltk::{ pattern_grid, PatternGrid }; +# #[ derive( Clone ) ] enum Msg { Node( u8 ), Released } +# struct App { path: Vec } +# impl App { fn _ex( &self ) -> PatternGrid { +pattern_grid( self.path.clone() ) + .on_node( Msg::Node ) + .on_release( Msg::Released ) +# }} +``` + +`on_node( f )` fires with a node index whenever a press or a drag reaches +a node, including nodes already in the path — appending, skipping a +repeat, or taking in the node a straight stroke crosses is the app's +rule, not the widget's. `on_release( msg )` fires when the finger lifts. +`hide_stroke( true )` draws only the dots, against shoulder surfing; +`error( true )` paints the current path in the palette's `danger` colour. + +It sizes itself square to `min( max_width, 264 )` design pixels; wrap it +in a `container( … ).max_width( … )` to pin it. + +**See also**: [`slider`](#slider), which owns the pointer during a drag +the same way. + --- ## Text input and display diff --git a/examples/widgets.rs b/examples/widgets.rs index 50de89f..0acaa7f 100644 --- a/examples/widgets.rs +++ b/examples/widgets.rs @@ -4,7 +4,7 @@ use ltk::{ App, Element, Keysym, WidgetId, column, row, text, text_edit, spacer, separator, slider, toggle, checkbox, radio, progress_bar, scroll, - spinner, tabs, + spinner, tabs, container, pattern_grid, }; #[ derive( Clone ) ] @@ -21,6 +21,8 @@ enum Msg SliderChanged( f32 ), SelectTab( usize ), NoteChanged( String ), + PatternNode( u8 ), + PatternReleased, Tick, } @@ -36,6 +38,8 @@ struct WidgetsApp volume: f32, tab: usize, note: String, + pattern: Vec, + pattern_done: Option, started_at: Instant, } @@ -55,6 +59,8 @@ impl WidgetsApp volume: 0.7, tab: 0, note: String::new(), + pattern: Vec::new(), + pattern_done: None, started_at: Instant::now(), } } @@ -81,7 +87,7 @@ impl App for WidgetsApp let title = text( "ltk widgets" ).size( 24.0 ).color( primary ).align_center(); // Segmented tab strip — each tab shows its own page below. - let tabs_strip: Element = tabs( [ "General", "Audio", "Network" ] ) + let tabs_strip: Element = tabs( [ "General", "Audio", "Network", "Security" ] ) .selected( self.tab ) .on_select( Msg::SelectTab ) .into(); @@ -125,8 +131,20 @@ impl App for WidgetsApp .id( WidgetId( "widgets/note" ) ) .on_change( Msg::NoteChanged ); + // Pattern grid: the app owns the path; the widget only reports nodes. + let pattern_label = match &self.pattern_done + { + Some( drawn ) => format!( "Pattern: {drawn}" ), + None => "Pattern (drag across the dots)".to_string(), + }; + let grid = container( + pattern_grid( self.pattern.clone() ) + .on_node( Msg::PatternNode ) + .on_release( Msg::PatternReleased ) + ).max_width( 220.0 ); + // One page per tab, so selecting a tab visibly swaps the content. - let page: Element = match self.tab.min( 2 ) + let page: Element = match self.tab.min( 3 ) { 0 => column::() .padding( 0.0 ) @@ -145,7 +163,7 @@ impl App for WidgetsApp .push( text( prog_label ).size( 13.0 ).color( secondary ) ) .push( progress_bar( self.progress ) ) .into(), - _ => column::() + 2 => column::() .padding( 0.0 ) .spacing( 12.0 ) .push( text( "Spinner" ).size( 13.0 ).color( secondary ) ) @@ -154,6 +172,12 @@ impl App for WidgetsApp .push( text( "Multiline text edit" ).size( 13.0 ).color( secondary ) ) .push( textarea ) .into(), + _ => column::() + .padding( 0.0 ) + .spacing( 12.0 ) + .push( text( pattern_label ).size( 13.0 ).color( secondary ) ) + .push( grid ) + .into(), }; let content = column::() @@ -189,6 +213,17 @@ impl App for WidgetsApp Msg::SliderChanged( v ) => self.volume = v, Msg::SelectTab( i ) => self.tab = i, Msg::NoteChanged( s ) => self.note = s, + Msg::PatternNode( n ) => + { + if self.pattern.is_empty() { self.pattern_done = None; } + if !self.pattern.contains( &n ) { self.pattern.push( n ); } + } + Msg::PatternReleased => + { + let drawn: Vec = self.pattern.iter().map( |n| n.to_string() ).collect(); + self.pattern_done = Some( drawn.join( "-" ) ); + self.pattern.clear(); + } Msg::Tick => {} } } diff --git a/src/a11y/tree.rs b/src/a11y/tree.rs index 8bdaa28..08eb00b 100644 --- a/src/a11y/tree.rs +++ b/src/a11y/tree.rs @@ -118,6 +118,7 @@ fn node_for( WidgetHandlers::ListItem { .. } => Role::ListItem, WidgetHandlers::WindowButton { .. } => Role::Button, WidgetHandlers::Slider { .. } => Role::Slider, + WidgetHandlers::PatternGrid { .. } => Role::GenericContainer, WidgetHandlers::TextEdit { multiline, .. } => if *multiline { Role::MultilineTextInput } else { Role::TextInput }, WidgetHandlers::None => Role::GenericContainer, diff --git a/src/draw/layout.rs b/src/draw/layout.rs index 7d39d21..8f04b3c 100644 --- a/src/draw/layout.rs +++ b/src/draw/layout.rs @@ -461,6 +461,10 @@ pub( crate ) fn layout_and_draw( { handlers.set_slider_thumb_px( crate::widget::slider::resolved_thumb_px( canvas ) ); } + if matches!( other, Element::PatternGrid( _ ) ) + { + handlers.set_pattern_snap_px( canvas.geom_px( crate::widget::pattern_grid::snap_design_px() ) ); + } ctx.widget_rects.push( LaidOutWidget { rect, diff --git a/src/input/gesture/mod.rs b/src/input/gesture/mod.rs index 805c4b5..752abff 100644 --- a/src/input/gesture/mod.rs +++ b/src/input/gesture/mod.rs @@ -255,6 +255,9 @@ impl GestureState 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 ); + } else if w.handlers.is_drag_target() { + self.dragging_slider = Some( idx ); + initial_slider_msg = w.handlers.pattern_node_msg( w.rect, pos ); } } } @@ -316,8 +319,13 @@ impl GestureState { 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 ); + let msg = if w.handlers.is_slider() + { + let value = w.handlers.slider_value_from_pos( w.rect, pos ); + w.handlers.slider_change_msg( value ) + } else { + w.handlers.pattern_node_msg( w.rect, pos ) + }; return MoveOutcome::Slider { msg }; } return MoveOutcome::Idle; @@ -513,8 +521,14 @@ impl GestureState self.start = None; if let Some( w ) = find_widget( widget_rects, slider_idx ) { - let value = w.handlers.slider_value_from_pos( w.rect, pos ); - if let Some( msg ) = w.handlers.slider_release_msg( value ) + let msg = if w.handlers.is_slider() + { + let value = w.handlers.slider_value_from_pos( w.rect, pos ); + w.handlers.slider_release_msg( value ) + } else { + w.handlers.pattern_release_msg() + }; + if let Some( msg ) = msg { events.push( ReleaseEvent::PushMsg( msg ) ); } diff --git a/src/input/keyboard/text_keys.rs b/src/input/keyboard/text_keys.rs index 31a33d5..c85b9ad 100644 --- a/src/input/keyboard/text_keys.rs +++ b/src/input/keyboard/text_keys.rs @@ -9,10 +9,19 @@ use crate::tree::find_handlers; impl AppData { + /// Whether the focused widget is an editable text input; any other + /// focused widget lets text keys bubble to the app. + fn focused_text_input( &self, focus: SurfaceFocus ) -> bool + { + let ss = self.surface( focus ); + ss.focused_idx + .and_then( |idx| find_handlers( &ss.frame.widget_rects, idx ) ) + .is_some_and( |h| h.is_text_input() ) + } + 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 ); } + if self.focused_text_input( focus ) { 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 ); @@ -21,11 +30,9 @@ impl AppData 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 ); } + // removes the char *after* the cursor. + if self.focused_text_input( focus ) { 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 ); @@ -77,10 +84,7 @@ impl AppData 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 ).frame.widget_rects, idx ) - .map( |h| h.is_text_input() ) ).unwrap_or( false ); + let is_text = self.focused_text_input( focus ); 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 ); @@ -104,11 +108,8 @@ impl AppData 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 ).frame.widget_rects, idx ) - .map( |h| h.is_text_input() ) ).unwrap_or( false ); - let extend = self.shift_pressed; + let is_text = self.focused_text_input( focus ); + 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 { @@ -127,36 +128,25 @@ impl AppData 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 ).frame.widget_rects, idx ) - .map( |h| h.is_text_input() ) ).unwrap_or( false ); - if is_text { self.handle_cursor_home( focus, self.shift_pressed ); } + if self.focused_text_input( focus ) { 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 ).frame.widget_rects, idx ) - .map( |h| h.is_text_input() ) ).unwrap_or( false ); - if is_text { self.handle_cursor_end( focus, self.shift_pressed ); } + if self.focused_text_input( focus ) { 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 ss = self.surface( focus ); + let press = ss.focused_idx + .and_then( |idx| find_handlers( &ss.frame.widget_rects, idx ) ) + .and_then( |h| h.press_msg() ); + if let Some( msg ) = press { - let press = find_handlers( &self.surface( focus ).frame.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, " " ); - } + self.pending_msgs.push( msg ); + } else if self.focused_text_input( focus ) { + 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 ); @@ -165,14 +155,13 @@ impl AppData 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() + } else if self.focused_text_input( focus ) { if let Some( txt ) = event.utf8.clone() { diff --git a/src/input/pointer/helpers.rs b/src/input/pointer/helpers.rs index 401a207..e8e2bcb 100644 --- a/src/input/pointer/helpers.rs +++ b/src/input/pointer/helpers.rs @@ -37,6 +37,6 @@ pub( crate ) fn hover_affects_paint( ) -> bool { idx.and_then( |i| find_handlers( widget_rects, i ) ) - .map( |h| !h.is_slider() ) + .map( |h| !h.is_drag_target() ) .unwrap_or( false ) } diff --git a/src/lib.rs b/src/lib.rs index f6a6e06..062f3b9 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -88,7 +88,7 @@ //! - **Stateful binary controls** — [`toggle()`], [`checkbox()`], //! [`radio()`]. //! - **Continuous controls** — [`slider()`], [`vslider()`], -//! [`progress_bar()`]. +//! [`progress_bar()`], [`pattern_grid()`]. //! - **Text** — [`text()`], [`text_edit()`]. //! - **Decoration** — [`container()`], [`separator()`], [`img_widget()`]. //! - **Clipping wrappers** — [`scroll()`], [`viewport()`], [`flex()`]. @@ -358,6 +358,7 @@ pub use widget::rich_text::{ rich_text, RichText, LinkSpan }; pub use widget::button::ButtonVariant; pub use widget::slider::{ Slider, slider, SliderAxis }; pub use widget::vslider::{ VSlider, vslider }; +pub use widget::pattern_grid::{ PatternGrid, pattern_grid }; pub use widget::text::TextAlign; pub use widget::toggle::{ Toggle, toggle }; pub use widget::separator::{ Separator, separator }; @@ -426,6 +427,7 @@ pub mod widgets // Continuous controls Slider, slider, SliderAxis, VSlider, vslider, + PatternGrid, pattern_grid, ProgressBar, progress_bar, // Composite picker Combo, ComboState, combo, diff --git a/src/widget/button/mod.rs b/src/widget/button/mod.rs index 8d6d30d..10c820d 100644 --- a/src/widget/button/mod.rs +++ b/src/widget/button/mod.rs @@ -371,12 +371,26 @@ impl Button } ButtonContent::Icon { .. } => { - let s = self.resolved_icon_size( canvas ).min( max_width ); - ( s, s ) + if self.is_framed() + { + let w = self.width.map( |l| canvas.resolve_geom( l ).min( max_width ) ); + let h = self.height.map( |l| canvas.resolve_geom( l ) ); + ( w.or( h ).unwrap_or( 0.0 ), h.or( w ).unwrap_or( 0.0 ) ) + } else { + let s = self.resolved_icon_size( canvas ).min( max_width ); + ( s, s ) + } } } } + /// An icon button given an explicit size is a framed key: it paints its + /// variant like a text button and centres the icon inside. + fn is_framed( &self ) -> bool + { + self.width.is_some() || self.height.is_some() + } + /// Draw the button into `canvas` at `rect`. /// /// `focused` draws a keyboard-focus ring; `hovered` and `pressed` apply @@ -398,13 +412,26 @@ impl Button fn draw_text_button( &self, canvas: &mut Canvas, rect: Rect, focused: bool, label: &str ) { - let is_disabled = self.on_press.is_none(); - let fs = self.label_font_size( canvas ); - let text_y = rect.y + (rect.height + fs) / 2.0 - 2.0; + let fs = self.label_font_size( canvas ); + let text_y = rect.y + (rect.height + fs) / 2.0 - 2.0; // `rect` is what the layout granted, which is not always the // preferred width: a row that cannot fit its children shrinks them. - let label = super::elide( canvas, label, fs, rect.width - canvas.geom_px( theme::PAD_H ) * 2.0 ); + let label = super::elide( canvas, label, fs, rect.width - canvas.geom_px( theme::PAD_H ) * 2.0 ); + let text_c = self.paint_frame( canvas, rect, focused ); + let text_w = canvas.measure_text( &label, fs ); + canvas.draw_text( + &label, + rect.x + (rect.width - text_w) / 2.0, + text_y, + fs, + text_c, + ); + } + /// Background, border and focus ring of the variant; returns the foreground colour. + fn paint_frame( &self, canvas: &mut Canvas, rect: Rect, focused: bool ) -> Color + { + let is_disabled = self.on_press.is_none(); match self.variant { ButtonVariant::Primary => @@ -427,14 +454,7 @@ impl Button theme::RADIUS + theme::FOCUS_W + 2.0, ); } - let text_w = canvas.measure_text( &label, fs ); - canvas.draw_text( - &label, - rect.x + (rect.width - text_w) / 2.0, - text_y, - fs, - text_c, - ); + text_c } ButtonVariant::Secondary => { @@ -453,14 +473,7 @@ impl Button theme::RADIUS + theme::FOCUS_W + 2.0, ); } - let text_w = canvas.measure_text( &label, fs ); - canvas.draw_text( - &label, - rect.x + (rect.width - text_w) / 2.0, - text_y, - fs, - text_c, - ); + text_c } ButtonVariant::Tertiary => { @@ -470,14 +483,7 @@ impl Button let ring = rect.expand( 2.0 ); canvas.stroke_rect( ring, theme::focus_color(), theme::FOCUS_W, theme::RADIUS ); } - let text_w = canvas.measure_text( &label, fs ); - canvas.draw_text( - &label, - rect.x + (rect.width - text_w) / 2.0, - text_y, - fs, - text_c, - ); + text_c } } } @@ -494,6 +500,21 @@ impl Button img_h: u32, ) { + if self.is_framed() + { + self.paint_frame( canvas, rect, focused ); + let s = self.resolved_icon_size( canvas ).min( rect.width ).min( rect.height ); + let dest = Rect + { + x: rect.x + ( rect.width - s ) / 2.0, + y: rect.y + ( rect.height - s ) / 2.0, + width: s, + height: s, + }; + canvas.draw_image_data( rgba, img_w, img_h, dest, 1.0 ); + return; + } + // Semi-transparent circular overlay behind the icon for hover / press feedback let circle_pad = 8.0_f32; let r = rect.width.min( rect.height ) / 2.0 + circle_pad; diff --git a/src/widget/button/tests.rs b/src/widget/button/tests.rs index d601b92..4ea849d 100644 --- a/src/widget/button/tests.rs +++ b/src/widget/button/tests.rs @@ -17,6 +17,17 @@ fn icon_button_focusable_by_default() assert!( b.focusable ); } +#[ test ] +fn an_icon_button_is_framed_only_when_given_a_size() +{ + let bare = Button::<()>::new_icon( Arc::new( vec![] ), 0, 0 ); + assert!( !bare.is_framed() ); + let keyed = Button::<()>::new_icon( Arc::new( vec![] ), 0, 0 ).width( 64.0 ).height( 64.0 ); + assert!( keyed.is_framed() ); + let tall = Button::<()>::new_icon( Arc::new( vec![] ), 0, 0 ).height( 48.0 ); + assert!( tall.is_framed() ); +} + #[ test ] fn focusable_builder_disables_focus() { diff --git a/src/widget/element.rs b/src/widget/element.rs index 1b57a66..c9ea508 100644 --- a/src/widget/element.rs +++ b/src/widget/element.rs @@ -7,7 +7,7 @@ use crate::render::Canvas; use super::{ anchored_overlay, button, carousel, checkbox, container, external, flex, image, list_item, pressable, progress_bar, radio, rich_text, scroll, separator, - slider, spinner, text, text_edit, toggle, viewport, vslider, window_button, + pattern_grid, slider, spinner, text, text_edit, toggle, viewport, vslider, window_button, }; use super::handlers::WidgetHandlers; use super::MapFn; @@ -29,6 +29,7 @@ pub enum Element WrapGrid( crate::layout::wrap_grid::WrapGrid ), Slider( slider::Slider ), VSlider( vslider::VSlider ), + PatternGrid( pattern_grid::PatternGrid ), Toggle( toggle::Toggle ), Separator( separator::Separator ), ProgressBar( progress_bar::ProgressBar ), @@ -64,6 +65,7 @@ impl Element 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::PatternGrid( g ) => g.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, canvas ), @@ -108,6 +110,7 @@ impl Element Element::WrapGrid( _ ) => {} Element::Slider( s ) => s.draw( canvas, rect, focused ), Element::VSlider( s ) => s.draw( canvas, rect, focused ), + Element::PatternGrid( g ) => g.draw( canvas, rect, focused ), Element::Container( _ ) => {} Element::Toggle( t ) => t.draw( canvas, rect, focused ), Element::Separator( s ) => s.draw( canvas, rect ), @@ -193,7 +196,7 @@ impl Element { Element::Button( b ) => b.focusable, Element::TextEdit( t ) => !t.read_only, - Element::Slider( _ ) | Element::VSlider( _ ) => true, + Element::Slider( _ ) | Element::VSlider( _ ) | Element::PatternGrid( _ ) => true, Element::Toggle( _ ) | Element::Checkbox( _ ) | Element::Radio( _ ) | Element::ListItem( _ ) => true, Element::WindowButton( b ) => b.focusable, _ => false, @@ -237,6 +240,7 @@ impl Element // set, so the static value here is just the hover state. Element::Slider( _ ) => Pointer, Element::VSlider( _ ) => Pointer, + Element::PatternGrid( _ ) => Pointer, _ => Default, } } @@ -322,6 +326,15 @@ impl Element password_toggle_msg: t.password_toggle.as_ref().map( |( _, m )| m.clone() ), } } + Element::PatternGrid( g ) => + { + WidgetHandlers::PatternGrid + { + on_node: g.on_node.clone(), + on_release: g.on_release.clone(), + snap_px: pattern_grid::snap_design_px(), + } + } Element::Slider( s ) => { WidgetHandlers::Slider @@ -412,6 +425,7 @@ impl Element 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::PatternGrid( g ) => Element::PatternGrid( g.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 ), diff --git a/src/widget/handlers.rs b/src/widget/handlers.rs index 42c46f6..2edf60d 100644 --- a/src/widget/handlers.rs +++ b/src/widget/handlers.rs @@ -87,6 +87,15 @@ pub enum WidgetHandlers /// same thumb the renderer drew. Unused by the vertical axis. thumb_px: f32, }, + /// A drag target like a slider, but reporting the node under the + /// pointer instead of a value. + PatternGrid + { + on_node: Option Msg>>, + on_release: Option, + /// Snap radius, resolved through the widget-scaling mode at layout time. + snap_px: f32, + }, } impl Drop for WidgetHandlers @@ -157,6 +166,15 @@ impl Clone for WidgetHandlers thumb_px: *thumb_px, } } + WidgetHandlers::PatternGrid { on_node, on_release, snap_px } => + { + WidgetHandlers::PatternGrid + { + on_node: on_node.clone(), + on_release: on_release.clone(), + snap_px: *snap_px, + } + } } } } @@ -180,11 +198,46 @@ impl WidgetHandlers WidgetHandlers::ListItem { on_press } => on_press.is_none(), WidgetHandlers::WindowButton { on_press } => on_press.is_none(), WidgetHandlers::Slider { on_change, .. } => on_change.is_none(), + WidgetHandlers::PatternGrid { on_node, .. } => on_node.is_none(), WidgetHandlers::TextEdit { on_change, on_submit, .. } => on_change.is_none() && on_submit.is_none(), WidgetHandlers::None => true, } } + /// Sliders and pattern grids both own the pointer for the whole drag. + pub fn is_drag_target( &self ) -> bool + { + matches!( self, WidgetHandlers::Slider { .. } | WidgetHandlers::PatternGrid { .. } ) + } + + /// The message a pattern grid fires for the node under `pos`, if any. + pub fn pattern_node_msg( &self, rect: Rect, pos: Point ) -> Option + { + match self + { + WidgetHandlers::PatternGrid { on_node: Some( f ), snap_px, .. } => + super::pattern_grid::node_at( rect, pos, *snap_px ).map( |node| f( node ) ), + _ => None, + } + } + + pub fn pattern_release_msg( &self ) -> Option + { + match self + { + WidgetHandlers::PatternGrid { on_release, .. } => on_release.clone(), + _ => None, + } + } + + pub( crate ) fn set_pattern_snap_px( &mut self, px: f32 ) + { + if let WidgetHandlers::PatternGrid { snap_px, .. } = self + { + *snap_px = px; + } + } + /// `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 diff --git a/src/widget/mod.rs b/src/widget/mod.rs index 1717495..f0df29a 100755 --- a/src/widget/mod.rs +++ b/src/widget/mod.rs @@ -31,7 +31,7 @@ //! * **Stateful binary controls**: [`toggle::Toggle`], //! [`checkbox::Checkbox`], [`radio::Radio`]. //! * **Continuous controls**: [`slider::Slider`], [`vslider::VSlider`], -//! [`progress_bar::ProgressBar`]. +//! [`progress_bar::ProgressBar`], [`pattern_grid::PatternGrid`]. //! * **Text**: [`text::Text`], [`text_edit::TextEdit`]. //! * **Images / decoration**: [`image::Image`], [`separator::Separator`], //! [`container::Container`]. @@ -69,6 +69,7 @@ pub mod scroll; pub mod viewport; pub mod slider; pub mod vslider; +pub mod pattern_grid; pub mod toggle; pub mod separator; pub mod progress_bar; diff --git a/src/widget/pattern_grid/mod.rs b/src/widget/pattern_grid/mod.rs new file mode 100644 index 0000000..443f5ad --- /dev/null +++ b/src/widget/pattern_grid/mod.rs @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! A 3×3 unlock-pattern grid. The widget is stateless: the app owns the +//! visited node list and passes it back in for drawing. A press or a drag +//! that reaches a node fires `on_node( index )` with the node's index, +//! 0 to 8 row-major; the app decides whether to append it. Release fires +//! `on_release`. + +use std::sync::Arc; +use crate::types::{ Color, Point, Rect }; +use crate::render::Canvas; +use super::Element; + +mod theme; +#[ cfg( test ) ] +mod tests; + +pub const NODES: u8 = 9; + +pub struct PatternGrid +{ + pub( crate ) path: Vec, + pub( crate ) on_node: Option Msg>>, + pub( crate ) on_release: Option, + pub( crate ) hide_stroke: bool, + pub( crate ) error: bool, +} + +impl PatternGrid +{ + pub fn new( path: Vec ) -> Self + { + Self { path, on_node: None, on_release: None, hide_stroke: false, error: false } + } + + pub fn on_node( mut self, f: impl Fn( u8 ) -> Msg + 'static ) -> Self + { + self.on_node = Some( Arc::new( f ) ); + self + } + + pub fn on_release( mut self, msg: Msg ) -> Self + { + self.on_release = Some( msg ); + self + } + + /// Draw only the nodes, never the stroke: against shoulder surfing. + pub fn hide_stroke( mut self, hide: bool ) -> Self + { + self.hide_stroke = hide; + self + } + + /// Paint the current path in the danger colour. + pub fn error( mut self, error: bool ) -> Self + { + self.error = error; + self + } + + pub( crate ) fn map_msg( self, f: &super::MapFn ) -> PatternGrid + where + U: Clone + 'static, + Msg: 'static, + { + let on_node = self.on_node.map( |old| -> Arc U> + { + let mapper = Arc::clone( f ); + Arc::new( move |n| ( *mapper )( ( *old )( n ) ) ) + } ); + PatternGrid + { + path: self.path, + on_node, + on_release: self.on_release.map( |m| ( *f )( m ) ), + hide_stroke: self.hide_stroke, + error: self.error, + } + } + + pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 ) + { + let size = canvas.geom_px( theme::SIZE ).min( max_width ); + ( size, size ) + } + + pub fn draw( &self, canvas: &mut Canvas, rect: Rect, _focused: bool ) + { + let palette = crate::theme::palette(); + let active = if self.error { palette.danger } else { palette.accent }; + let node_r = canvas.geom_px( theme::NODE_RADIUS ); + let dot_r = canvas.geom_px( theme::DOT_RADIUS ); + let stroke_w = canvas.geom_px( theme::STROKE_W ); + + if !self.hide_stroke + { + for pair in self.path.windows( 2 ) + { + let a = node_center( rect, pair[ 0 ] ); + let b = node_center( rect, pair[ 1 ] ); + canvas.draw_line( a.x, a.y, b.x, b.y, active, stroke_w ); + } + } + + for node in 0..NODES + { + let c = node_center( rect, node ); + let visited = self.path.contains( &node ); + if visited + { + let ring = Color { a: active.a * theme::RING_ALPHA, ..active }; + circle( canvas, c, node_r, ring ); + } + circle( canvas, c, dot_r, if visited { active } else { palette.text_secondary } ); + } + } +} + +fn circle( canvas: &mut Canvas, center: Point, radius: f32, color: Color ) +{ + let rect = Rect { x: center.x - radius, y: center.y - radius, width: radius * 2.0, height: radius * 2.0 }; + canvas.fill_rect( rect, color, radius ); +} + +/// Centre of node `index` (0 to 8, row-major) inside `rect`. +pub fn node_center( rect: Rect, index: u8 ) -> Point +{ + let col = ( index % 3 ) as f32; + let row = ( index / 3 ) as f32; + let side = rect.width.min( rect.height ); + let ox = rect.x + ( rect.width - side ) / 2.0; + let oy = rect.y + ( rect.height - side ) / 2.0; + let cell = side / 3.0; + Point { x: ox + cell * ( col + 0.5 ), y: oy + cell * ( row + 0.5 ) } +} + +/// The node under `pos`, if within the snap radius of one. +pub fn node_at( rect: Rect, pos: Point, snap_radius: f32 ) -> Option +{ + ( 0..NODES ).find( |&node| + { + let c = node_center( rect, node ); + ( pos.x - c.x ).hypot( pos.y - c.y ) <= snap_radius + } ) +} + +/// Snap radius in design pixels, resolved by the input path through the canvas density. +pub fn snap_design_px() -> f32 +{ + theme::SNAP_RADIUS +} + +pub fn pattern_grid( path: Vec ) -> PatternGrid +{ + PatternGrid::new( path ) +} + +impl From> for Element +{ + fn from( g: PatternGrid ) -> Self + { + Element::PatternGrid( g ) + } +} diff --git a/src/widget/pattern_grid/tests.rs b/src/widget/pattern_grid/tests.rs new file mode 100644 index 0000000..c05fd88 --- /dev/null +++ b/src/widget/pattern_grid/tests.rs @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use super::*; +use crate::widget::Element; + +fn square() -> Rect +{ + Rect { x: 10.0, y: 20.0, width: 300.0, height: 300.0 } +} + +#[ test ] +fn nodes_sit_at_cell_centres() +{ + let r = square(); + assert_eq!( node_center( r, 0 ), Point { x: 60.0, y: 70.0 } ); + assert_eq!( node_center( r, 4 ), Point { x: 160.0, y: 170.0 } ); + assert_eq!( node_center( r, 8 ), Point { x: 260.0, y: 270.0 } ); +} + +#[ test ] +fn a_wide_rect_centres_the_grid() +{ + let r = Rect { x: 0.0, y: 0.0, width: 500.0, height: 300.0 }; + assert_eq!( node_center( r, 1 ), Point { x: 250.0, y: 50.0 } ); +} + +#[ test ] +fn node_at_snaps_within_the_radius_only() +{ + let r = square(); + assert_eq!( node_at( r, Point { x: 65.0, y: 66.0 }, 30.0 ), Some( 0 ) ); + assert_eq!( node_at( r, Point { x: 110.0, y: 70.0 }, 30.0 ), None ); + assert_eq!( node_at( r, Point { x: 255.0, y: 275.0 }, 30.0 ), Some( 8 ) ); +} + +#[ test ] +fn builders_keep_the_path_and_flags() +{ + let g = pattern_grid::<()>( vec![ 0, 1, 2 ] ).hide_stroke( true ).error( true ); + assert_eq!( g.path, vec![ 0, 1, 2 ] ); + assert!( g.hide_stroke && g.error ); + assert!( g.on_node.is_none() && g.on_release.is_none() ); +} + +#[ test ] +fn handlers_report_the_node_under_the_pointer() +{ + #[ derive( Clone, Debug, PartialEq ) ] + enum Msg { Node( u8 ), Done } + + let element: Element = pattern_grid( Vec::new() ).on_node( Msg::Node ).on_release( Msg::Done ).into(); + let mut handlers = element.handlers(); + handlers.set_pattern_snap_px( 30.0 ); + assert!( handlers.is_drag_target() ); + assert!( !handlers.is_disabled() ); + assert_eq!( handlers.pattern_node_msg( square(), Point { x: 160.0, y: 170.0 } ), Some( Msg::Node( 4 ) ) ); + assert_eq!( handlers.pattern_node_msg( square(), Point { x: 0.0, y: 0.0 } ), None ); + assert_eq!( handlers.pattern_release_msg(), Some( Msg::Done ) ); +} + +#[ test ] +fn map_msg_rewraps_both_callbacks() +{ + #[ derive( Clone, Debug, PartialEq ) ] + enum Inner { Node( u8 ), Done } + #[ derive( Clone, Debug, PartialEq ) ] + enum Outer { Wrapped( Inner ) } + + let f: crate::widget::MapFn = Arc::new( Outer::Wrapped ); + let g = pattern_grid( Vec::new() ).on_node( Inner::Node ).on_release( Inner::Done ).map_msg( &f ); + assert_eq!( g.on_node.as_ref().map( |cb| cb( 3 ) ), Some( Outer::Wrapped( Inner::Node( 3 ) ) ) ); + assert_eq!( g.on_release, Some( Outer::Wrapped( Inner::Done ) ) ); +} diff --git a/src/widget/pattern_grid/theme.rs b/src/widget/pattern_grid/theme.rs new file mode 100644 index 0000000..f6e5e2b --- /dev/null +++ b/src/widget/pattern_grid/theme.rs @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +pub const SIZE: f32 = 264.0; +pub const DOT_RADIUS: f32 = 7.0; +pub const NODE_RADIUS: f32 = 22.0; +pub const STROKE_W: f32 = 6.0; +pub const SNAP_RADIUS: f32 = 30.0; +pub const RING_ALPHA: f32 = 0.25;