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
12pub 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 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 pub fn leading(mut self, w: impl Widget + 'static) -> Self { self.leading = Some(Box::new(w)); self }
82 pub fn trailing(mut self, w: impl Widget + 'static) -> Self { self.trailing = Some(Box::new(w)); self }
85 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 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 pub fn background(mut self, c: Color) -> Self { self.background = Some(c); self }
105 pub fn border(mut self, c: Color) -> Self { self.border_color = Some(c); self }
107 pub fn focus_color(mut self, c: Color) -> Self { self.focus_color = Some(c); self }
109 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 pub fn controller(mut self, c: EditController) -> Self {
125 self.controller = Some(c);
126 self
127 }
128 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 pub fn cursor_style(mut self, s: CursorStyle) -> Self {
143 self.cursor_style = Some(s);
144 self
145 }
146 pub fn keyboard_type(mut self, kt: rosace_core::KeyboardType) -> Self {
152 self.keyboard_type = kt;
153 self
154 }
155 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 f.validate();
177 self.field = Some(f);
178 self
179 }
180 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
197pub(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 let focus = ctx.focus_node_seeded(self.focused);
220 ctx.register_focus(focus.clone());
221 let is_focused = focus.is_focused();
222
223 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 let state = ctx.text_edit();
269 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 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 ctx.record(DrawCommand::PushClip { rect: r });
339
340 if is_focused {
341 super::request_animation();
346
347 if let Some((sel_start, sel_end)) = state.selection_range() {
348 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 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 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 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 if is_focused && state.selection_range().is_none() {
411 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 ctx.record(DrawCommand::PopClip);
430
431 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 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 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 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 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 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 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#[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] 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 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 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 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 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 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 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}