384 lines
11 KiB
Rust
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 );
|
|
}
|
|
}
|