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()
}