// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. 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 { /// 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, /// 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, /// 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 WindowButton { /// 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 ) -> 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( self, f: &super::MapFn ) -> WindowButton 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/.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/`. 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 From> for Element { fn from( b: WindowButton ) -> Self { Element::WindowButton( b ) } } /// Create a window-decoration button. pub fn window_button( kind: WindowButtonKind ) -> WindowButton { WindowButton::new( kind ) } /// Create the standard minimize / maximize-or-restore / close control group. pub fn window_controls( minimize: Option, maximize_kind: WindowButtonKind, maximize: Option, close: Option, ) -> Row { row::() .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 ); } }