refactor: split every monolithic module into focused submodules
Each source file that had grown beyond a single concern is replaced by an identically-named directory containing focused submodules. `src/event_loop/mod.rs` (878 lines) becomes a directory with clipboard, context_menu, cursor_shape, drag, focus, handlers, invalidation, overlays_reconcile, repeat, run, surface, text_editing, and tooltip. Every widget, input handler, and theme component follows the same split. Public interfaces are unchanged — only the internal file layout moves. image bumped from 0.25.2 to 0.25.9.
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! Modal / non-modal centered dialog: a darkened scrim with a card
|
||||
//! holding a title, an optional subtitle, an optional body, and a row
|
||||
//! of action buttons.
|
||||
//!
|
||||
//! The widget is implemented as a thin builder over existing
|
||||
//! primitives: at conversion time
|
||||
//! ([`From<Dialog<Msg>> for Element<Msg>`](Dialog#impl-From%3CDialog%3CMsg%3E%3E-for-Element%3CMsg%3E))
|
||||
//! it lowers itself to a [`Stack`](crate::layout::stack::Stack) of
|
||||
//!
|
||||
//! 1. a full-surface [`Pressable`](crate::widget::pressable::Pressable)
|
||||
//! scrim — `swallow=true` so it absorbs every pointer event that
|
||||
//! misses the card (so widgets behind the dialog cannot be clicked),
|
||||
//! `on_escape=cancel_msg` so the keyboard ESC handler can find it,
|
||||
//! and `on_press=dismiss_msg` only when the dialog is non-modal and
|
||||
//! a `dismiss_on_scrim` was configured;
|
||||
//! 2. a centered card backed by a flat opaque fill (`palette.surface`
|
||||
//! with alpha forced to 1.0 — themed `surface-card` / similar
|
||||
//! Glass surfaces ship as translucent for the rest of the toolkit,
|
||||
//! but a confirmation dialog must read against any background, so
|
||||
//! the dialog opts out of the Glass chrome). The card wraps a
|
||||
//! *card-area* `Pressable( swallow=true )` so the body itself
|
||||
//! silently absorbs taps and only clicks strictly outside the card
|
||||
//! can dismiss the dialog. Inside the card sits a column with the
|
||||
//! title (700-weight, wraps), the subtitle
|
||||
//! (`text_secondary`, wraps), the user-supplied body, and a
|
||||
//! right-aligned actions row.
|
||||
//!
|
||||
//! ## Example
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! # use ltk::{ button, dialog, ButtonVariant, Element };
|
||||
//! # #[ derive( Clone ) ] enum Msg { Cancel, Confirm }
|
||||
//! # fn _ex() -> Element<Msg> {
|
||||
//! dialog()
|
||||
//! .title( "Delete partition?" )
|
||||
//! .subtitle( "This will erase every file on /dev/sda2." )
|
||||
//! .cancel( Msg::Cancel )
|
||||
//! .action( button::<Msg>( "Cancel" )
|
||||
//! .variant( ButtonVariant::Tertiary )
|
||||
//! .on_press( Msg::Cancel ) )
|
||||
//! .action( button::<Msg>( "Delete" )
|
||||
//! .variant( ButtonVariant::Primary )
|
||||
//! .on_press( Msg::Confirm ) )
|
||||
//! .into()
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
//! ## Modality and dismissal
|
||||
//!
|
||||
//! `modal` is `true` by default — every pointer event outside the card
|
||||
//! is silently swallowed and underlying widgets cannot be reached. Set
|
||||
//! `modal( false )` to let pointer events pass through to the
|
||||
//! application **except** that you can still wire a
|
||||
//! [`Dialog::dismiss_on_scrim`] message that fires only when the user
|
||||
//! taps strictly outside the card. `dismiss_on_scrim` is rejected at
|
||||
//! build time for a modal dialog (the contract is "modality means no
|
||||
//! escape", so an "escape via tap-outside" message is contradictory).
|
||||
//!
|
||||
//! `Esc` always fires the [`Dialog::cancel`] message — independent of
|
||||
//! modality. Wire `cancel` to the same message your "Cancel" /
|
||||
//! "Dismiss" action button uses so keyboard ESC matches the click
|
||||
//! behaviour.
|
||||
|
||||
use crate::layout::column::column;
|
||||
use crate::layout::row::row;
|
||||
use crate::layout::spacer::spacer;
|
||||
use crate::layout::stack::stack;
|
||||
use crate::types::{ Color, Corners };
|
||||
|
||||
use super::container::container;
|
||||
use super::pressable::pressable;
|
||||
use super::text;
|
||||
use super::Element;
|
||||
|
||||
#[ cfg( test ) ]
|
||||
mod tests;
|
||||
|
||||
/// Default scrim opacity over the underlying surface.
|
||||
pub const SCRIM_ALPHA: f32 = 0.45;
|
||||
/// Default card max-width (logical pixels). Override with
|
||||
/// [`Dialog::max_width`].
|
||||
pub const DEFAULT_MAX_WIDTH: f32 = 480.0;
|
||||
/// Default card corner radius.
|
||||
pub const CARD_RADIUS: f32 = 16.0;
|
||||
/// Default card padding (uniform).
|
||||
pub const CARD_PADDING: f32 = 24.0;
|
||||
/// Default vertical gap between the title, subtitle, body, and
|
||||
/// actions row.
|
||||
pub const SECTION_GAP: f32 = 12.0;
|
||||
/// Default horizontal gap between action buttons.
|
||||
pub const ACTION_GAP: f32 = 8.0;
|
||||
/// Default title font size.
|
||||
pub const TITLE_SIZE: f32 = 22.0;
|
||||
/// Default title weight.
|
||||
pub const TITLE_WEIGHT: u16 = 700;
|
||||
/// Default subtitle font size.
|
||||
pub const SUBTITLE_SIZE: f32 = 14.0;
|
||||
|
||||
/// A centered confirmation dialog with optional title, subtitle, body
|
||||
/// and action buttons.
|
||||
pub struct Dialog<Msg: Clone>
|
||||
{
|
||||
pub title: Option<String>,
|
||||
pub subtitle: Option<String>,
|
||||
/// User-supplied body element rendered between the subtitle and
|
||||
/// the action row. Use this for sliders, lists, spinners or any
|
||||
/// other custom content.
|
||||
pub body: Option<Box<Element<Msg>>>,
|
||||
pub actions: Vec<Element<Msg>>,
|
||||
pub modal: bool,
|
||||
/// Optional message dispatched when the user taps the scrim
|
||||
/// (strictly outside the card). Always `None` when [`Self::modal`]
|
||||
/// is `true`. Construction panics if both are set together.
|
||||
pub dismiss_msg: Option<Msg>,
|
||||
/// Optional message dispatched when the user presses `Escape`
|
||||
/// while the dialog is on screen. Wire this to the same message
|
||||
/// your "Cancel" action button uses.
|
||||
pub cancel_msg: Option<Msg>,
|
||||
pub max_width: f32,
|
||||
}
|
||||
|
||||
impl<Msg: Clone> Default for Dialog<Msg>
|
||||
{
|
||||
fn default() -> Self
|
||||
{
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<Msg: Clone> Dialog<Msg>
|
||||
{
|
||||
/// Construct a default modal dialog with no title, subtitle,
|
||||
/// body, or actions. Build it up with the chained setters.
|
||||
pub fn new() -> Self
|
||||
{
|
||||
Self
|
||||
{
|
||||
title: None,
|
||||
subtitle: None,
|
||||
body: None,
|
||||
actions: Vec::new(),
|
||||
modal: true,
|
||||
dismiss_msg: None,
|
||||
cancel_msg: None,
|
||||
max_width: DEFAULT_MAX_WIDTH,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the dialog title. Wraps across multiple lines if it does
|
||||
/// not fit on a single one.
|
||||
pub fn title( mut self, t: impl Into<String> ) -> Self
|
||||
{
|
||||
self.title = Some( t.into() );
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the dialog subtitle. Wraps across multiple lines if it
|
||||
/// does not fit on a single one.
|
||||
pub fn subtitle( mut self, s: impl Into<String> ) -> Self
|
||||
{
|
||||
self.subtitle = Some( s.into() );
|
||||
self
|
||||
}
|
||||
|
||||
/// Replace the dialog body with a custom element — slider,
|
||||
/// progress indicator, list, anything. Rendered between the
|
||||
/// subtitle and the action row.
|
||||
pub fn body( mut self, e: impl Into<Element<Msg>> ) -> Self
|
||||
{
|
||||
self.body = Some( Box::new( e.into() ) );
|
||||
self
|
||||
}
|
||||
|
||||
/// Append an action element to the right-aligned action row.
|
||||
/// Typically a [`Button`](crate::widget::button::Button); any
|
||||
/// `Element` is accepted.
|
||||
pub fn action( mut self, e: impl Into<Element<Msg>> ) -> Self
|
||||
{
|
||||
self.actions.push( e.into() );
|
||||
self
|
||||
}
|
||||
|
||||
/// Toggle modality. Default: `true` (every pointer event outside
|
||||
/// the card is silently absorbed).
|
||||
pub fn modal( mut self, on: bool ) -> Self
|
||||
{
|
||||
self.modal = on;
|
||||
self
|
||||
}
|
||||
|
||||
/// Bind a message dispatched when the user taps the scrim
|
||||
/// (outside the card). Only valid for non-modal dialogs;
|
||||
/// construction panics if combined with `modal( true )`.
|
||||
pub fn dismiss_on_scrim( mut self, msg: Msg ) -> Self
|
||||
{
|
||||
self.dismiss_msg = Some( msg );
|
||||
self
|
||||
}
|
||||
|
||||
/// Bind a message dispatched when the user presses `Escape`.
|
||||
/// Wire the same message your "Cancel" / "Dismiss" action button
|
||||
/// uses so keyboard and pointer behaviour match.
|
||||
pub fn cancel( mut self, msg: Msg ) -> Self
|
||||
{
|
||||
self.cancel_msg = Some( msg );
|
||||
self
|
||||
}
|
||||
|
||||
/// Override the card's maximum width in logical pixels. Default
|
||||
/// is `480.0`.
|
||||
pub fn max_width( mut self, w: f32 ) -> Self
|
||||
{
|
||||
self.max_width = w;
|
||||
self
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl<Msg: Clone + 'static> From<Dialog<Msg>> for Element<Msg>
|
||||
{
|
||||
fn from( d: Dialog<Msg> ) -> Element<Msg>
|
||||
{
|
||||
assert!(
|
||||
!( d.modal && d.dismiss_msg.is_some() ),
|
||||
"dialog: dismiss_on_scrim is not valid when modal=true",
|
||||
);
|
||||
|
||||
let palette = crate::theme::palette();
|
||||
|
||||
// 1. Inner card column: title, subtitle, body, actions.
|
||||
let mut card_col = column::<Msg>().spacing( SECTION_GAP );
|
||||
if let Some( title ) = d.title
|
||||
{
|
||||
card_col = card_col.push(
|
||||
text( title )
|
||||
.size( TITLE_SIZE )
|
||||
.weight( TITLE_WEIGHT )
|
||||
.color( palette.text_primary )
|
||||
.wrap( true ),
|
||||
);
|
||||
}
|
||||
if let Some( subtitle ) = d.subtitle
|
||||
{
|
||||
card_col = card_col.push(
|
||||
text( subtitle )
|
||||
.size( SUBTITLE_SIZE )
|
||||
.color( palette.text_secondary )
|
||||
.wrap( true ),
|
||||
);
|
||||
}
|
||||
if let Some( body ) = d.body
|
||||
{
|
||||
card_col = card_col.push( *body );
|
||||
}
|
||||
if !d.actions.is_empty()
|
||||
{
|
||||
let mut actions_row = row::<Msg>().spacing( ACTION_GAP ).push( spacer() );
|
||||
for a in d.actions
|
||||
{
|
||||
actions_row = actions_row.push( a );
|
||||
}
|
||||
card_col = card_col.push( actions_row );
|
||||
}
|
||||
|
||||
// 2. Card surface — flat opaque fill, no themed surface stack.
|
||||
// Themed surfaces (`surface-card` / `surface-dialog`) ship as
|
||||
// translucent Glass for the rest of the toolkit; for a
|
||||
// confirmation dialog the body must read against any
|
||||
// background, so we force `palette.surface` to full opacity
|
||||
// and skip the Glass chrome.
|
||||
let card_bg = Color { a: 1.0, ..palette.surface };
|
||||
let card = container::<Msg>( card_col )
|
||||
.background( card_bg )
|
||||
.radius( Corners::all( CARD_RADIUS ) )
|
||||
.padding( CARD_PADDING );
|
||||
|
||||
// 3. Card-area swallow: a Pressable wrapping the card so
|
||||
// taps on the body silently absorb without firing the
|
||||
// scrim's `dismiss_msg`. Always armed (modal or not) — the
|
||||
// card never dismisses.
|
||||
let card_swallow = pressable::<Msg>( card ).swallow( true );
|
||||
|
||||
// 4. Center the card on the screen. The outer column claims
|
||||
// the full surface; `center_y` + `align_center_x` keep the
|
||||
// card vertically and horizontally centered, and `max_width`
|
||||
// caps it at `d.max_width` even on ultra-wide layouts.
|
||||
let centered = column::<Msg>()
|
||||
.center_y( true )
|
||||
.align_center_x( true )
|
||||
.max_width( d.max_width )
|
||||
.push( card_swallow );
|
||||
|
||||
// 5. Scrim — a full-bleed Pressable with the dim layer
|
||||
// rendered behind it. `swallow=true` means it always shows
|
||||
// up in `widget_rects` (modal dialogs need this so missing
|
||||
// hits do not fall through to the underlying app); when
|
||||
// non-modal AND a `dismiss_msg` was configured, taps fire
|
||||
// the message. The cancel-on-ESC binding lives here too.
|
||||
let scrim_bg = container::<Msg>( spacer() )
|
||||
.background( Color { r: 0.0, g: 0.0, b: 0.0, a: SCRIM_ALPHA } )
|
||||
.radius( Corners::ZERO );
|
||||
let mut scrim_press = pressable::<Msg>( scrim_bg ).swallow( true );
|
||||
if let Some( msg ) = d.dismiss_msg
|
||||
{
|
||||
scrim_press = scrim_press.on_press( msg );
|
||||
}
|
||||
if let Some( msg ) = d.cancel_msg
|
||||
{
|
||||
scrim_press = scrim_press.on_escape( msg );
|
||||
}
|
||||
|
||||
// 6. Stack scrim + centered card. Layout walker pushes
|
||||
// children in order, so the scrim's `flat_idx` < the card
|
||||
// area's < each action button's; `iter().rev()` hit-testing
|
||||
// therefore finds buttons first, then the card-area
|
||||
// swallow, and finally the scrim outside the card.
|
||||
stack::<Msg>()
|
||||
.push( scrim_press )
|
||||
.push( centered )
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct a [`Dialog`]. See the type's documentation for the full
|
||||
/// builder API and the lowering details.
|
||||
pub fn dialog<Msg: Clone>() -> Dialog<Msg>
|
||||
{
|
||||
Dialog::new()
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
use super::*;
|
||||
use crate::layout::spacer::spacer;
|
||||
|
||||
#[ derive( Clone, Debug, PartialEq, Eq ) ]
|
||||
enum Msg { Cancel, Dismiss }
|
||||
|
||||
#[ test ]
|
||||
fn new_defaults_are_modal_with_no_content()
|
||||
{
|
||||
let d = Dialog::<Msg>::new();
|
||||
assert!( d.modal );
|
||||
assert!( d.title.is_none() );
|
||||
assert!( d.subtitle.is_none() );
|
||||
assert!( d.body.is_none() );
|
||||
assert!( d.actions.is_empty() );
|
||||
assert!( d.dismiss_msg.is_none() );
|
||||
assert!( d.cancel_msg.is_none() );
|
||||
assert_eq!( d.max_width, DEFAULT_MAX_WIDTH );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn title_and_subtitle_builders_set_strings()
|
||||
{
|
||||
let d = Dialog::<Msg>::new()
|
||||
.title( "Hello" )
|
||||
.subtitle( "World" );
|
||||
assert_eq!( d.title.as_deref(), Some( "Hello" ) );
|
||||
assert_eq!( d.subtitle.as_deref(), Some( "World" ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn body_builder_replaces_existing_body()
|
||||
{
|
||||
let d = Dialog::<Msg>::new()
|
||||
.body( spacer() )
|
||||
.body( spacer() );
|
||||
assert!( d.body.is_some() );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn action_builder_appends_in_order()
|
||||
{
|
||||
let d = Dialog::<Msg>::new()
|
||||
.action( spacer() )
|
||||
.action( spacer() )
|
||||
.action( spacer() );
|
||||
assert_eq!( d.actions.len(), 3 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn modal_builder_toggles_flag()
|
||||
{
|
||||
assert!( !Dialog::<Msg>::new().modal( false ).modal );
|
||||
assert!( Dialog::<Msg>::new().modal( true ).modal );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn dismiss_on_scrim_builder_records_message()
|
||||
{
|
||||
let d = Dialog::<Msg>::new()
|
||||
.modal( false )
|
||||
.dismiss_on_scrim( Msg::Dismiss );
|
||||
assert_eq!( d.dismiss_msg, Some( Msg::Dismiss ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn cancel_builder_records_escape_message()
|
||||
{
|
||||
let d = Dialog::<Msg>::new().cancel( Msg::Cancel );
|
||||
assert_eq!( d.cancel_msg, Some( Msg::Cancel ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn max_width_builder_overrides_default()
|
||||
{
|
||||
let d = Dialog::<Msg>::new().max_width( 720.0 );
|
||||
assert_eq!( d.max_width, 720.0 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
#[ should_panic( expected = "dismiss_on_scrim is not valid when modal=true" ) ]
|
||||
fn modal_with_dismiss_panics_at_lower()
|
||||
{
|
||||
// Construction allows the combination; only the lowering
|
||||
// to `Element` enforces the contract — that is where the
|
||||
// invariant matters because that is where input dispatch
|
||||
// would have to honour two contradictory rules.
|
||||
let d = Dialog::<Msg>::new()
|
||||
.modal( true )
|
||||
.dismiss_on_scrim( Msg::Dismiss );
|
||||
let _: Element<Msg> = d.into();
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn non_modal_with_dismiss_lowers_cleanly()
|
||||
{
|
||||
let d = Dialog::<Msg>::new()
|
||||
.modal( false )
|
||||
.dismiss_on_scrim( Msg::Dismiss )
|
||||
.cancel( Msg::Cancel )
|
||||
.title( "Title" );
|
||||
// Lowering must not panic.
|
||||
let _: Element<Msg> = d.into();
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn modal_with_cancel_only_lowers_cleanly()
|
||||
{
|
||||
// modal=true + cancel(...) (no dismiss_on_scrim) is the
|
||||
// canonical confirm-dialog shape.
|
||||
let d = Dialog::<Msg>::new()
|
||||
.title( "Confirm" )
|
||||
.subtitle( "Are you sure?" )
|
||||
.cancel( Msg::Cancel );
|
||||
let _: Element<Msg> = d.into();
|
||||
}
|
||||
Reference in New Issue
Block a user