ltk/widget/button/
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
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>

use std::sync::Arc;
use crate::types::{ Color, Rect, WidgetId };
use crate::render::Canvas;
use super::Element;

// Theme colors driven by the process-wide palette (see `ltk::theme`).
// Non-colour geometry (radius, font size, focus width, etc.) is static — only
// palette tokens respond to light/dark mode.
mod theme;

#[ cfg( test ) ]
mod tests;

/// Visual style of a text button.
#[ derive( Clone, Default ) ]
pub enum ButtonVariant
{
	/// Filled with the brand color — use for the primary call-to-action.
	#[ default ]
	Primary,
	/// White background with a dark border — use for secondary actions.
	Secondary,
	/// Text-only, no background — use for low-emphasis actions.
	Tertiary,
}

/// Internal content of a button — either a text label or a PNG icon.
pub enum ButtonContent
{
	/// A text label rendered with the theme font.
	Text( String ),
	/// An RGBA image used as the button face (Arc avoids per-frame cloning).
	Icon { rgba: Arc<Vec<u8>>, img_w: u32, img_h: u32 },
}

/// A pressable button widget.
///
/// Create text buttons with [`button()`](crate::button()) and icon buttons with
/// [`icon_button()`](crate::icon_button()). Buttons that step a value
/// (date / time pickers, numeric spinners) can opt into press-and-
/// hold repeat via [`Self::repeating`] — the runtime then re-fires
/// `on_press` while the button is held, at the keyboard's repeat
/// cadence.
pub struct Button<Msg: Clone>
{
	/// The visual content of this button.
	pub content:   ButtonContent,
	/// Message emitted when the button is pressed, or `None` if disabled.
	pub on_press:  Option<Msg>,
	/// Message emitted when the user holds the button for
	/// [`App::long_press_duration`](crate::app::App::long_press_duration)
	/// without moving past the tolerance, OR when the user right-clicks
	/// with the mouse. `None` leaves the button without a context-menu
	/// equivalent. The fire does NOT by itself put the gesture into
	/// drag mode — that is governed by [`Self::on_drag_start`].
	pub on_long_press: Option<Msg>,
	/// Drag-arm message. Fires when the press transitions into a drag:
	/// touch on hold-timer expiry (in addition to `on_long_press`),
	/// mouse on motion past the drag-promotion threshold (without
	/// firing the menu). Independent of `on_long_press` so a button
	/// can open a menu without becoming draggable, or be draggable
	/// without showing a menu.
	pub on_drag_start: Option<Msg>,
	/// Visual variant controlling colors and borders.
	pub variant:   ButtonVariant,
	/// Width and height in pixels for icon buttons. Defaults to `48.0`.
	pub icon_size: f32,
	/// Optional stable identifier for focus management.
	pub id:        Option<WidgetId>,
	/// Whether this button participates in keyboard focus (Tab). Default: `true`.
	pub focusable: bool,
	/// Override the pointer cursor shape on hover. `None` falls back
	/// to the `Pointer` (hand) default for clickable widgets.
	pub cursor:    Option<crate::types::CursorShape>,
	/// When `true`, holding the button down auto-fires the
	/// `on_press` message: one immediate fire on press, then an
	/// initial delay (≈ 500 ms — same as the keyboard) followed by
	/// repeats every ~120 ms (≈ 8 Hz, deliberately slower than the
	/// keyboard's 30 Hz so a stepper does not whip past the
	/// target). The runtime cancels the timer on release, on touch
	/// cancel, and on long-press promotion. Default `false` — most
	/// buttons fire on tap only.
	pub repeating: bool,
	pub tooltip:   Option<String>,
}

impl<Msg: Clone> Button<Msg>
{
	/// Create a text button with the given label.
	pub fn new( label: String ) -> Self
	{
		Self
		{
			content:       ButtonContent::Text( label ),
			on_press:      None,
			on_long_press: None,
			on_drag_start: None,
			variant:       ButtonVariant::Primary,
			icon_size:     theme::HEIGHT,
			id:            None,
			focusable:     true,
			cursor:        None,
			repeating:     false,
			tooltip:       None,
		}
	}

	/// Hint shown after a 600 ms pointer dwell. Pointer-only.
	pub fn tooltip( mut self, text: impl Into<String> ) -> Self
	{
		self.tooltip = Some( text.into() );
		self
	}

	/// Override the pointer cursor shape shown on hover.
	pub fn cursor( mut self, shape: crate::types::CursorShape ) -> Self
	{
		self.cursor = Some( shape );
		self
	}

	/// Create an icon button from a shared RGBA buffer.
	///
	/// `img_w` and `img_h` must match the dimensions of `rgba`.
	pub fn new_icon( rgba: Arc<Vec<u8>>, img_w: u32, img_h: u32 ) -> Self
	{
		Self
		{
			content:       ButtonContent::Icon { rgba, img_w, img_h },
			on_press:      None,
			on_long_press: None,
			on_drag_start: None,
			variant:       ButtonVariant::Tertiary,
			icon_size:     theme::HEIGHT,
			id:            None,
			focusable:     true,
			cursor:        None,
			repeating:     false,
			tooltip:       None,
		}
	}

	/// Set the message emitted when the button is pressed.
	pub fn on_press( mut self, msg: Msg ) -> Self
	{
		self.on_press = Some( msg );
		self
	}

	/// Optionally set the message — `None` leaves the button disabled.
	pub fn on_press_maybe( mut self, msg: Option<Msg> ) -> Self
	{
		self.on_press = msg;
		self
	}

	/// Auto-fire `on_press` while the button is held down. The
	/// runtime fires once on press, then re-fires after the
	/// keyboard's repeat *delay* (≈ 500 ms) and at a fixed ~120 ms
	/// (≈ 8 Hz) interval afterwards — slow enough to release on
	/// the value the user wants, fast enough to ramp. Each tick
	/// re-reads `on_press` from the live widget tree, so a
	/// stepper-style button whose message is `"go to value + 1"`
	/// keeps stepping correctly as the value updates.
	///
	/// Mutually compatible with `on_long_press` only in spirit —
	/// once the long-press message fires the gesture machine
	/// transitions to drag mode and the repeat timer is cancelled
	/// regardless of `repeating`. Default `false`.
	pub fn repeating( mut self, on: bool ) -> Self
	{
		self.repeating = on;
		self
	}

	/// Attach a long-press message. Fires when the press has been held
	/// stationary for [`App::long_press_duration`](crate::app::App::long_press_duration),
	/// or when the user right-clicks with the mouse. By itself this does
	/// NOT put the gesture into drag mode — that is governed by
	/// [`Self::on_drag_start`]. The regular `on_press` is suppressed
	/// only when the press has been promoted to a drag (drag-arm fired).
	pub fn on_long_press( mut self, msg: Msg ) -> Self
	{
		self.on_long_press = Some( msg );
		self
	}

	/// Attach a drag-arm message. Fires when the press transitions into
	/// drag mode — touch on hold-timer expiry (alongside `on_long_press`),
	/// mouse on motion past the drag-promotion threshold (without firing
	/// `on_long_press`). Independent of the menu so a button can be
	/// draggable without showing a menu, or open a menu without becoming
	/// draggable.
	pub fn on_drag_start( mut self, msg: Msg ) -> Self
	{
		self.on_drag_start = Some( msg );
		self
	}

	/// Control whether this button receives keyboard focus (Tab navigation).
	/// Set to `false` for purely decorative or status-indicator buttons.
	pub fn focusable( mut self, yes: bool ) -> Self
	{
		self.focusable = yes;
		self
	}

	/// Set the visual variant.
	pub fn variant( mut self, v: ButtonVariant ) -> Self
	{
		self.variant = v;
		self
	}

	/// Set the display size (width = height) for icon buttons in pixels.
	pub fn icon_size( mut self, size: f32 ) -> Self
	{
		self.icon_size = size;
		self
	}

	/// Assign a stable identifier for focus management.
	pub fn id( mut self, id: WidgetId ) -> Self
	{
		self.id = Some( id );
		self
	}

	/// Bounding box of everything the button can paint at `rect`, across every
	/// interaction state. This is the sum of: icon-button hover/press circle
	/// (radius `rect.min_dim / 2 + 8`), focus ring (grows `FOCUS_W + 1` beyond
	/// that), stroke half-width (`FOCUS_W / 2`), plus ~1 px of antialiasing
	/// bleed. Text buttons only have the focus ring.
	///
	/// The partial-redraw path uses this to know how much canvas area to
	/// invalidate when the button transitions in/out of a state.
	pub fn paint_bounds( &self, rect: crate::types::Rect ) -> crate::types::Rect
	{
		let stroke_bleed = theme::FOCUS_W * 0.5 + 1.0;
		match &self.content
		{
			ButtonContent::Icon { .. } =>
			{
				// The circle grows 8 px beyond the icon rect, the focus ring grows
				// `FOCUS_W + 1` beyond the circle.
				let circle_pad = 8.0_f32;
				let ring_pad   = theme::FOCUS_W + 1.0;
				rect.expand( circle_pad + ring_pad + stroke_bleed )
			}
			ButtonContent::Text( _ ) => match self.variant
			{
				ButtonVariant::Primary | ButtonVariant::Secondary =>
				{
					rect.expand( theme::FOCUS_W + 2.0 + stroke_bleed )
				}
				ButtonVariant::Tertiary => rect.expand( 2.0 + stroke_bleed ),
			},
		}
	}

	/// Return the preferred `(width, height)` given available `max_width`.
	pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
	{
		match &self.content
		{
			ButtonContent::Text( label ) =>
			{
				let text_w = canvas.measure_text( label, theme::FONT_SIZE );
				let w = (text_w + theme::PAD_H * 2.0).min( max_width );
				( w, theme::HEIGHT )
			}
			ButtonContent::Icon { .. } =>
			{
				let s = self.icon_size.min( max_width );
				( s, s )
			}
		}
	}

	/// Draw the button into `canvas` at `rect`.
	///
	/// `focused` draws a keyboard-focus ring; `hovered` and `pressed` apply
	/// pointer/touch state overlays (icon buttons only).
	pub fn draw( &self, canvas: &mut Canvas, rect: Rect, focused: bool, hovered: bool, pressed: bool )
	{
		match &self.content
		{
			ButtonContent::Text( label ) =>
			{
				self.draw_text_button( canvas, rect, focused, label );
			}
			ButtonContent::Icon { rgba, img_w, img_h } =>
			{
				self.draw_icon_button( canvas, rect, focused, hovered, pressed, rgba, *img_w, *img_h );
			}
		}
	}

	fn draw_text_button( &self, canvas: &mut Canvas, rect: Rect, focused: bool, label: &str )
	{
		let is_disabled = self.on_press.is_none();
		let text_y      = rect.y + (rect.height + theme::FONT_SIZE) / 2.0 - 2.0;

		match self.variant
		{
			ButtonVariant::Primary =>
			{
				let bg       = if is_disabled { theme::p_disabled_bg()   } else { theme::p_default_bg()   };
				let text_c   = if is_disabled { theme::p_disabled_text() } else { theme::p_default_text() };
				let border_c = theme::p_default_border();
				canvas.fill_rect( rect, bg, theme::RADIUS );
				if !is_disabled
				{
					canvas.stroke_rect( rect, border_c, theme::P_BORDER_W, theme::RADIUS );
				}
				if focused
				{
					let ring = rect.expand( theme::FOCUS_W + 2.0 );
					canvas.stroke_rect(
						ring,
						theme::focus_color(),
						theme::FOCUS_W,
						theme::RADIUS + theme::FOCUS_W + 2.0,
					);
				}
				let text_w = canvas.measure_text( label, theme::FONT_SIZE );
				canvas.draw_text(
					label,
					rect.x + (rect.width - text_w) / 2.0,
					text_y,
					theme::FONT_SIZE,
					text_c,
				);
			}
			ButtonVariant::Secondary =>
			{
				let bg       = if is_disabled { theme::s_disabled_bg()     } else { theme::s_bg()     };
				let text_c   = if is_disabled { theme::p_disabled_text()   } else { theme::t_text()   };
				let border_c = if is_disabled { theme::s_disabled_border() } else { theme::s_border() };
				canvas.fill_rect( rect, bg, theme::RADIUS );
				canvas.stroke_rect( rect, border_c, theme::S_BORDER_W, theme::RADIUS );
				if focused
				{
					let ring = rect.expand( theme::FOCUS_W + 2.0 );
					canvas.stroke_rect(
						ring,
						theme::focus_color(),
						theme::FOCUS_W,
						theme::RADIUS + theme::FOCUS_W + 2.0,
					);
				}
				let text_w = canvas.measure_text( label, theme::FONT_SIZE );
				canvas.draw_text(
					label,
					rect.x + (rect.width - text_w) / 2.0,
					text_y,
					theme::FONT_SIZE,
					text_c,
				);
			}
			ButtonVariant::Tertiary =>
			{
				let text_c = if is_disabled { theme::p_disabled_text() } else { theme::t_text() };
				if focused
				{
					let ring = rect.expand( 2.0 );
					canvas.stroke_rect( ring, theme::focus_color(), theme::FOCUS_W, theme::RADIUS );
				}
				let text_w = canvas.measure_text( label, theme::FONT_SIZE );
				canvas.draw_text(
					label,
					rect.x + (rect.width - text_w) / 2.0,
					text_y,
					theme::FONT_SIZE,
					text_c,
				);
			}
		}
	}

	fn draw_icon_button(
		&self,
		canvas:  &mut Canvas,
		rect:    Rect,
		focused: bool,
		hovered: bool,
		pressed: bool,
		rgba:    &[u8],
		img_w:   u32,
		img_h:   u32,
	)
	{
		// Semi-transparent circular overlay behind the icon for hover / press feedback
		let circle_pad = 8.0_f32;
		let r          = rect.width.min( rect.height ) / 2.0 + circle_pad;
		let cx         = rect.x + rect.width  / 2.0;
		let cy         = rect.y + rect.height / 2.0;
		let circle     = Rect
		{
			x:      cx - r,
			y:      cy - r,
			width:  r * 2.0,
			height: r * 2.0,
		};
		// Hover / press feedback is the theme's primary text colour
		// at low alpha — works as a "lighten" in light mode (where
		// text_primary tends to be dark and the underlying icon is
		// dark) and as a subtle wash in dark mode without baking in
		// a fixed white.
		let fp = crate::theme::palette().text_primary;
		if pressed
		{
			canvas.fill_rect( circle, Color::rgba( fp.r, fp.g, fp.b, 0.18 ), r );
		} else if hovered {
			canvas.fill_rect( circle, Color::rgba( fp.r, fp.g, fp.b, 0.10 ), r );
		}
		if focused
		{
			let ring = circle.expand( theme::FOCUS_W + 1.0 );
			canvas.stroke_rect( ring, theme::focus_color(), theme::FOCUS_W, r + theme::FOCUS_W + 1.0 );
		}
		canvas.draw_image_data( rgba, img_w, img_h, rect, 1.0 );
	}

	/// Wrap this button in an [`Element`].
	pub fn into_element( self ) -> Element<Msg>
	{
		Element::Button( self )
	}

	/// Re-tag this button's three message slots through `f`. Called by
	/// [`Element::map`] while walking a sub-tree.
	pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> Button<U>
	where
		U: Clone + 'static,
		Msg: 'static,
	{
		Button
		{
			content:       self.content,
			on_press:      self.on_press.map( |m| ( *f )( m ) ),
			on_long_press: self.on_long_press.map( |m| ( *f )( m ) ),
			on_drag_start: self.on_drag_start.map( |m| ( *f )( m ) ),
			variant:       self.variant,
			icon_size:     self.icon_size,
			id:            self.id,
			focusable:     self.focusable,
			cursor:        self.cursor,
			repeating:     self.repeating,
			tooltip:       self.tooltip,
		}
	}
}