Files
ltk/src/widget/window_button.rs

384 lines
11 KiB
Rust

// 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
{
use crate::types::Color;
pub fn icon() -> Color { crate::theme::window_controls().icon }
pub fn hover_bg() -> Color { crate::theme::window_controls().hover_bg }
pub fn pressed_bg() -> Color { crate::theme::window_controls().pressed_bg }
pub fn focus_color() -> Color { crate::theme::window_controls().focus_ring }
pub fn close_hover() -> Color { crate::theme::window_controls().close_hover_bg }
pub fn close_icon() -> Color { crate::theme::window_controls().close_icon }
pub const SIZE: f32 = 36.0;
pub const RADIUS: f32 = 10.0;
pub const FOCUS_W: f32 = 2.0;
pub const STROKE_W: f32 = 2.0;
}
/// 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 ) )
}
#[ cfg( test ) ]
mod tests
{
use super::*;
use crate::render::Canvas;
#[ test ]
fn default_size_is_decoration_sized()
{
let canvas = Canvas::new( 100, 100 );
let b = window_button::<()>( WindowButtonKind::Close );
assert_eq!( b.preferred_size( 100.0, &canvas ), ( theme::SIZE, theme::SIZE ) );
}
#[ test ]
fn controls_has_three_children()
{
let controls = window_controls::<()>(
Some( () ), WindowButtonKind::Maximize, Some( () ), Some( () ),
);
assert_eq!( controls.children.len(), 3 );
}
#[ test ]
fn icon_names_resolve_each_kind_to_the_window_catalogue()
{
// Lock the catalogue paths so a future rename of the on-disk
// SVG (e.g. close.svg → x.svg) breaks this test instead of
// silently falling back to the programmatic glyph at runtime.
assert_eq!( icon_name( WindowButtonKind::Minimize ), "window/minimize" );
assert_eq!( icon_name( WindowButtonKind::Maximize ), "window/maximize" );
assert_eq!( icon_name( WindowButtonKind::Restore ), "window/restore" );
assert_eq!( icon_name( WindowButtonKind::Close ), "window/close" );
}
#[ test ]
fn paint_bounds_unchanged_after_glyph_refactor()
{
// The SVG glyph paints inside the button rect just like the
// programmatic one did, so paint_bounds must stay equal to
// the previous value (rect expanded by the focus-ring slack).
let b = window_button::<()>( WindowButtonKind::Close );
let rect = Rect { x: 10.0, y: 10.0, width: 36.0, height: 36.0 };
let bounds = b.paint_bounds( rect );
let slack = theme::FOCUS_W * 1.5 + 2.0;
assert!( ( bounds.x - ( rect.x - slack ) ).abs() < 0.01 );
assert!( ( bounds.y - ( rect.y - slack ) ).abs() < 0.01 );
assert!( ( bounds.width - ( rect.width + slack * 2.0 ) ).abs() < 0.01 );
assert!( ( bounds.height - ( rect.height + slack * 2.0 ) ).abs() < 0.01 );
}
}