The grid's preferred size was the theme's fluid constant (264 px at the reference surface) capped by the width the parent allocates, so a caller could only shrink it: a lock screen that wants the grid to span most of a phone's width had no way to ask for it, and a container's max_width did nothing past the default. PatternGrid gains a size field with a size( impl Into<Length> ) builder; preferred_size resolves it through Canvas::resolve_geom like the other geometry overrides and still caps it at max_width, and map_msg carries it across. Without the builder the fluid default applies as before. docs/widgets.md describes the builder next to hide_stroke and error; CHANGELOG lists it under Unreleased.
181 lines
4.7 KiB
Rust
181 lines
4.7 KiB
Rust
// 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, Length, 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,
|
||
pub( crate ) size: Option<Length>,
|
||
}
|
||
|
||
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, size: None }
|
||
}
|
||
|
||
/// Side of the square grid; without it the theme's fluid default applies.
|
||
pub fn size( mut self, s: impl Into<Length> ) -> Self
|
||
{
|
||
self.size = Some( s.into() );
|
||
self
|
||
}
|
||
|
||
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,
|
||
size: self.size,
|
||
}
|
||
}
|
||
|
||
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 )
|
||
{
|
||
let size = match self.size
|
||
{
|
||
Some( l ) => canvas.resolve_geom( l ),
|
||
None => canvas.geom_px( theme::SIZE ),
|
||
};
|
||
let size = 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 )
|
||
}
|
||
}
|