Skip to main content

rosace_widgets/tree/
text_input.rs

1use std::sync::Arc;
2
3use rosace_core::types::{Point, Rect, Size};
4use rosace_render::{Color, DrawCommand, FontWeight};
5use super::{Widget, LayoutCtx, PaintCtx};
6use super::container::draw_rounded_rect_pub;
7use super::text_edit::{
8    char_byte_offset, char_count, grapheme_boundaries, style_runs, CursorShape, CursorStyle,
9    EditController, EditableDecl, LineLayout, SpanFn, TextLayoutSnapshot,
10};
11
12/// A single-line text input field.
13///
14/// Real keyboard editing (D112/Phase 28 Step 1): click to focus, type,
15/// arrow-key navigation, Shift+arrow selection, Home/End, Cmd/Ctrl+A
16/// select-all, Cmd/Ctrl+C/X/V clipboard — all dispatched by the engine
17/// against this widget's persistent render-tree node
18/// (`PaintCtx::register_editable`/`text_edit`), not by this `paint(&self)`
19/// call itself (which can't mutate anything). This widget stays a
20/// CONTROLLED component, the same convention `Slider`/`Switch`/`Checkbox`
21/// already use: the app owns the true `String` (typically a `ctx.state`
22/// atom), passes it in via `.value()`, and gets edits back via
23/// `.on_change()`. What this widget's own render-tree node persists is
24/// only the ephemeral editing chrome (caret position, selection).
25pub struct TextInput {
26    pub value: String,
27    pub placeholder: String,
28    pub focused: bool,
29    pub obscure: bool,
30    pub width: Option<f32>,
31    pub height: f32,
32    /// `None` = read from the active theme's `typography.body_medium`
33    /// (D127 "environment" track — see `Checkbox::resolved_font_size`'s doc
34    /// for the reasoning).
35    pub font_size: Option<f32>,
36    pub radius: f32,
37    background: Option<Color>,
38    border_color: Option<Color>,
39    focus_color: Option<Color>,
40    on_change: Option<Arc<dyn Fn(String) + Send + Sync>>,
41    controller: Option<EditController>,
42    spans: Option<Arc<SpanFn>>,
43    cursor_style: Option<CursorStyle>,
44    keyboard_type: rosace_core::KeyboardType,
45    field: Option<rosace_forms::FormField>,
46    filters: Vec<super::text_edit::InputFilter>,
47    leading: Option<super::BoxedWidget>,
48    trailing: Option<super::BoxedWidget>,
49    on_trailing: Option<Arc<dyn Fn() + Send + Sync>>,
50}
51
52impl TextInput {
53    pub fn new() -> Self {
54        Self {
55            value: String::new(),
56            placeholder: String::from("Type here..."),
57            focused: false,
58            obscure: false,
59            width: None,
60            height: 36.0,
61            font_size: None,
62            radius: 6.0,
63            background: None,
64            border_color: None,
65            focus_color: None,
66            on_change: None,
67            controller: None,
68            spans: None,
69            cursor_style: None,
70            keyboard_type: rosace_core::KeyboardType::default(),
71            field: None,
72            filters: Vec::new(),
73            leading: None,
74            trailing: None,
75            on_trailing: None,
76        }
77    }
78    /// An adornment INSIDE the field, at the left (a search/prefix icon, `$`, …).
79    /// Rendered inside the box; the text insets past it. This is what makes a
80    /// `SearchBar` just a `TextInput` — `.leading(Icon::new(Search))`.
81    pub fn leading(mut self, w: impl Widget + 'static) -> Self { self.leading = Some(Box::new(w)); self }
82    /// An adornment INSIDE the field, at the right (clear ×, password eye,
83    /// validation status, unit suffix…). Make it tappable with `.on_trailing`.
84    pub fn trailing(mut self, w: impl Widget + 'static) -> Self { self.trailing = Some(Box::new(w)); self }
85    /// Tap handler for the trailing adornment (e.g. clear the field, toggle
86    /// password visibility). The trailing zone owns its own hit region.
87    pub fn on_trailing(mut self, f: impl Fn() + Send + Sync + 'static) -> Self {
88        self.on_trailing = Some(Arc::new(f)); self
89    }
90    pub fn value(mut self, v: impl Into<String>) -> Self { self.value = v.into(); self }
91    pub fn placeholder(mut self, p: impl Into<String>) -> Self { self.placeholder = p.into(); self }
92    /// Seed this input as focused on its FIRST paint only (a one-shot
93    /// request, not a per-frame re-request — see `PaintCtx::focus_node_seeded`).
94    /// Real, persistent focus state now lives on this widget's own
95    /// [`rosace_a11y::FocusNode`] (auto-created, zero wiring required),
96    /// driven by click/Tab from then on.
97    pub fn focused(mut self) -> Self { self.focused = true; self }
98    pub fn obscure(mut self) -> Self { self.obscure = true; self }
99    pub fn width(mut self, w: f32) -> Self { self.width = Some(w); self }
100    pub fn height(mut self, h: f32) -> Self { self.height = h; self }
101    /// Box fill color (a fixed dark tone if unset — kept as the
102    /// long-standing default rather than switched to a theme token, since
103    /// that would visibly shift every existing app using this widget).
104    pub fn background(mut self, c: Color) -> Self { self.background = Some(c); self }
105    /// Unfocused border color.
106    pub fn border(mut self, c: Color) -> Self { self.border_color = Some(c); self }
107    /// Focused border color (also thickens slightly, unchanged).
108    pub fn focus_color(mut self, c: Color) -> Self { self.focus_color = Some(c); self }
109    /// Report edits — called by the engine's key/click dispatch whenever
110    /// this input's value actually changes (typing, paste, cut). Without
111    /// this, the input still accepts keystrokes/selection/caret movement
112    /// (all real, all repainted) but the displayed value never advances,
113    /// same "controlled with no listener does nothing" behavior as
114    /// `Slider`/`Switch` today.
115    pub fn on_change(mut self, f: impl Fn(String) + Send + Sync + 'static) -> Self {
116        self.on_change = Some(Arc::new(f));
117        self
118    }
119    /// Attach a programmatic [`EditController`] (D116) — app-constructed
120    /// and passed in (the `FocusNode` precedent), reachable from OUTSIDE
121    /// the widget tree entirely (a toolbar button's `on_press` has no
122    /// access to this field's render-tree node otherwise). Optional: most
123    /// fields never need one.
124    pub fn controller(mut self, c: EditController) -> Self {
125        self.controller = Some(c);
126        self
127    }
128    /// The markdown/syntax-highlighting seam (D116 Step 5): a tokenizer
129    /// that inspects the current value (and, when available, the char
130    /// range that changed since the last call — `None` on the first call)
131    /// and returns styled [`super::text_edit::Span`]s. Never applied to
132    /// an obscured (password) field. This crate never learns what
133    /// markdown is — the app brings the tokenizer.
134    pub fn spans(mut self, f: impl Fn(&str, Option<(usize, usize)>) -> Vec<super::text_edit::Span> + Send + Sync + 'static) -> Self {
135        self.spans = Some(Arc::new(f));
136        self
137    }
138    /// Per-field caret override — width/color/corner radius/blink rate/
139    /// shape (`Bar`/`Block`/`Underline`/`Custom`). Falls back to the
140    /// theme's `CursorStyle` extension (`ThemeData::ext`/`with_ext`, D105)
141    /// if set, then to [`CursorStyle::default`].
142    pub fn cursor_style(mut self, s: CursorStyle) -> Self {
143        self.cursor_style = Some(s);
144        self
145    }
146    /// Which OS soft-keyboard layout a mobile host should show while this
147    /// field is focused (D116 Step 6) — `Email`/`Numeric`/`Url`/`Phone`.
148    /// Pure data on desktop (no hardware keyboard has "layouts" to pick);
149    /// real effect is a mobile-host FFI concern (`rosace_core::keyboard_type()`,
150    /// polled the same way camera permission is).
151    pub fn keyboard_type(mut self, kt: rosace_core::KeyboardType) -> Self {
152        self.keyboard_type = kt;
153        self
154    }
155    /// Bind this field to a [`rosace_forms::FormField`] (D116 Phase 28
156    /// Step 8) — the primary way to wire form validation. Sets the
157    /// widget's initial value from `f.get()` and installs an `on_change`
158    /// that writes back into the field (`f.set(v)`) AND immediately
159    /// re-validates (`f.validate()`), so an inline error caption below
160    /// the field and a submit button's `.disabled_if(!form.is_valid())`
161    /// both update live as the user types — not just on submit. Calling
162    /// `.on_change()` again AFTER `.field()` overrides this binding;
163    /// call `.field()` last if you need both.
164    pub fn field(mut self, f: rosace_forms::FormField) -> Self {
165        self.value = f.get();
166        let bound = f.clone();
167        self.on_change = Some(Arc::new(move |v| {
168            bound.set(v);
169            bound.validate();
170        }));
171        // Validate immediately (every rebuild, not just on edit) so
172        // `is_valid()`/an inline error reflect the CURRENT value even
173        // before the user has touched the field — an empty Required
174        // field must gate a submit button from the very start, not only
175        // after the user has typed something once.
176        f.validate();
177        self.field = Some(f);
178        self
179    }
180    /// Input filters (D116 Step 8) — applied to every edit (typed chars,
181    /// paste, IME commit, controller ops) before it reaches `on_change`.
182    /// See [`super::text_edit::InputFilter`].
183    pub fn filters(mut self, filters: Vec<super::text_edit::InputFilter>) -> Self {
184        self.filters = filters;
185        self
186    }
187
188    fn resolved_font_size(&self, theme: &rosace_theme::ThemeData) -> f32 {
189        self.font_size.unwrap_or(theme.typography.body_medium.size)
190    }
191}
192
193impl Default for TextInput {
194    fn default() -> Self { Self::new() }
195}
196
197/// Extra vertical space reserved for an inline validation error caption
198/// below a bound field (D116 Step 8) — pushes following siblings down,
199/// same as any real form's error text.
200pub(super) const ERROR_ROW_H: f32 = 18.0;
201
202impl Widget for TextInput {
203    fn layout(&self, ctx: &LayoutCtx) -> Size {
204        let constraints = ctx.constraints;
205        let show_error = self.field.as_ref().is_some_and(|f| f.is_touched() && !f.is_valid());
206        Size {
207            width:  self.width.unwrap_or(super::avail_w(constraints)),
208            height: self.height + if show_error { ERROR_ROW_H } else { 0.0 },
209        }
210    }
211
212    fn paint(&self, ctx: &mut PaintCtx) {
213        ctx.semantics(super::Semantics::new(rosace_core::Role::TextInput)
214            .label(&self.placeholder).value(&self.value));
215        let font_size = self.resolved_font_size(&ctx.theme);
216
217        // Own persistent FocusNode (D112) — click-to-focus/Tab work with
218        // zero required wiring; `.focused()` seeds ONLY the first paint.
219        let focus = ctx.focus_node_seeded(self.focused);
220        ctx.register_focus(focus.clone());
221        let is_focused = focus.is_focused();
222
223        // The INPUT BOX only — `ctx.rect` may be taller than `self.height`
224        // when an error caption is reserved below it (D116 Step 8).
225        let full_rect = ctx.rect;
226        let r = Rect { origin: full_rect.origin, size: Size { width: full_rect.size.width, height: self.height } };
227
228        let bg = self.background.unwrap_or(Color::rgb(15, 16, 28));
229        let border = if is_focused {
230            self.focus_color.unwrap_or(Color::rgb(110, 75, 210))
231        } else {
232            self.border_color.unwrap_or(Color::rgb(32, 35, 58))
233        };
234
235        draw_rounded_rect_pub(ctx, r, bg, self.radius);
236        ctx.stroke_rrect(r, self.radius, border, if is_focused { 1.5 } else { 1.0 });
237
238        let has_value = !self.value.is_empty();
239        let display = if has_value {
240            if self.obscure {
241                "•".repeat(self.value.chars().count())
242            } else {
243                self.value.clone()
244            }
245        } else {
246            self.placeholder.clone()
247        };
248
249        let text_color = if has_value {
250            Color::rgb(220, 222, 240)
251        } else {
252            Color::rgb(80, 85, 118)
253        };
254
255        let line_h = ctx.font.line_height(font_size);
256        let ty = ((r.size.height - line_h) / 2.0).max(0.0);
257
258        // Horizontal scroll-into-view (D116 Step 3 — the `scroll_x` field's
259        // long-declared-but-unwired half). Shift the content left just
260        // enough to keep the caret inside the field when the value
261        // overflows the visible width, the way every single-line editor
262        // does. Persisted through `set_scroll_x` so it survives repaints
263        // instead of snapping back to 0; recomputed and clamped here each
264        // paint against the CURRENT value/caret so deleting text lets the
265        // content scroll back. Both the exported hit-test/IME layout below
266        // and every glyph draw are shifted by the SAME `scroll_x`, so a
267        // click, the caret, and the OS candidate window can never drift.
268        let state = ctx.text_edit();
269        // Adornment zones eat into the text content area: a leading/trailing
270        // icon gets a square, field-tall zone; the text starts after leading
271        // and ends before trailing. This is what makes `TextInput` a search
272        // field (leading icon) / password field (trailing eye) with no
273        // separate widget. `left_inset`/`right_inset` replace the flat 10px
274        // padding wherever it positioned text/caret/hit-test.
275        let base_pad = 10.0_f32;
276        let left_inset = if self.leading.is_some() { self.height } else { base_pad };
277        let right_inset = if self.trailing.is_some() { self.height } else { base_pad };
278        let inset = left_inset;
279        let visible_w = (r.size.width - left_inset - right_inset).max(0.0);
280        let cursor_byte = char_byte_offset(&display, state.cursor());
281        let caret_rel = ctx.font.measure_text(&display[..cursor_byte], font_size);
282        let total_w = ctx.font.measure_text(&display, font_size);
283        let mut scroll_x = state.scroll_x;
284        if is_focused {
285            if caret_rel < scroll_x {
286                scroll_x = caret_rel;
287            } else if caret_rel > scroll_x + visible_w {
288                scroll_x = caret_rel - visible_w;
289            }
290        }
291        scroll_x = scroll_x.clamp(0.0, (total_w - visible_w).max(0.0));
292        if (scroll_x - state.scroll_x).abs() > f32::EPSILON {
293            ctx.set_scroll_x(scroll_x);
294        }
295
296        // TextLayoutSnapshot (D116 layer 3) — built ONCE per paint, from
297        // grapheme boundaries of the REAL value (obscured or not; dots
298        // don't preserve multi-char grapheme clustering, so boundaries
299        // always come from `self.value`, only the measured WIDTHS come
300        // from `display`). Reused below for both the exported hit-test
301        // seam and this widget's own caret/selection rendering, so the
302        // two can never drift out of sync. Every boundary is shifted by
303        // `-scroll_x` so the whole layout (caret, selection, hit-test)
304        // moves as one when the field scrolls horizontally.
305        let boundary_chars = grapheme_boundaries(&self.value);
306        let boundary_x: Vec<f32> = boundary_chars
307            .iter()
308            .map(|&c| {
309                let bx = char_byte_offset(&display, c);
310                r.origin.x + inset - scroll_x + ctx.font.measure_text(&display[..bx], font_size)
311            })
312            .collect();
313        let layout = TextLayoutSnapshot {
314            lines: vec![LineLayout {
315                char_range: (0, char_count(&self.value)),
316                y: r.origin.y + ty,
317                height: line_h,
318                boundary_chars,
319                boundary_x,
320            }],
321        };
322
323        ctx.register_editable(EditableDecl {
324            value: self.value.clone(),
325            rect: r,
326            multiline: false,
327            obscure: self.obscure,
328            on_change: self.on_change.clone().unwrap_or_else(|| Arc::new(|_| {})),
329            controller: self.controller.clone(),
330            layout: layout.clone(),
331            filters: self.filters.clone(),
332        });
333
334        // Clip content to the field so scrolled-out glyphs (and any
335        // overflow past either edge) are trimmed at the box. Popped before
336        // the selection handles + validation caption below, which live
337        // OUTSIDE the field bounds and must not be clipped.
338        ctx.record(DrawCommand::PushClip { rect: r });
339
340        if is_focused {
341            // Keep frames flowing for the caret blink WHILE focused only
342            // (D111's lesson: default-on continuous animation everywhere
343            // is exactly the mistake that phase corrected — this is
344            // conditional on real focus, not a blanket default).
345            super::request_animation();
346
347            if let Some((sel_start, sel_end)) = state.selection_range() {
348                // Tint behind the glyphs — color from the theme's
349                // SelectionStyle (D105 ext; flat default = the exact
350                // pre-themeable look). Handles + the glass lens paint
351                // AFTER the text, further down.
352                let sel_style = ctx.theme.ext::<super::SelectionStyle>().cloned().unwrap_or_default();
353                let x0 = layout.x_of(sel_start).unwrap_or(r.origin.x + 10.0);
354                let x1 = layout.x_of(sel_end).unwrap_or(x0);
355                ctx.fill_rect(Rect {
356                    origin: Point { x: x0, y: r.origin.y + ty },
357                    size: Size { width: x1 - x0, height: line_h },
358                }, sel_style.highlight);
359            }
360
361            // IME preedit underline (D116 Step 6) — the universal
362            // CJK-composition convention, marking the uncommitted text
363            // that's still being composed.
364            if let Some((ims, ime_)) = state.ime_range {
365                let x0 = layout.x_of(ims).unwrap_or(r.origin.x + 10.0);
366                let x1 = layout.x_of(ime_).unwrap_or(x0);
367                ctx.fill_rect(Rect {
368                    origin: Point { x: x0, y: r.origin.y + ty + line_h - 1.0 },
369                    size: Size { width: (x1 - x0).max(1.0), height: 1.5 },
370                }, text_color);
371            }
372
373            // Report this field's caret rect to the platform (D116 Step
374            // 6) so the OS's CJK candidate window anchors near it instead
375            // of wherever it defaults to.
376            let cursor_x = layout.x_of(state.cursor()).unwrap_or(r.origin.x + 10.0);
377            rosace_core::set_ime_cursor_area(Some(Rect {
378                origin: Point { x: cursor_x, y: r.origin.y + ty },
379                size: Size { width: 2.0, height: line_h },
380            }));
381            rosace_core::set_keyboard_type(self.keyboard_type);
382        }
383
384        // Styled spans (D116 Step 5) — the markdown/syntax-highlighting
385        // seam. Never applied to obscured fields or the placeholder.
386        if let Some(spans_fn) = self.spans.as_ref().filter(|_| has_value && !self.obscure) {
387            let spans = spans_fn(&self.value, state.last_edit_range);
388            let line = &layout.lines[0];
389            for (rs, re, color, weight) in style_runs(&spans, line.char_range.0, line.char_range.1) {
390                if rs >= re { continue; }
391                let rb = char_byte_offset(&self.value, rs);
392                let reb = char_byte_offset(&self.value, re);
393                let run_x = line.x_at(rs);
394                ctx.record(DrawCommand::DrawText {
395                    text: self.value[rb..reb].to_string(),
396                    origin: Point { x: run_x, y: r.origin.y + ty },
397                    color: color.unwrap_or(text_color),
398                    px: font_size,
399                    weight: weight.unwrap_or(FontWeight::Regular),
400                });
401            }
402        } else {
403            ctx.text(&display, inset - scroll_x, ty, text_color, font_size);
404        }
405
406        // Caret (content — inside the clip so it's trimmed at the field
407        // edge when the value is scrolled). Drawn before PopClip; the
408        // selection chrome below is popped OUT so its grips can hang past
409        // the box bottom.
410        if is_focused && state.selection_range().is_none() {
411            // Caret hidden while a selection is active (matches the
412            // selection-highlight-instead-of-caret convention every
413            // desktop text field uses).
414            let style = self.cursor_style.clone()
415                .unwrap_or_else(|| ctx.theme.ext::<CursorStyle>().cloned().unwrap_or_default());
416            let t = super::anim_clock() - state.last_edit_at;
417            let blink_on = t < 0.5 || (((t - 0.5) / style.blink_rate) as i64 % 2 == 0);
418            if blink_on {
419                let line = &layout.lines[0];
420                let cursor_x = line.x_at(state.cursor());
421                let cy = r.origin.y + ty;
422                paint_caret(ctx, &style, cursor_x, cy, line_h, font_size, line, state.cursor());
423            }
424        }
425
426        // Content done — release the clip so the selection handles (which
427        // hang below the line) and the validation caption (below the field)
428        // paint unclipped.
429        ctx.record(DrawCommand::PopClip);
430
431        // Selection chrome ABOVE the glyphs (D116 Step 7 handles + the
432        // theme-driven glass lens): drawn after the text so the lens can
433        // sample — and magnify — the glyphs themselves.
434        if is_focused {
435            if let Some((sel_start, sel_end)) = state.selection_range() {
436                let sel_style = ctx.theme.ext::<super::SelectionStyle>().cloned().unwrap_or_default();
437                let x0 = layout.x_of(sel_start).unwrap_or(r.origin.x + 10.0);
438                let x1 = layout.x_of(sel_end).unwrap_or(x0);
439                // Grip anchors stay at the line BOTTOM in both kinds —
440                // `engine.rs`'s handle_anchor targets exactly that point,
441                // so restyling must not move the draggable position.
442                let handle_y = r.origin.y + ty + line_h;
443                match sel_style.kind {
444                    super::SelectionKind::Flat => {
445                        ctx.fill_circle(Point { x: x0, y: handle_y }, 4.0, sel_style.handle);
446                        ctx.fill_circle(Point { x: x1, y: handle_y }, 4.0, sel_style.handle);
447                    }
448                    super::SelectionKind::Glass => {
449                        // Lens sized to the MAGNIFIED selection: `sel × zoom`
450                        // about the selection center, so every zoomed glyph
451                        // fits exactly inside the pill and nothing renders
452                        // past the end bars (found live: a center-zoom over
453                        // an unscaled rect pushed edge glyphs beyond the
454                        // handle — the "t after the cursor" bug). The
455                        // shader's `center + (uv-center)/zoom` sampling then
456                        // lands exactly on the unscaled selection window.
457                        //
458                        // ALL geometry (pill, bars, grips) comes from
459                        // `SelectionStyle::glass_lens` — the engine's
460                        // handle-drag grab uses the SAME function, so the
461                        // visible lollipops and the draggable anchors can
462                        // never drift apart.
463                        let g = sel_style.glass_lens(x0, x1, r.origin.y + ty, line_h);
464                        let lens = Rect {
465                            origin: Point { x: g.rect.0, y: g.rect.1 },
466                            size: Size { width: g.rect.2, height: g.rect.3 },
467                        };
468                        // Full stadium radius — the real liquid-glass pill.
469                        ctx.shader_fill(
470                            lens,
471                            rosace_shader::builtin::SELECTION_LENS,
472                            super::SelectionStyle::lens_uniforms(g.rect.3 / 2.0, sel_style.zoom),
473                        );
474                        // Lollipops: an end bar at each pill edge with its
475                        // grip hanging directly beneath — one connected
476                        // object, cursors always after the last magnified
477                        // glyph.
478                        for x in [g.bar_x.0, g.bar_x.1] {
479                            ctx.fill_rrect(Rect {
480                                origin: Point { x: x - 1.0, y: g.rect.1 + 3.0 },
481                                size: Size { width: 2.0, height: g.rect.3 - 6.0 },
482                            }, 1.0, sel_style.handle);
483                            ctx.fill_circle(Point { x, y: g.grip_y }, 4.5, sel_style.handle);
484                        }
485                        let _ = handle_y;
486                    }
487                }
488            }
489        }
490
491        // Inline validation error (D116 Step 8) — shown only once the
492        // field has been touched (real desktop/mobile convention: don't
493        // flash "required" on a form the user hasn't even reached yet).
494        // `Role::Alert` matches the one other place this framework
495        // surfaces an error message (`Toast::error`).
496        if let Some(field) = &self.field {
497            if field.is_touched() {
498                if let Some(err) = field.errors().first() {
499                    ctx.semantics(super::Semantics::new(rosace_core::Role::Alert).label(&err.message));
500                    ctx.record(DrawCommand::DrawText {
501                        text: err.message.clone(),
502                        origin: Point { x: full_rect.origin.x + 2.0, y: r.origin.y + r.size.height + 2.0 },
503                        color: Color::rgb(230, 90, 90),
504                        px: 10.0,
505                        weight: FontWeight::Regular,
506                    });
507                }
508            }
509        }
510
511        // ── Adornments (drawn last, centered in their reserved zones) ────────
512        let adorn = |w: &super::BoxedWidget, font: &rosace_render::FontCache, theme: &rosace_theme::ThemeData| -> Size {
513            let lc = super::LayoutCtx::new(rosace_layout::Constraints::loose(self.height, self.height), font, theme);
514            w.layout(&lc)
515        };
516        if let Some(w) = &self.leading {
517            let sz = adorn(w, ctx.font, &ctx.theme);
518            let cw = sz.width.min(self.height); let ch = sz.height.min(self.height);
519            let rect = Rect {
520                origin: Point { x: r.origin.x + (self.height - cw) / 2.0, y: r.origin.y + (self.height - ch) / 2.0 },
521                size: Size { width: cw, height: ch },
522            };
523            w.paint(&mut ctx.child(rect));
524        }
525        if let Some(w) = &self.trailing {
526            let sz = adorn(w, ctx.font, &ctx.theme);
527            let cw = sz.width.min(self.height); let ch = sz.height.min(self.height);
528            let zx = r.origin.x + r.size.width - self.height;
529            let rect = Rect {
530                origin: Point { x: zx + (self.height - cw) / 2.0, y: r.origin.y + (self.height - ch) / 2.0 },
531                size: Size { width: cw, height: ch },
532            };
533            let mut child = ctx.child(rect);
534            if let Some(cb) = &self.on_trailing { child.register_hit(Arc::clone(cb)); }
535            w.paint(&mut child);
536        }
537    }
538}
539
540/// Render the caret per `style.shape` (D116 Step 5) — shared by
541/// `TextInput` and `TextArea` so both stay pixel-consistent.
542// Both call sites already hold these values as locals with these exact
543// names — a params struct would only re-name them.
544#[allow(clippy::too_many_arguments)]
545pub(super) fn paint_caret(
546    ctx: &mut PaintCtx, style: &CursorStyle, x: f32, y: f32, line_h: f32, font_size: f32,
547    line: &LineLayout, cursor: usize,
548) {
549    match &style.shape {
550        CursorShape::Bar => {
551            ctx.fill_rrect(Rect {
552                origin: Point { x, y: y + 1.0 },
553                size: Size { width: style.width, height: (font_size - 1.0).max(4.0) },
554            }, style.corner_radius, style.color);
555        }
556        CursorShape::Block => {
557            let idx = line.boundary_chars.iter().position(|&c| c == cursor);
558            let next_x = idx.and_then(|i| line.boundary_x.get(i + 1).copied()).unwrap_or(x + 8.0);
559            let width = (next_x - x).max(2.0);
560            ctx.fill_rrect(Rect {
561                origin: Point { x, y },
562                size: Size { width, height: line_h },
563            }, style.corner_radius, Color::rgba(style.color.r, style.color.g, style.color.b, 90));
564        }
565        CursorShape::Underline => {
566            let idx = line.boundary_chars.iter().position(|&c| c == cursor);
567            let next_x = idx.and_then(|i| line.boundary_x.get(i + 1).copied()).unwrap_or(x + 8.0);
568            let width = (next_x - x).max(2.0);
569            ctx.fill_rect(Rect {
570                origin: Point { x, y: y + line_h - 2.0 },
571                size: Size { width, height: 2.0 },
572            }, style.color);
573        }
574        CursorShape::Custom(painter) => {
575            let rect = Rect {
576                origin: Point { x, y: y + 1.0 },
577                size: Size { width: style.width, height: (font_size - 1.0).max(4.0) },
578            };
579            painter(ctx, rect);
580        }
581    }
582}
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587    use rosace_render::{FontCache, PictureRecorder};
588    use rosace_theme::built_in;
589    use std::cell::RefCell;
590    use std::rc::Rc;
591    use std::sync::atomic::{AtomicBool, Ordering};
592    use crate::tree::RenderTree;
593
594    fn line() -> LineLayout {
595        LineLayout {
596            char_range: (0, 3),
597            y: 0.0,
598            height: 20.0,
599            boundary_chars: vec![0, 1, 2, 3],
600            boundary_x: vec![10.0, 18.0, 26.0, 34.0],
601        }
602    }
603
604    fn make_ctx<'a>(recorder: &'a mut PictureRecorder, font: &'a FontCache) -> PaintCtx<'a> {
605        let theme = built_in::dark_theme();
606        PaintCtx::root(
607            recorder,
608            Rect { origin: Point { x: 0.0, y: 0.0 }, size: Size { width: 200.0, height: 60.0 } },
609            font,
610            theme,
611            Rc::new(RefCell::new(RenderTree::new())),
612        )
613    }
614
615    #[test]
616    #[ignore] // ADORN_PNG=/path cargo test -p rosace-widgets adornment_showcase -- --ignored --nocapture
617    fn adornment_showcase() {
618        use super::super::app::WidgetApp;
619        use super::super::{Column, Icon, IconKind, Text as WText};
620        use crate::EdgeInsets;
621        let out = std::env::var("ADORN_PNG").unwrap_or_else(|_| "adornments.png".to_string());
622        let col = Column::new().spacing(14.0).padding(EdgeInsets::all(20.0))
623            .child(TextInput::new().value("laptop").width(240.0)
624                .leading(Icon::new(IconKind::Search).size(18.0)))
625            .child(TextInput::new().value("clear me").width(240.0)
626                .trailing(WText::new("\u{00d7}").size(16.0)).on_trailing(|| {}))
627            .child(TextInput::new().value("secret").obscure().width(240.0)
628                .leading(Icon::new(IconKind::User).size(16.0))
629                .trailing(WText::new("\u{1F441}").size(14.0)).on_trailing(|| {}));
630        std::fs::write(&out, WidgetApp::new(300, 200).dark().render_png(&col)).unwrap();
631        println!("wrote {out}");
632    }
633
634    #[test]
635    fn bar_shape_paints_a_thin_filled_rrect() {
636        let font = FontCache::embedded();
637        let mut recorder = PictureRecorder::new();
638        let mut ctx = make_ctx(&mut recorder, &font);
639        let style = CursorStyle::default();
640        paint_caret(&mut ctx, &style, 10.0, 0.0, 20.0, 11.0, &line(), 1);
641        let picture = recorder.finish();
642        match picture.commands.last().expect("must record a paint command") {
643            DrawCommand::FillRRect { rect, .. } => {
644                assert!(rect.size.width < 3.0, "Bar must be thin, got width {}", rect.size.width);
645            }
646            other => panic!("expected FillRRect for Bar, got {other:?}"),
647        }
648    }
649
650    #[test]
651    fn block_shape_paints_a_wider_rect_spanning_to_the_next_glyph_boundary() {
652        let font = FontCache::embedded();
653        let mut recorder = PictureRecorder::new();
654        let mut ctx = make_ctx(&mut recorder, &font);
655        let style = CursorStyle { shape: CursorShape::Block, ..Default::default() };
656        // Cursor at char 0 (x=10.0); next boundary (char 1) is at x=18.0.
657        paint_caret(&mut ctx, &style, 10.0, 0.0, 20.0, 11.0, &line(), 0);
658        let picture = recorder.finish();
659        match picture.commands.last().expect("must record a paint command") {
660            DrawCommand::FillRRect { rect, .. } => {
661                assert_eq!(rect.size.width, 8.0, "Block must span to the next glyph boundary (18.0 - 10.0)");
662            }
663            other => panic!("expected FillRRect for Block, got {other:?}"),
664        }
665    }
666
667    #[test]
668    fn underline_shape_paints_a_thin_rect_at_the_bottom_of_the_line() {
669        let font = FontCache::embedded();
670        let mut recorder = PictureRecorder::new();
671        let mut ctx = make_ctx(&mut recorder, &font);
672        let style = CursorStyle { shape: CursorShape::Underline, ..Default::default() };
673        paint_caret(&mut ctx, &style, 10.0, 0.0, 20.0, 11.0, &line(), 0);
674        let picture = recorder.finish();
675        match picture.commands.last().expect("must record a paint command") {
676            DrawCommand::FillRect { rect, .. } => {
677                assert_eq!(rect.origin.y, 18.0, "Underline must sit at the bottom of the line (y + line_h - 2.0)");
678                assert_eq!(rect.size.height, 2.0);
679            }
680            other => panic!("expected FillRect for Underline, got {other:?}"),
681        }
682    }
683
684    #[test]
685    fn custom_shape_delegates_to_the_app_supplied_painter() {
686        let font = FontCache::embedded();
687        let mut recorder = PictureRecorder::new();
688        let mut ctx = make_ctx(&mut recorder, &font);
689        let called = Arc::new(AtomicBool::new(false));
690        let called2 = called.clone();
691        let style = CursorStyle {
692            shape: CursorShape::Custom(Arc::new(move |_ctx, _rect| {
693                called2.store(true, Ordering::SeqCst);
694            })),
695            ..Default::default()
696        };
697        paint_caret(&mut ctx, &style, 10.0, 0.0, 20.0, 11.0, &line(), 0);
698        assert!(called.load(Ordering::SeqCst), "Custom shape must invoke the app's painter, not a built-in default");
699    }
700
701    #[test]
702    fn background_border_focus_color_builders_do_not_change_layout_size() {
703        let font = rosace_render::FontCache::embedded();
704        let theme = rosace_theme::built_in::dark_theme();
705        let ctx = LayoutCtx::new(rosace_layout::Constraints::loose(400.0, 400.0), &font, &theme);
706        let base = TextInput::new().width(200.0);
707        let customized = TextInput::new().width(200.0)
708            .background(Color::rgb(10, 10, 10))
709            .border(Color::rgb(200, 0, 0))
710            .focus_color(Color::rgb(0, 200, 0));
711        assert_eq!(base.layout(&ctx), customized.layout(&ctx));
712    }
713
714    /// Paint a focused, selection-carrying input under `theme` and return
715    /// whether the picture contains a `ShaderFill` (the glass lens).
716    fn selection_paints_a_lens(theme: rosace_theme::ThemeData) -> bool {
717        use super::super::text_edit::Selection;
718        let font = FontCache::embedded();
719        let mut recorder = PictureRecorder::new();
720        let tree = Rc::new(RefCell::new(RenderTree::new()));
721        tree.borrow_mut().node_mut(RenderTree::ROOT).text_edit.selection =
722            Selection::range(0, 5);
723        let mut ctx = PaintCtx::root(
724            &mut recorder,
725            Rect { origin: Point { x: 0.0, y: 0.0 }, size: Size { width: 300.0, height: 40.0 } },
726            &font,
727            theme,
728            tree,
729        );
730        TextInput::new().value("hello world").focused().paint(&mut ctx);
731        let picture = recorder.finish();
732        picture.commands.iter().any(|c| matches!(c, DrawCommand::ShaderFill { .. }))
733    }
734
735    #[test]
736    fn glass_selection_theme_paints_the_magnifier_lens() {
737        let theme = rosace_theme::built_in::dark_theme()
738            .with_ext(super::super::SelectionStyle::glass());
739        assert!(selection_paints_a_lens(theme), "glass theme must emit the lens ShaderFill");
740    }
741
742    #[test]
743    fn default_theme_selection_stays_flat_with_no_lens() {
744        assert!(
745            !selection_paints_a_lens(rosace_theme::built_in::dark_theme()),
746            "no SelectionStyle registered must keep the flat look — zero shader quads"
747        );
748    }
749
750    /// Paint a focused, single-line `TextInput` whose value overflows the
751    /// field with the caret at the END, and return the `scroll_x` written
752    /// back into the tree plus whether the content was clip-bracketed.
753    fn paint_overflowing(width: f32, value: &str, focused: bool) -> (f32, bool, bool) {
754        use super::super::text_edit::Selection;
755        let font = FontCache::embedded();
756        let mut recorder = PictureRecorder::new();
757        let tree = Rc::new(RefCell::new(RenderTree::new()));
758        let len = value.chars().count();
759        // Collapsed caret at the very end — the overflow case.
760        tree.borrow_mut().node_mut(RenderTree::ROOT).text_edit.selection =
761            Selection::range(len, len);
762        let mut ctx = PaintCtx::root(
763            &mut recorder,
764            Rect { origin: Point { x: 0.0, y: 0.0 }, size: Size { width, height: 40.0 } },
765            &font,
766            rosace_theme::built_in::dark_theme(),
767            tree.clone(),
768        );
769        let mut input = TextInput::new().value(value);
770        if focused {
771            input = input.focused();
772        }
773        input.paint(&mut ctx);
774        let picture = recorder.finish();
775        let scroll_x = tree.borrow().node(RenderTree::ROOT).text_edit.scroll_x;
776        let has_push = picture.commands.iter().any(|c| matches!(c, DrawCommand::PushClip { .. }));
777        let has_pop = picture.commands.iter().any(|c| matches!(c, DrawCommand::PopClip));
778        (scroll_x, has_push, has_pop)
779    }
780
781    #[test]
782    fn caret_at_end_of_overflowing_value_scrolls_content_left_and_clips() {
783        // A long value in a narrow field, caret at the end: the content
784        // MUST shift left (scroll_x > 0) so the caret stays visible, and
785        // the glyphs MUST be clip-bracketed to the field box.
786        let (scroll_x, has_push, has_pop) =
787            paint_overflowing(100.0, "the quick brown fox jumps over the lazy dog", true);
788        assert!(scroll_x > 0.0, "overflowing focused field must scroll left, got scroll_x={scroll_x}");
789        assert!(has_push && has_pop, "content must be bracketed by PushClip/PopClip");
790    }
791
792    #[test]
793    fn short_value_never_scrolls() {
794        // A value that fits leaves scroll_x at 0 — no gratuitous shift.
795        let (scroll_x, _, _) = paint_overflowing(300.0, "hi", true);
796        assert_eq!(scroll_x, 0.0, "a value that fits must not scroll");
797    }
798}