widgets, input: PatternGrid for pattern unlock, framed icon buttons, and text keys that bubble past a focused non-text widget; Debian 0.3.0-2
PatternGrid is a 3×3 drag widget for the unlock pattern. It is stateless: the app owns the visited nodes and passes them back in for drawing; a press or a drag that reaches a node within the snap radius fires on_node with the index, 0 to 8 row-major, and release fires on_release, so the crossing and minimum-length rules stay in the app. Geometry lives in widget/pattern_grid/theme.rs (size, dot and node radii, stroke width, snap radius, ring alpha), and the snap radius is resolved through the widget-scaling mode at layout time like the slider thumb (set_pattern_snap_px). The gesture machine reuses the slider's drag ownership: WidgetHandlers::is_drag_target covers both, so hover painting, pointer capture and release behave the same, and hover_affects_paint no longer special-cases sliders. The a11y tree maps it to GenericContainer, since a drawn gesture has no assistive equivalent and the app must offer another method. Exported as pattern_grid / PatternGrid, documented in widgets.md, theming.md and architecture.md, with tests for node centres, a wide rect, snapping, the builders, the handler snapshot and map_msg, and a Security tab in examples/widgets.rs that shows the drawn path. Icon buttons given a width or height are framed keys: preferred_size honours the explicit size instead of the icon size, and drawing paints the variant's background, border and focus ring, shared with text buttons through paint_frame, then centres the icon. An icon button without a size keeps the circular hover overlay it had. This is what lets the PIN keypad's backspace and OK keys sit in the same circles as the digits. Keyboard routing now matches what App::on_key documents. handle_key_default, Backspace, Delete and Space checked focused_idx.is_some() and sent the key into text insertion whenever any widget had focus, which is a no-op on a button, so a focused button or pressable swallowed typed characters. focused_text_input replaces the five copies of that check; characters, Backspace and Delete stay in a focused text input and reach the app otherwise, Return and Space still go to the focused widget's submit or press message when it has one, and the arrow, Home and End handlers use the same helper. The lockscreen relies on this to take PIN digits with its dots row focused. debian/changelog: 0.3.0-2.
This commit is contained in:
Vendored
+8
@@ -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 <pedro.echanove@liberux.net> Wed, 09 Sep 2026 12:00:00 +0200
|
||||||
|
|
||||||
ltk (0.3.0-1) unstable; urgency=low
|
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/<app_id>/ (session id, clean-exit marker, App::save_state bytes; saved periodically, on close and on SIGTERM/SIGINT), clean exit on signals.
|
* New upstream release: xdg-session-management-v1 client support (in-tree wayland-scanner bindings under protocols/), runtime session persistence in $XDG_STATE_HOME/<app_id>/ (session id, clean-exit marker, App::save_state bytes; saved periodically, on close and on SIGTERM/SIGINT), clean exit on signals.
|
||||||
|
|||||||
@@ -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_swipe_up`, `on_swipe_down`, `on_swipe_progress`, `on_swipe_down_progress` (follow-the-finger).
|
||||||
- `on_tap` — taps that miss every widget.
|
- `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.
|
- `swipe_threshold`, `swipe_down_threshold` — gesture sensitivity.
|
||||||
|
|
||||||
**Implement for animations and focus**
|
**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.
|
**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:
|
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:
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -428,11 +428,11 @@ of them falls back to embedded defaults.
|
|||||||
| `surface` | card / panel surface | color |
|
| `surface` | card / panel surface | color |
|
||||||
| `surface-alt` | text-input field background | color |
|
| `surface-alt` | text-input field background | color |
|
||||||
| `text-primary` | regular text colour | color |
|
| `text-primary` | regular text colour | color |
|
||||||
| `text-secondary` | muted / placeholder text | color |
|
| `text-secondary` | muted / placeholder text, pattern-grid idle dots | color |
|
||||||
| `accent` | toggle on, slider fill, focus ring | color |
|
| `accent` | toggle on, slider fill, focus ring, pattern-grid stroke and visited dots | color |
|
||||||
| `divider` | separator, toggle off, list item border | color |
|
| `divider` | separator, toggle off, list item border | color |
|
||||||
| `icon` | icon-button glyph colour | 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 |
|
| `danger-bg` | soft fill behind error states | color |
|
||||||
|
|
||||||
The default theme defines `danger` but not `danger-bg`, which
|
The default theme defines `danger` but not `danger-bg`, which
|
||||||
|
|||||||
+52
-1
@@ -16,7 +16,7 @@ patterns built from these widgets, see [`docs/cookbook.md`](./cookbook.md).
|
|||||||
- [Stateful binary controls](#stateful-binary-controls)
|
- [Stateful binary controls](#stateful-binary-controls)
|
||||||
- [`toggle`](#toggle) · [`checkbox`](#checkbox) · [`radio`](#radio)
|
- [`toggle`](#toggle) · [`checkbox`](#checkbox) · [`radio`](#radio)
|
||||||
- [Continuous controls](#continuous-controls)
|
- [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 input and display](#text-input-and-display)
|
||||||
- [`text`](#text) · [`text_edit`](#text_edit) · [`rich_text`](#rich_text)
|
- [`text`](#text) · [`text_edit`](#text_edit) · [`rich_text`](#rich_text)
|
||||||
- [Decoration and chrome](#decoration-and-chrome)
|
- [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
|
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.
|
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<Vec<u8>>, w: u32, h: u32 ) -> Element<Msg> {
|
||||||
|
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)
|
**See also**: [`button`](#button), [`window_button`](#window_button)
|
||||||
(specialised icon button for window decorations).
|
(specialised icon button for window decorations).
|
||||||
|
|
||||||
@@ -324,6 +343,38 @@ progress_bar( self.download_fraction )
|
|||||||
**See also**: [`spinner`](#spinner) for indeterminate operations
|
**See also**: [`spinner`](#spinner) for indeterminate operations
|
||||||
without a known fraction.
|
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<u8> }
|
||||||
|
# impl App { fn _ex( &self ) -> PatternGrid<Msg> {
|
||||||
|
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
|
## Text input and display
|
||||||
|
|||||||
+39
-4
@@ -4,7 +4,7 @@ use ltk::{
|
|||||||
App, Element, Keysym, WidgetId,
|
App, Element, Keysym, WidgetId,
|
||||||
column, row, text, text_edit, spacer, separator, slider,
|
column, row, text, text_edit, spacer, separator, slider,
|
||||||
toggle, checkbox, radio, progress_bar, scroll,
|
toggle, checkbox, radio, progress_bar, scroll,
|
||||||
spinner, tabs,
|
spinner, tabs, container, pattern_grid,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[ derive( Clone ) ]
|
#[ derive( Clone ) ]
|
||||||
@@ -21,6 +21,8 @@ enum Msg
|
|||||||
SliderChanged( f32 ),
|
SliderChanged( f32 ),
|
||||||
SelectTab( usize ),
|
SelectTab( usize ),
|
||||||
NoteChanged( String ),
|
NoteChanged( String ),
|
||||||
|
PatternNode( u8 ),
|
||||||
|
PatternReleased,
|
||||||
Tick,
|
Tick,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +38,8 @@ struct WidgetsApp
|
|||||||
volume: f32,
|
volume: f32,
|
||||||
tab: usize,
|
tab: usize,
|
||||||
note: String,
|
note: String,
|
||||||
|
pattern: Vec<u8>,
|
||||||
|
pattern_done: Option<String>,
|
||||||
started_at: Instant,
|
started_at: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +59,8 @@ impl WidgetsApp
|
|||||||
volume: 0.7,
|
volume: 0.7,
|
||||||
tab: 0,
|
tab: 0,
|
||||||
note: String::new(),
|
note: String::new(),
|
||||||
|
pattern: Vec::new(),
|
||||||
|
pattern_done: None,
|
||||||
started_at: Instant::now(),
|
started_at: Instant::now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,7 +87,7 @@ impl App for WidgetsApp
|
|||||||
let title = text( "ltk widgets" ).size( 24.0 ).color( primary ).align_center();
|
let title = text( "ltk widgets" ).size( 24.0 ).color( primary ).align_center();
|
||||||
|
|
||||||
// Segmented tab strip — each tab shows its own page below.
|
// Segmented tab strip — each tab shows its own page below.
|
||||||
let tabs_strip: Element<Msg> = tabs( [ "General", "Audio", "Network" ] )
|
let tabs_strip: Element<Msg> = tabs( [ "General", "Audio", "Network", "Security" ] )
|
||||||
.selected( self.tab )
|
.selected( self.tab )
|
||||||
.on_select( Msg::SelectTab )
|
.on_select( Msg::SelectTab )
|
||||||
.into();
|
.into();
|
||||||
@@ -125,8 +131,20 @@ impl App for WidgetsApp
|
|||||||
.id( WidgetId( "widgets/note" ) )
|
.id( WidgetId( "widgets/note" ) )
|
||||||
.on_change( Msg::NoteChanged );
|
.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.
|
// One page per tab, so selecting a tab visibly swaps the content.
|
||||||
let page: Element<Msg> = match self.tab.min( 2 )
|
let page: Element<Msg> = match self.tab.min( 3 )
|
||||||
{
|
{
|
||||||
0 => column::<Msg>()
|
0 => column::<Msg>()
|
||||||
.padding( 0.0 )
|
.padding( 0.0 )
|
||||||
@@ -145,7 +163,7 @@ impl App for WidgetsApp
|
|||||||
.push( text( prog_label ).size( 13.0 ).color( secondary ) )
|
.push( text( prog_label ).size( 13.0 ).color( secondary ) )
|
||||||
.push( progress_bar( self.progress ) )
|
.push( progress_bar( self.progress ) )
|
||||||
.into(),
|
.into(),
|
||||||
_ => column::<Msg>()
|
2 => column::<Msg>()
|
||||||
.padding( 0.0 )
|
.padding( 0.0 )
|
||||||
.spacing( 12.0 )
|
.spacing( 12.0 )
|
||||||
.push( text( "Spinner" ).size( 13.0 ).color( secondary ) )
|
.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( text( "Multiline text edit" ).size( 13.0 ).color( secondary ) )
|
||||||
.push( textarea )
|
.push( textarea )
|
||||||
.into(),
|
.into(),
|
||||||
|
_ => column::<Msg>()
|
||||||
|
.padding( 0.0 )
|
||||||
|
.spacing( 12.0 )
|
||||||
|
.push( text( pattern_label ).size( 13.0 ).color( secondary ) )
|
||||||
|
.push( grid )
|
||||||
|
.into(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let content = column::<Msg>()
|
let content = column::<Msg>()
|
||||||
@@ -189,6 +213,17 @@ impl App for WidgetsApp
|
|||||||
Msg::SliderChanged( v ) => self.volume = v,
|
Msg::SliderChanged( v ) => self.volume = v,
|
||||||
Msg::SelectTab( i ) => self.tab = i,
|
Msg::SelectTab( i ) => self.tab = i,
|
||||||
Msg::NoteChanged( s ) => self.note = s,
|
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<String> = self.pattern.iter().map( |n| n.to_string() ).collect();
|
||||||
|
self.pattern_done = Some( drawn.join( "-" ) );
|
||||||
|
self.pattern.clear();
|
||||||
|
}
|
||||||
Msg::Tick => {}
|
Msg::Tick => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ fn node_for<Msg: Clone>(
|
|||||||
WidgetHandlers::ListItem { .. } => Role::ListItem,
|
WidgetHandlers::ListItem { .. } => Role::ListItem,
|
||||||
WidgetHandlers::WindowButton { .. } => Role::Button,
|
WidgetHandlers::WindowButton { .. } => Role::Button,
|
||||||
WidgetHandlers::Slider { .. } => Role::Slider,
|
WidgetHandlers::Slider { .. } => Role::Slider,
|
||||||
|
WidgetHandlers::PatternGrid { .. } => Role::GenericContainer,
|
||||||
WidgetHandlers::TextEdit { multiline, .. } =>
|
WidgetHandlers::TextEdit { multiline, .. } =>
|
||||||
if *multiline { Role::MultilineTextInput } else { Role::TextInput },
|
if *multiline { Role::MultilineTextInput } else { Role::TextInput },
|
||||||
WidgetHandlers::None => Role::GenericContainer,
|
WidgetHandlers::None => Role::GenericContainer,
|
||||||
|
|||||||
@@ -461,6 +461,10 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
|
|||||||
{
|
{
|
||||||
handlers.set_slider_thumb_px( crate::widget::slider::resolved_thumb_px( canvas ) );
|
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
|
ctx.widget_rects.push( LaidOutWidget
|
||||||
{
|
{
|
||||||
rect,
|
rect,
|
||||||
|
|||||||
@@ -255,6 +255,9 @@ impl<Msg: Clone> GestureState<Msg>
|
|||||||
self.dragging_slider = Some( idx );
|
self.dragging_slider = Some( idx );
|
||||||
let value = w.handlers.slider_value_from_pos( w.rect, pos );
|
let value = w.handlers.slider_value_from_pos( w.rect, pos );
|
||||||
initial_slider_msg = w.handlers.slider_change_msg( value );
|
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 );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,9 +318,14 @@ impl<Msg: Clone> GestureState<Msg>
|
|||||||
if let Some( slider_idx ) = self.dragging_slider
|
if let Some( slider_idx ) = self.dragging_slider
|
||||||
{
|
{
|
||||||
if let Some( w ) = find_widget( widget_rects, slider_idx )
|
if let Some( w ) = find_widget( widget_rects, slider_idx )
|
||||||
|
{
|
||||||
|
let msg = if w.handlers.is_slider()
|
||||||
{
|
{
|
||||||
let value = w.handlers.slider_value_from_pos( w.rect, pos );
|
let value = w.handlers.slider_value_from_pos( w.rect, pos );
|
||||||
let msg = w.handlers.slider_change_msg( value );
|
w.handlers.slider_change_msg( value )
|
||||||
|
} else {
|
||||||
|
w.handlers.pattern_node_msg( w.rect, pos )
|
||||||
|
};
|
||||||
return MoveOutcome::Slider { msg };
|
return MoveOutcome::Slider { msg };
|
||||||
}
|
}
|
||||||
return MoveOutcome::Idle;
|
return MoveOutcome::Idle;
|
||||||
@@ -512,9 +520,15 @@ impl<Msg: Clone> GestureState<Msg>
|
|||||||
self.scroll_drag_started = false;
|
self.scroll_drag_started = false;
|
||||||
self.start = None;
|
self.start = None;
|
||||||
if let Some( w ) = find_widget( widget_rects, slider_idx )
|
if let Some( w ) = find_widget( widget_rects, slider_idx )
|
||||||
|
{
|
||||||
|
let msg = if w.handlers.is_slider()
|
||||||
{
|
{
|
||||||
let value = w.handlers.slider_value_from_pos( w.rect, pos );
|
let value = w.handlers.slider_value_from_pos( w.rect, pos );
|
||||||
if let Some( msg ) = w.handlers.slider_release_msg( value )
|
w.handlers.slider_release_msg( value )
|
||||||
|
} else {
|
||||||
|
w.handlers.pattern_release_msg()
|
||||||
|
};
|
||||||
|
if let Some( msg ) = msg
|
||||||
{
|
{
|
||||||
events.push( ReleaseEvent::PushMsg( msg ) );
|
events.push( ReleaseEvent::PushMsg( msg ) );
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,19 @@ use crate::tree::find_handlers;
|
|||||||
|
|
||||||
impl<A: App> AppData<A>
|
impl<A: App> AppData<A>
|
||||||
{
|
{
|
||||||
|
/// 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 )
|
pub( super ) fn handle_key_backspace( &mut self, focus: SurfaceFocus, event: &KeyEvent )
|
||||||
{
|
{
|
||||||
let focused = self.surface( focus ).focused_idx;
|
if self.focused_text_input( focus ) { self.handle_backspace( focus ); }
|
||||||
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 )
|
else if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed )
|
||||||
{
|
{
|
||||||
self.pending_msgs.push( msg );
|
self.pending_msgs.push( msg );
|
||||||
@@ -21,11 +30,9 @@ impl<A: App> AppData<A>
|
|||||||
|
|
||||||
pub( super ) fn handle_key_delete( &mut self, focus: SurfaceFocus, event: &KeyEvent )
|
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
|
// Supr / Forward-Delete: same shape as Backspace but
|
||||||
// removes the char *after* the cursor. When no widget
|
// removes the char *after* the cursor.
|
||||||
// is focused, the keysym bubbles to the app.
|
if self.focused_text_input( focus ) { self.handle_delete_forward( focus ); }
|
||||||
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 )
|
else if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed )
|
||||||
{
|
{
|
||||||
self.pending_msgs.push( msg );
|
self.pending_msgs.push( msg );
|
||||||
@@ -77,10 +84,7 @@ impl<A: App> AppData<A>
|
|||||||
|
|
||||||
pub( super ) fn handle_key_arrow_vertical( &mut self, focus: SurfaceFocus, event: &KeyEvent )
|
pub( super ) fn handle_key_arrow_vertical( &mut self, focus: SurfaceFocus, event: &KeyEvent )
|
||||||
{
|
{
|
||||||
let focused = self.surface( focus ).focused_idx;
|
let is_text = self.focused_text_input( focus );
|
||||||
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 reverse = event.keysym == Keysym::Up;
|
let reverse = event.keysym == Keysym::Up;
|
||||||
let extend = self.shift_pressed;
|
let extend = self.shift_pressed;
|
||||||
let intercepted = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed );
|
let intercepted = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed );
|
||||||
@@ -104,10 +108,7 @@ impl<A: App> AppData<A>
|
|||||||
|
|
||||||
pub( super ) fn handle_key_arrow_horizontal( &mut self, focus: SurfaceFocus, event: &KeyEvent )
|
pub( super ) fn handle_key_arrow_horizontal( &mut self, focus: SurfaceFocus, event: &KeyEvent )
|
||||||
{
|
{
|
||||||
let focused = self.surface( focus ).focused_idx;
|
let is_text = self.focused_text_input( focus );
|
||||||
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 extend = self.shift_pressed;
|
||||||
let intercepted = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed );
|
let intercepted = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed );
|
||||||
if let Some( msg ) = intercepted
|
if let Some( msg ) = intercepted
|
||||||
@@ -127,36 +128,25 @@ impl<A: App> AppData<A>
|
|||||||
|
|
||||||
pub( super ) fn handle_key_home( &mut self, focus: SurfaceFocus )
|
pub( super ) fn handle_key_home( &mut self, focus: SurfaceFocus )
|
||||||
{
|
{
|
||||||
let focused = self.surface( focus ).focused_idx;
|
if self.focused_text_input( focus ) { self.handle_cursor_home( focus, self.shift_pressed ); }
|
||||||
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 ); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub( super ) fn handle_key_end( &mut self, focus: SurfaceFocus )
|
pub( super ) fn handle_key_end( &mut self, focus: SurfaceFocus )
|
||||||
{
|
{
|
||||||
let focused = self.surface( focus ).focused_idx;
|
if self.focused_text_input( focus ) { self.handle_cursor_end( focus, self.shift_pressed ); }
|
||||||
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 ); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub( super ) fn handle_key_space( &mut self, focus: SurfaceFocus, event: &KeyEvent )
|
pub( super ) fn handle_key_space( &mut self, focus: SurfaceFocus, event: &KeyEvent )
|
||||||
{
|
{
|
||||||
let focused = self.surface( focus ).focused_idx;
|
let ss = self.surface( focus );
|
||||||
if let Some( idx ) = focused
|
let press = ss.focused_idx
|
||||||
{
|
.and_then( |idx| find_handlers( &ss.frame.widget_rects, idx ) )
|
||||||
let press = find_handlers( &self.surface( focus ).frame.widget_rects, idx )
|
|
||||||
.and_then( |h| h.press_msg() );
|
.and_then( |h| h.press_msg() );
|
||||||
if let Some( msg ) = press
|
if let Some( msg ) = press
|
||||||
{
|
{
|
||||||
self.pending_msgs.push( msg );
|
self.pending_msgs.push( msg );
|
||||||
} else {
|
} else if self.focused_text_input( focus ) {
|
||||||
// Focused widget is a TextEdit (or has no on_press) — insert space as text
|
|
||||||
self.handle_text_insert( 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 )
|
} else if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed )
|
||||||
{
|
{
|
||||||
self.pending_msgs.push( msg );
|
self.pending_msgs.push( msg );
|
||||||
@@ -165,14 +155,13 @@ impl<A: App> AppData<A>
|
|||||||
|
|
||||||
pub( super ) fn handle_key_default( &mut self, focus: SurfaceFocus, event: &KeyEvent )
|
pub( super ) fn handle_key_default( &mut self, focus: SurfaceFocus, event: &KeyEvent )
|
||||||
{
|
{
|
||||||
let focused = self.surface( focus ).focused_idx;
|
|
||||||
if self.ctrl_pressed
|
if self.ctrl_pressed
|
||||||
{
|
{
|
||||||
if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, true, self.shift_pressed )
|
if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, true, self.shift_pressed )
|
||||||
{
|
{
|
||||||
self.pending_msgs.push( msg );
|
self.pending_msgs.push( msg );
|
||||||
}
|
}
|
||||||
} else if focused.is_some()
|
} else if self.focused_text_input( focus )
|
||||||
{
|
{
|
||||||
if let Some( txt ) = event.utf8.clone()
|
if let Some( txt ) = event.utf8.clone()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -37,6 +37,6 @@ pub( crate ) fn hover_affects_paint<Msg: Clone>(
|
|||||||
) -> bool
|
) -> bool
|
||||||
{
|
{
|
||||||
idx.and_then( |i| find_handlers( widget_rects, i ) )
|
idx.and_then( |i| find_handlers( widget_rects, i ) )
|
||||||
.map( |h| !h.is_slider() )
|
.map( |h| !h.is_drag_target() )
|
||||||
.unwrap_or( false )
|
.unwrap_or( false )
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -88,7 +88,7 @@
|
|||||||
//! - **Stateful binary controls** — [`toggle()`], [`checkbox()`],
|
//! - **Stateful binary controls** — [`toggle()`], [`checkbox()`],
|
||||||
//! [`radio()`].
|
//! [`radio()`].
|
||||||
//! - **Continuous controls** — [`slider()`], [`vslider()`],
|
//! - **Continuous controls** — [`slider()`], [`vslider()`],
|
||||||
//! [`progress_bar()`].
|
//! [`progress_bar()`], [`pattern_grid()`].
|
||||||
//! - **Text** — [`text()`], [`text_edit()`].
|
//! - **Text** — [`text()`], [`text_edit()`].
|
||||||
//! - **Decoration** — [`container()`], [`separator()`], [`img_widget()`].
|
//! - **Decoration** — [`container()`], [`separator()`], [`img_widget()`].
|
||||||
//! - **Clipping wrappers** — [`scroll()`], [`viewport()`], [`flex()`].
|
//! - **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::button::ButtonVariant;
|
||||||
pub use widget::slider::{ Slider, slider, SliderAxis };
|
pub use widget::slider::{ Slider, slider, SliderAxis };
|
||||||
pub use widget::vslider::{ VSlider, vslider };
|
pub use widget::vslider::{ VSlider, vslider };
|
||||||
|
pub use widget::pattern_grid::{ PatternGrid, pattern_grid };
|
||||||
pub use widget::text::TextAlign;
|
pub use widget::text::TextAlign;
|
||||||
pub use widget::toggle::{ Toggle, toggle };
|
pub use widget::toggle::{ Toggle, toggle };
|
||||||
pub use widget::separator::{ Separator, separator };
|
pub use widget::separator::{ Separator, separator };
|
||||||
@@ -426,6 +427,7 @@ pub mod widgets
|
|||||||
// Continuous controls
|
// Continuous controls
|
||||||
Slider, slider, SliderAxis,
|
Slider, slider, SliderAxis,
|
||||||
VSlider, vslider,
|
VSlider, vslider,
|
||||||
|
PatternGrid, pattern_grid,
|
||||||
ProgressBar, progress_bar,
|
ProgressBar, progress_bar,
|
||||||
// Composite picker
|
// Composite picker
|
||||||
Combo, ComboState, combo,
|
Combo, ComboState, combo,
|
||||||
|
|||||||
+46
-25
@@ -371,11 +371,25 @@ impl<Msg: Clone> Button<Msg>
|
|||||||
}
|
}
|
||||||
ButtonContent::Icon { .. } =>
|
ButtonContent::Icon { .. } =>
|
||||||
{
|
{
|
||||||
|
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 );
|
let s = self.resolved_icon_size( canvas ).min( max_width );
|
||||||
( s, s )
|
( 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`.
|
/// Draw the button into `canvas` at `rect`.
|
||||||
///
|
///
|
||||||
@@ -398,13 +412,26 @@ impl<Msg: Clone> Button<Msg>
|
|||||||
|
|
||||||
fn draw_text_button( &self, canvas: &mut Canvas, rect: Rect, focused: bool, label: &str )
|
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 fs = self.label_font_size( canvas );
|
||||||
let text_y = rect.y + (rect.height + fs) / 2.0 - 2.0;
|
let text_y = rect.y + (rect.height + fs) / 2.0 - 2.0;
|
||||||
// `rect` is what the layout granted, which is not always the
|
// `rect` is what the layout granted, which is not always the
|
||||||
// preferred width: a row that cannot fit its children shrinks them.
|
// 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
|
match self.variant
|
||||||
{
|
{
|
||||||
ButtonVariant::Primary =>
|
ButtonVariant::Primary =>
|
||||||
@@ -427,14 +454,7 @@ impl<Msg: Clone> Button<Msg>
|
|||||||
theme::RADIUS + theme::FOCUS_W + 2.0,
|
theme::RADIUS + theme::FOCUS_W + 2.0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let text_w = canvas.measure_text( &label, fs );
|
text_c
|
||||||
canvas.draw_text(
|
|
||||||
&label,
|
|
||||||
rect.x + (rect.width - text_w) / 2.0,
|
|
||||||
text_y,
|
|
||||||
fs,
|
|
||||||
text_c,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
ButtonVariant::Secondary =>
|
ButtonVariant::Secondary =>
|
||||||
{
|
{
|
||||||
@@ -453,14 +473,7 @@ impl<Msg: Clone> Button<Msg>
|
|||||||
theme::RADIUS + theme::FOCUS_W + 2.0,
|
theme::RADIUS + theme::FOCUS_W + 2.0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let text_w = canvas.measure_text( &label, fs );
|
text_c
|
||||||
canvas.draw_text(
|
|
||||||
&label,
|
|
||||||
rect.x + (rect.width - text_w) / 2.0,
|
|
||||||
text_y,
|
|
||||||
fs,
|
|
||||||
text_c,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
ButtonVariant::Tertiary =>
|
ButtonVariant::Tertiary =>
|
||||||
{
|
{
|
||||||
@@ -470,14 +483,7 @@ impl<Msg: Clone> Button<Msg>
|
|||||||
let ring = rect.expand( 2.0 );
|
let ring = rect.expand( 2.0 );
|
||||||
canvas.stroke_rect( ring, theme::focus_color(), theme::FOCUS_W, theme::RADIUS );
|
canvas.stroke_rect( ring, theme::focus_color(), theme::FOCUS_W, theme::RADIUS );
|
||||||
}
|
}
|
||||||
let text_w = canvas.measure_text( &label, fs );
|
text_c
|
||||||
canvas.draw_text(
|
|
||||||
&label,
|
|
||||||
rect.x + (rect.width - text_w) / 2.0,
|
|
||||||
text_y,
|
|
||||||
fs,
|
|
||||||
text_c,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -494,6 +500,21 @@ impl<Msg: Clone> Button<Msg>
|
|||||||
img_h: u32,
|
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
|
// Semi-transparent circular overlay behind the icon for hover / press feedback
|
||||||
let circle_pad = 8.0_f32;
|
let circle_pad = 8.0_f32;
|
||||||
let r = rect.width.min( rect.height ) / 2.0 + circle_pad;
|
let r = rect.width.min( rect.height ) / 2.0 + circle_pad;
|
||||||
|
|||||||
@@ -17,6 +17,17 @@ fn icon_button_focusable_by_default()
|
|||||||
assert!( b.focusable );
|
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 ]
|
#[ test ]
|
||||||
fn focusable_builder_disables_focus()
|
fn focusable_builder_disables_focus()
|
||||||
{
|
{
|
||||||
|
|||||||
+16
-2
@@ -7,7 +7,7 @@ use crate::render::Canvas;
|
|||||||
use super::{
|
use super::{
|
||||||
anchored_overlay, button, carousel, checkbox, container, external, flex,
|
anchored_overlay, button, carousel, checkbox, container, external, flex,
|
||||||
image, list_item, pressable, progress_bar, radio, rich_text, scroll, separator,
|
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::handlers::WidgetHandlers;
|
||||||
use super::MapFn;
|
use super::MapFn;
|
||||||
@@ -29,6 +29,7 @@ pub enum Element<Msg: Clone>
|
|||||||
WrapGrid( crate::layout::wrap_grid::WrapGrid<Msg> ),
|
WrapGrid( crate::layout::wrap_grid::WrapGrid<Msg> ),
|
||||||
Slider( slider::Slider<Msg> ),
|
Slider( slider::Slider<Msg> ),
|
||||||
VSlider( vslider::VSlider<Msg> ),
|
VSlider( vslider::VSlider<Msg> ),
|
||||||
|
PatternGrid( pattern_grid::PatternGrid<Msg> ),
|
||||||
Toggle( toggle::Toggle<Msg> ),
|
Toggle( toggle::Toggle<Msg> ),
|
||||||
Separator( separator::Separator ),
|
Separator( separator::Separator ),
|
||||||
ProgressBar( progress_bar::ProgressBar ),
|
ProgressBar( progress_bar::ProgressBar ),
|
||||||
@@ -64,6 +65,7 @@ impl<Msg: Clone> Element<Msg>
|
|||||||
Element::WrapGrid( g ) => g.preferred_size( max_width, canvas ),
|
Element::WrapGrid( g ) => g.preferred_size( max_width, canvas ),
|
||||||
Element::Slider( s ) => s.preferred_size( max_width, canvas ),
|
Element::Slider( s ) => s.preferred_size( max_width, canvas ),
|
||||||
Element::VSlider( 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::Container( c ) => c.preferred_size( max_width, canvas ),
|
||||||
Element::Toggle( t ) => t.preferred_size( max_width, canvas ),
|
Element::Toggle( t ) => t.preferred_size( max_width, canvas ),
|
||||||
Element::Separator( s ) => s.preferred_size( max_width, canvas ),
|
Element::Separator( s ) => s.preferred_size( max_width, canvas ),
|
||||||
@@ -108,6 +110,7 @@ impl<Msg: Clone> Element<Msg>
|
|||||||
Element::WrapGrid( _ ) => {}
|
Element::WrapGrid( _ ) => {}
|
||||||
Element::Slider( s ) => s.draw( canvas, rect, focused ),
|
Element::Slider( s ) => s.draw( canvas, rect, focused ),
|
||||||
Element::VSlider( 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::Container( _ ) => {}
|
||||||
Element::Toggle( t ) => t.draw( canvas, rect, focused ),
|
Element::Toggle( t ) => t.draw( canvas, rect, focused ),
|
||||||
Element::Separator( s ) => s.draw( canvas, rect ),
|
Element::Separator( s ) => s.draw( canvas, rect ),
|
||||||
@@ -193,7 +196,7 @@ impl<Msg: Clone> Element<Msg>
|
|||||||
{
|
{
|
||||||
Element::Button( b ) => b.focusable,
|
Element::Button( b ) => b.focusable,
|
||||||
Element::TextEdit( t ) => !t.read_only,
|
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::Toggle( _ ) | Element::Checkbox( _ ) | Element::Radio( _ ) | Element::ListItem( _ ) => true,
|
||||||
Element::WindowButton( b ) => b.focusable,
|
Element::WindowButton( b ) => b.focusable,
|
||||||
_ => false,
|
_ => false,
|
||||||
@@ -237,6 +240,7 @@ impl<Msg: Clone> Element<Msg>
|
|||||||
// set, so the static value here is just the hover state.
|
// set, so the static value here is just the hover state.
|
||||||
Element::Slider( _ ) => Pointer,
|
Element::Slider( _ ) => Pointer,
|
||||||
Element::VSlider( _ ) => Pointer,
|
Element::VSlider( _ ) => Pointer,
|
||||||
|
Element::PatternGrid( _ ) => Pointer,
|
||||||
_ => Default,
|
_ => Default,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -322,6 +326,15 @@ impl<Msg: Clone> Element<Msg>
|
|||||||
password_toggle_msg: t.password_toggle.as_ref().map( |( _, m )| m.clone() ),
|
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 ) =>
|
Element::Slider( s ) =>
|
||||||
{
|
{
|
||||||
WidgetHandlers::Slider
|
WidgetHandlers::Slider
|
||||||
@@ -412,6 +425,7 @@ impl<Msg: Clone + 'static> Element<Msg>
|
|||||||
Element::Viewport( v ) => Element::Viewport( v.map_msg( f ) ),
|
Element::Viewport( v ) => Element::Viewport( v.map_msg( f ) ),
|
||||||
Element::WrapGrid( g ) => Element::WrapGrid( g.map_msg( f ) ),
|
Element::WrapGrid( g ) => Element::WrapGrid( g.map_msg( f ) ),
|
||||||
Element::Slider( s ) => Element::Slider( s.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::VSlider( s ) => Element::VSlider( s.map_msg( f ) ),
|
||||||
Element::Toggle( t ) => Element::Toggle( t.map_msg( f ) ),
|
Element::Toggle( t ) => Element::Toggle( t.map_msg( f ) ),
|
||||||
Element::Separator( s ) => Element::Separator( s ),
|
Element::Separator( s ) => Element::Separator( s ),
|
||||||
|
|||||||
@@ -87,6 +87,15 @@ pub enum WidgetHandlers<Msg: Clone>
|
|||||||
/// same thumb the renderer drew. Unused by the vertical axis.
|
/// same thumb the renderer drew. Unused by the vertical axis.
|
||||||
thumb_px: f32,
|
thumb_px: f32,
|
||||||
},
|
},
|
||||||
|
/// A drag target like a slider, but reporting the node under the
|
||||||
|
/// pointer instead of a value.
|
||||||
|
PatternGrid
|
||||||
|
{
|
||||||
|
on_node: Option<Arc<dyn Fn( u8 ) -> Msg>>,
|
||||||
|
on_release: Option<Msg>,
|
||||||
|
/// Snap radius, resolved through the widget-scaling mode at layout time.
|
||||||
|
snap_px: f32,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<Msg: Clone> Drop for WidgetHandlers<Msg>
|
impl<Msg: Clone> Drop for WidgetHandlers<Msg>
|
||||||
@@ -157,6 +166,15 @@ impl<Msg: Clone> Clone for WidgetHandlers<Msg>
|
|||||||
thumb_px: *thumb_px,
|
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<Msg: Clone> WidgetHandlers<Msg>
|
|||||||
WidgetHandlers::ListItem { on_press } => on_press.is_none(),
|
WidgetHandlers::ListItem { on_press } => on_press.is_none(),
|
||||||
WidgetHandlers::WindowButton { on_press } => on_press.is_none(),
|
WidgetHandlers::WindowButton { on_press } => on_press.is_none(),
|
||||||
WidgetHandlers::Slider { on_change, .. } => on_change.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::TextEdit { on_change, on_submit, .. } => on_change.is_none() && on_submit.is_none(),
|
||||||
WidgetHandlers::None => true,
|
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<Msg>
|
||||||
|
{
|
||||||
|
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<Msg>
|
||||||
|
{
|
||||||
|
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
|
/// `true` when this is a [`WidgetHandlers::TextEdit`] whose source
|
||||||
/// widget was built with `.multiline( true )`. The keyboard
|
/// widget was built with `.multiline( true )`. The keyboard
|
||||||
/// dispatch reads this so pressing Enter inserts a `\n` instead of
|
/// dispatch reads this so pressing Enter inserts a `\n` instead of
|
||||||
|
|||||||
+2
-1
@@ -31,7 +31,7 @@
|
|||||||
//! * **Stateful binary controls**: [`toggle::Toggle`],
|
//! * **Stateful binary controls**: [`toggle::Toggle`],
|
||||||
//! [`checkbox::Checkbox`], [`radio::Radio`].
|
//! [`checkbox::Checkbox`], [`radio::Radio`].
|
||||||
//! * **Continuous controls**: [`slider::Slider`], [`vslider::VSlider`],
|
//! * **Continuous controls**: [`slider::Slider`], [`vslider::VSlider`],
|
||||||
//! [`progress_bar::ProgressBar`].
|
//! [`progress_bar::ProgressBar`], [`pattern_grid::PatternGrid`].
|
||||||
//! * **Text**: [`text::Text`], [`text_edit::TextEdit`].
|
//! * **Text**: [`text::Text`], [`text_edit::TextEdit`].
|
||||||
//! * **Images / decoration**: [`image::Image`], [`separator::Separator`],
|
//! * **Images / decoration**: [`image::Image`], [`separator::Separator`],
|
||||||
//! [`container::Container`].
|
//! [`container::Container`].
|
||||||
@@ -69,6 +69,7 @@ pub mod scroll;
|
|||||||
pub mod viewport;
|
pub mod viewport;
|
||||||
pub mod slider;
|
pub mod slider;
|
||||||
pub mod vslider;
|
pub mod vslider;
|
||||||
|
pub mod pattern_grid;
|
||||||
pub mod toggle;
|
pub mod toggle;
|
||||||
pub mod separator;
|
pub mod separator;
|
||||||
pub mod progress_bar;
|
pub mod progress_bar;
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
|
//! 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<Msg: Clone>
|
||||||
|
{
|
||||||
|
pub( crate ) path: Vec<u8>,
|
||||||
|
pub( crate ) on_node: Option<Arc<dyn Fn( u8 ) -> Msg>>,
|
||||||
|
pub( crate ) on_release: Option<Msg>,
|
||||||
|
pub( crate ) hide_stroke: bool,
|
||||||
|
pub( crate ) error: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Msg: Clone> PatternGrid<Msg>
|
||||||
|
{
|
||||||
|
pub fn new( path: Vec<u8> ) -> 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<U>( self, f: &super::MapFn<Msg, U> ) -> PatternGrid<U>
|
||||||
|
where
|
||||||
|
U: Clone + 'static,
|
||||||
|
Msg: 'static,
|
||||||
|
{
|
||||||
|
let on_node = self.on_node.map( |old| -> Arc<dyn Fn( u8 ) -> 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<u8>
|
||||||
|
{
|
||||||
|
( 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<Msg: Clone>( path: Vec<u8> ) -> PatternGrid<Msg>
|
||||||
|
{
|
||||||
|
PatternGrid::new( path )
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Msg: Clone + 'static> From<PatternGrid<Msg>> for Element<Msg>
|
||||||
|
{
|
||||||
|
fn from( g: PatternGrid<Msg> ) -> Self
|
||||||
|
{
|
||||||
|
Element::PatternGrid( g )
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
|
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<Msg> = 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<Inner, Outer> = 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 ) ) );
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
|
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;
|
||||||
Reference in New Issue
Block a user