ltk/widget/dialog/mod.rs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 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()
}