Skip to main content

teksilo_widgets/
password_field.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `PasswordField` — secure single-line text entry with a reveal
5//! toggle, masking, Caps Lock warning, and clipboard protection.
6//!
7//! A thin, ergonomic preset over a secure
8//! [`TextInputField`] composed
9//! `SpinBox`-style: the field + an embedded reveal button live inside
10//! one bordered frame with a unified focus halo. Masking happens at the
11//! text-engine layer (one echo glyph per source `char`), so the
12//! plaintext never reaches the shaper or glyph atlas while masked, and
13//! caret / selection / hit-test stay correct.
14//!
15//! Feature parity target: Qt `QLineEdit` echo modes, SwiftUI
16//! `SecureField`, WinUI `PasswordBox` / `PasswordRevealMode`, and the
17//! Android `password_toggle`.
18//!
19//! # Example
20//!
21//! ```ignore
22//! let password = ctx.signal(String::new());
23//! PasswordField::new(password.clone())
24//!     .label(tr!(password()))               // or .label(lit!("Password"))
25//!     .placeholder(tr!(password_hint()))    // i18n-first; `_literal` twins bypass i18n
26//!     .validator(|s| if s.len() >= 8 {
27//!         ValidationOutcome::Valid
28//!     } else {
29//!         ValidationOutcome::Invalid { message: "Too short".into() }
30//!     })
31//! ```
32
33#[cfg(test)]
34mod tests;
35
36use std::rc::Rc;
37use teksilo_i18n::lit;
38
39use teksilo_canvas::{Point, Rect, SizeProposal};
40use teksilo_core::accesskit::{Live, Role};
41use teksilo_core::build_context::BuildContext;
42use teksilo_core::event::{EventResponse, WidgetEvent};
43use teksilo_core::signal::{Prop, Signal};
44use teksilo_core::styles::{
45    SharedTextInputStyle, TextInputStyle, TextInputStyleConfig, TextInputValidationLevel,
46    TextInputVariant,
47};
48use teksilo_core::widget::{CursorIcon, LayoutContext, LayoutResponse, Widget, WidgetPlacement};
49use teksilo_core::widget_builder::WidgetBuilder;
50use teksilo_core::widget_id::WidgetId;
51use teksilo_tokens::{Alignment, TextRole, TextStyleRole};
52
53use crate::icon_button::{BuiltInIcons, IconButton};
54use crate::primitives::text_input_field::{TextInputField, ValidationFeedback, ValidationOutcome};
55use crate::primitives::validation_strip::ValidationStrip;
56use crate::primitives::{
57    Center, Expand, HStack, MinSize, Padding, Shrinkable, TextWidget, VStack, ZStack,
58};
59use crate::tooltip::{self, RichTooltipSource};
60
61// Re-export the masking enums so callers can write
62// `PasswordField::new(p).echo_mode(EchoMode::RevealWhileTyping)` from a
63// single import path.
64pub use crate::primitives::text_input_field::{AtRevealPolicy, EchoMode};
65use teksilo_i18n::LocalizedString;
66
67/// The caps-lock indicator glyph: U+21EA UPWARDS WHITE ARROW FROM BAR,
68/// the conventional Caps Lock symbol (also used by macOS).
69const CAPS_LOCK_GLYPH: &str = "\u{21EA}";
70
71/// How the reveal affordance behaves. Mirrors WinUI's
72/// `PasswordRevealMode`.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
74pub enum RevealMode {
75    /// A click (or Space / Enter while focused) flips between masked and
76    /// revealed. Backed by [`IconButton::visibility_toggle`]; fully
77    /// keyboard- and screen-reader-accessible. (Default.)
78    #[default]
79    Toggle,
80    /// Press-and-hold to reveal, release to re-mask (WinUI "Peek").
81    /// Pointer-oriented; prefer [`Toggle`](Self::Toggle) for keyboard
82    /// accessibility.
83    Hold,
84    /// No reveal button — the field is always masked per its
85    /// [`EchoMode`].
86    None,
87}
88
89/// Secure single-line text entry. See the [module docs](self).
90pub struct PasswordField {
91    text: Signal<String>,
92    placeholder: LocalizedString,
93    label: LocalizedString,
94    /// Enabled state, static or reactive; forwarded to the arena at
95    /// build time.
96    enabled: Prop<bool>,
97    read_only: bool,
98    max_length: Option<usize>,
99    char_filter: Option<Rc<dyn Fn(char) -> bool>>,
100    validator: Option<Rc<dyn Fn(&str) -> ValidationOutcome>>,
101    on_submit: Option<Box<dyn Fn(&mut teksilo_core::widget::EventContext)>>,
102    on_blur: Option<Box<dyn Fn(&mut teksilo_core::widget::EventContext)>>,
103    min_width: Option<f32>,
104    variant: TextInputVariant,
105    style_override: Option<SharedTextInputStyle>,
106
107    // ── Secure-specific ─────────────────────────────────────────────
108    echo_mode: EchoMode,
109    echo_char: char,
110    reveal_mode: RevealMode,
111    revealed: Option<Signal<bool>>,
112    allow_copy: bool,
113    caps_lock_warning: bool,
114    at_reveal_policy: AtRevealPolicy,
115
116    // ── Tooltips (mutually exclusive, last-call-wins) ───────────────
117    tooltip_text: Option<LocalizedString>,
118    rich_tooltip_source: Option<RichTooltipSource>,
119    composite_tooltip_content: Option<Box<dyn Widget>>,
120
121    // ── Internal ────────────────────────────────────────────────────
122    revealed_signal: Signal<bool>,
123    root_child_id: Option<WidgetId>,
124}
125
126impl std::fmt::Debug for PasswordField {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        f.debug_struct("PasswordField")
129            .field("label", &self.label)
130            .field("echo_mode", &self.echo_mode)
131            .field("reveal_mode", &self.reveal_mode)
132            .finish_non_exhaustive()
133    }
134}
135
136impl PasswordField {
137    /// Construct a secure field bound to `password`.
138    pub fn new(password: Signal<String>) -> Self {
139        Self {
140            text: password,
141            placeholder: LocalizedString::literal(String::new()),
142            label: LocalizedString::literal(String::new()),
143            enabled: Prop::Static(true),
144            read_only: false,
145            max_length: None,
146            char_filter: None,
147            validator: None,
148            on_submit: None,
149            on_blur: None,
150            min_width: None,
151            variant: TextInputVariant::default(),
152            style_override: None,
153            echo_mode: EchoMode::Masked,
154            echo_char: '\u{2022}',
155            reveal_mode: RevealMode::Toggle,
156            revealed: None,
157            allow_copy: false,
158            caps_lock_warning: true,
159            at_reveal_policy: AtRevealPolicy::SwapRole,
160            tooltip_text: None,
161            rich_tooltip_source: None,
162            composite_tooltip_content: None,
163            revealed_signal: Signal::new(false),
164            root_child_id: None,
165        }
166    }
167
168    /// Placeholder shown when empty. Never masked.
169    pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self {
170        let ls: LocalizedString = text.into();
171        self.placeholder = ls;
172        self
173    }
174
175    /// Accessible name, applied to the `Role::PasswordInput` field node.
176    /// Strongly recommended for screen-reader users.
177    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
178        let ls: LocalizedString = label.into();
179        self.label = ls;
180        self
181    }
182
183    /// Set the enabled state, statically or reactively. Forwarded to the
184    /// arena at build time — a bound `Signal<bool>` updates live as it
185    /// changes.
186    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
187        self.enabled = enabled.into();
188        self
189    }
190
191    /// Read-only: selection works, edits don't.
192    pub fn read_only(mut self, read_only: bool) -> Self {
193        self.read_only = read_only;
194        self
195    }
196
197    /// Hard cap on length in `char`s.
198    pub fn max_length(mut self, max_length: usize) -> Self {
199        self.max_length = Some(max_length);
200        self
201    }
202
203    /// Per-character input filter (applied to keystrokes, IME commits,
204    /// and paste).
205    pub fn char_filter(mut self, f: impl Fn(char) -> bool + 'static) -> Self {
206        self.char_filter = Some(Rc::new(f));
207        self
208    }
209
210    /// Commit-time validator (Enter / blur). Drives the inline
211    /// validation strip and `aria-invalid`.
212    pub fn validator(mut self, f: impl Fn(&str) -> ValidationOutcome + 'static) -> Self {
213        self.validator = Some(Rc::new(f));
214        self
215    }
216
217    /// Fired on Enter (focus stays put).
218    pub fn on_submit_fn(
219        mut self,
220        f: impl Fn(&mut teksilo_core::widget::EventContext) + 'static,
221    ) -> Self {
222        self.on_submit = Some(Box::new(f));
223        self
224    }
225
226    /// Fired once per focus-loss.
227    pub fn on_blur_fn(
228        mut self,
229        f: impl Fn(&mut teksilo_core::widget::EventContext) + 'static,
230    ) -> Self {
231        self.on_blur = Some(Box::new(f));
232        self
233    }
234
235    /// Minimum frame width (logical px). Default 65.
236    pub fn min_width(mut self, width: f32) -> Self {
237        self.min_width = Some(width);
238        self
239    }
240
241    /// Frame variant (Outlined / Filled / Underline / Bare).
242    pub fn variant(mut self, variant: TextInputVariant) -> Self {
243        self.variant = variant;
244        self
245    }
246
247    /// Per-instance style override.
248    pub fn style(mut self, style: impl TextInputStyle) -> Self {
249        self.style_override = Some(Rc::new(style));
250        self
251    }
252
253    /// Override the masking glyph (default `'•'`).
254    pub fn echo_char(mut self, c: char) -> Self {
255        self.echo_char = c;
256        self
257    }
258
259    /// Set the [`EchoMode`] (default [`EchoMode::Masked`]).
260    pub fn echo_mode(mut self, mode: EchoMode) -> Self {
261        self.echo_mode = mode;
262        self
263    }
264
265    /// Set the [`RevealMode`] (default [`RevealMode::Toggle`]).
266    pub fn reveal_mode(mut self, mode: RevealMode) -> Self {
267        self.reveal_mode = mode;
268        self
269    }
270
271    /// Bind an external reveal signal (shared with other UI, observed
272    /// for analytics, or driven programmatically). Defaults to an
273    /// internal signal exposed via [`revealed_signal`](Self::revealed_signal).
274    pub fn revealed(mut self, revealed: Signal<bool>) -> Self {
275        self.revealed = Some(revealed);
276        self
277    }
278
279    /// Permit copy / cut even while masked (default `false`). Copy is
280    /// always allowed while revealed regardless of this flag.
281    pub fn allow_copy(mut self, allow: bool) -> Self {
282        self.allow_copy = allow;
283        self
284    }
285
286    /// Show a Caps Lock warning when focused with Caps Lock on (default
287    /// `true`). The warning is announced to screen readers via a polite
288    /// live region.
289    pub fn caps_lock_warning(mut self, on: bool) -> Self {
290        self.caps_lock_warning = on;
291        self
292    }
293
294    /// How a *revealed* field reports to assistive tech (default
295    /// [`AtRevealPolicy::SwapRole`]).
296    pub fn at_reveal_policy(mut self, policy: AtRevealPolicy) -> Self {
297        self.at_reveal_policy = policy;
298        self
299    }
300
301    /// Plain single-line tooltip shown on hover.
302    ///
303    /// Mutually exclusive with [`rich_tooltip_key`](Self::rich_tooltip_key),
304    /// [`rich_tooltip`](Self::rich_tooltip),
305    /// [`rich_tooltip_content`](Self::rich_tooltip_content), and
306    /// [`composite_tooltip`](Self::composite_tooltip) — calling any of them
307    /// clears the others.
308    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
309        self.tooltip_text = Some(text.into());
310        self.rich_tooltip_source = None;
311        self.composite_tooltip_content = None;
312        self
313    }
314
315    /// Registry-keyed rich tooltip.
316    ///
317    /// Mutually exclusive with the other tooltip setters.
318    pub fn rich_tooltip_key(mut self, key: impl Into<String>) -> Self {
319        self.rich_tooltip_source = Some(RichTooltipSource::Key(key.into()));
320        self.tooltip_text = None;
321        self.composite_tooltip_content = None;
322        self
323    }
324
325    /// Inline rich tooltip (canonical name: accepts a
326    /// [`TooltipContent`](tooltip::TooltipContent) directly without a
327    /// registry key).
328    ///
329    /// Mutually exclusive with the other tooltip setters.
330    pub fn rich_tooltip_content(mut self, content: tooltip::TooltipContent) -> Self {
331        self.rich_tooltip_source = Some(RichTooltipSource::Content(content));
332        self.tooltip_text = None;
333        self.composite_tooltip_content = None;
334        self
335    }
336
337    /// Inline rich tooltip.
338    ///
339    /// Mutually exclusive with the other tooltip setters.
340    /// Prefer [`rich_tooltip_content`](Self::rich_tooltip_content) for the
341    /// canonical API.
342    pub fn rich_tooltip(mut self, content: tooltip::TooltipContent) -> Self {
343        self.rich_tooltip_source = Some(RichTooltipSource::Content(content));
344        self.tooltip_text = None;
345        self.composite_tooltip_content = None;
346        self
347    }
348
349    /// Composite (arbitrary-widget) tooltip.
350    ///
351    /// Mutually exclusive with the other tooltip setters.
352    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
353        self.composite_tooltip_content = Some(Box::new(content));
354        self.tooltip_text = None;
355        self.rich_tooltip_source = None;
356        self
357    }
358
359    /// The reveal-state signal (`true` = plaintext shown). Useful to
360    /// observe or drive reveal programmatically.
361    pub fn revealed_signal(&self) -> Signal<bool> {
362        self.revealed
363            .clone()
364            .unwrap_or_else(|| self.revealed_signal.clone())
365    }
366
367    /// The bound password signal.
368    pub fn text(&self) -> Signal<String> {
369        self.text.clone()
370    }
371}
372
373impl Widget for PasswordField {
374    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
375        use crate::styles::recipe_text_input_style as field_dims;
376
377        let self_id = ctx.self_id();
378        ctx.enabled_when(self_id, self.enabled.clone());
379
380        // Reveal signal: external binding wins, else the internal one.
381        let revealed = self
382            .revealed
383            .clone()
384            .unwrap_or_else(|| self.revealed_signal.clone());
385
386        // Unified halo signals — lit when the field OR the reveal button
387        // is focused / hovered (strict-descendant `focus_within` /
388        // `hover_within` on the editor row).
389        let focused = ctx.signal(false);
390        let hovered = ctx.signal(false);
391
392        let inner_height =
393            (field_dims::TEXT_FIELD_HEIGHT - 2.0 * field_dims::TEXT_FIELD_BORDER_WIDTH).max(0.0);
394        let text_area_height =
395            (inner_height - 2.0 * field_dims::TEXT_FIELD_PADDING_VERTICAL).max(0.0);
396
397        // ── Secure inner field ──────────────────────────────────────
398        let mut field = TextInputField::new(self.text.clone())
399            .enabled(self.enabled.clone())
400            .read_only(self.read_only)
401            .placeholder(self.placeholder.clone())
402            .text_height(text_area_height)
403            .secure(self.echo_mode)
404            .echo_char(self.echo_char)
405            .at_reveal_policy(self.at_reveal_policy)
406            .allow_copy(self.allow_copy)
407            .revealed(revealed.clone());
408        if let Some(max) = self.max_length {
409            field = field.max_length(max);
410        }
411        if let Some(f) = self.char_filter.take() {
412            field = field.char_filter(move |c| (f)(c));
413        }
414        if let Some(cb) = self.on_submit.take() {
415            field = field.on_submit_fn(move |ctx| (cb)(ctx));
416        }
417        if let Some(cb) = self.on_blur.take() {
418            field = field.on_blur_fn(move |ctx| (cb)(ctx));
419        }
420        if let Some(validator) = self.validator.take() {
421            field = field.validator(move |s| (validator)(s));
422        }
423        let inner_feedback = field.validation_feedback_signal();
424
425        // The field carries the `Role::PasswordInput` AT node, so the
426        // accessible name belongs on it.
427        let field_id = if self.label.resolve_now().is_empty() {
428            ctx.add(field)
429        } else {
430            ctx.add(field.access_label(self.label.clone()))
431        };
432
433        let padded_field = ctx.add(
434            Padding::new(
435                field_dims::TEXT_FIELD_PADDING_VERTICAL,
436                0.0,
437                field_dims::TEXT_FIELD_PADDING_VERTICAL,
438                0.0,
439            )
440            .child_id(field_id),
441        );
442
443        // Placeholder overlay (never masked) shares the field's column.
444        // `Expand::horizontal().respect_intrinsic()` keeps the field's natural
445        // width as the column basis (snug when unconstrained, fills a wide
446        // frame via flex); `Shrinkable` adds a shrink weight so a narrow row
447        // compresses the column and the field scrolls instead of overflowing.
448        // (Mirrors `TextInput`.)
449        let text_column_id = if self.placeholder.resolve_now().is_empty() {
450            ctx.add(
451                Shrinkable::new().child(
452                    Expand::horizontal()
453                        .respect_intrinsic()
454                        .child_id(padded_field),
455                ),
456            )
457        } else {
458            let ph = TextWidget::new(self.placeholder.clone())
459                .style(TextStyleRole::Body)
460                .color(TextRole::Secondary)
461                .single_line()
462                .a11y_hidden();
463            // Leading-aligned on the vertical midline; align mode measures
464            // the placeholder under the column's bounds, so the
465            // `single_line()` TextWidget ellipsizes when the field is too
466            // narrow. (Mirrors `TextInput`.)
467            let ph_id = ctx.add(
468                Expand::new()
469                    .respect_intrinsic()
470                    .align_child(Alignment::CENTER_LEADING)
471                    .child(ph),
472            );
473            let text_for_vis = self.text.clone();
474            let visible = text_for_vis.map(|t| t.is_empty());
475            ctx.visible_when(ph_id, visible);
476            ctx.add(
477                Shrinkable::new().child(
478                    Expand::horizontal()
479                        .respect_intrinsic()
480                        .child(ZStack::new().add_child(ph_id).add_child(padded_field)),
481                ),
482            )
483        };
484
485        // ── Editor row: [text_column] [caps?] [reveal?] ─────────────
486        let mut row = HStack::new().spacing(4.0);
487        row = row.add_child(text_column_id);
488
489        // Caps Lock warning glyph + polite live region.
490        if self.caps_lock_warning
491            && let Some(window) = ctx.window()
492        {
493            let caps = window.caps_lock().clone();
494            let warn = TextWidget::new(lit!(CAPS_LOCK_GLYPH))
495                .style(TextStyleRole::Body)
496                .color(TextRole::Secondary)
497                .single_line()
498                .access_role(Role::Status)
499                .access_live(Live::Polite)
500                .access_label(teksilo_i18n::tr_widget!(a11y_caps_lock_on()));
501            let warn_id = ctx.add(warn);
502            let visible = caps.zip(&focused).map(|(c, f)| *c && *f);
503            ctx.visible_when(warn_id, visible);
504            row = row.add_child(warn_id);
505        }
506
507        // Reveal affordance.
508        match self.reveal_mode {
509            RevealMode::Toggle => {
510                let reveal = IconButton::visibility_toggle(revealed.clone())
511                    .embedded()
512                    .focusable(true)
513                    .access_label(teksilo_i18n::tr_widget!(a11y_password_reveal()));
514                row = row.add_child(ctx.add(reveal));
515            }
516            RevealMode::Hold => {
517                let icon = (BuiltInIcons::global().eye)();
518                let revealed_hold = revealed.clone();
519                let hold = MinSize::new(24.0, 24.0)
520                    .child(Center::new().child(icon))
521                    .on_pointer_event(move |event, ctx| match event {
522                        WidgetEvent::PointerDown { .. } => {
523                            revealed_hold.set(true);
524                            ctx.request_frame();
525                            EventResponse::Handled
526                        }
527                        WidgetEvent::PointerUp { .. } | WidgetEvent::PointerLeave => {
528                            revealed_hold.set(false);
529                            ctx.request_frame();
530                            EventResponse::Handled
531                        }
532                        _ => EventResponse::Ignored,
533                    })
534                    .cursor(CursorIcon::Pointer)
535                    .access_role(Role::Button)
536                    .access_label(teksilo_i18n::tr_widget!(a11y_password_reveal()));
537                row = row.add_child(ctx.add(hold));
538            }
539            RevealMode::None => {}
540        }
541
542        let row_id = ctx.add(
543            row.focus_within(focused.clone())
544                .hover_within(hovered.clone()),
545        );
546
547        // ── Frame chrome via the (reused) TextInputStyle ────────────
548        let effective_enabled = ctx.effective_enabled_signal(self_id);
549        let is_disabled = effective_enabled.map(|on| !*on);
550        let validation_level = inner_feedback.map(|fb| match fb {
551            ValidationFeedback::Invalid { .. } => TextInputValidationLevel::Error,
552            ValidationFeedback::Corrected { .. } => TextInputValidationLevel::Corrected,
553            ValidationFeedback::Pristine | ValidationFeedback::Valid => {
554                TextInputValidationLevel::None
555            }
556        });
557
558        let style: SharedTextInputStyle = self
559            .style_override
560            .clone()
561            .or_else(|| ctx.theme().style_slots.text_input.clone())
562            .unwrap_or_else(|| Rc::new(crate::styles::RecipeTextInputStyle::default()));
563
564        let cfg = TextInputStyleConfig {
565            editor: row_id,
566            is_focused: focused.clone(),
567            is_hovered: hovered.clone(),
568            is_disabled,
569            validation: validation_level,
570            variant: self.variant,
571        };
572        let chrome_id = style.make_body(&cfg, ctx);
573
574        let min_w = self.min_width.unwrap_or(65.0);
575        let frame_id =
576            ctx.add(MinSize::new(min_w, field_dims::TEXT_FIELD_HEIGHT).child_id(chrome_id));
577
578        // ── Inline validation strip ─────────────────────────────────
579        let strip_id = ctx.add(ValidationStrip::new(inner_feedback));
580
581        // WCAG 3.3.1 / 3.3.3: announce the validation message as the field's
582        // description when focused (mirrors `TextInput`).
583        ctx.access_described_by(field_id, strip_id);
584
585        // Wrap the frame in `Expand::horizontal().respect_intrinsic()` so it
586        // claims the VStack's full width (a VStack lays a child out at its
587        // measured width, not stretched) while keeping the frame's natural
588        // width as the basis when unconstrained. Mirrors `TextInput`.
589        let framed_id = ctx.add(Expand::horizontal().respect_intrinsic().child_id(frame_id));
590        let root_id = ctx.add(
591            VStack::new()
592                .spacing(field_dims::TEXT_FIELD_VALIDATION_STRIP_GAP)
593                .add_child(framed_id)
594                .add_child(strip_id),
595        );
596
597        // Tooltips — mutually exclusive (setters clear the others).
598        if let Some(content) = self.composite_tooltip_content.take() {
599            let delay = ctx.theme().motion.tooltip_delay_heavy;
600            tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
601        } else if let Some(source) = self.rich_tooltip_source.take() {
602            let delay = ctx.theme().motion.tooltip_delay;
603            tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
604        } else if let Some(text) = self.tooltip_text.clone() {
605            let delay = ctx.theme().motion.tooltip_delay;
606            crate::tooltip::attach_plain_tooltip(ctx, root_id, text, delay);
607        }
608
609        self.root_child_id = Some(root_id);
610        vec![root_id]
611    }
612
613    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
614        // The `Shrinkable` + `respect_intrinsic` editor column reports the
615        // field's natural width when unconstrained, fills a wide frame, and
616        // compresses on a deficit — so just forward the child's response.
617        self.root_child_id
618            .and_then(|id| ctx.child_size(id, proposal))
619            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
620            .into()
621    }
622
623    fn place_children(
624        &self,
625        bounds: Rect,
626        _proposal: SizeProposal,
627        children: &mut [WidgetPlacement],
628        _ctx: &LayoutContext,
629    ) {
630        if let Some(p) = children.first_mut() {
631            p.origin = Point::new(bounds.x, bounds.y);
632            p.size = bounds.size();
633        }
634    }
635
636    fn children(&self) -> Vec<WidgetId> {
637        self.root_child_id.into_iter().collect()
638    }
639}