Files
ltk/src/draw/layout.rs
T
admin 02ab10b3ef
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
State restored on an ordinary launch for apps that ask, focus that follows its widget's id, buttons that do not take the focus when pressed, a focus ring only after the keyboard, and a browser app's surfaces copied rather than transferred, which WebKit got wrong
Restore on launch. `restore_state` follows Android's saved-instance-state contract: the saved bytes come back after a crash or a session restore, and a launch from the launcher starts fresh. Some apps keep state their users expect to find however the last run ended, as an Android browser keeps its tabs in its own storage even when closed from the app switcher. `App::restore_on_launch`, false by default, has the runtime restore on a plain launch too; `SessionRuntime::bootstrap` asks it before deciding. Saving, the file layout and the window geometry are the same either way. Its documentation explains when it is asked, that it is not when persistence is off, that `restore_state` must then merge what the launch itself asks for, such as a URL on the command line, and that what `save_state` returns now outlives every exit; it carries a compiled example. `restore_state`'s documentation, `docs/onboarding.md` and the cookbook's session state recipe mention it.
Focus on a press. `Button::focusable( false )` and `WindowButton` kept out of the Tab cycle but still took the focus when pressed, and Enter then pressed them again, which closed a browser's tab when the user pressed Enter in a page after closing another tab. `LaidOutWidget` gains `focus_on_press`, from `Element::focus_on_press`: a button's `focusable`, a `WindowButton`'s, a pressable's new `Pressable::focus_on_press`, true by default, and true for every other widget. `focus_pressed` replaces `set_focus` for pointer and touch presses and leaves the focus where it was when the widget does not take it. Enter's fallback to the widget under the pointer, which `hovered_idx` follows, skips such widgets too, and a rebuild that leaves the focus on one drops it (`drop_misplaced_focus`). The close buttons of `tab_strip`, on the desktop and on the phone's cards, are not focusable; `symbol_button` takes whether its button is.
Focus by id. The focus was held as the index of a widget in the laid-out tree, so a rebuild that inserted or removed widgets before it moved it to whatever widget took that index. `WidgetId` becomes a name and a key, `WidgetId::keyed( name, key )` telling apart widgets of the same kind, such as the tabs of a strip; `WidgetId( "name" )` still builds one, through a `const fn` of that name, so the calls in every app compile unchanged, and only code that read `.0`, the toasts, the combo and the tooltip here, moves to the derived `Hash` or `name()`. After each layout `follow_focused_id` finds the focused id and moves the focus to its new index with the field's pending text, cursor, selection and scroll; it drops the focus when the widget is gone, keeps it when a scroll only clipped it, and leaves it when the widget at the old index still has the id. `draw_surface` returns whether the focus moved, and its callers draw the surface again rather than marking it done, since the frame just drawn showed the focus in the old place. Widgets without an id keep the index. Each tab and each card of `tab_strip` carries `WidgetId::keyed` with the tab's key, so a chosen tab keeps the focus while others close.
Focus ring. A press that focused a button, a toggle or a list item left its ring drawn, which browsers have not done since `:focus-visible`. `SurfaceState::focus_visible` is set by Tab and Shift+Tab and cleared by a press that moves the focus, `show_focus_ring` redrawing the whole surface when it changes, since the partial redraw only sees indices. The draw is given the focused index only while the ring shows or the widget is a text field, which draws its cursor however it was focused; the focus itself is unchanged, so Enter and Space still press the widget.
Key repeat. A key repeated by the platform rather than by ltk's own timer reached the focused widget even while `App::claims_keyboard` was true; `key_repeated` checks it as `key_press` does.
Browser surfaces. Every surface is painted through the app's one WebGL context on one `OffscreenCanvas`, and was handed to its own canvas, a `bitmaprenderer`, with `transferToImageBitmap`. WebKit reuses the buffer of a bitmap it was handed, so in a WebKit browser each surface showed another's frame, or none, and only the last one painted was right: forge/web's background, dock and windows came and went in a WPE browser while Chromium showed them all. A test page with two canvases fed from one offscreen canvas showed their colours swapped in WPE and right with a copy. Each surface's canvas is now a `2d` one, and `present_copy` copies the offscreen canvas with `drawImage` in `copy` composition, set before every copy since resizing a canvas resets its context, so a transparent frame replaces the last rather than leaving trails behind an animation. Subsurfaces go the same way. `WebSurfaceState` loses `bitmap` and `new` its `gpu` argument, and web-sys loses the `ImageBitmap` and `ImageBitmapRenderingContext` features.
CHANGELOG records each of these.
Not done. The tests that build `LaidOutWidget` by hand set the new field, but no test covers a press on a non-focusable button, the focus following an id or the ring. For one frame after a rebuild moves the focused widget, its ring and its cursor may show in the old place.
2026-11-01 09:19:23 +01:00

654 lines
23 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::{ Corners, 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
{
let child = &s.children[ child_i ];
// A placed child may carry a clip rect (Android clipChildren): clip the
// canvas to it for the child's subtree, then restore the prior clip.
// The clip is in the Stack's coordinate space (like the placed rect),
// so it is shifted by the Stack's origin to match — otherwise a Stack
// laid out at a non-zero origin clips in the wrong place.
if let Some( clip ) = child.7
{
let saved = canvas.clip_bounds();
let clip = Rect { x: rect.x + clip.x, y: rect.y + clip.y, width: clip.width, height: clip.height };
canvas.set_clip_rects( &[ clip ] );
idx = layout_and_draw::<Msg>( &child.0, canvas, child_rect, ctx, idx );
canvas.set_clip_rects( &saved );
}
else
{
idx = layout_and_draw::<Msg>( &child.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::Carousel( car ) =>
{
let child_rects = car.layout( rect, canvas );
let mut idx = flat_idx;
for ( child_rect, child_i ) in child_rects
{
idx = layout_and_draw::<Msg>( &car.children[child_i], canvas, child_rect, ctx, idx );
}
idx
}
Element::TabRow( r ) =>
{
// The child is the scroll of the tabs, whose offset is keyed by this same index.
let inner = r.arrange( rect, canvas );
let offset = ctx.scroll_offsets.get( &flat_idx ).copied().unwrap_or( ( 0.0, 0.0 ) );
if let Some( x ) = r.scroll_to( offset.0, inner.width, canvas )
{
ctx.scroll_offsets.insert( flat_idx, ( x, offset.1 ) );
}
layout_and_draw::<Msg>( r.child.as_ref(), canvas, inner, ctx, flat_idx )
}
Element::TabCells( cells ) =>
{
cells.motion.set_origin( ctx.origin );
let child_rects = cells.layout( rect, canvas );
let mut idx = flat_idx;
for ( child_rect, child_i ) in child_rects
{
idx = layout_and_draw::<Msg>( cells.child( child_i ), canvas, child_rect, ctx, idx );
}
idx
}
Element::TabCards( cards ) =>
{
cards.motion.set_origin( ctx.origin );
let child_rects = cards.layout( rect, canvas );
let mut idx = flat_idx;
for ( child_rect, child_i ) in child_rects
{
idx = layout_and_draw::<Msg>( cards.child( 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: a.left( anchor, w, rect ),
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,
focus_on_press: p.focus_on_press,
cursor: p.cursor.unwrap_or( crate::types::CursorShape::Pointer ),
tooltip: None,
accessible_label: None,
is_live_region: ctx.live_depth > 0,
} );
}
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 live_inc = if c.a11y_live { 1 } else { 0 };
ctx.live_depth += live_inc;
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 );
}
if c.blur_behind
{
let Corners { tl, tr, br, bl } = c.corners;
let radius = tl.max( tr ).max( br ).max( bl ).min( rect.width * 0.5 ).min( rect.height * 0.5 );
let on_surface = Rect { x: rect.x + ctx.origin.0, y: rect.y + ctx.origin.1, ..rect };
ctx.blur_regions.push( ( on_surface, radius ) );
}
let pad_l = canvas.resolve_geom( c.pad_left );
let pad_r = canvas.resolve_geom( c.pad_right );
let pad_t = canvas.resolve_geom( c.pad_top );
let pad_b = canvas.resolve_geom( c.pad_bottom );
let inner = crate::types::Rect
{
x: rect.x + pad_l,
y: rect.y + pad_t,
width: ( rect.width - pad_l - pad_r ).max( 0.0 ),
height: ( rect.height - pad_t - pad_b ).max( 0.0 ),
};
let result = layout_and_draw::<Msg>( c.child.as_ref(), canvas, inner, ctx, flat_idx );
ctx.live_depth -= live_inc;
canvas.set_global_alpha( saved_alpha );
result
}
Element::Scroll( s ) =>
{
let my_idx = flat_idx;
let axis = s.axis;
let ( ox_raw, oy_raw ) = ctx.scroll_offsets.get( &my_idx ).copied().unwrap_or( ( 0.0, 0.0 ) );
// Width-aware preferred size: when the axis allows horizontal
// overflow we let the child report its natural width by
// asking with `f32::INFINITY`; otherwise the child stays
// clamped to the viewport so wraps still happen.
let child_w_max = if axis.allows_x() { f32::INFINITY } else { rect.width };
let ( child_w, child_h ) = s.child.preferred_size( child_w_max, canvas );
let ox = if axis.allows_x() { crate::widget::scroll::clamp_offset( ox_raw, child_w, rect.width ) } else { 0.0 };
let oy = if axis.allows_y() { crate::widget::scroll::clamp_offset( oy_raw, child_h, rect.height ) } else { 0.0 };
// Write the clamped offsets back so input handlers (wheel /
// drag) cannot accumulate past the content extents. Without
// this, repeated wheel ticks past the edge keep growing
// `*_raw` and the user has to "undo" that excess before the
// content scrolls back. The clamp is idempotent for in-range
// values, so the only effect is collapsing out-of-range
// entries to the legitimate maximum.
if ox != ox_raw || oy != oy_raw
{
ctx.scroll_offsets.insert( my_idx, ( ox, oy ) );
}
// 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 )
.map( |mut c| { if c.size() != ( sw, sh ) { c.resize( sw, sh ); } c } )
.unwrap_or_else( || canvas.sub_canvas( sw, sh ) );
sub.clear();
// Child content fills at least the full viewport on each
// allowed axis so that children with `Bottom`/`Right`
// alignment are positioned correctly when the content is
// shorter than the viewport along that axis.
let effective_w = if axis.allows_x() { child_w.max( rect.width ) } else { rect.width };
let effective_h = if axis.allows_y() { child_h.max( rect.height ) } else { rect.height };
// Shift child by `(-ox, -oy)` so scrolled content appears at
// `(0, 0)` of the sub-canvas.
let child_rect = Rect { x: -ox, y: -oy, width: effective_w, height: effective_h };
let rects_before = ctx.widget_rects.len();
let scroll_rects_before = ctx.scroll_rects.len();
let origin = ctx.origin;
ctx.origin = ( origin.0 + rect.x, origin.1 + rect.y );
let next_idx = layout_and_draw::<Msg>( s.child.as_ref(), &mut sub, child_rect, ctx, my_idx + 1 );
ctx.origin = origin;
// 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 `-oy` shift the child layout pass
// applied: `content_y = w.rect.y - child_rect.y = w.rect.y + oy`.
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 + oy, w.rect.height ) )
.collect();
if !navigable.is_empty()
{
ctx.scroll_navigable_items.insert( my_idx, navigable );
}
for mut w in new_rects
{
let content = Rect { x: w.rect.x + ox, y: w.rect.y + oy, ..w.rect };
// 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 — now bi-axial because Scroll::horizontal /
// Scroll::both push content off-screen along x too.
let visible_y = w.rect.y + w.rect.height > rect.y && w.rect.y < rect.y + rect.height;
let visible_x = w.rect.x + w.rect.width > rect.x && w.rect.x < rect.x + rect.width;
if visible_x && visible_y
{
ctx.widget_rects.push( w );
} else if let Some( id ) = w.id {
ctx.scroll_clipped.push( ( id, my_idx, content ) );
}
}
// 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, crate::widget::scroll::ScrollAxis )> =
ctx.scroll_rects.drain( scroll_rects_before.. ).collect();
for ( mut r, idx, ax ) 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, ax ) );
}
}
ctx.scroll_rects.push( ( rect, my_idx, axis ) );
canvas.blit( &sub, rect.x as i32, rect.y as i32 );
ctx.scroll_canvases.insert( my_idx, sub );
next_idx
}
// Without a fade the clip is a plain scissor on the same canvas: no
// offscreen pass, no blit, and the child's textures stay in the
// parent's caches.
Element::Viewport( v ) if v.fade_bottom <= 0.0 =>
{
let child_h = v.child.preferred_size( rect.width, canvas ).1;
let effective_h = child_h.max( rect.height );
let saved_clip = canvas.clip_bounds();
let clip = if saved_clip.is_empty()
{
rect
} else {
let ( mut x0, mut y0, mut x1, mut y1 ) = ( f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY );
for r in &saved_clip
{
x0 = x0.min( r.x );
y0 = y0.min( r.y );
x1 = x1.max( r.x + r.width );
y1 = y1.max( r.y + r.height );
}
clamp_rect_to( rect, Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } )
};
canvas.set_clip_rects( &[ clip ] );
let prev_viewport = if v.local_viewport
{
canvas.layout_viewport_override( Some( ( rect.width, rect.height ) ) )
} else {
None
};
let child_rect = Rect { x: rect.x, y: rect.y, 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(), canvas, child_rect, ctx, flat_idx );
if v.local_viewport { canvas.layout_viewport_override( prev_viewport ); }
canvas.set_clip_rects( &saved_clip );
let new_rects: Vec<LaidOutWidget<Msg>> = ctx.widget_rects.drain( rects_before.. ).collect();
for mut w in new_rects
{
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 );
}
}
let new_scroll_rects: Vec<( Rect, usize, crate::widget::scroll::ScrollAxis )> =
ctx.scroll_rects.drain( scroll_rects_before.. ).collect();
for ( r, idx, ax ) in new_scroll_rects
{
let clamped = clamp_rect_to( r, rect );
if clamped.width > 0.0 && clamped.height > 0.0
{
ctx.scroll_rects.push( ( clamped, idx, ax ) );
}
}
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 seq = ctx.viewport_seq;
ctx.viewport_seq += 1;
let mut sub = ctx.viewport_canvases.remove( &seq )
.map( |mut c| { if c.size() != ( vw, vh ) { c.resize( vw, vh ); } c } )
.unwrap_or_else( || canvas.sub_canvas( vw, vh ) );
if v.local_viewport { sub.set_local_layout_viewport(); }
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 blur_before = ctx.blur_regions.len();
let origin = ctx.origin;
ctx.origin = ( origin.0 + rect.x, origin.1 + rect.y );
let next_idx = layout_and_draw::<Msg>( v.child.as_ref(), &mut sub, child_rect, ctx, flat_idx );
ctx.origin = origin;
let view_on_surface = Rect { x: rect.x + origin.0, y: rect.y + origin.1, ..rect };
let clipped: Vec<( Rect, f32 )> = ctx.blur_regions.drain( blur_before.. )
.map( |( r, radius )| ( clamp_rect_to( r, view_on_surface ), radius ) )
.filter( |( r, _ )| r.width > 0.0 && r.height > 0.0 )
.collect();
ctx.blur_regions.extend( clipped );
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, crate::widget::scroll::ScrollAxis )> =
ctx.scroll_rects.drain( scroll_rects_before.. ).collect();
for ( mut r, idx, ax ) 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, ax ) );
}
}
canvas.blit_fade_bottom( &sub, rect.x as i32, rect.y as i32, v.fade_bottom );
ctx.viewport_canvases.insert( seq, sub );
next_idx
}
Element::RichText( r ) =>
{
r.draw( canvas, rect );
// One hit rect per link line, each its own flat_idx so hover/press
// state does not bleed between links sharing a paragraph.
let link_rects = r.link_rects( rect, canvas );
let consumed = link_rects.len().max( 1 );
for ( k, ( lr, msg ) ) in link_rects.into_iter().enumerate()
{
ctx.widget_rects.push( LaidOutWidget
{
rect: lr,
flat_idx: flat_idx + k,
id: None,
paint_rect: lr,
handlers: WidgetHandlers::Button
{
on_press: Some( msg ),
on_long_press: None,
on_drag_start: None,
on_escape: None,
repeating: false,
},
keyboard_focusable: false,
focus_on_press: true,
cursor: crate::types::CursorShape::Pointer,
tooltip: None,
accessible_label: None,
is_live_region: ctx.live_depth > 0,
} );
}
flat_idx + consumed
}
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 text_scroll = ctx.text_scroll.get( &flat_idx ).copied().unwrap_or_default();
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,
};
if let Some( shown ) = other.draw( canvas, rect, is_focused, is_hovered, is_pressed, cursor_pos, sel_anchor, text_scroll )
{
if shown != text_scroll { ctx.text_scroll.insert( flat_idx, shown ); }
}
if other.is_interactive()
{
let mut handlers = other.handlers();
// Horizontal sliders resolve their thumb size through the
// widget-scaling mode here — layout has the canvas — so the
// input path maps clicks against the same thumb the renderer
// drew (see `slider::value_from_x_in_rect`).
if matches!( other, Element::Slider( _ ) )
{
handlers.set_slider_thumb_px( crate::widget::slider::resolved_thumb_px( canvas ) );
}
if matches!( other, Element::PatternGrid( _ ) )
{
handlers.set_pattern_snap_px( canvas.geom_px( crate::widget::pattern_grid::snap_design_px() ) );
}
ctx.widget_rects.push( LaidOutWidget
{
rect,
flat_idx,
id: widget_id,
paint_rect: other.paint_bounds( rect ),
handlers,
keyboard_focusable: other.is_focusable(),
focus_on_press: other.focus_on_press(),
cursor: other.cursor_shape(),
tooltip: other.tooltip().map( str::to_string ),
accessible_label: other.accessible_label(),
is_live_region: ctx.live_depth > 0,
} );
}
else
{
let live = ctx.live_depth > 0;
match other
{
Element::Text( t ) if !t.content.is_empty() =>
{
ctx.accessible_extras.push( crate::a11y::tree::AccessibleExtra
{
rect, label: Some( t.content.clone() ), live,
kind: crate::a11y::tree::AccessibleExtraKind::Label,
} );
}
Element::Image( _ ) =>
{
ctx.accessible_extras.push( crate::a11y::tree::AccessibleExtra
{
rect, label: None, live,
kind: crate::a11y::tree::AccessibleExtraKind::Image,
} );
}
Element::Separator( _ ) =>
{
ctx.accessible_extras.push( crate::a11y::tree::AccessibleExtra
{
rect, label: None, live,
kind: crate::a11y::tree::AccessibleExtraKind::Separator,
} );
}
Element::ProgressBar( p ) =>
{
ctx.accessible_extras.push( crate::a11y::tree::AccessibleExtra
{
rect, label: None, live,
kind: crate::a11y::tree::AccessibleExtraKind::Progress( p.value ),
} );
}
_ => {}
}
}
flat_idx + 1
}
}
}