ltk/widget/window_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
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>

use super::Element;
use crate::layout::row::{ row, Row };
use crate::render::Canvas;
use crate::types::{ Color, Rect, WidgetId };

mod theme;

#[ cfg( test ) ]
mod tests;

/// Semantic role for a window-decoration button.
///
/// Drives both the rendered glyph (a horizontal bar for minimize, a square
/// outline for maximize, etc.) and the close button's special hover
/// treatment (red surface tint instead of the neutral hover wash). Maps
/// 1:1 to the four standard title-bar controls on Windows / GNOME / macOS.
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
pub enum WindowButtonKind
{
	/// Hide the window to the dock / taskbar.
	Minimize,
	/// Maximize the window to fill the available output.
	Maximize,
	/// Restore a previously-maximized window to its original size.
	Restore,
	/// Close the window. Renders with a destructive (red) hover surface.
	Close,
}

/// Button styled for compositor / window decorations.
///
/// The widget is intentionally policy-free: it paints a standard control and
/// emits the message supplied by the caller. Forge remains responsible for
/// deciding what that message does to the window.
pub struct WindowButton<Msg: Clone>
{
	/// Which decoration role this button paints.
	pub kind:      WindowButtonKind,
	/// Message emitted on activation. `None` greys the button and skips
	/// the hover / pressed surface — useful for "maximize disabled" on
	/// fixed-size windows.
	pub on_press:  Option<Msg>,
	/// Square hit-target size in logical pixels. Clamped to a 20 px floor
	/// by [`Self::size`] so the button stays touchable.
	pub size:      f32,
	/// Optional stable identifier for focus management.
	pub id:        Option<WidgetId>,
	/// Whether this button takes part in the Tab / Shift+Tab cycle. Defaults
	/// to `false` to match desktop convention — title-bar chrome on macOS,
	/// GNOME and Windows is click/touch-only and never steals keyboard focus
	/// from window content. Opt in with [`Self::focusable`] for shells where
	/// keyboard reachability of decorations matters (accessibility, no-mouse
	/// kiosks). Pointer / touch hit testing is unaffected by this flag.
	pub focusable: bool,
}

impl<Msg: Clone> WindowButton<Msg>
{
	/// Create a window-decoration button of the given kind. The button is
	/// inert (no callback) until [`Self::on_press`] is configured.
	pub fn new( kind: WindowButtonKind ) -> Self
	{
		Self
		{
			kind,
			on_press:  None,
			size:      theme::SIZE,
			id:        None,
			focusable: false,
		}
	}

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

	/// Like [`Self::on_press`] but keeps the disabled state when `None`
	/// is passed — useful when the message depends on a runtime
	/// condition (e.g. maximize is disabled for fixed-size windows).
	pub fn on_press_maybe( mut self, msg: Option<Msg> ) -> Self
	{
		self.on_press = msg;
		self
	}

	/// Override the square hit-target size in logical pixels. Clamped to
	/// a 20 px floor so the button remains touchable.
	pub fn size( mut self, size: f32 ) -> Self
	{
		self.size = size.max( 20.0 );
		self
	}

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

	/// Opt into keyboard focus traversal. Defaults to `false` so the
	/// button does not steal Tab focus from window content.
	pub fn focusable( mut self, yes: bool ) -> Self
	{
		self.focusable = yes;
		self
	}

	pub fn preferred_size( &self, max_width: f32, _canvas: &Canvas ) -> ( f32, f32 )
	{
		let s = self.size.min( max_width );
		( s, s )
	}

	pub fn paint_bounds( &self, rect: Rect ) -> Rect
	{
		rect.expand( theme::FOCUS_W * 1.5 + 2.0 )
	}

	pub fn draw(
		&self,
		canvas:  &mut Canvas,
		rect:    Rect,
		focused: bool,
		hovered: bool,
		pressed: bool,
	)
	{
		let disabled = self.on_press.is_none();
		let bg = if disabled
		{
			Color::TRANSPARENT
		}
		else if pressed
		{
			theme::pressed_bg()
		}
		else if hovered && self.kind == WindowButtonKind::Close
		{
			theme::close_hover()
		}
		else if hovered
		{
			theme::hover_bg()
		}
		else
		{
			Color::TRANSPARENT
		};

		if bg.a > 0.0
		{
			canvas.fill_rect( rect, bg, theme::RADIUS );
		}

		if focused
		{
			canvas.stroke_rect(
				rect.expand( theme::FOCUS_W + 1.0 ),
				theme::focus_color(),
				theme::FOCUS_W,
				theme::RADIUS + theme::FOCUS_W + 1.0,
			);
		}

		let icon_color = if hovered && self.kind == WindowButtonKind::Close && !disabled
		{
			theme::close_icon()
		}
		else
		{
			let base = theme::icon();
			if disabled
			{
				Color::rgba( base.r, base.g, base.b, base.a * 0.35 )
			}
			else
			{
				base
			}
		};
		draw_glyph( canvas, self.kind, rect, icon_color );
	}

	pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> WindowButton<U>
	where
		U: Clone + 'static,
		Msg: 'static,
	{
		WindowButton
		{
			kind:      self.kind,
			on_press:  self.on_press.map( |m| ( *f )( m ) ),
			size:      self.size,
			id:        self.id,
			focusable: self.focusable,
		}
	}
}

/// Catalogue path for the symbolic SVG that paints `kind`. Each entry
/// resolves through the theme's `icons/catalogue/filled/window/<kind>.svg`
/// (or the `line/` fallback the catalogue lookup applies automatically).
fn icon_name( kind: WindowButtonKind ) -> &'static str
{
	match kind
	{
		WindowButtonKind::Minimize => "window/minimize",
		WindowButtonKind::Maximize => "window/maximize",
		WindowButtonKind::Restore  => "window/restore",
		WindowButtonKind::Close    => "window/close",
	}
}

/// Render the glyph for `kind` centered inside `rect`, tinted with
/// `color`. Tries the theme catalogue first via [`crate::theme::icon_rgba`]
/// + [`crate::theme::tint_symbolic`]; falls back to the programmatic
/// line / rect drawing in [`draw_symbol`] when the active theme has no
/// catalogue entry for `window/<kind>`.
fn draw_glyph( canvas: &mut Canvas, kind: WindowButtonKind, rect: Rect, color: Color )
{
	let s = rect.width.min( rect.height );
	let icon_px = ( s * 0.44 ).round().max( 8.0 ) as u32;

	if let Some( ( rgba, iw, ih ) ) = crate::theme::icon_rgba( icon_name( kind ), icon_px )
	{
		let tinted = crate::theme::tint_symbolic( &rgba, color );
		// Centre the rasterised glyph inside the button rect. Round to
		// integer offsets so the bilinear sampler hits texel centres
		// and the glyph stays crisp.
		let cx = rect.x + rect.width  / 2.0;
		let cy = rect.y + rect.height / 2.0;
		let dest = Rect
		{
			x:      ( cx - iw as f32 / 2.0 ).round(),
			y:      ( cy - ih as f32 / 2.0 ).round(),
			width:  iw as f32,
			height: ih as f32,
		};
		canvas.draw_image_data( &tinted, iw, ih, dest, 1.0 );
	}
	else
	{
		// No catalogue entry → fall back to the programmatic glyph so
		// chrome stays usable on themes that don't ship
		// `icons/catalogue/filled/window/`.
		draw_symbol( canvas, kind, rect, color );
	}
}

fn draw_symbol( canvas: &mut Canvas, kind: WindowButtonKind, rect: Rect, color: Color )
{
	let cx = rect.x + rect.width  / 2.0;
	let cy = rect.y + rect.height / 2.0;
	let s  = rect.width.min( rect.height );
	let a  = s * 0.22;
	let w  = theme::STROKE_W;

	match kind
	{
		WindowButtonKind::Minimize =>
		{
			let y = cy + a * 0.65;
			canvas.draw_line( cx - a, y, cx + a, y, color, w );
		}
		WindowButtonKind::Maximize =>
		{
			let r = Rect { x: cx - a, y: cy - a, width: a * 2.0, height: a * 2.0 };
			canvas.stroke_rect( r, color, w, 2.0 );
		}
		WindowButtonKind::Restore =>
		{
			let back  = Rect { x: cx - a * 0.45, y: cy - a,        width: a * 1.55, height: a * 1.55 };
			let front = Rect { x: cx - a,        y: cy - a * 0.45, width: a * 1.55, height: a * 1.55 };
			canvas.stroke_rect( back,  color, w, 2.0 );
			canvas.stroke_rect( front, color, w, 2.0 );
		}
		WindowButtonKind::Close =>
		{
			canvas.draw_line( cx - a, cy - a, cx + a, cy + a, color, w );
			canvas.draw_line( cx + a, cy - a, cx - a, cy + a, color, w );
		}
	}
}

impl<Msg: Clone + 'static> From<WindowButton<Msg>> for Element<Msg>
{
	fn from( b: WindowButton<Msg> ) -> Self
	{
		Element::WindowButton( b )
	}
}

/// Create a window-decoration button.
pub fn window_button<Msg: Clone>( kind: WindowButtonKind ) -> WindowButton<Msg>
{
	WindowButton::new( kind )
}

/// Create the standard minimize / maximize-or-restore / close control group.
pub fn window_controls<Msg: Clone + 'static>(
	minimize:      Option<Msg>,
	maximize_kind: WindowButtonKind,
	maximize:      Option<Msg>,
	close:         Option<Msg>,
) -> Row<Msg>
{
	row::<Msg>()
		.spacing( 4.0 )
		.push( window_button( WindowButtonKind::Minimize ).on_press_maybe( minimize ) )
		.push( window_button( maximize_kind                ).on_press_maybe( maximize ) )
		.push( window_button( WindowButtonKind::Close    ).on_press_maybe( close    ) )
}