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