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.
269 lines
7.7 KiB
Rust
269 lines
7.7 KiB
Rust
use std::time::Instant;
|
|
|
|
use ltk::{
|
|
App, Element, Keysym, WidgetId,
|
|
column, row, text, text_edit, spacer, separator, slider,
|
|
toggle, checkbox, radio, progress_bar, scroll,
|
|
spinner, tabs, container, pattern_grid,
|
|
};
|
|
|
|
#[ derive( Clone ) ]
|
|
enum Msg
|
|
{
|
|
ToggleWifi,
|
|
ToggleBluetooth,
|
|
ToggleDarkMode,
|
|
CheckNotifications,
|
|
CheckUpdates,
|
|
SelectThemeLight,
|
|
SelectThemeDark,
|
|
SelectThemeAuto,
|
|
SliderChanged( f32 ),
|
|
SelectTab( usize ),
|
|
NoteChanged( String ),
|
|
PatternNode( u8 ),
|
|
PatternReleased,
|
|
Tick,
|
|
}
|
|
|
|
struct WidgetsApp
|
|
{
|
|
wifi: bool,
|
|
bluetooth: bool,
|
|
dark_mode: bool,
|
|
notifications: bool,
|
|
updates: bool,
|
|
theme: usize,
|
|
progress: f32,
|
|
volume: f32,
|
|
tab: usize,
|
|
note: String,
|
|
pattern: Vec<u8>,
|
|
pattern_done: Option<String>,
|
|
started_at: Instant,
|
|
}
|
|
|
|
impl WidgetsApp
|
|
{
|
|
fn new() -> Self
|
|
{
|
|
Self
|
|
{
|
|
wifi: true,
|
|
bluetooth: false,
|
|
dark_mode: true,
|
|
notifications: true,
|
|
updates: false,
|
|
theme: 2,
|
|
progress: 0.65,
|
|
volume: 0.7,
|
|
tab: 0,
|
|
note: String::new(),
|
|
pattern: Vec::new(),
|
|
pattern_done: None,
|
|
started_at: Instant::now(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl App for WidgetsApp
|
|
{
|
|
type Message = Msg;
|
|
|
|
// Stateless demo: nothing worth restoring.
|
|
fn app_id( &self ) -> &str { "net.liberux.ltk.example.widgets" }
|
|
fn save_state( &self ) -> Option<Vec<u8>> { None }
|
|
fn restore_state( &mut self, _state: Vec<u8> ) {}
|
|
|
|
fn view( &self ) -> Element<Msg>
|
|
{
|
|
// Pull text colours from the active theme so the example
|
|
// reads correctly under both light and dark mode without
|
|
// touching the example code.
|
|
let palette = ltk::theme_palette();
|
|
let primary = palette.text_primary;
|
|
let secondary = palette.text_secondary;
|
|
|
|
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<Msg> = tabs( [ "General", "Audio", "Network", "Security" ] )
|
|
.selected( self.tab )
|
|
.on_select( Msg::SelectTab )
|
|
.into();
|
|
|
|
let toggles = column::<Msg>()
|
|
.spacing( 0.0 )
|
|
.push( text( "Toggle" ).size( 13.0 ).color( secondary ) )
|
|
.push( toggle( self.wifi ).label( "Wi-Fi" ).on_toggle( Msg::ToggleWifi ) )
|
|
.push( toggle( self.bluetooth ).label( "Bluetooth" ).on_toggle( Msg::ToggleBluetooth ) )
|
|
.push( toggle( self.dark_mode ).label( "Dark mode" ).on_toggle( Msg::ToggleDarkMode ) );
|
|
|
|
let checkboxes = column::<Msg>()
|
|
.spacing( 0.0 )
|
|
.push( text( "Checkbox" ).size( 13.0 ).color( secondary ) )
|
|
.push( checkbox( self.notifications ).label( "Notifications" ).on_toggle( Msg::CheckNotifications ) )
|
|
.push( checkbox( self.updates ).label( "Auto updates" ).on_toggle( Msg::CheckUpdates ) );
|
|
|
|
let radios = column::<Msg>()
|
|
.spacing( 0.0 )
|
|
.push( text( "Radio" ).size( 13.0 ).color( secondary ) )
|
|
.push( radio( self.theme == 0 ).label( "Light" ).on_select( Msg::SelectThemeLight ) )
|
|
.push( radio( self.theme == 1 ).label( "Dark" ).on_select( Msg::SelectThemeDark ) )
|
|
.push( radio( self.theme == 2 ).label( "Auto" ).on_select( Msg::SelectThemeAuto ) );
|
|
|
|
let vol_label = format!( "Volume: {:.0}%", self.volume * 100.0 );
|
|
let prog_label = format!( "Progress: {:.0}%", self.progress * 100.0 );
|
|
|
|
// Spinner — animation phase derived from the wall clock so the
|
|
// arc rotates at one revolution per second.
|
|
let phase = self.started_at.elapsed().as_secs_f32();
|
|
let spin_row: Element<Msg> = row::<Msg>()
|
|
.spacing( 12.0 )
|
|
.push( spinner().phase( phase ) )
|
|
.push( text( "Loading…" ).size( 14.0 ).color( primary ) )
|
|
.into();
|
|
|
|
// Multiline text area — Enter inserts a newline, Tab leaves it.
|
|
let textarea = text_edit( "Type a note (multi-line)", &self.note )
|
|
.multiline( true )
|
|
.rows( 4 )
|
|
.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<Msg> = match self.tab.min( 3 )
|
|
{
|
|
0 => column::<Msg>()
|
|
.padding( 0.0 )
|
|
.spacing( 12.0 )
|
|
.push( toggles )
|
|
.push( separator() )
|
|
.push( checkboxes )
|
|
.push( separator() )
|
|
.push( radios )
|
|
.into(),
|
|
1 => column::<Msg>()
|
|
.padding( 0.0 )
|
|
.spacing( 12.0 )
|
|
.push( text( vol_label ).size( 13.0 ).color( secondary ) )
|
|
.push( slider( self.volume ).on_change( Msg::SliderChanged ) )
|
|
.push( text( prog_label ).size( 13.0 ).color( secondary ) )
|
|
.push( progress_bar( self.progress ) )
|
|
.into(),
|
|
2 => column::<Msg>()
|
|
.padding( 0.0 )
|
|
.spacing( 12.0 )
|
|
.push( text( "Spinner" ).size( 13.0 ).color( secondary ) )
|
|
.push( spin_row )
|
|
.push( separator() )
|
|
.push( text( "Multiline text edit" ).size( 13.0 ).color( secondary ) )
|
|
.push( textarea )
|
|
.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>()
|
|
.padding( 32.0 )
|
|
.spacing( 12.0 )
|
|
.push( title )
|
|
.push( tabs_strip )
|
|
.push( separator() )
|
|
.push( page )
|
|
.push( spacer() )
|
|
.push(
|
|
text( "Esc = quit" )
|
|
.size( 12.0 )
|
|
.color( secondary )
|
|
.align_center(),
|
|
);
|
|
|
|
scroll( content ).into()
|
|
}
|
|
|
|
fn update( &mut self, msg: Msg )
|
|
{
|
|
match msg
|
|
{
|
|
Msg::ToggleWifi => self.wifi = !self.wifi,
|
|
Msg::ToggleBluetooth => self.bluetooth = !self.bluetooth,
|
|
Msg::ToggleDarkMode => self.dark_mode = !self.dark_mode,
|
|
Msg::CheckNotifications => self.notifications = !self.notifications,
|
|
Msg::CheckUpdates => self.updates = !self.updates,
|
|
Msg::SelectThemeLight => self.theme = 0,
|
|
Msg::SelectThemeDark => self.theme = 1,
|
|
Msg::SelectThemeAuto => self.theme = 2,
|
|
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<String> = self.pattern.iter().map( |n| n.to_string() ).collect();
|
|
self.pattern_done = Some( drawn.join( "-" ) );
|
|
self.pattern.clear();
|
|
}
|
|
Msg::Tick => {}
|
|
}
|
|
}
|
|
|
|
fn on_key( &mut self, keysym: Keysym ) -> Option<Msg>
|
|
{
|
|
if keysym == Keysym::Escape { std::process::exit( 0 ); }
|
|
None
|
|
}
|
|
|
|
// Drive the spinner — the runtime keeps redrawing while this returns
|
|
// `true`, and `view()` reads the wall clock to compute the phase, so
|
|
// no explicit Tick message is even needed for the visual to advance.
|
|
fn is_animating( &self ) -> bool { true }
|
|
|
|
fn poll_interval( &self ) -> Option<std::time::Duration>
|
|
{
|
|
// Suppress the dead-code warning on Msg::Tick; a real app would
|
|
// also use the timer to drive non-visual periodic work here.
|
|
Some( std::time::Duration::from_millis( 250 ) )
|
|
}
|
|
|
|
fn poll_external( &mut self ) -> Vec<Msg>
|
|
{
|
|
// Consumed: the timer above keeps the runtime warm, but the
|
|
// spinner reads the wall clock directly so no Tick is required.
|
|
// We still emit one occasionally so the message variant is
|
|
// exercised — keeps the example honest about the pattern apps
|
|
// usually use for clock work.
|
|
if self.started_at.elapsed().as_millis() % 1_000 < 250
|
|
{
|
|
vec![ Msg::Tick ]
|
|
} else {
|
|
vec![]
|
|
}
|
|
}
|
|
}
|
|
|
|
fn main()
|
|
{
|
|
ltk::run( WidgetsApp::new() );
|
|
}
|