`Container::max_width(px)` mirrors the same flag on `Column` / `Row` — the container reports `min( offered, px )` upwards and the draw pass caps `rect.width` to `px`, so a decorated child wrapped in `container().max_width(260)` no longer needs a `column()`-of-one shell just to access the cap. Propagated through `map_msg`, covered by a test, and documented under the `container` section of `docs/widgets.md`.
`Text::no_truncate()` opts out of the default ellipsis behaviour: when `truncate = false` the draw pass paints the full string even if `measure(text) > rect.width`. Useful for very short labels (calendar days "1"–"31", day-of-week stubs "Lu" / "Mi" / "Sá") inside grid slots whose width is dictated by the parent — a couple of pixels of overflow centred in the rect is invisible, while "..." in place of a single-digit number is loud.
`DatePicker::width(px)` is a layout hint: when set, `build()` derives `header_fs`, `dow_fs` and `day_fs` from the slot width so the worst-case label in each row ("September 2026" in the header, "Mié" / "Sáb" in the DOW row, "30" in the day cell) fits at a sensible size; the design defaults stay as upper bounds. Day and DOW cells also flip on `Text::no_truncate()` as a safety net — heuristic font sizing can't perfectly predict glyph widths across families, and overflowing one pixel beats truncating to "...".
387 lines
13 KiB
Rust
387 lines
13 KiB
Rust
// SPDX-License-Identifier: LGPL-2.1-only
|
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
|
|
|
//! Element-tree recursive walker.
|
|
//!
|
|
//! [`layout_and_draw`] is the single bottom of the draw pipeline:
|
|
//! given an [`Element`] + its allocated rect, it lays out children,
|
|
//! paints leaves, threads scroll sub-canvases, and records the
|
|
//! [`LaidOutWidget`] entries the input layer will hit-test against.
|
|
//! Both software and GPU paths funnel every widget through this one
|
|
//! function.
|
|
|
|
use crate::render::Canvas;
|
|
use crate::types::Rect;
|
|
use crate::widget::{ Element, LaidOutWidget, WidgetHandlers };
|
|
|
|
use super::DrawCtx;
|
|
use super::damage::clamp_rect_to;
|
|
|
|
pub( crate ) fn layout_and_draw<Msg: Clone>(
|
|
element: &Element<Msg>,
|
|
canvas: &mut Canvas,
|
|
rect: Rect,
|
|
ctx: &mut DrawCtx<Msg>,
|
|
flat_idx: usize,
|
|
) -> usize
|
|
{
|
|
match element
|
|
{
|
|
Element::Column( col ) =>
|
|
{
|
|
let child_rects = col.layout( rect, canvas );
|
|
let mut idx = flat_idx;
|
|
for ( child_rect, child_i ) in child_rects
|
|
{
|
|
idx = layout_and_draw::<Msg>( &col.children[child_i], canvas, child_rect, ctx, idx );
|
|
}
|
|
idx
|
|
}
|
|
Element::Row( r ) =>
|
|
{
|
|
let child_rects = r.layout( rect, canvas );
|
|
let mut idx = flat_idx;
|
|
for ( child_rect, child_i ) in child_rects
|
|
{
|
|
idx = layout_and_draw::<Msg>( &r.children[child_i], canvas, child_rect, ctx, idx );
|
|
}
|
|
idx
|
|
}
|
|
Element::Stack( s ) =>
|
|
{
|
|
let child_rects = s.layout( rect, canvas );
|
|
let mut idx = flat_idx;
|
|
for ( child_rect, child_i ) in child_rects
|
|
{
|
|
idx = layout_and_draw::<Msg>( &s.children[ child_i ].0, canvas, child_rect, ctx, idx );
|
|
}
|
|
idx
|
|
}
|
|
Element::WrapGrid( g ) =>
|
|
{
|
|
let child_rects = g.layout( rect, canvas );
|
|
let mut idx = flat_idx;
|
|
for ( child_rect, child_i ) in child_rects
|
|
{
|
|
idx = layout_and_draw::<Msg>( &g.children[child_i], canvas, child_rect, ctx, idx );
|
|
}
|
|
idx
|
|
}
|
|
Element::Flex( f ) =>
|
|
{
|
|
// `Flex` is invisible chrome: it claimed leftover width up at
|
|
// `Row::layout`, here we just unwrap it and draw the child
|
|
// inside the allocated rect. No flat-index of its own.
|
|
layout_and_draw::<Msg>( f.child.as_ref(), canvas, rect, ctx, flat_idx )
|
|
}
|
|
Element::AnchoredOverlay( a ) =>
|
|
{
|
|
// Look up the anchor's rect from the previous frame's
|
|
// `widget_rects` snapshot. If found, place the child flush
|
|
// below the anchor at the child's intrinsic size; if not,
|
|
// fall back to the parent-supplied rect so the child still
|
|
// renders (modal-style, typically a one-frame artefact on
|
|
// the first frame after open).
|
|
let anchor_rect = ctx.previous_widget_rects.iter()
|
|
.find( |w| w.id == Some( a.anchor_id ) )
|
|
.map( |w| w.rect );
|
|
let target = match anchor_rect
|
|
{
|
|
Some( anchor ) =>
|
|
{
|
|
// Use the child's intrinsic preferred size so the
|
|
// popup keeps its design width / height regardless
|
|
// of how wide the trigger pill happens to be.
|
|
let ( w, h ) = a.child.preferred_size( rect.width, canvas );
|
|
Rect
|
|
{
|
|
x: anchor.x,
|
|
y: anchor.y + anchor.height + a.gap,
|
|
width: w,
|
|
height: h,
|
|
}
|
|
}
|
|
None => rect,
|
|
};
|
|
layout_and_draw::<Msg>( a.child.as_ref(), canvas, target, ctx, flat_idx )
|
|
}
|
|
Element::Pressable( p ) =>
|
|
{
|
|
// Push the wrapper's hit rect *before* recursing so that any
|
|
// interactive child pushed during recursion sits later in
|
|
// `widget_rects` and wins under `iter().rev()` hit testing.
|
|
let my_idx = flat_idx;
|
|
if p.has_handler()
|
|
{
|
|
ctx.widget_rects.push( LaidOutWidget
|
|
{
|
|
rect,
|
|
flat_idx: my_idx,
|
|
id: p.id,
|
|
paint_rect: rect,
|
|
handlers: WidgetHandlers::Button
|
|
{
|
|
on_press: p.on_press.clone(),
|
|
on_long_press: p.on_long_press.clone(),
|
|
on_drag_start: p.on_drag_start.clone(),
|
|
on_escape: p.on_escape.clone(),
|
|
repeating: false,
|
|
},
|
|
keyboard_focusable: false,
|
|
cursor: p.cursor.unwrap_or( crate::types::CursorShape::Pointer ),
|
|
} );
|
|
}
|
|
layout_and_draw::<Msg>( p.child.as_ref(), canvas, rect, ctx, my_idx + 1 )
|
|
}
|
|
Element::Container( c ) =>
|
|
{
|
|
let saved_alpha = canvas.global_alpha();
|
|
canvas.set_global_alpha( saved_alpha * c.opacity );
|
|
|
|
let rect = if let Some( mw ) = c.max_width
|
|
{
|
|
crate::types::Rect { width: rect.width.min( mw ), ..rect }
|
|
} else {
|
|
rect
|
|
};
|
|
|
|
// Surface slot takes precedence over flat background; falls
|
|
// through to `c.background` when the slot is absent (third-
|
|
// party theme without the named surface — content still
|
|
// renders, just without the themed chrome).
|
|
let painted = match c.surface.as_deref()
|
|
{
|
|
Some( slot ) => match crate::theme::resolve_surface( slot )
|
|
{
|
|
Some( ( surf, outer ) ) =>
|
|
{
|
|
canvas.fill_surface
|
|
(
|
|
rect,
|
|
&surf.fill,
|
|
&outer,
|
|
&surf.inset_shadows,
|
|
c.corners,
|
|
);
|
|
true
|
|
}
|
|
None => false,
|
|
},
|
|
None => false,
|
|
};
|
|
if !painted
|
|
{
|
|
if let Some( ref bg ) = c.background
|
|
{
|
|
canvas.fill_paint_rect( rect, bg, c.corners );
|
|
}
|
|
}
|
|
if let Some( ( color, width ) ) = c.border
|
|
{
|
|
canvas.stroke_rect( rect, color, width, c.corners );
|
|
}
|
|
let inner = crate::types::Rect
|
|
{
|
|
x: rect.x + c.pad_left,
|
|
y: rect.y + c.pad_top,
|
|
width: ( rect.width - c.pad_left - c.pad_right ).max( 0.0 ),
|
|
height: ( rect.height - c.pad_top - c.pad_bottom ).max( 0.0 ),
|
|
};
|
|
let result = layout_and_draw::<Msg>( c.child.as_ref(), canvas, inner, ctx, flat_idx );
|
|
|
|
canvas.set_global_alpha( saved_alpha );
|
|
result
|
|
}
|
|
Element::Scroll( s ) =>
|
|
{
|
|
let my_idx = flat_idx;
|
|
let offset_raw = ctx.scroll_offsets.get( &my_idx ).copied().unwrap_or( 0.0 );
|
|
let child_h = s.child.preferred_size( rect.width, canvas ).1;
|
|
let offset = crate::widget::scroll::clamp_offset( offset_raw, child_h, rect.height );
|
|
// Write the clamped offset back so input handlers (wheel /
|
|
// drag) cannot accumulate past the content extents. Without
|
|
// this, repeated wheel ticks past the bottom keep growing
|
|
// `offset_raw` and the user has to "undo" that excess before
|
|
// the content scrolls back up. The clamp is idempotent for
|
|
// in-range values, so the only effect is collapsing
|
|
// out-of-range entries to the legitimate maximum.
|
|
if offset != offset_raw
|
|
{
|
|
ctx.scroll_offsets.insert( my_idx, offset );
|
|
}
|
|
|
|
// Reuse the sub-canvas from the previous frame if its size matches;
|
|
// only reallocate when the viewport is resized.
|
|
let sw = (rect.width.ceil() as u32).max( 1 );
|
|
let sh = (rect.height.ceil() as u32).max( 1 );
|
|
let mut sub = ctx.scroll_canvases.remove( &my_idx )
|
|
.filter( |c| c.size() == ( sw, sh ) )
|
|
.unwrap_or_else( || canvas.sub_canvas( sw, sh ) );
|
|
sub.clear();
|
|
|
|
// Child content fills at least the full viewport height so that
|
|
// children with VAlign::Bottom are positioned correctly when the
|
|
// content is shorter than the viewport.
|
|
let effective_h = child_h.max( rect.height );
|
|
// Shift child up by offset so scrolled content appears at y=0 in
|
|
// the sub-canvas.
|
|
let child_rect = Rect { x: 0.0, y: -offset, width: rect.width, height: effective_h };
|
|
|
|
let rects_before = ctx.widget_rects.len();
|
|
let scroll_rects_before = ctx.scroll_rects.len();
|
|
let next_idx = layout_and_draw::<Msg>( s.child.as_ref(), &mut sub, child_rect, ctx, my_idx + 1 );
|
|
|
|
// Translate widget_rects from sub-canvas space to global space; drop
|
|
// clipped ones. Also clamp `paint_rect` to the scroll viewport so a
|
|
// widget painting outside the sub-canvas does not invalidate pixels
|
|
// it cannot actually reach (the sub-canvas is blitted as a whole,
|
|
// so nothing outside the viewport appears on screen anyway).
|
|
let new_rects: Vec<LaidOutWidget<Msg>> = ctx.widget_rects.drain( rects_before.. ).collect();
|
|
|
|
// Capture every interactive item the child laid out — including
|
|
// items that the visibility filter below will discard — so the
|
|
// keyboard handler can step `hovered_idx` item-by-item without
|
|
// caring about which items are currently scrolled into view.
|
|
// The recorded Y is in pre-translation, pre-offset coordinates
|
|
// (i.e. `child_y` relative to the start of the scroll content),
|
|
// recovered by undoing the `-offset` shift the child layout pass
|
|
// applied: `content_y = w.rect.y - child_rect.y = w.rect.y + offset`.
|
|
let navigable: Vec<( usize, f32, f32 )> = new_rects.iter()
|
|
.filter( |w| w.handlers.is_navigable_list_item() )
|
|
.map( |w| ( w.flat_idx, w.rect.y + offset, w.rect.height ) )
|
|
.collect();
|
|
if !navigable.is_empty()
|
|
{
|
|
ctx.scroll_navigable_items.insert( my_idx, navigable );
|
|
}
|
|
|
|
for mut w in new_rects
|
|
{
|
|
// Sub-canvas origin (0,0) maps to global (rect.x, rect.y).
|
|
w.rect.x += rect.x;
|
|
w.rect.y += rect.y;
|
|
w.paint_rect.x += rect.x;
|
|
w.paint_rect.y += rect.y;
|
|
w.paint_rect = clamp_rect_to( w.paint_rect, rect );
|
|
// Only keep widgets at least partially visible inside the viewport.
|
|
if w.rect.y + w.rect.height > rect.y && w.rect.y < rect.y + rect.height
|
|
{
|
|
ctx.widget_rects.push( w );
|
|
}
|
|
}
|
|
|
|
// Same translation for scroll_rects pushed by any nested
|
|
// scroll widgets — without this, the wheel handler hit-tests
|
|
// in surface coords against rects in sub-canvas coords and
|
|
// only matches when the sub-canvas happens to start at
|
|
// (0, 0) of the surface. Clamp to the outer scroll rect so
|
|
// the inner scroll only captures wheel events inside its
|
|
// visible region.
|
|
let new_scroll_rects: Vec<( Rect, usize )> =
|
|
ctx.scroll_rects.drain( scroll_rects_before.. ).collect();
|
|
for ( mut r, idx ) in new_scroll_rects
|
|
{
|
|
r.x += rect.x;
|
|
r.y += rect.y;
|
|
let clamped = clamp_rect_to( r, rect );
|
|
if clamped.width > 0.0 && clamped.height > 0.0
|
|
{
|
|
ctx.scroll_rects.push( ( clamped, idx ) );
|
|
}
|
|
}
|
|
|
|
ctx.scroll_rects.push( ( rect, my_idx ) );
|
|
|
|
canvas.blit( &sub, rect.x as i32, rect.y as i32 );
|
|
|
|
ctx.scroll_canvases.insert( my_idx, sub );
|
|
|
|
next_idx
|
|
}
|
|
Element::Viewport( v ) =>
|
|
{
|
|
let child_h = v.child.preferred_size( rect.width, canvas ).1;
|
|
let effective_h = child_h.max( rect.height );
|
|
let vw = ( rect.width.ceil() as u32 ).max( 1 );
|
|
let vh = ( rect.height.ceil() as u32 ).max( 1 );
|
|
let mut sub = canvas.sub_canvas( vw, vh );
|
|
sub.clear();
|
|
|
|
let child_rect = Rect { x: 0.0, y: 0.0, width: rect.width, height: effective_h };
|
|
let rects_before = ctx.widget_rects.len();
|
|
let scroll_rects_before = ctx.scroll_rects.len();
|
|
let next_idx = layout_and_draw::<Msg>( v.child.as_ref(), &mut sub, child_rect, ctx, flat_idx );
|
|
|
|
let new_rects: Vec<LaidOutWidget<Msg>> = ctx.widget_rects.drain( rects_before.. ).collect();
|
|
for mut w in new_rects
|
|
{
|
|
w.rect.x += rect.x;
|
|
w.rect.y += rect.y;
|
|
w.paint_rect.x += rect.x;
|
|
w.paint_rect.y += rect.y;
|
|
w.paint_rect = clamp_rect_to( w.paint_rect, rect );
|
|
if w.rect.y + w.rect.height > rect.y && w.rect.y < rect.y + rect.height
|
|
{
|
|
ctx.widget_rects.push( w );
|
|
}
|
|
}
|
|
|
|
// Translate scroll_rects pushed by nested scroll widgets
|
|
// from sub-canvas to surface space. Same reasoning as the
|
|
// scroll-inside-scroll case above.
|
|
let new_scroll_rects: Vec<( Rect, usize )> =
|
|
ctx.scroll_rects.drain( scroll_rects_before.. ).collect();
|
|
for ( mut r, idx ) in new_scroll_rects
|
|
{
|
|
r.x += rect.x;
|
|
r.y += rect.y;
|
|
let clamped = clamp_rect_to( r, rect );
|
|
if clamped.width > 0.0 && clamped.height > 0.0
|
|
{
|
|
ctx.scroll_rects.push( ( clamped, idx ) );
|
|
}
|
|
}
|
|
|
|
canvas.blit_fade_bottom( &sub, rect.x as i32, rect.y as i32, v.fade_bottom );
|
|
next_idx
|
|
}
|
|
other =>
|
|
{
|
|
// Gate the focus ring by `is_focusable`: a widget that opts out
|
|
// of keyboard focus (e.g. `Button::focusable(false)`) should not
|
|
// paint a ring even if it ends up in `focused_idx` after a tap.
|
|
let is_focused = ctx.focused_idx == Some( flat_idx ) && other.is_focusable();
|
|
let is_hovered = ctx.hovered_idx == Some( flat_idx );
|
|
let is_pressed = ctx.pressed_idx == Some( flat_idx );
|
|
let cursor_pos = ctx.cursor_state.get( &flat_idx ).copied().unwrap_or( 0 );
|
|
let sel_anchor = ctx.selection_anchor.get( &flat_idx ).copied().unwrap_or( cursor_pos );
|
|
let widget_id = match other
|
|
{
|
|
Element::Button( b ) => b.id,
|
|
Element::TextEdit( t ) => t.id,
|
|
Element::Toggle( t ) => t.id,
|
|
Element::Checkbox( c ) => c.id,
|
|
Element::Radio( r ) => r.id,
|
|
Element::ListItem( l ) => l.id,
|
|
Element::WindowButton( b ) => b.id,
|
|
_ => None,
|
|
};
|
|
other.draw( canvas, rect, is_focused, is_hovered, is_pressed, cursor_pos, sel_anchor );
|
|
if other.is_interactive()
|
|
{
|
|
ctx.widget_rects.push( LaidOutWidget
|
|
{
|
|
rect,
|
|
flat_idx,
|
|
id: widget_id,
|
|
paint_rect: other.paint_bounds( rect ),
|
|
handlers: other.handlers(),
|
|
keyboard_focusable: other.is_focusable(),
|
|
cursor: other.cursor_shape(),
|
|
} );
|
|
}
|
|
flat_idx + 1
|
|
}
|
|
}
|
|
}
|