First commit. Version 0.1.0
This commit is contained in:
383
src/widget/window_button.rs
Normal file
383
src/widget/window_button.rs
Normal file
@@ -0,0 +1,383 @@
|
||||
// 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 );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user