Files
ltk/src/layout/row.rs
T
admin 3edd822918
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
Toplevel events name the window, apps hear the pointer hover a surface and where their blurred cards were laid out, and rows can align to the bottom
A shell could not tell the windows of an app apart, follow the mouse over one of its overlays, or tell its compositor where the translucent cards of a surface ended up. crustace's floating dock needs all three, to activate and close single windows, to magnify the icons under the pointer and to have forge blur behind its pill and its Quick Settings cards.
`ToplevelEvent::Opened` carries the handle's `identifier`, the compositor's token for that one window, and its `title`, both from `ForeignToplevelInfo`; `toplevel_opened` builds the event for the new and the updated handle. Matches on `Opened { id, app_id }` have to add the fields or `..`.
`App::on_pointer_hover( SurfaceTarget, Option<( x, y )> )` is called on every mouse motion over a surface, in its physical pixels, and with `None` from the pointer's `leave`, which ltk did not handle before. `SurfaceFocus::target` gives the `SurfaceTarget` of a focus.
`Container::blur_behind()` marks a container whose rect and largest corner radius, capped at half its shorter side, the layout pass records in surface coordinates through the `DrawCtx::origin` offset of the sub-canvases, clipped by the viewports it sits in. `commit_draw_ctx` keeps them in `FrameState`, and after a frame where they changed `draw_frame` hands them to `App::on_blur_regions( SurfaceTarget, &[ ( Rect, f32 ) ] )`, for the main surface and each overlay.
`Row::align_bottom()` pins the children to the bottom edge, so taller siblings grow upward. `docs/architecture.md` lists `on_pointer_hover` among the input hooks.
Not done. `on_blur_regions`, `blur_behind` and `align_bottom` are not in the documentation, and there are no tests for them. The regions are reported after the frame is committed, so a compositor that receives them over IPC is one frame behind a surface whose cards move.
2026-10-15 14:49:25 +02:00

556 lines
17 KiB
Rust

// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::{ Length, Rect };
use crate::render::Canvas;
use crate::widget::Element;
/// A horizontal layout container.
///
/// Children are arranged left-to-right. Use [`Row::align_right`] to
/// push the content block to the right edge of the available width.
///
/// ```rust,no_run
/// # use std::sync::Arc;
/// # use ltk::{ icon_button, row, Element };
/// # #[ derive( Clone ) ] enum Msg { A, B }
/// # fn _ex( a_rgba: Arc<Vec<u8>>, b_rgba: Arc<Vec<u8>>, w: u32, h: u32 ) -> Element<Msg> {
/// row()
/// .spacing( 16.0 )
/// .align_right()
/// .push( icon_button( a_rgba, w, h ).on_press( Msg::A ) )
/// .push( icon_button( b_rgba, w, h ).on_press( Msg::B ) )
/// .into()
/// # }
/// ```
///
/// `spacing` and `padding` accept any [`crate::Length`]:
///
/// ```rust,no_run
/// # use std::sync::Arc;
/// # use ltk::{ icon_button, row, Length, Element };
/// # #[ derive( Clone ) ] enum Msg { A, B }
/// # fn _ex( a_rgba: Arc<Vec<u8>>, b_rgba: Arc<Vec<u8>>, w: u32, h: u32 ) -> Element<Msg> {
/// row()
/// // 2 % of the viewport's smaller side, never below 8 px.
/// .spacing( Length::vmin( 2.0 ).at_least( 8.0 ) )
/// .push( icon_button( a_rgba, w, h ).on_press( Msg::A ) )
/// .push( icon_button( b_rgba, w, h ).on_press( Msg::B ) )
/// .into()
/// # }
/// ```
pub struct Row<Msg: Clone>
{
pub( crate ) children: Vec<Element<Msg>>,
/// Horizontal gap between children. [`Length`]; default `8.0` px.
pub( crate ) spacing: Length,
/// Padding on all sides. [`Length`]; default `0.0` px.
pub( crate ) padding: Length,
pub( crate ) align_right: bool,
pub( crate ) align_top: bool,
pub( crate ) align_bottom: bool,
pub( crate ) fill_height: bool,
/// When false the row keeps every child at its preferred width even if
/// the cluster overflows. Default true.
pub( crate ) shrink: bool,
}
impl<Msg: Clone> Row<Msg>
{
pub fn new() -> Self
{
Self
{
children: Vec::new(),
spacing: Length::px( 8.0 ),
padding: Length::px( 0.0 ),
align_right: false,
align_top: false,
align_bottom: false,
fill_height: false,
shrink: true,
}
}
/// Append a child widget or layout.
pub fn push( mut self, e: impl Into<Element<Msg>> ) -> Self
{
self.children.push( e.into() );
self
}
/// Set the horizontal gap between children. Default: `8.0` px. Accepts
/// any [`Length`] so the gap can scale with the viewport.
pub fn spacing( mut self, s: impl Into<Length> ) -> Self
{
self.spacing = s.into();
self
}
/// Set the padding (all sides). Default: `0.0` px. Accepts any
/// [`Length`].
pub fn padding( mut self, p: impl Into<Length> ) -> Self
{
self.padding = p.into();
self
}
#[ inline ]
fn resolved_spacing( &self, canvas: &Canvas ) -> f32
{
canvas.resolve_geom( self.spacing )
}
#[ inline ]
fn resolved_padding( &self, canvas: &Canvas ) -> f32
{
canvas.resolve_geom( self.padding )
}
/// Keep every child at its preferred width even when the cluster does not
/// fit, letting it overflow the row's rect.
///
/// The default distributes the shortfall instead, which is what a row of
/// labelled buttons wants. Opt out for strips that are deliberately wider
/// than their viewport — a carousel rail, a tray meant to be scrolled —
/// where shrinking to fit would defeat the point.
pub fn no_shrink( mut self ) -> Self
{
self.shrink = false;
self
}
/// Push the content block to the right edge of the available width.
pub fn align_right( mut self ) -> Self
{
self.align_right = true;
self
}
/// Pin children to the top edge instead of the default vertical
/// centering, so siblings of slightly different heights (e.g. two
/// cards whose text metrics differ) share the same top line.
pub fn align_top( mut self ) -> Self
{
self.align_top = true;
self
}
/// Pin children to the bottom edge, so taller siblings grow upward.
pub fn align_bottom( mut self ) -> Self
{
self.align_bottom = true;
self
}
/// Stretch every child to the row's inner height, so siblings share
/// both the top and the bottom line regardless of their natural
/// heights. The row's own height still comes from the tallest
/// child's preferred size; children whose content should track the
/// stretch need internal flexible spacers.
pub fn fill_height( mut self ) -> Self
{
self.fill_height = true;
self
}
/// Return the preferred `(width, height)` given available `max_width`.
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{
let pad = self.resolved_padding( canvas );
let spacing = self.resolved_spacing( canvas );
// Width contribution of every fixed (non-flex, non-flex-spacer) child.
// Used to compute the residual width that wrap-style children will
// actually render in, so their reported height matches the layout.
let inner_w = ( max_width - pad * 2.0 ).max( 0.0 );
let gaps = spacing * self.children.len().saturating_sub( 1 ) as f32;
let fixed_w: f32 = self.children.iter()
.filter( |c| match c
{
Element::Flex( _ ) => false,
Element::Spacer( s ) => s.resolved_width( canvas ).is_some(),
_ => true,
} )
.map( |c| c.preferred_size( max_width, canvas ).0 )
.sum();
let residual = ( inner_w - fixed_w - gaps ).max( 0.0 );
let max_h: f32 = self.children.iter()
.map( |c| match c
{
Element::Flex( _ ) => c.preferred_size( residual, canvas ).1,
Element::Spacer( _ ) => c.preferred_size( max_width, canvas ).1,
_ => c.preferred_size( max_width, canvas ).1,
} )
.fold( 0.0_f32, f32::max );
// `align_right` and any flex / weight-only spacer child make the row
// claim the full `max_width`: in those cases the row's rendered width
// comes from leftover distribution (or the right-edge anchor), not
// from the sum of children's preferred widths. Reporting only the
// natural sum would leave the parent allocating a too-narrow rect and
// the flex children would collapse to 0.
let has_flex = self.children.iter().any( |c| match c
{
Element::Flex( _ ) => true,
Element::Spacer( s ) => s.resolved_width( canvas ).is_none(),
_ => false,
} );
let w = if self.align_right || has_flex
{
max_width
} else {
let total_w: f32 = self.children.iter()
.map( |c| c.preferred_size( max_width, canvas ).0 )
.sum::<f32>()
+ gaps
+ pad * 2.0;
total_w.min( max_width )
};
( w, max_h + pad * 2.0 )
}
pub fn draw( &self, _canvas: &mut Canvas, _rect: Rect, _focused: bool ) {}
/// Layout children within rect and return `(rect, child_index)` pairs.
///
/// Flexible [`Spacer`](crate::layout::spacer::Spacer) children claim the
/// leftover horizontal space: non-spacer widgets keep their preferred
/// width, the remaining width (after subtracting spacing + padding) is
/// distributed between spacers in proportion to their `weight`. When no
/// spacers are present the cluster is centered (or right-aligned via
/// [`Row::align_right`]).
pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<(Rect, usize)>
{
let pad = self.resolved_padding( canvas );
let spacing = self.resolved_spacing( canvas );
let inner_h = rect.height - pad * 2.0;
// Spacers and `Flex` wrappers report 0 width here; their real width
// comes from the flex distribution below.
let sizes: Vec<(f32, f32)> = self.children.iter()
.map( |c| c.preferred_size( rect.width, canvas ) )
.collect();
let gaps = spacing * self.children.len().saturating_sub( 1 ) as f32;
let fixed_w: f32 = self.children.iter().zip( sizes.iter() )
.filter( |( c, _ )| match c
{
// Pure flex children (`Flex` and weight-only `Spacer`) take
// width from the leftover pool; everything else, including
// `Spacer::width(...)`-pinned spacers, contributes to the
// fixed-width tally.
Element::Flex( _ ) => false,
Element::Spacer( s ) => s.resolved_width( canvas ).is_some(),
_ => true,
} )
.map( |( _, ( w, _ ) )| *w )
.sum();
let total_weight: u32 = self.children.iter()
.filter_map( |c| match c {
Element::Spacer( s ) if s.resolved_width( canvas ).is_none() => Some( s.weight ),
Element::Flex( f ) => Some( f.weight ),
_ => None,
} )
.sum();
let inner_w = ( rect.width - pad * 2.0 ).max( 0.0 );
// Deficit distribution: cap the widest children first, so a long
// label gives up its width before a neighbouring icon does. A uniform
// scale-down would thin a header's back arrow along with its title.
// Spacers keep the width they were pinned to — an explicit gap is a
// decision, not slack.
let mut flexible: Vec<f32> = self.children.iter().zip( sizes.iter() )
.filter( |( c, _ )| !matches!( c, Element::Flex( _ ) | Element::Spacer( _ ) ) )
.map( |( _, ( w, _ ) )| *w )
.collect();
let flexible_w: f32 = flexible.iter().sum();
let rigid_w = ( fixed_w - flexible_w ).max( 0.0 );
let cap = if self.shrink
{
width_cap( &mut flexible, ( inner_w - gaps - rigid_w ).max( 0.0 ) )
} else {
f32::INFINITY
};
let laid_w = rigid_w + flexible.iter().map( |w| w.min( cap ) ).sum::<f32>();
let leftover = ( inner_w - laid_w - gaps ).max( 0.0 );
let has_spacers = total_weight > 0;
let ( start_x, flex_unit ) = if has_spacers
{
// Spacers and `Flex` wrappers claim the leftover; the cluster
// sits flush to the left edge of the inner rect.
( rect.x + pad, leftover / total_weight as f32 )
}
else if self.align_right
{
( rect.x + rect.width - ( laid_w + gaps ) - pad, 0.0 )
}
else
{
( rect.x + ( rect.width - laid_w - gaps ) / 2.0, 0.0 )
};
let mut x = start_x;
let mut result = Vec::with_capacity( self.children.len() );
for ( i, ( (w, h), child) ) in sizes.into_iter().zip( self.children.iter() ).enumerate()
{
let width = match child
{
Element::Spacer( s ) => match s.resolved_width( canvas )
{
Some( fw ) => fw,
None => flex_unit * s.weight as f32,
},
Element::Flex( f ) => flex_unit * f.weight as f32,
_ => w.min( cap ),
};
let ( y, height ) = if self.fill_height && !matches!( child, Element::Spacer( _ ) )
{
( rect.y + pad, inner_h )
} else if self.align_top {
( rect.y + pad, h )
} else if self.align_bottom {
( rect.y + pad + inner_h - h, h )
} else {
( rect.y + pad + ( inner_h - h ) / 2.0, h )
};
result.push( ( Rect { x, y, width, height }, i ) );
x += width + spacing;
}
result
}
pub( crate ) fn map_msg<U>( self, f: &crate::widget::MapFn<Msg, U> ) -> Row<U>
where
U: Clone + 'static,
Msg: 'static,
{
Row
{
children: self.children.into_iter().map( |c| c.map_arc( f ) ).collect(),
spacing: self.spacing,
padding: self.padding,
align_right: self.align_right,
align_top: self.align_top,
align_bottom: self.align_bottom,
fill_height: self.fill_height,
shrink: self.shrink,
}
}
}
/// Largest per-child width `c` for which `sum( min( w, c ) ) <= available`.
///
/// Water filling: the widest children give up width first and the narrow ones
/// are left alone until the wide ones have been levelled down to them. In a
/// header that means the title absorbs the whole shortfall while the back
/// arrow beside it keeps its size. Sorts `widths` in place; returns infinity
/// when everything already fits.
fn width_cap( widths: &mut [f32], available: f32 ) -> f32
{
if widths.iter().sum::<f32>() <= available
{
return f32::INFINITY;
}
widths.sort_by( |a, b| a.partial_cmp( b ).unwrap_or( std::cmp::Ordering::Equal ) );
let mut prefix = 0.0_f32;
for ( i, w ) in widths.iter().enumerate()
{
let remaining = ( widths.len() - i ) as f32;
let cap = ( available - prefix ) / remaining;
if cap <= *w
{
return cap.max( 0.0 );
}
prefix += *w;
}
0.0
}
/// Create an empty row layout.
pub fn row<Msg: Clone>() -> Row<Msg>
{
Row::new()
}
impl<Msg: Clone> Default for Row<Msg>
{
fn default() -> Self
{
Self::new()
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
use crate::render::Canvas;
use crate::types::Rect;
fn make_canvas() -> Canvas { Canvas::new( 800, 600 ) }
/// Children whose preferred widths overflow the rect are scaled down to
/// fit, rather than spilling symmetrically out of it.
#[ test ]
fn overflowing_children_shrink_to_fit()
{
let canvas = make_canvas();
let build = || row::<()>()
.spacing( 0.0 )
.padding( 0.0 )
.push( crate::toggle( false ) )
.push( crate::toggle( false ) );
let natural = build().preferred_size( 10_000.0, &canvas ).0;
assert!( natural > 0.0, "toggles must report a width for this to test anything" );
let rect = Rect { x: 0.0, y: 0.0, width: natural / 2.0, height: 48.0 };
let laid: f32 = build().layout( rect, &canvas )
.iter().map( |( r, _ )| r.width ).sum();
assert!( laid <= rect.width + 0.5, "laid out {laid} into {}", rect.width );
let kept: f32 = build().no_shrink().layout( rect, &canvas )
.iter().map( |( r, _ )| r.width ).sum();
assert!( kept > rect.width, "no_shrink kept {kept}, expected wider than {}", rect.width );
}
/// The shortfall comes off the widest child first: a narrow neighbour —
/// a header's back arrow next to its title — keeps its size.
#[ test ]
fn narrow_child_survives_a_wide_neighbour()
{
let mut w = vec![ 20.0_f32, 300.0 ];
let cap = width_cap( &mut w, 200.0 );
assert_eq!( 20.0_f32.min( cap ), 20.0 );
assert_eq!( 300.0_f32.min( cap ), 180.0 );
}
/// Equal children share the shortfall equally, which is what a segmented
/// control wants.
#[ test ]
fn equal_children_shrink_equally()
{
let mut w = vec![ 100.0_f32, 100.0, 100.0 ];
let cap = width_cap( &mut w, 150.0 );
assert_eq!( cap, 50.0 );
}
#[ test ]
fn nothing_shrinks_when_it_all_fits()
{
let mut w = vec![ 10.0_f32, 20.0 ];
assert_eq!( width_cap( &mut w, 100.0 ), f32::INFINITY );
}
/// Pinned spacers are an explicit gap, not slack, so the shrink pass
/// leaves them alone.
#[ test ]
fn pinned_spacers_keep_their_width_when_shrinking()
{
let canvas = make_canvas();
let r = row::<()>()
.spacing( 0.0 )
.padding( 0.0 )
.push( crate::spacer().width( 60.0 ).height( 10.0 ) )
.push( crate::toggle( false ) );
let rect = Rect { x: 0.0, y: 0.0, width: 70.0, height: 48.0 };
let cells = r.layout( rect, &canvas );
assert_eq!( cells[ 0 ].0.width, 60.0 );
}
#[ test ]
fn align_right_returns_full_max_width()
{
let canvas = make_canvas();
let r = row::<()>().align_right();
let ( w, _ ) = r.preferred_size( 500.0, &canvas );
assert_eq!( w, 500.0 );
}
#[ test ]
fn align_right_true_regardless_of_children()
{
let canvas = make_canvas();
let r = row::<()>().align_right().spacing( 999.0 );
let ( w, _ ) = r.preferred_size( 300.0, &canvas );
assert_eq!( w, 300.0 );
}
#[ test ]
fn centered_empty_row_returns_zero_width()
{
let canvas = make_canvas();
let r = row::<()>();
let ( w, _ ) = r.preferred_size( 500.0, &canvas );
assert_eq!( w, 0.0 );
}
#[ test ]
fn centered_empty_row_returns_zero_height()
{
let canvas = make_canvas();
let r = row::<()>().padding( 0.0 );
let ( _, h ) = r.preferred_size( 500.0, &canvas );
assert_eq!( h, 0.0 );
}
#[ test ]
fn padding_adds_to_height()
{
let canvas = make_canvas();
let r = row::<()>().padding( 8.0 );
let ( _, h ) = r.preferred_size( 500.0, &canvas );
assert_eq!( h, 16.0 );
}
#[ test ]
fn layout_of_empty_row_is_empty()
{
let canvas = make_canvas();
let r = row::<()>().align_right();
let rect = Rect { x: 0., y: 0., width: 400., height: 48. };
assert!( r.layout( rect, &canvas ).is_empty() );
}
#[ test ]
fn vmin_padding_doubles_around_content()
{
// 800x600 canvas → vmin = 600. 4 % = 24 px each side → 48 px height.
let canvas = make_canvas();
let r = row::<()>().padding( Length::vmin( 4.0 ) );
let ( _, h ) = r.preferred_size( 500.0, &canvas );
assert_eq!( h, 48.0 );
}
#[ test ]
fn vmin_spacing_pins_visible_layout_gap()
{
// Two fixed-width spacers separated by a vmin spacing of 5 %.
// Canvas vmin = 600 → 30 px between the inner edges. With both
// spacers 10 px wide, the second one's `x` minus the first one's
// `x + width` must equal the gap regardless of where the row chose
// to anchor the cluster (centered, since there are no flex spacers).
let canvas = make_canvas();
let r = row::<()>()
.padding( 0.0 )
.spacing( Length::vmin( 5.0 ) )
.push( crate::spacer().width( 10.0 ) )
.push( crate::spacer().width( 10.0 ) );
let rect = Rect { x: 0., y: 0., width: 200., height: 48. };
let placed = r.layout( rect, &canvas );
assert_eq!( placed.len(), 2 );
let ( first_rect, _ ) = placed[ 0 ];
let ( second_rect, _ ) = placed[ 1 ];
let gap = second_rect.x - ( first_rect.x + first_rect.width );
assert!( ( gap - 30.0 ).abs() < 1e-3, "expected ~30 px gap, got {gap}" );
}
}